diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000..ab36f92 Binary files /dev/null and b/backend/.DS_Store differ diff --git a/backend/.gitignore b/backend/.gitignore index c1eeb98..91c51e5 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -43,4 +43,4 @@ db_dev.trace.db ### yml /src/main/resources/application-secret.yml apiV1.json -.env +.env \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index ff298d7..a74b2a6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/build.gradle b/backend/build.gradle index 5ee546c..0992cb2 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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') { diff --git a/backend/docker-compose-prod.yml b/backend/docker-compose-prod.yml index 127e43c..dcf939e 100644 --- a/backend/docker-compose-prod.yml +++ b/backend/docker-compose-prod.yml @@ -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 @@ -71,4 +89,4 @@ networks: driver: bridge volumes: - mysql_prod_data: + mysql_prod_data: \ No newline at end of file diff --git a/backend/src/.DS_Store b/backend/src/.DS_Store new file mode 100644 index 0000000..d6da1a1 Binary files /dev/null and b/backend/src/.DS_Store differ diff --git a/backend/src/main/.DS_Store b/backend/src/main/.DS_Store new file mode 100644 index 0000000..8c43484 Binary files /dev/null and b/backend/src/main/.DS_Store differ diff --git a/backend/src/main/java/.DS_Store b/backend/src/main/java/.DS_Store new file mode 100644 index 0000000..52bb3a5 Binary files /dev/null and b/backend/src/main/java/.DS_Store differ diff --git a/backend/src/main/java/com/.DS_Store b/backend/src/main/java/com/.DS_Store new file mode 100644 index 0000000..14f70ea Binary files /dev/null and b/backend/src/main/java/com/.DS_Store differ diff --git a/backend/src/main/java/com/Catch_Course/.DS_Store b/backend/src/main/java/com/Catch_Course/.DS_Store new file mode 100644 index 0000000..b62a53c Binary files /dev/null and b/backend/src/main/java/com/Catch_Course/.DS_Store differ diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentOutbox.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentOutbox.java new file mode 100644 index 0000000..93d66a1 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentOutbox.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentOutboxRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentOutboxRepository.java new file mode 100644 index 0000000..f619c27 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentOutboxRepository.java @@ -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 { + List findTop100ByOrderByCreatedAtAsc(); +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/service/OutboxScheduler.java b/backend/src/main/java/com/Catch_Course/domain/payments/service/OutboxScheduler.java new file mode 100644 index 0000000..025c5f6 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/service/OutboxScheduler.java @@ -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 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()); + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java b/backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java index a191a0e..a653f38 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java @@ -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; @@ -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; @@ -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) { @@ -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)); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index d28e72f..a864a46 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -71,4 +71,4 @@ spring: app: registration-time: start: "09:00" - duration-in-minutes: 900 + duration-in-minutes: 1080 diff --git a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationControllerTest.java b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationControllerTest.java index 3b99b75..db7f711 100644 --- a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationControllerTest.java +++ b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationControllerTest.java @@ -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.*; @@ -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"))