Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added backend/.DS_Store
Binary file not shown.
2 changes: 1 addition & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ db_dev.trace.db
### yml
/src/main/resources/application-secret.yml
apiV1.json
.env
.env
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ COPY --from=builder /app/build/libs/*-SNAPSHOT.jar app.jar
# 8080 포트를 외부에 노출
EXPOSE 8080
# "java -jar app.jar" 명령어로 Spring Boot 애플리케이션을 실행
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-jar", "app.jar"]
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dependencies {
// 모니터링
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'io.prometheus:prometheus-metrics-model:1.0.0'
}

tasks.named('test') {
Expand Down
20 changes: 19 additions & 1 deletion backend/docker-compose-prod.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
services:
# Spring Boot 애플리케이션 서비스 추가
app-prod:
image: ghcr.io/wonseokyoon/catch-course:latest
container_name: app-prod
restart: always
depends_on:
- mysql-db-prod
- redis-prod
- kafka-prod
ports:
- "8080:8080"
networks:
- prod-network
environment:
- SPRING_PROFILES_ACTIVE=prod
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}

mysql-db-prod:
image: mysql:8.0
container_name: mysql-db-prod
Expand Down Expand Up @@ -71,4 +89,4 @@ networks:
driver: bridge

volumes:
mysql_prod_data:
mysql_prod_data:
Binary file added backend/src/.DS_Store
Binary file not shown.
Binary file added backend/src/main/.DS_Store
Binary file not shown.
Binary file added backend/src/main/java/.DS_Store
Binary file not shown.
Binary file added backend/src/main/java/com/.DS_Store
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.Catch_Course.domain.payments.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class PaymentOutbox {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private Long aggregateId;

@Column(nullable = false)
private String aggregateType;

@Column(columnDefinition = "TEXT", nullable = false)
private String payload;

@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;

@Builder
public PaymentOutbox(Long aggregateId, String aggregateType, String payload) {
this.aggregateId = aggregateId;
this.aggregateType = aggregateType;
this.payload = payload;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.Catch_Course.domain.payments.repository;

import com.Catch_Course.domain.payments.entity.PaymentOutbox;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface PaymentOutboxRepository extends JpaRepository<PaymentOutbox, Long> {
List<PaymentOutbox> findTop100ByOrderByCreatedAtAsc();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.Catch_Course.domain.payments.service;

import com.Catch_Course.domain.payments.entity.PaymentOutbox;
import com.Catch_Course.domain.payments.repository.PaymentOutboxRepository;
import com.Catch_Course.global.kafka.dto.PaymentCancelRequest;
import com.Catch_Course.global.kafka.producer.PaymentCancelProducer;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxScheduler {

private final PaymentOutboxRepository paymentOutboxRepository;
private final PaymentCancelProducer paymentCancelProducer;
private final ObjectMapper objectMapper;

// 10초마다 실행 (fixedDelayString = "10000")
@Scheduled(fixedDelay = 10000)
@Transactional
public void pollAndPublish() {
List<PaymentOutbox> events = paymentOutboxRepository.findTop100ByOrderByCreatedAtAsc();
if (events.isEmpty()) {
return;
}

log.info("[Outbox] 미처리 이벤트 {}건을 조회했습니다. 메시지 발행을 시작합니다.", events.size());

for (PaymentOutbox event : events) {
try {
// 2. 페이로드를 역직렬화
PaymentCancelRequest payload = objectMapper.readValue(event.getPayload(), PaymentCancelRequest.class);

// 3. 메시지 발행
paymentCancelProducer.send(payload);

// 4. 아웃박스 테이블에서 삭제
paymentOutboxRepository.delete(event);

} catch (Exception e) {
log.error("[Outbox] 이벤트 처리 실패. eventId: {}. 에러: {}", event.getId(), e.getMessage());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import com.Catch_Course.domain.member.entity.Member;
import com.Catch_Course.domain.payments.dto.PaymentDto;
import com.Catch_Course.domain.payments.entity.Payment;
import com.Catch_Course.domain.payments.entity.PaymentOutbox;
import com.Catch_Course.domain.payments.entity.PaymentStatus;
import com.Catch_Course.domain.payments.repository.PaymentOutboxRepository;
import com.Catch_Course.domain.payments.repository.PaymentRepository;
import com.Catch_Course.domain.reservation.entity.Reservation;
import com.Catch_Course.domain.reservation.entity.ReservationStatus;
Expand All @@ -12,9 +14,9 @@
import com.Catch_Course.global.kafka.dto.PaymentCancelRequest;
import com.Catch_Course.global.kafka.producer.PaymentCancelProducer;
import com.Catch_Course.global.payment.TossPaymentsService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -35,7 +37,8 @@ public class PaymentService {
private final ReservationRepository reservationRepository;
private final TossPaymentsService tossPaymentsService;
private final PaymentCancelProducer paymentCancelProducer;
private final ApplicationEventPublisher eventPublisher;
private final PaymentOutboxRepository paymentOutboxRepository;
private final ObjectMapper objectMapper;

public PaymentDto getPayment(Member member, Long reservationId) {

Expand Down Expand Up @@ -149,13 +152,24 @@ public PaymentDto deletePaymentRequest(Member member, Long reservationId) {
throw new ServiceException("409-2", "이미 취소된 결제입니다.");
}

// 취소 요청 상태
// DB 상태 변경
payment.setStatus(PaymentStatus.CANCEL_REQUESTED);
paymentRepository.save(payment);

// 메세지 직접 발행 대신 내부 이벤트로 발행
String cancelReason = "고객 요청";
PaymentCancelRequest request = new PaymentCancelRequest(payment, cancelReason, reservation, member);
eventPublisher.publishEvent(request);
PaymentCancelRequest requestPayload = new PaymentCancelRequest(payment, cancelReason, reservation, member);

try {
String payloadJson = objectMapper.writeValueAsString(requestPayload);
PaymentOutbox outboxEvent = PaymentOutbox.builder()
.aggregateId(payment.getId())
.aggregateType("PAYMENT_CANCEL")
.payload(payloadJson)
.build();
paymentOutboxRepository.save(outboxEvent);
} catch (Exception e) {
throw new RuntimeException("Outbox payload 직렬화에 실패했습니다.", e);
}

return new PaymentDto(paymentRepository.save(payment));
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,4 @@ spring:
app:
registration-time:
start: "09:00"
duration-in-minutes: 900
duration-in-minutes: 1080
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
import java.util.List;
import java.util.function.Supplier;

import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
Expand Down Expand Up @@ -229,14 +231,28 @@ void cancelReservation() throws Exception {
@DisplayName("수강 취소 실패 - 이미 취소")
void cancelReservation2() throws Exception {
Long courseId = 1L;
cancelReservation(); // 수강 취소
Thread.sleep(1000);
cancelReservation();

// then 1: DB에서 방금 생성된 예약을 직접 조회하여 ID를 확보
Course course = courseService.findById(courseId);
Reservation reservation = reservationRepository.findByStudentAndCourse(loginedMember, course)
.orElseThrow(() -> new AssertionError("테스트 예약을 찾을 수 없습니다."));
Long reservationId = reservation.getId();

// then 2: Awaitility를 사용해 비동기 처리가 완료될 때까지 대기
await().atMost(5, SECONDS).untilAsserted(() -> {
Reservation updatedReservation = reservationRepository.findById(reservationId)
.orElseThrow(() -> new AssertionError("예약을 찾을 수 없습니다."));
assertThat(updatedReservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED);
});

// when 2: 두 번째 수강 취소 요청
ResultActions resultActions = mvc.perform(
delete("/api/reserve?courseId=%d".formatted(courseId))
.header("Authorization", "Bearer " + token)
).andDo(print());

// then 3: 최종 결과 검증
resultActions
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("409-5"))
Expand Down
Loading