From 9bb6fd146415b512c8e4e10b279910cfad532583 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Tue, 14 Oct 2025 18:02:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix=EB=B2=84=EC=A0=84=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.DS_Store | Bin 0 -> 6148 bytes backend/.gitignore | 2 +- backend/build.gradle | 1 + backend/src/.DS_Store | Bin 0 -> 6148 bytes backend/src/main/.DS_Store | Bin 0 -> 6148 bytes backend/src/main/java/.DS_Store | Bin 0 -> 6148 bytes backend/src/main/java/com/.DS_Store | Bin 0 -> 6148 bytes .../src/main/java/com/Catch_Course/.DS_Store | Bin 0 -> 6148 bytes backend/src/main/resources/application.yml | 2 +- .../controller/ReservationControllerTest.java | 20 ++++++++++++++++-- 10 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 backend/.DS_Store create mode 100644 backend/src/.DS_Store create mode 100644 backend/src/main/.DS_Store create mode 100644 backend/src/main/java/.DS_Store create mode 100644 backend/src/main/java/com/.DS_Store create mode 100644 backend/src/main/java/com/Catch_Course/.DS_Store diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ab36f92165ca9685c440f8abc624b8e5c3847a74 GIT binary patch literal 6148 zcmeHK%}T>S5Z-O8O({YS3VK`cS}?XnC|*LWFJMFuDm5`hgE3o@)*MP9cYPsW#OHBl zcLNr47O^w1`_1oe_JiyXV~l%?=$J8^F=jzSX*G6d@*oSE2P z2mJOX%UQ-A!s7e)CviS^oKN0pwzjt0R@>@Ycm9(s{CO~6|Q;Ud7X5 zV(*^GbRNX%G*bm}G=-Gg>o|>M;mJiB<*L@#0jpzmCicN{*&p_uo)|czRZlEOs1FAx zt5wI^J3KnQm^>#hseIFXa-dtuj=>7vK{3mD^|LgQ=_7cm>?(_p7$63S0b*dI88F9! z)!k?oXz|1VF;K?=tMu>oLT?(j6xp`u6 zT@HR>@?3+ZMqSRhni`fWCGK00Z}teHGMkfjZ>521|`N3i?$! PAYB9$A=D8Azres3Yo$rH literal 0 HcmV?d00001 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/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/src/.DS_Store b/backend/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d6da1a1f08ec6ee7f91a8e24cf2e24443dac5c70 GIT binary patch literal 6148 zcmeHK%}T>S5Z-O0O({YS3VI88Eg0J(6fYsx7cim+m70*E!F0DYsXdfJ?)pN$h|lB9 z?nW%utB9R}-EV$(vma!C7-Kw`CC7|8j4>M;B1dJ7pnGkoW|9#(juFrENu0?T>^Bqp z>ww>Gu`!ES%EZ_2k0)807oPXdYjtaDyJ0u%mVFmK%6V8s#Vik^=?z+!Qf6_bNAXoU znh%}bGbxHF6{D#tNRtty++L?5k@G;#M3Sr8PzUU$-5fdxi$$l`@!D?J>o42xqK|s7 zd$L?M?Y+aJ(~IF#HWuEQW)4!_LWNB(LF zS;PP_@Xr|F)*u`Nuqbo3ep?=%wJx-KXegLhq5=Z?$|V2{+(-6RP{&==AS5Z<-XrW7Fu1-&hJEg0J(6fdFH7cim+m70*E(U>hw+8jzDcYPsW#OHBl zcOw?-Rm9G~?l-@?*$=Wmj4|GyMn{Y}j4>M;B1dI~pnGL#!z3ee93w2UahS*u>^Bqp z>ww>Gu}2oL84JFCe>hI!EO*^^zEQI_x9WD?ZrZp0gDm_!$fsF9m|UZEA!QO)dJta5 zqhjdnoXRv0;&e1o1#vWjl$)zKjbzc6(=^Idt*--i!)^?n{rSAzZM!YeaeIrFnDhZ$W?BEwFopDbi^~3-%u*^VBn+~4;XYk9ceB>{e zkVOm-1OJQxZVmiF9~NcK)^E$hvsOU6gNA~6B`P4GFI@t_zS5Z<-brW7Fu1-&hJEg0J(6fYsx7cim+m718M!8BW%)Er77cYPsW#OHBl zcLNr47O^w1`_1oe_JiyXV~l%?=!7wwF=jzShXm0Pctd`ZW?)_(3_<4{oGB23lqID%@5>|Q;UdNMS zZ10`RG!No*GFJt0G=Y@6n>dYR;mJiBWvbTK0jq7b$M)fJ*&FnnuIM|%RaY#Bs1N$5 zt5w_DKRQ0U9KR&fRK96GInb?S*I)(jpqS;n`tvlA=@WRW>?(_p7$63S0b*dI88F9! z)!AqkXz|1VF;K?=tMu>oLT?(j6xp`u6 zT@HR>@?3+ZMqSRhni`fWCGK00Z}t0~OS9fjZ>521|`N3i?$! PAYB9$A=D8Azres3)pSXu literal 0 HcmV?d00001 diff --git a/backend/src/main/java/com/.DS_Store b/backend/src/main/java/com/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..14f70eacaa9401b68e911a8948d79e1d52e264ac GIT binary patch literal 6148 zcmeHK%}N6?5T3NvZmB{K3VK`cTClc7C|;JfzJM!wP^r7N*u`~I_QxJdVek4vzKGA` zOp=PF_ToXL%)sQEOlCIZ%aTa|Ky%}+JyOc8Y7q;hL1*7Spw%3zU>IKm#Rtdpygdw-rK{S-puFRrgqH88AnlRo&n<^3s?AX5e=W(D`7a5;_KRjq2#YhOUn^ULqtx zo8A(Hwn4{Wt`SF2gib}&slq%lgic4lZQ>k*xkjB1Ld}fdF*6JELJ?|q^xG;NgrkvL zW`G%3W}v9MHLCyT>)-#&N!()wn1PLAK$QA!zl%#UwRL52RBLV2dsGsN%Qb$bprJ}J f#!@LhKvjZ%n+!z9V6G88D0~r6G;qTV{3!#UgPczE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b62a53cef40c3eacef800ee023b7ee1c5648da88 GIT binary patch literal 6148 zcmeHK%}T>S5T0#on^J@x6!f;>wP0JSP`rd%U%-eSRBA#>4W`-BruI+@x$6u0B0i5Z zyIbjxUPWaFX20FpnS}YW>|_8ybf#egpb7vEDq*gS!xuvPq+61+mI9*C&xjxiW|L76 zt62%WZ$G5b) zQYsFXb3eF_`m<4v4}&y4>AFNvLKWHBG{aj`hrjQ+CR|>fwCeY&F_7 z*=(OIYI5Fg)@!nL+FC5i&i>Ky*=6r39wzF=Fe&iRY1y^7gjbxbCH3r$lUOAKv{OtG z1`uP)0j3gnVfEoB#42VM4>vk|DTU)o1qgpFbA5cli oFEjWVf)ibe(U(f`0jd_XOPV0M7BhorLE#?(O#=_iz>hNU0ogr@4*&oF literal 0 HcmV?d00001 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")) From 4f85e0bc75cd37a8d767db9f23befd3cd74c6c7d Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Tue, 14 Oct 2025 18:03:54 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=B2=84=EC=A0=84=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payments/entity/PaymentOutbox.java | 42 +++++++++++++++ .../repository/PaymentOutboxRepository.java | 10 ++++ .../payments/service/OutboxScheduler.java | 52 +++++++++++++++++++ .../payments/service/PaymentService.java | 26 +++++++--- 4 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentOutbox.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentOutboxRepository.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/service/OutboxScheduler.java 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)); }