From ceb97107d7c9f71e44ac2d8edaa73471dfb845a9 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Thu, 14 Aug 2025 12:01:51 +0900 Subject: [PATCH 01/37] =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentController.java | 42 +++++++++++++++++++ .../domain/payments/dto/PaymentDto.java | 27 ++++++++++++ .../domain/payments/dto/PaymentRequest.java | 19 +++++++++ .../domain/payments/entity/Payment.java | 40 ++++++++++++++++++ .../domain/payments/entity/PaymentStatus.java | 8 ++++ .../repository/PaymentRepository.java | 13 ++++++ .../payments/service/PaymentService.java | 36 ++++++++++++++++ .../reservation/entity/Reservation.java | 4 ++ .../reservation/entity/ReservationStatus.java | 3 +- .../repository/ReservationRepository.java | 3 ++ .../resources/application-secret.yml.default | 8 ++++ backend/src/main/resources/application.yml | 5 --- 12 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentRequest.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java new file mode 100644 index 0000000..ed84757 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -0,0 +1,42 @@ +package com.Catch_Course.domain.payments.controller; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.payments.dto.PaymentDto; +import com.Catch_Course.domain.payments.service.PaymentService; +import com.Catch_Course.global.Rq; +import com.Catch_Course.global.dto.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "paymentController", description = "결제 관련 API") +@RestController +@RequestMapping("/api/payment") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + private final Rq rq; + + + @Operation(summary = "결제 정보 조회") + @GetMapping() + public RsData getPayment(@RequestParam Long reservationId) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + PaymentDto paymentDto = paymentService.getPayment(member, reservationId); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + paymentDto + ); + } + + + +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java new file mode 100644 index 0000000..c1e908a --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java @@ -0,0 +1,27 @@ +package com.Catch_Course.domain.payments.dto; + +import com.Catch_Course.domain.payments.entity.Payment; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class PaymentDto { + private long reservationId; + private String orderId; + private long amount; + private String paymentKey; + private String status; + @JsonProperty("createdDatetime") + private LocalDateTime createdDate; + + public PaymentDto(Payment payment) { + this.reservationId = payment.getReservation().getId(); + this.orderId = payment.getMerchantUid(); + this.amount = payment.getAmount(); + this.paymentKey = payment.getPaymentKey(); + this.status = payment.getStatus().toString(); + this.createdDate = payment.getCreatedDate(); + } +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentRequest.java b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentRequest.java new file mode 100644 index 0000000..4361b52 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentRequest.java @@ -0,0 +1,19 @@ +package com.Catch_Course.domain.payments.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PaymentRequest { + private String paymentKey; + private String orderId; + private Long amount; + + public PaymentRequest(String paymentKey, String orderId, Long amount) { + this.paymentKey = paymentKey; + this.orderId = orderId; + this.amount = amount; + } + +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java new file mode 100644 index 0000000..1653812 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java @@ -0,0 +1,40 @@ +package com.Catch_Course.domain.payments.entity; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.global.entity.BaseTime; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@SuperBuilder +public class Payment extends BaseTime { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false, unique = true) + private Reservation reservation; + + @OneToOne(fetch = FetchType.LAZY) + private Member member; + + @Column(unique = true, nullable = false) + private String merchantUid; // 가맹점 주문번호(toss payments orderId) + + @Column(nullable = false) + private long amount; // 금액 + + private String paymentKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java new file mode 100644 index 0000000..246f4d8 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java @@ -0,0 +1,8 @@ +package com.Catch_Course.domain.payments.entity; + +public enum PaymentStatus { + PENDING, // 결제 대기 + PAID, // 결제 완료 + CANCELLED, // 결제 취소 + FAILED, // 결제 실패 +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java new file mode 100644 index 0000000..623e4e2 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java @@ -0,0 +1,13 @@ +package com.Catch_Course.domain.payments.repository; + +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.reservation.entity.Reservation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PaymentRepository extends JpaRepository { + Optional findByReservation(Reservation reservation); +} 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 new file mode 100644 index 0000000..c604f8e --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java @@ -0,0 +1,36 @@ +package com.Catch_Course.domain.payments.service; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.member.repository.MemberRepository; +import com.Catch_Course.domain.payments.dto.PaymentDto; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.repository.PaymentRepository; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final MemberRepository memberRepository; + private final ReservationRepository reservationRepository; + + public PaymentDto getPayment(Member member, Long reservationId) { + // reservation 이력 조회 + Reservation reservation = reservationRepository.findByIdAndStudent(reservationId,member) + .orElseThrow(() -> new ServiceException("404-3","수강신청 이력이 없습니다.")); + + Payment payment = paymentRepository.findByReservation(reservation) + .orElseThrow(() -> new ServiceException("404-5","결제 정보가 없습니다.")); + + return new PaymentDto(payment); + } +} diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java index 667d31f..8553b52 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java @@ -2,6 +2,7 @@ import com.Catch_Course.domain.course.entity.Course; import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.payments.entity.Payment; import com.Catch_Course.global.entity.BaseTime; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -30,4 +31,7 @@ public class Reservation extends BaseTime { @Enumerated(EnumType.STRING) private ReservationStatus status; // 신청 상태 + + @OneToOne(mappedBy = "reservation", fetch = FetchType.LAZY) + private Payment payment; } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java index 7ec75dd..8850d7d 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java @@ -3,5 +3,6 @@ public enum ReservationStatus { COMPLETED, // 신청 완료 WAITING, // 대기 - FAILED // 실패 + FAILED, // 실패 + PENDING // 결제 대기 } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index 9f365f5..7c4f8f8 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -19,4 +19,7 @@ public interface ReservationRepository extends JpaRepository @EntityGraph(attributePaths = {"course", "student"}) Page findAllByStudentAndStatus(Member member, ReservationStatus reservationStatus, Pageable pageable); + + @EntityGraph(attributePaths = {"course", "student", "payment"}) + Optional findByIdAndStudent(Long reservationId, Member member); } diff --git a/backend/src/main/resources/application-secret.yml.default b/backend/src/main/resources/application-secret.yml.default index 76ef0ee..85c3321 100644 --- a/backend/src/main/resources/application-secret.yml.default +++ b/backend/src/main/resources/application-secret.yml.default @@ -14,6 +14,7 @@ spring: google: client-id: client-secret: + mail: username: password: @@ -21,3 +22,10 @@ spring: custom: jwt: secret-key: + expire-seconds: + refresh-expire-seconds: + + toss: + payment: + client: + secret: \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ed099cd..2f27675 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -67,11 +67,6 @@ spring: spring.json.trusted.packages: "*" auto-offset-reset: earliest -custom: - jwt: - expire-seconds: "#{60 * 60}" - refresh-expire-seconds: "#{60 * 60 * 24 * 7}" - app: registration-time: start: "09:00" From 5f6b147eb010eb03b1c426d329ee07ea7cdf8ed0 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Thu, 14 Aug 2025 12:25:03 +0900 Subject: [PATCH 02/37] =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payments/controller/PaymentController.java | 15 +++++++++++++++ .../payments/repository/PaymentRepository.java | 3 +++ .../domain/payments/service/PaymentService.java | 16 ++++++++++++++++ .../repository/ReservationRepository.java | 4 ++++ 4 files changed, 38 insertions(+) diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java index ed84757..1a271ea 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -13,6 +13,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Tag(name = "paymentController", description = "결제 관련 API") @RestController @RequestMapping("/api/payment") @@ -37,6 +39,19 @@ public RsData getPayment(@RequestParam Long reservationId) { ); } + @Operation(summary = "결제 목록 조회") + @GetMapping() + public RsData> getPayments(@RequestParam Long reservationId) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + List paymentDtos = paymentService.getPayments(member); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + paymentDtos + ); + } } diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java index 623e4e2..5ac4a5d 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java @@ -5,9 +5,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository public interface PaymentRepository extends JpaRepository { Optional findByReservation(Reservation reservation); + + List findByReservationIn(List reservationList); } 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 c604f8e..c1081f7 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 @@ -13,6 +13,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -33,4 +35,18 @@ public PaymentDto getPayment(Member member, Long reservationId) { return new PaymentDto(payment); } + + public List getPayments(Member member) { + List reservationList = reservationRepository.findByStudent(member); + + List payments = paymentRepository.findByReservationIn(reservationList); + + if(payments.isEmpty()) { + throw new ServiceException("404-5","결제 정보가 없습니다."); + } + + return payments.stream() + .map(PaymentDto::new) + .toList(); + } } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index 7c4f8f8..20e6511 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -10,6 +10,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -22,4 +23,7 @@ public interface ReservationRepository extends JpaRepository @EntityGraph(attributePaths = {"course", "student", "payment"}) Optional findByIdAndStudent(Long reservationId, Member member); + + @EntityGraph(attributePaths = {"course", "student", "payment"}) + List findByStudent(Member member); } From c0816510f4bcdc8d80408875fab157036a0d8643 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Thu, 14 Aug 2025 13:05:06 +0900 Subject: [PATCH 03/37] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EA=B3=BC=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentController.java | 41 ++++++++++++-- .../repository/PaymentRepository.java | 2 + .../payments/service/PaymentService.java | 49 +++++++++++++++- .../reservation/dto/ReservationDto.java | 2 + .../reservation/entity/Reservation.java | 3 + .../repository/ReservationRepository.java | 5 +- .../service/ReservationService.java | 7 +++ .../global/payment/TossPaymentsService.java | 56 +++++++++++++++++++ .../global/restClient/RestTemplateConfig.java | 14 +++++ 9 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java create mode 100644 backend/src/main/java/com/Catch_Course/global/restClient/RestTemplateConfig.java diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java index 1a271ea..8d4d5ee 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -7,11 +7,11 @@ import com.Catch_Course.global.dto.RsData; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.hibernate.validator.constraints.Length; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -41,7 +41,7 @@ public RsData getPayment(@RequestParam Long reservationId) { @Operation(summary = "결제 목록 조회") @GetMapping() - public RsData> getPayments(@RequestParam Long reservationId) { + public RsData> getPayments() { Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 List paymentDtos = paymentService.getPayments(member); @@ -54,4 +54,35 @@ public RsData> getPayments(@RequestParam Long reservationId) { } + @Operation(summary = "결제 생성 및 요청") + @PostMapping("/request") + public RsData requestPayment(@RequestParam Long reservationId) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + PaymentDto paymentDto = paymentService.requestPayment(member, reservationId); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + paymentDto + ); + } + + record confirmPaymentReqBody(@NotBlank String paymentKey, + @NotBlank @Length(min = 3) String orderId, + @NotBlank @Length(min = 3) Long amount) { + } + + @Operation(summary = "결제 승인") + @PostMapping("/confirm") + public RsData confirmPayment(@RequestBody @Valid confirmPaymentReqBody body) { + PaymentDto paymentDto = paymentService.confirmPayment(body.paymentKey, body.orderId, body.amount); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + paymentDto + ); + } + } diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java index 5ac4a5d..13cdcdc 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java @@ -13,4 +13,6 @@ public interface PaymentRepository extends JpaRepository { Optional findByReservation(Reservation reservation); List findByReservationIn(List reservationList); + + Optional findByMerchantUid(String orderId); } 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 c1081f7..d1d7fea 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 @@ -1,19 +1,23 @@ package com.Catch_Course.domain.payments.service; import com.Catch_Course.domain.member.entity.Member; -import com.Catch_Course.domain.member.repository.MemberRepository; import com.Catch_Course.domain.payments.dto.PaymentDto; import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.entity.PaymentStatus; import com.Catch_Course.domain.payments.repository.PaymentRepository; import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.entity.ReservationStatus; import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.domain.reservation.service.ReservationService; import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.payment.TossPaymentsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -22,8 +26,9 @@ public class PaymentService { private final PaymentRepository paymentRepository; - private final MemberRepository memberRepository; private final ReservationRepository reservationRepository; + private final ReservationService reservationService; + private final TossPaymentsService tossPaymentsService; public PaymentDto getPayment(Member member, Long reservationId) { // reservation 이력 조회 @@ -49,4 +54,44 @@ public List getPayments(Member member) { .map(PaymentDto::new) .toList(); } + + @Transactional + public PaymentDto requestPayment(Member member, Long reservationId) { + Reservation reservation = reservationService.findByIdAndStudent(reservationId,member); + + String merchantUid = UUID.randomUUID().toString(); + long amount = reservation.getPrice(); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(member) + .merchantUid(merchantUid) + .amount(amount) + .status(PaymentStatus.PENDING) + .build(); + + Payment savedPayment = paymentRepository.save(payment); + return new PaymentDto(savedPayment); + } + + @Transactional + public PaymentDto confirmPayment(String paymentKey, String orderId, Long amount) { + Payment payment = paymentRepository.findByMerchantUid(orderId) + .orElseThrow(() -> new ServiceException("404-5","결제 정보가 없습니다.")); + + if(payment.getStatus() != PaymentStatus.PENDING) { + throw new ServiceException("409-2","이미 처리된 결제입니다."); + } + if(payment.getAmount() != amount){ + throw new ServiceException("400-4","결제 금액이 일치하지 않습니다."); + } + + tossPaymentsService.confirm(paymentKey,orderId,amount); + + payment.setStatus(PaymentStatus.PAID); + payment.setPaymentKey(paymentKey); + payment.getReservation().setStatus(ReservationStatus.COMPLETED); + + return new PaymentDto(paymentRepository.save(payment)); + } } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java b/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java index 043ceed..acc339b 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java @@ -13,6 +13,7 @@ public class ReservationDto { private long studentId; // 학생 id private String studentName; // 학생 이름 private String status; // 예약 상태 + private Long price; @JsonProperty("createdDatetime") private LocalDateTime createdDate; @@ -22,6 +23,7 @@ public ReservationDto(Reservation reservation) { this.studentId = reservation.getStudent().getId(); this.studentName = reservation.getStudent().getNickname(); this.status = reservation.getStatus().toString(); + this.price = reservation.getPrice(); this.createdDate = reservation.getCreatedDate(); } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java index 8553b52..4e087e6 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java @@ -34,4 +34,7 @@ public class Reservation extends BaseTime { @OneToOne(mappedBy = "reservation", fetch = FetchType.LAZY) private Payment payment; + + @Column(nullable = false) + private Long price; } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index 20e6511..c1c5f61 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -21,9 +21,12 @@ public interface ReservationRepository extends JpaRepository @EntityGraph(attributePaths = {"course", "student"}) Page findAllByStudentAndStatus(Member member, ReservationStatus reservationStatus, Pageable pageable); - @EntityGraph(attributePaths = {"course", "student", "payment"}) + @EntityGraph(attributePaths = {"course", "student"}) Optional findByIdAndStudent(Long reservationId, Member member); @EntityGraph(attributePaths = {"course", "student", "payment"}) List findByStudent(Member member); + + @EntityGraph(attributePaths = {"course", "student", "payment"}) + Optional findByIdAndStudentAndStatus(Long reservationId, Member member, ReservationStatus reservationStatus); } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index 12c1986..3e396ba 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -155,4 +155,11 @@ public void saveDeleteHistory(Long memberId, Long courseId) { .build() ); } + + @Transactional(readOnly = true) + public Reservation findByIdAndStudent(Long reservationId, Member member) { + return reservationRepository.findByIdAndStudentAndStatus(reservationId,member,ReservationStatus.PENDING) + .orElseThrow(() -> new ServiceException("404-3","수강신청 이력이 없습니다.")); + } + } diff --git a/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java b/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java new file mode 100644 index 0000000..bab4a53 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java @@ -0,0 +1,56 @@ +package com.Catch_Course.global.payment; + +import com.Catch_Course.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TossPaymentsService { + @Value("${custom.toss.payment.secret}") + private String secretKey; + private final RestTemplate restTemplate; + private final String TOSS_CONFIRM_URL = "https://api.tosspayments.com/v1/payments/confirm"; + + public void confirm(String paymentKey, String orderId, Long amount) { + log.info("Toss Payments 결제 승인 요청 시작: orderId={}", orderId); + + HttpHeaders headers = createHeaders(); + Map requestBody = Map.of( + "paymentKey", paymentKey, + "orderId", orderId, + "amount", amount + ); + + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + try { + // API 호출 + restTemplate.postForObject(TOSS_CONFIRM_URL, requestEntity, Map.class); + log.info("Toss Payments 결제 승인 성공: orderId={}", orderId); + } catch (Exception e) { + log.error("Toss Payments API 연동 실패: {}", e.getMessage()); + throw new ServiceException("400-5", "결제 승인 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Basic " + Base64.getEncoder().encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8))); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + return headers; + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/restClient/RestTemplateConfig.java b/backend/src/main/java/com/Catch_Course/global/restClient/RestTemplateConfig.java new file mode 100644 index 0000000..1a46263 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/restClient/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.Catch_Course.global.restClient; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file From e215d1c728537dbd1dd7c3328f5e6f86718fde6d Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Thu, 14 Aug 2025 14:48:01 +0900 Subject: [PATCH 04/37] =?UTF-8?q?refactor:=20=EC=88=98=EA=B0=95=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EA=B2=B0=EC=A0=9C=20=EC=83=81=ED=83=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 19 +++++++++++++++++-- .../service/ReservationService.java | 17 +++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java b/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java index f428863..d6a28f6 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java @@ -54,9 +54,9 @@ public RsData cancelReservation(@RequestParam Long courseId) { ); } - @Operation(summary = "수강 목록 조회") + @Operation(summary = "수강 목록 조회(결제 완료)") @GetMapping("/me") - public RsData getReservations(@RequestParam(defaultValue = "1") int page, + public RsData getReservationsCompleted(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "5") int pageSize) { Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 @@ -69,4 +69,19 @@ public RsData getReservations(@RequestParam(defaultValue = "1") int pag ); } + @Operation(summary = "수강 목록 조회(결제 대기)") + @GetMapping("/me/pending") + public RsData getReservations(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "5") int pageSize) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + Page reservationPage = reservationService.getReservationsPending(member, page, pageSize); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + new PageDto(reservationPage) + ); + } + } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index 3e396ba..c829368 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -68,7 +68,7 @@ public Reservation addToQueue(Member member, Long courseId) { private void handleDuplicateReservation(Reservation reservation) { ReservationStatus status = reservation.getStatus(); - if (status.equals(ReservationStatus.COMPLETED)) { + if (status.equals(ReservationStatus.COMPLETED) || status.equals(ReservationStatus.PENDING)) { throw new ServiceException("409-1", "이미 신청한 강의입니다."); } else if (status.equals(ReservationStatus.WAITING)) { throw new ServiceException("409-3", "이미 대기열에 등록된 신청입니다."); @@ -108,6 +108,19 @@ public Page getReservations(Member member, int page, int pageSize) return reservations; } + @Transactional(readOnly = true) + public Page getReservationsPending(Member member, int page, int pageSize) { + Pageable pageable = PageRequest.of(page - 1, pageSize); + + Page reservations = reservationRepository.findAllByStudentAndStatus(member, ReservationStatus.PENDING, pageable); + + if (reservations.isEmpty()) { + throw new ServiceException("404-3", "수강신청 이력이 없습니다."); + } + + return reservations; + } + /** * Kafka Consumer에 의해 호출 될 실제 수강 신청 처리 메서드 */ @@ -136,7 +149,7 @@ public void processReservation(Long courseId, Long memberId) { course.increaseReservation(); courseRepository.save(course); - reservation.setStatus(ReservationStatus.COMPLETED); + reservation.setStatus(ReservationStatus.PENDING); NotificationDto notificationDto = new NotificationDto(reservation,"수강 신청이 성공하였습니다."); sseService.sendToClient(memberId,"ReservationResult", notificationDto); notificationService.saveNotification(memberId, notificationDto); From 991e7cbca27e1ead5037ec1ec5bf752f20250154 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Thu, 14 Aug 2025 15:14:53 +0900 Subject: [PATCH 05/37] =?UTF-8?q?refactor:=20reservation=EA=B3=BC=20paymen?= =?UTF-8?q?t=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/course/entity/Course.java | 1 + .../controller/PaymentController.java | 8 ++- .../repository/PaymentRepository.java | 6 ++- .../payments/service/PaymentService.java | 53 ++++++++++++------- .../repository/ReservationRepository.java | 7 --- .../service/ReservationService.java | 1 + 6 files changed, 46 insertions(+), 30 deletions(-) diff --git a/backend/src/main/java/com/Catch_Course/domain/course/entity/Course.java b/backend/src/main/java/com/Catch_Course/domain/course/entity/Course.java index be85e83..0988eaa 100644 --- a/backend/src/main/java/com/Catch_Course/domain/course/entity/Course.java +++ b/backend/src/main/java/com/Catch_Course/domain/course/entity/Course.java @@ -26,6 +26,7 @@ public class Course extends BaseTime { private String content; // 강의 내용 private long capacity; // 정원 private long currentRegistration; // 현재 등록 인원 + private Long price; public void canModify(Member member) { if (member == null) { diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java index 8d4d5ee..a02895c 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -25,16 +25,17 @@ public class PaymentController { private final Rq rq; - @Operation(summary = "결제 정보 조회") + @Operation(summary = "수강신청 정보로 결제 정보 조회") @GetMapping() public RsData getPayment(@RequestParam Long reservationId) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 PaymentDto paymentDto = paymentService.getPayment(member, reservationId); return new RsData<>( "200-1", - "신청 목록 조회가 완료되었습니다.", + "결제 정보 조회가 완료되었습니다.", paymentDto ); } @@ -42,6 +43,7 @@ public RsData getPayment(@RequestParam Long reservationId) { @Operation(summary = "결제 목록 조회") @GetMapping() public RsData> getPayments() { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 List paymentDtos = paymentService.getPayments(member); @@ -57,6 +59,7 @@ public RsData> getPayments() { @Operation(summary = "결제 생성 및 요청") @PostMapping("/request") public RsData requestPayment(@RequestParam Long reservationId) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 PaymentDto paymentDto = paymentService.requestPayment(member, reservationId); @@ -76,6 +79,7 @@ record confirmPaymentReqBody(@NotBlank String paymentKey, @Operation(summary = "결제 승인") @PostMapping("/confirm") public RsData confirmPayment(@RequestBody @Valid confirmPaymentReqBody body) { + PaymentDto paymentDto = paymentService.confirmPayment(body.paymentKey, body.orderId, body.amount); return new RsData<>( diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java index 13cdcdc..a61af37 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java @@ -1,6 +1,8 @@ package com.Catch_Course.domain.payments.repository; +import com.Catch_Course.domain.member.entity.Member; import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.entity.PaymentStatus; import com.Catch_Course.domain.reservation.entity.Reservation; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -12,7 +14,7 @@ public interface PaymentRepository extends JpaRepository { Optional findByReservation(Reservation reservation); - List findByReservationIn(List reservationList); - Optional findByMerchantUid(String orderId); + + List findByMemberAndStatus(Member member, PaymentStatus paymentStatus); } 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 d1d7fea..605ef78 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 @@ -31,23 +31,23 @@ public class PaymentService { private final TossPaymentsService tossPaymentsService; public PaymentDto getPayment(Member member, Long reservationId) { + // reservation 이력 조회 - Reservation reservation = reservationRepository.findByIdAndStudent(reservationId,member) - .orElseThrow(() -> new ServiceException("404-3","수강신청 이력이 없습니다.")); + Reservation reservation = reservationRepository.findByIdAndStudentAndStatus(reservationId, member, ReservationStatus.COMPLETED) + .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); Payment payment = paymentRepository.findByReservation(reservation) - .orElseThrow(() -> new ServiceException("404-5","결제 정보가 없습니다.")); + .orElseThrow(() -> new ServiceException("404-5", "결제 정보가 없습니다.")); return new PaymentDto(payment); } public List getPayments(Member member) { - List reservationList = reservationRepository.findByStudent(member); - List payments = paymentRepository.findByReservationIn(reservationList); + List payments = paymentRepository.findByMemberAndStatus(member, PaymentStatus.PAID); - if(payments.isEmpty()) { - throw new ServiceException("404-5","결제 정보가 없습니다."); + if (payments.isEmpty()) { + throw new ServiceException("404-5", "결제 정보가 없습니다."); } return payments.stream() @@ -57,7 +57,8 @@ public List getPayments(Member member) { @Transactional public PaymentDto requestPayment(Member member, Long reservationId) { - Reservation reservation = reservationService.findByIdAndStudent(reservationId,member); + + Reservation reservation = reservationService.findByIdAndStudent(reservationId, member); String merchantUid = UUID.randomUUID().toString(); long amount = reservation.getPrice(); @@ -76,21 +77,35 @@ public PaymentDto requestPayment(Member member, Long reservationId) { @Transactional public PaymentDto confirmPayment(String paymentKey, String orderId, Long amount) { + Payment payment = paymentRepository.findByMerchantUid(orderId) - .orElseThrow(() -> new ServiceException("404-5","결제 정보가 없습니다.")); + .orElseThrow(() -> new ServiceException("404-5", "결제 정보가 없습니다.")); - if(payment.getStatus() != PaymentStatus.PENDING) { - throw new ServiceException("409-2","이미 처리된 결제입니다."); + if (payment.getStatus() != PaymentStatus.PENDING) { + throw new ServiceException("409-2", "이미 처리된 결제입니다."); } - if(payment.getAmount() != amount){ - throw new ServiceException("400-4","결제 금액이 일치하지 않습니다."); + if (payment.getAmount() != amount) { + throw new ServiceException("400-4", "결제 금액이 일치하지 않습니다."); + } + try{ + // 최종 승인 + tossPaymentsService.confirm(paymentKey, orderId, amount); + + // 상태 변경 + payment.setStatus(PaymentStatus.PAID); + payment.setPaymentKey(paymentKey); + payment.getReservation().setStatus(ReservationStatus.COMPLETED); + } catch (ServiceException e) { + // 결제 실패 + // todo: 메세지를 남겨서 후처리 + log.error("결제 승인 실패: orderId={}, reason={}", orderId, e.getMessage()); + + payment.setStatus(PaymentStatus.FAILED); + throw new ServiceException("500-1", "결제 중 알 수 없는 오류가 발생했습니다."); + } finally { + // 상태 저장 + paymentRepository.save(payment); } - - tossPaymentsService.confirm(paymentKey,orderId,amount); - - payment.setStatus(PaymentStatus.PAID); - payment.setPaymentKey(paymentKey); - payment.getReservation().setStatus(ReservationStatus.COMPLETED); return new PaymentDto(paymentRepository.save(payment)); } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index c1c5f61..c18806f 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -10,7 +10,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.List; import java.util.Optional; @Repository @@ -21,12 +20,6 @@ public interface ReservationRepository extends JpaRepository @EntityGraph(attributePaths = {"course", "student"}) Page findAllByStudentAndStatus(Member member, ReservationStatus reservationStatus, Pageable pageable); - @EntityGraph(attributePaths = {"course", "student"}) - Optional findByIdAndStudent(Long reservationId, Member member); - - @EntityGraph(attributePaths = {"course", "student", "payment"}) - List findByStudent(Member member); - @EntityGraph(attributePaths = {"course", "student", "payment"}) Optional findByIdAndStudentAndStatus(Long reservationId, Member member, ReservationStatus reservationStatus); } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index c829368..3e10847 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -58,6 +58,7 @@ public Reservation addToQueue(Member member, Long courseId) { .student(member) .course(course) .status(ReservationStatus.WAITING) + .price(course.getPrice()) .build(); // 메세지 전송 From 8e35005917159b74f555b9549d59e3d1a0d87804 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Thu, 14 Aug 2025 15:32:54 +0900 Subject: [PATCH 06/37] =?UTF-8?q?fix:=20=EC=82=AC=EC=86=8C=ED=95=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payments/controller/PaymentController.java | 4 ++-- .../Catch_Course/global/security/CustomOauth2UserService.java | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java index a02895c..0759526 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -26,8 +26,8 @@ public class PaymentController { @Operation(summary = "수강신청 정보로 결제 정보 조회") - @GetMapping() - public RsData getPayment(@RequestParam Long reservationId) { + @GetMapping("/{reservationId}") + public RsData getPayment(@RequestBody Long reservationId) { Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 diff --git a/backend/src/main/java/com/Catch_Course/global/security/CustomOauth2UserService.java b/backend/src/main/java/com/Catch_Course/global/security/CustomOauth2UserService.java index 9b51630..995cbc5 100644 --- a/backend/src/main/java/com/Catch_Course/global/security/CustomOauth2UserService.java +++ b/backend/src/main/java/com/Catch_Course/global/security/CustomOauth2UserService.java @@ -61,8 +61,7 @@ private SocialInfo extractUserInfo(String providerType, String oauthId, Map Date: Thu, 14 Aug 2025 16:55:19 +0900 Subject: [PATCH 07/37] =?UTF-8?q?FE:=20=EA=B2=B0=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=BD=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/course/controller/CourseController.java | 5 +++-- .../domain/course/service/CourseService.java | 5 +++-- .../com/Catch_Course/global/init/BaseInitData.java | 10 +++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java b/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java index 0c3c6c9..82d6961 100644 --- a/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java +++ b/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java @@ -119,7 +119,8 @@ public RsData modify(@PathVariable long id, @RequestBody @Valid ModifyReqB record WriteReqBody( @NotBlank @Length(min = 3) String title, @NotBlank @Length(min = 3) String content, - @NotNull @Min(1) long capacity + @NotNull @Min(1) long capacity, + @NotNull @Min(1000) long price ) { } @@ -130,7 +131,7 @@ record WriteReqBody( public RsData write(@RequestBody @Valid WriteReqBody body) { Member dummyMember = rq.getDummyMember(); - Course course = courseService.write(dummyMember, body.title(), body.content(), body.capacity()); + Course course = courseService.write(dummyMember, body.title(), body.content(), body.capacity(),body.price()); return new RsData<>( "200-1", diff --git a/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java b/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java index 1769357..cc82e42 100644 --- a/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java +++ b/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java @@ -22,7 +22,7 @@ public class CourseService { private final CourseRepository courseRepository; @Transactional - public Course write(Member member, String title, String content, long capacity) { + public Course write(Member member, String title, String content, long capacity, long price) { return courseRepository.save( Course @@ -32,6 +32,7 @@ public Course write(Member member, String title, String content, long capacity) .content(content) .capacity(capacity) .currentRegistration(0) + .price(price) .build() ); } @@ -76,6 +77,6 @@ public void modify(Course course, String title, String content, long capacity) { public Course findById(long courseId) { return courseRepository.findById(courseId) - .orElseThrow(() -> new ServiceException("404-1","존재하지 않는 강의입니다.")); + .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); } } diff --git a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java index 4c62ea0..e2fb318 100644 --- a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java +++ b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java @@ -55,15 +55,15 @@ public void courseInit() { Member user2 = memberService.findByUsername("user2").get(); - courseService.write(user2, "수학 강의", "일반 수학 기초", 50); - courseService.write(user2, "국어 강의", "비문학 기초", 100); - courseService.write(user2, "인기 폭발 강의", "늦으면 없다", 1); + courseService.write(user2, "수학 강의", "일반 수학 기초", 50,10000); + courseService.write(user2, "국어 강의", "비문학 기초", 100,20000); + courseService.write(user2, "인기 폭발 강의", "늦으면 없다", 1,5000); for (int i = 2; i < 50; i++) { int userId = (i % 5) + 2; Member tempUser = memberService.findByUsername("user%d".formatted(userId)).get(); - courseService.write(tempUser, "강의 제목 %d".formatted(i), "강의 내용 %d".formatted(i), 50); + courseService.write(tempUser, "강의 제목 %d".formatted(i), "강의 내용 %d".formatted(i), 50,1000 * i); } - courseService.write(user2, "인기 폭발123 강의", "늦으면 없213다", 1); + courseService.write(user2, "인기 폭발123 강의", "늦으면 없213다", 1,500000); } } From 57ce46112ebefb5cb3c4b92b84060950e4420d48 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Thu, 14 Aug 2025 16:55:38 +0900 Subject: [PATCH 08/37] =?UTF-8?q?FE:=20=EA=B2=B0=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20UI=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/ClientLayout.tsx | 2 +- frontend/src/app/ClientPage.tsx | 20 ++- frontend/src/app/payment/fail/page.tsx | 64 +++++++ frontend/src/app/payment/success/page.tsx | 64 +++++++ frontend/src/app/toss/ClientPage.tsx | 196 ++++++++++++++++++++++ frontend/src/app/toss/page.tsx | 9 + frontend/src/middleware.ts | 2 +- 7 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/payment/fail/page.tsx create mode 100644 frontend/src/app/payment/success/page.tsx create mode 100644 frontend/src/app/toss/ClientPage.tsx create mode 100644 frontend/src/app/toss/page.tsx diff --git a/frontend/src/app/ClientLayout.tsx b/frontend/src/app/ClientLayout.tsx index 5aa4bc7..741e8a9 100644 --- a/frontend/src/app/ClientLayout.tsx +++ b/frontend/src/app/ClientLayout.tsx @@ -37,7 +37,7 @@ export default function ClinetLayout({ }; async function fetchLoginMember() { - const response = await client.GET("/api/members/me", { + const response = await client.GET("/api/profile/me", { credentials: "include", }); diff --git a/frontend/src/app/ClientPage.tsx b/frontend/src/app/ClientPage.tsx index 9705364..460b9db 100644 --- a/frontend/src/app/ClientPage.tsx +++ b/frontend/src/app/ClientPage.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { LoginMemberContext } from "@/stores/auth/loginMemberStore"; +// 경로를 상대 경로로 수정하여 오류를 해결합니다. +import { LoginMemberContext } from "../stores/auth/loginMemberStore"; import { use } from "react"; export default function Page() { @@ -8,16 +9,29 @@ export default function Page() { return ( <> + {/* 로그인이 되어있지 않은 경우, 카카오 로그인 버튼을 보여줍니다. */} {!isLogin && ( )} - {isLogin &&
{loginMember.nickname}님 환영합니다.
} + + {/* 로그인이 되어있는 경우, 환영 메시지와 함께 결제 페이지로 이동하는 버튼을 보여줍니다. */} + {isLogin && ( +
+
{loginMember.nickname}님, 환영합니다.
+

수강 신청을 완료하려면 결제를 진행해주세요.

+ +
+ )} ); } diff --git a/frontend/src/app/payment/fail/page.tsx b/frontend/src/app/payment/fail/page.tsx new file mode 100644 index 0000000..c2bed0a --- /dev/null +++ b/frontend/src/app/payment/fail/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Suspense, useEffect, useState } from 'react'; + +// Suspense로 감싸야 URL 파라미터를 안전하게 읽을 수 있습니다. +export default function FailPage() { + return ( + Loading...}> + + + ); +} + +function FailContent() { + // next/navigation 대신 브라우저 표준 API를 사용하도록 수정 + const [params, setParams] = useState({ + errorCode: '', + errorMessage: '', + orderId: '', + }); + + useEffect(() => { + const searchParams = new URLSearchParams(window.location.search); + setParams({ + errorCode: searchParams.get('code') || '', + errorMessage: searchParams.get('message') || '', + orderId: searchParams.get('orderId') || '', + }); + }, []); + + return ( +
+
+
+ + + +
+

결제 실패

+

결제 중 오류가 발생했습니다.

+ +
+
+ 주문번호: + {params.orderId} +
+
+ 오류코드: + {params.errorCode} +
+
+ 오류메시지: + {params.errorMessage} +
+
+ + {/* next/link 대신 표준 a 태그를 사용하도록 수정 */} + + 결제 페이지로 다시 시도 + +
+
+ ); +} diff --git a/frontend/src/app/payment/success/page.tsx b/frontend/src/app/payment/success/page.tsx new file mode 100644 index 0000000..738f460 --- /dev/null +++ b/frontend/src/app/payment/success/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Suspense, useEffect, useState } from 'react'; + +// Suspense로 감싸야 useSearchParams를 사용할 수 있습니다. +export default function SuccessPage() { + return ( + Loading...}> + + + ); +} + +function SuccessContent() { + // next/navigation 대신 브라우저 표준 API를 사용하도록 수정 + const [params, setParams] = useState({ + paymentKey: '', + orderId: '', + amount: '', + }); + + useEffect(() => { + const searchParams = new URLSearchParams(window.location.search); + setParams({ + paymentKey: searchParams.get('paymentKey') || '', + orderId: searchParams.get('orderId') || '', + amount: searchParams.get('amount') || '', + }); + }, []); + + return ( +
+
+
+ + + +
+

결제 성공

+

결제가 성공적으로 완료되었습니다.

+ +
+
+ 주문번호: + {params.orderId} +
+
+ 결제금액: + {Number(params.amount).toLocaleString()}원 +
+
+ 결제키: + {params.paymentKey} +
+
+ + {/* next/link 대신 표준 a 태그를 사용하도록 수정 */} + + 홈으로 돌아가기 + +
+
+ ); +} diff --git a/frontend/src/app/toss/ClientPage.tsx b/frontend/src/app/toss/ClientPage.tsx new file mode 100644 index 0000000..01323da --- /dev/null +++ b/frontend/src/app/toss/ClientPage.tsx @@ -0,0 +1,196 @@ +// ClientPage.tsx +"use client"; // 이 컴포넌트는 클라이언트 측에서 실행되어야 합니다. + +import React, { useState, useEffect } from 'react'; + +// --- 타입 정의 --- +interface TossPayments { + requestPayment: (paymentType: string, paymentData: PaymentData) => Promise; +} +interface PaymentData { + amount: number; + orderId: string; + orderName: string; + customerName: string; + successUrl: string; + failUrl: string; +} +interface SuccessData { + paymentKey: string; + orderId: string; + amount: number; +} +interface FailData { + errorCode: string; + errorMessage: string; + orderId: string; +} +interface PaymentDto { + merchantUid: string; + amount: number; + courseName: string; +} +declare global { + interface Window { + TossPayments: (clientKey: string) => TossPayments; + } +} + +// 컴포넌트 이름을 파일명과 일치시킵니다. +export default function ClientPage() { + // --- 상태 관리 --- + const [orderInfo, setOrderInfo] = useState(null); + const [tossPayments, setTossPayments] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [statusMessage, setStatusMessage] = useState('주문 정보를 불러오는 중...'); + + // --- 상수 정의 --- + const TOSS_CLIENT_KEY = 'test_ck_D5GePWvyJnrK0W0k6q8gLzN97Eoq'; + + // --- 함수: 백엔드에 결제 정보 생성 요청 --- + const fetchOrderInfo = async () => { + try { + // 실제로는 백엔드의 /api/payment/request API를 호출해야 합니다. + // 현재는 테스트를 위해 성공 응답을 시뮬레이션합니다. + const response: PaymentDto = { + merchantUid: `order_${new Date().getTime()}`, + amount: 35000, + courseName: '실전! 스프링 부트와 JPA 활용', + }; + setOrderInfo(response); + setStatusMessage('결제 준비가 완료되었습니다.'); + } catch (error) { + console.error('주문 정보 로딩 실패:', error); + setStatusMessage('주문 정보를 불러오는 데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // --- useEffect: SDK 로드 및 주문 정보 자동 요청 --- + useEffect(() => { + const script = document.createElement('script'); + script.src = 'https://js.tosspayments.com/v1'; + script.async = true; + document.head.appendChild(script); + + script.onload = () => { + if (window.TossPayments) { + const tossInstance = window.TossPayments(TOSS_CLIENT_KEY); + setTossPayments(tossInstance); + fetchOrderInfo(); + } + }; + + return () => { + document.head.removeChild(script); + }; + }, []); + + // --- 함수: 토스페이먼츠 결제창 호출 --- + const handleTossPayment = async () => { + if (!tossPayments || !orderInfo) return; + setIsLoading(true); + setStatusMessage('결제창을 호출하는 중...'); + try { + const result = await tossPayments.requestPayment('카드', { + amount: orderInfo.amount, + orderId: orderInfo.merchantUid, + orderName: orderInfo.courseName, + customerName: '김토스', // 실제로는 로그인된 사용자 이름 사용 + successUrl: `${window.location.origin}/payment/success`, // 성공 시 리다이렉트 될 URL + failUrl: `${window.location.origin}/payment/fail`, // 실패 시 리다이렉트 될 URL + }); + if ('paymentKey' in result) { + await handleConfirmPayment(result); + } else { + setStatusMessage(`결제 실패: ${result.errorMessage}`); + } + } catch (error) { + console.error('결제창 호출 오류:', error); + setStatusMessage('결제에 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // --- 함수: 백엔드에 최종 결제 승인 요청 --- + const handleConfirmPayment = async (data: SuccessData) => { + setStatusMessage('최종 결제를 승인하는 중...'); + try { + const response = await fetch('/api/payment/confirm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + paymentKey: data.paymentKey, + orderId: data.orderId, + amount: data.amount, + }), + }); + if (response.ok) { + setStatusMessage('결제가 성공적으로 완료되었습니다!'); + } else { + const errorData = await response.json(); + throw new Error(errorData.message || '결제 승인에 실패했습니다.'); + } + } catch (error) { + console.error('결제 승인 실패:', error); + setStatusMessage(String(error)); + } + } + + // --- UI 렌더링 --- + return ( +
+
+
+

결제하기

+

주문 내용을 확인 후 결제를 진행해주세요.

+
+ +
+ {isLoading ? ( +
+

{statusMessage}

+
+ ) : orderInfo ? ( + <> +
+ 주문명 + {orderInfo.courseName} +
+
+ 주문번호 + {orderInfo.merchantUid} +
+
+ 총 결제금액 + {orderInfo.amount.toLocaleString()}원 +
+ + ) : ( +
+

{statusMessage}

+
+ )} +
+ +
+ +
+ + {statusMessage.includes('완료') || statusMessage.includes('실패') ? ( +

+ {statusMessage} +

+ ) : null} +
+
+ ); +} diff --git a/frontend/src/app/toss/page.tsx b/frontend/src/app/toss/page.tsx new file mode 100644 index 0000000..4b5ea3f --- /dev/null +++ b/frontend/src/app/toss/page.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +// import 경로에 파일 확장자를 명시하여 모듈을 찾을 수 있도록 수정합니다. +import ClientPage from './ClientPage'; + +export default function TossPage() { + // /toss 경로로 접속하면 이 페이지가 보입니다. + // 실제 화면 내용은 ClientPage 컴포넌트가 담당합니다. + return ; +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index fc4de6d..edbb26d 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -20,7 +20,7 @@ export async function middleware(request: NextRequest) { async function refreshAccessToken() { const nextResponse = NextResponse.next(); - const response = await client.GET("/api/members/me", { + const response = await client.GET("/api/profile/me", { headers: { cookie: (await cookies()).toString(), }, From ae8aa0959b73de3c934cc82f7802385b79fb8579 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Thu, 14 Aug 2025 20:08:20 +0900 Subject: [PATCH 09/37] =?UTF-8?q?fix:=20=EA=B2=B0=EC=A0=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentController.java | 5 +- .../domain/payments/dto/PaymentDto.java | 4 + .../domain/payments/entity/Payment.java | 3 +- .../payments/service/PaymentService.java | 21 +- .../reservation/dto/ReservationDto.java | 2 + .../global/payment/TossPaymentsService.java | 20 +- frontend/next.config.ts | 10 + frontend/src/app/payment/fail/page.tsx | 64 ----- frontend/src/app/payment/success/page.tsx | 64 ----- frontend/src/app/toss/ClientPage.tsx | 264 ++++++++++-------- frontend/src/app/toss/fail/page.tsx | 40 +++ frontend/src/app/toss/success/page.tsx | 86 ++++++ .../src/components/business/ProfileMenu.tsx | 35 ++- 13 files changed, 348 insertions(+), 270 deletions(-) delete mode 100644 frontend/src/app/payment/fail/page.tsx delete mode 100644 frontend/src/app/payment/success/page.tsx create mode 100644 frontend/src/app/toss/fail/page.tsx create mode 100644 frontend/src/app/toss/success/page.tsx diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java index 0759526..5175e03 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.hibernate.validator.constraints.Length; import org.springframework.web.bind.annotation.*; @@ -27,7 +28,7 @@ public class PaymentController { @Operation(summary = "수강신청 정보로 결제 정보 조회") @GetMapping("/{reservationId}") - public RsData getPayment(@RequestBody Long reservationId) { + public RsData getPayment(@PathVariable Long reservationId) { Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 @@ -73,7 +74,7 @@ public RsData requestPayment(@RequestParam Long reservationId) { record confirmPaymentReqBody(@NotBlank String paymentKey, @NotBlank @Length(min = 3) String orderId, - @NotBlank @Length(min = 3) Long amount) { + @NotNull Long amount) { } @Operation(summary = "결제 승인") diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java index c1e908a..7a3cd27 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java @@ -9,6 +9,8 @@ @Getter public class PaymentDto { private long reservationId; + private String courseTitle; + private String instructor; private String orderId; private long amount; private String paymentKey; @@ -18,6 +20,8 @@ public class PaymentDto { public PaymentDto(Payment payment) { this.reservationId = payment.getReservation().getId(); + this.courseTitle = payment.getReservation().getCourse().getTitle(); + this.instructor = payment.getReservation().getCourse().getInstructor().getNickname(); this.orderId = payment.getMerchantUid(); this.amount = payment.getAmount(); this.paymentKey = payment.getPaymentKey(); diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java index 1653812..a883a69 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java @@ -22,7 +22,8 @@ public class Payment extends BaseTime { @JoinColumn(name = "reservation_id", nullable = false, unique = true) private Reservation reservation; - @OneToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) private Member member; @Column(unique = true, nullable = false) 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 605ef78..e2cc2bb 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 @@ -17,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; import java.util.UUID; @Service @@ -59,6 +60,21 @@ public List getPayments(Member member) { public PaymentDto requestPayment(Member member, Long reservationId) { Reservation reservation = reservationService.findByIdAndStudent(reservationId, member); + Optional opPayment = paymentRepository.findByReservation(reservation); + + if(opPayment.isPresent()) { + Payment payment = opPayment.get(); + if(payment.getStatus().equals(PaymentStatus.PAID)) { + throw new ServiceException("409-2", "이미 처리된 결제입니다."); + } else if(payment.getStatus().equals(PaymentStatus.CANCELLED)) { + // todo: 취소된 결제는 로그 남겨서 삭제처리 + throw new ServiceException("409-2", "취소된 결제입니다."); + } else{ + // 이미 테이블에 있는 경우(FAIL 이나 PENDING 상태) + payment.setStatus(PaymentStatus.PENDING); + return new PaymentDto(paymentRepository.save(payment)); + } + } String merchantUid = UUID.randomUUID().toString(); long amount = reservation.getPrice(); @@ -71,11 +87,10 @@ public PaymentDto requestPayment(Member member, Long reservationId) { .status(PaymentStatus.PENDING) .build(); - Payment savedPayment = paymentRepository.save(payment); - return new PaymentDto(savedPayment); + return new PaymentDto(paymentRepository.save(payment)); } - @Transactional + @Transactional(noRollbackFor = ServiceException.class) public PaymentDto confirmPayment(String paymentKey, String orderId, Long amount) { Payment payment = paymentRepository.findByMerchantUid(orderId) diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java b/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java index acc339b..c08a8ec 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java @@ -8,6 +8,7 @@ @Getter public class ReservationDto { + private long reservationId; private long courseId; // 강의 id private String courseTitle; // 강의 제목 private long studentId; // 학생 id @@ -18,6 +19,7 @@ public class ReservationDto { private LocalDateTime createdDate; public ReservationDto(Reservation reservation) { + this.reservationId = reservation.getId(); this.courseId = reservation.getCourse().getId(); this.courseTitle = reservation.getCourse().getTitle(); this.studentId = reservation.getStudent().getId(); diff --git a/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java b/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java index bab4a53..503ffe4 100644 --- a/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java +++ b/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java @@ -1,7 +1,6 @@ package com.Catch_Course.global.payment; import com.Catch_Course.global.exception.ServiceException; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; @@ -17,12 +16,16 @@ @Slf4j @Service -@RequiredArgsConstructor public class TossPaymentsService { - @Value("${custom.toss.payment.secret}") - private String secretKey; + + private final String secretKey; private final RestTemplate restTemplate; - private final String TOSS_CONFIRM_URL = "https://api.tosspayments.com/v1/payments/confirm"; + private static final String TOSS_CONFIRM_URL = "https://api.tosspayments.com/v1/payments/confirm"; + + public TossPaymentsService(@Value("${custom.toss.payment.secret}") String secretKey, RestTemplate restTemplate) { + this.secretKey = secretKey; + this.restTemplate = restTemplate; + } public void confirm(String paymentKey, String orderId, Long amount) { log.info("Toss Payments 결제 승인 요청 시작: orderId={}", orderId); @@ -37,6 +40,9 @@ public void confirm(String paymentKey, String orderId, Long amount) { HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); try { + // [추가] API 호출 직전, 실제 전송되는 헤더 값을 로그로 확인합니다. + log.info("전송될 Authorization 헤더: {}", headers.getFirst("Authorization")); + // API 호출 restTemplate.postForObject(TOSS_CONFIRM_URL, requestEntity, Map.class); log.info("Toss Payments 결제 승인 성공: orderId={}", orderId); @@ -48,7 +54,9 @@ public void confirm(String paymentKey, String orderId, Long amount) { private HttpHeaders createHeaders() { HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Basic " + Base64.getEncoder().encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8))); + // Base64 인코딩 시, 생성자에서 주입받은 final secretKey를 사용합니다. + String encodedKey = Base64.getEncoder().encodeToString((this.secretKey.trim() + ":").getBytes(StandardCharsets.UTF_8)); + headers.set("Authorization", "Basic " + encodedKey); headers.setContentType(MediaType.APPLICATION_JSON); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); return headers; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 0682c63..7987247 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,6 +1,16 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // API 요청을 백엔드(localhost:8080)로 전달하기 위한 프록시 설정 + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://localhost:8080/api/:path*", + }, + ]; + }, + // 기존 이미지 설정 유지 images: { dangerouslyAllowSVG: true, remotePatterns: [ diff --git a/frontend/src/app/payment/fail/page.tsx b/frontend/src/app/payment/fail/page.tsx deleted file mode 100644 index c2bed0a..0000000 --- a/frontend/src/app/payment/fail/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { Suspense, useEffect, useState } from 'react'; - -// Suspense로 감싸야 URL 파라미터를 안전하게 읽을 수 있습니다. -export default function FailPage() { - return ( - Loading...}> - - - ); -} - -function FailContent() { - // next/navigation 대신 브라우저 표준 API를 사용하도록 수정 - const [params, setParams] = useState({ - errorCode: '', - errorMessage: '', - orderId: '', - }); - - useEffect(() => { - const searchParams = new URLSearchParams(window.location.search); - setParams({ - errorCode: searchParams.get('code') || '', - errorMessage: searchParams.get('message') || '', - orderId: searchParams.get('orderId') || '', - }); - }, []); - - return ( -
-
-
- - - -
-

결제 실패

-

결제 중 오류가 발생했습니다.

- -
-
- 주문번호: - {params.orderId} -
-
- 오류코드: - {params.errorCode} -
-
- 오류메시지: - {params.errorMessage} -
-
- - {/* next/link 대신 표준 a 태그를 사용하도록 수정 */} - - 결제 페이지로 다시 시도 - -
-
- ); -} diff --git a/frontend/src/app/payment/success/page.tsx b/frontend/src/app/payment/success/page.tsx deleted file mode 100644 index 738f460..0000000 --- a/frontend/src/app/payment/success/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { Suspense, useEffect, useState } from 'react'; - -// Suspense로 감싸야 useSearchParams를 사용할 수 있습니다. -export default function SuccessPage() { - return ( - Loading...}> - - - ); -} - -function SuccessContent() { - // next/navigation 대신 브라우저 표준 API를 사용하도록 수정 - const [params, setParams] = useState({ - paymentKey: '', - orderId: '', - amount: '', - }); - - useEffect(() => { - const searchParams = new URLSearchParams(window.location.search); - setParams({ - paymentKey: searchParams.get('paymentKey') || '', - orderId: searchParams.get('orderId') || '', - amount: searchParams.get('amount') || '', - }); - }, []); - - return ( -
-
-
- - - -
-

결제 성공

-

결제가 성공적으로 완료되었습니다.

- -
-
- 주문번호: - {params.orderId} -
-
- 결제금액: - {Number(params.amount).toLocaleString()}원 -
-
- 결제키: - {params.paymentKey} -
-
- - {/* next/link 대신 표준 a 태그를 사용하도록 수정 */} - - 홈으로 돌아가기 - -
-
- ); -} diff --git a/frontend/src/app/toss/ClientPage.tsx b/frontend/src/app/toss/ClientPage.tsx index 01323da..d4d46c5 100644 --- a/frontend/src/app/toss/ClientPage.tsx +++ b/frontend/src/app/toss/ClientPage.tsx @@ -1,5 +1,4 @@ -// ClientPage.tsx -"use client"; // 이 컴포넌트는 클라이언트 측에서 실행되어야 합니다. +"use client"; import React, { useState, useEffect } from 'react'; @@ -25,49 +24,127 @@ interface FailData { errorMessage: string; orderId: string; } -interface PaymentDto { - merchantUid: string; +// [수정] 백엔드 응답에 맞춰 reservationId 필드 추가 +interface ReservationDto { + reservationId: number; + courseId: number; + courseTitle: string; + studentId: number; + studentName: string; + status: string; + price: number; + createdDate: string; +} +interface PaymentRequestResponseDto { + reservationId: number; + orderId: string; amount: number; - courseName: string; + paymentKey: string | null; + status: string; + createdDate: string; +} +interface LoginMember { + nickname: string | null; } + declare global { interface Window { TossPayments: (clientKey: string) => TossPayments; } } -// 컴포넌트 이름을 파일명과 일치시킵니다. export default function ClientPage() { - // --- 상태 관리 --- - const [orderInfo, setOrderInfo] = useState(null); + const [pendingReservations, setPendingReservations] = useState([]); const [tossPayments, setTossPayments] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [statusMessage, setStatusMessage] = useState('주문 정보를 불러오는 중...'); + const [statusMessage, setStatusMessage] = useState('결제 대기 목록을 불러오는 중...'); + const [currentUser, setCurrentUser] = useState(null); + const [payingItemId, setPayingItemId] = useState(null); - // --- 상수 정의 --- - const TOSS_CLIENT_KEY = 'test_ck_D5GePWvyJnrK0W0k6q8gLzN97Eoq'; + const TOSS_CLIENT_KEY = 'test_ck_LlDJaYngroz40d27Z0bXVezGdRpX'; - // --- 함수: 백엔드에 결제 정보 생성 요청 --- - const fetchOrderInfo = async () => { + const fetchPendingReservations = async () => { + setIsLoading(true); + setStatusMessage('결제 대기 목록을 불러오는 중...'); try { - // 실제로는 백엔드의 /api/payment/request API를 호출해야 합니다. - // 현재는 테스트를 위해 성공 응답을 시뮬레이션합니다. - const response: PaymentDto = { - merchantUid: `order_${new Date().getTime()}`, - amount: 35000, - courseName: '실전! 스프링 부트와 JPA 활용', + const fetchOptions = { + credentials: 'include' as RequestCredentials, + headers: { 'Content-Type': 'application/json' } }; - setOrderInfo(response); - setStatusMessage('결제 준비가 완료되었습니다.'); + + const pendingRes = await fetch(`/api/reserve/me/pending?page=1&pageSize=10`, fetchOptions); + if (pendingRes.status === 401) { + setStatusMessage('로그인이 필요합니다. 다시 로그인해주세요.'); + return; + } + if (!pendingRes.ok) { + throw new Error(`결제 대기 목록을 불러오는 데 실패했습니다. (상태: ${pendingRes.status})`); + } + + const pendingData = await pendingRes.json(); + + if (!pendingData.data || !pendingData.data.items || pendingData.data.items.length === 0) { + setStatusMessage('결제할 항목이 없습니다.'); + setPendingReservations([]); + return; + } + + const reservations: ReservationDto[] = pendingData.data.items.map((item: any) => ({ + ...item, + createdDate: item.createdDatetime + })); + + setPendingReservations(reservations); + if (reservations.length > 0) { + setCurrentUser({ nickname: reservations[0].studentName }); + } + setStatusMessage(''); + } catch (error) { - console.error('주문 정보 로딩 실패:', error); - setStatusMessage('주문 정보를 불러오는 데 실패했습니다.'); + console.error('결제 대기 목록 조회 실패:', error); + setStatusMessage(error instanceof Error ? error.message : String(error)); } finally { setIsLoading(false); } }; - // --- useEffect: SDK 로드 및 주문 정보 자동 요청 --- + const handlePaymentRequest = async (reservation: ReservationDto) => { + if (!tossPayments) { + alert("결제 모듈이 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요."); + return; + } + setPayingItemId(reservation.reservationId); + try { + const fetchOptions = { + method: 'POST', + credentials: 'include' as RequestCredentials, + headers: { 'Content-Type': 'application/json' } + }; + + // [수정] reservationId 파라미터에 reservation.reservationId를 전달 + const requestRes = await fetch(`/api/payment/request?reservationId=${reservation.reservationId}`, fetchOptions); + if (!requestRes.ok) { + throw new Error(`결제 요청에 실패했습니다. (상태: ${requestRes.status})`); + } + const paymentData: PaymentRequestResponseDto = (await requestRes.json()).data; + + await tossPayments.requestPayment('카드', { + amount: paymentData.amount, + orderId: paymentData.orderId, + orderName: reservation.courseTitle, + customerName: reservation.studentName, + successUrl: `${window.location.origin}/toss/success`, + failUrl: `${window.location.origin}/toss/fail`, + }); + + } catch (error) { + console.error(`[${reservation.courseTitle}] 결제 처리 실패:`, error); + alert(`'${reservation.courseTitle}' 결제에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setPayingItemId(null); + } + }; + useEffect(() => { const script = document.createElement('script'); script.src = 'https://js.tosspayments.com/v1'; @@ -76,120 +153,69 @@ export default function ClientPage() { script.onload = () => { if (window.TossPayments) { - const tossInstance = window.TossPayments(TOSS_CLIENT_KEY); - setTossPayments(tossInstance); - fetchOrderInfo(); + setTossPayments(window.TossPayments(TOSS_CLIENT_KEY)); } }; + fetchPendingReservations(); + return () => { - document.head.removeChild(script); + if (document.head.contains(script)) { + document.head.removeChild(script); + } }; }, []); - // --- 함수: 토스페이먼츠 결제창 호출 --- - const handleTossPayment = async () => { - if (!tossPayments || !orderInfo) return; - setIsLoading(true); - setStatusMessage('결제창을 호출하는 중...'); - try { - const result = await tossPayments.requestPayment('카드', { - amount: orderInfo.amount, - orderId: orderInfo.merchantUid, - orderName: orderInfo.courseName, - customerName: '김토스', // 실제로는 로그인된 사용자 이름 사용 - successUrl: `${window.location.origin}/payment/success`, // 성공 시 리다이렉트 될 URL - failUrl: `${window.location.origin}/payment/fail`, // 실패 시 리다이렉트 될 URL - }); - if ('paymentKey' in result) { - await handleConfirmPayment(result); - } else { - setStatusMessage(`결제 실패: ${result.errorMessage}`); - } - } catch (error) { - console.error('결제창 호출 오류:', error); - setStatusMessage('결제에 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; - - // --- 함수: 백엔드에 최종 결제 승인 요청 --- - const handleConfirmPayment = async (data: SuccessData) => { - setStatusMessage('최종 결제를 승인하는 중...'); - try { - const response = await fetch('/api/payment/confirm', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - paymentKey: data.paymentKey, - orderId: data.orderId, - amount: data.amount, - }), - }); - if (response.ok) { - setStatusMessage('결제가 성공적으로 완료되었습니다!'); - } else { - const errorData = await response.json(); - throw new Error(errorData.message || '결제 승인에 실패했습니다.'); - } - } catch (error) { - console.error('결제 승인 실패:', error); - setStatusMessage(String(error)); - } - } - - // --- UI 렌더링 --- return ( -
-
+
+
-

결제하기

-

주문 내용을 확인 후 결제를 진행해주세요.

+

결제 대기 목록

+ {currentUser &&

{currentUser.nickname}님의 결제를 기다리는 수강 목록입니다.

}
-
+
{isLoading ? (
+

{statusMessage}

- ) : orderInfo ? ( - <> -
- 주문명 - {orderInfo.courseName} -
-
- 주문번호 - {orderInfo.merchantUid} -
-
- 총 결제금액 - {orderInfo.amount.toLocaleString()}원 -
- + ) : pendingReservations.length > 0 ? ( +
    + {pendingReservations.map((reservation) => ( + // [수정] React key를 reservation.reservationId로 변경 +
  • +
    +
    +
    +

    {reservation.courseTitle}

    +

    {reservation.price.toLocaleString()}원

    +
    +

    신청일: {new Date(reservation.createdDate).toLocaleDateString()}

    +
    +
    +

    상태: {reservation.status}

    +
    + +
    +
    +
    +
  • + ))} +
) : ( -
-

{statusMessage}

+
+

{statusMessage || '결제할 항목이 없습니다.'}

)}
- -
- -
- - {statusMessage.includes('완료') || statusMessage.includes('실패') ? ( -

- {statusMessage} -

- ) : null}
); diff --git a/frontend/src/app/toss/fail/page.tsx b/frontend/src/app/toss/fail/page.tsx new file mode 100644 index 0000000..cc51bfa --- /dev/null +++ b/frontend/src/app/toss/fail/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useSearchParams, useRouter } from 'next/navigation'; +import React, { Suspense } from 'react'; + +function FailComponent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const errorCode = searchParams.get('code'); + const errorMessage = searchParams.get('message'); + + return ( +
+
+ + + +

결제 실패

+
+

오류 코드: {errorCode}

+

오류 메시지: {errorMessage}

+
+ +
+
+ ); +} + +export default function FailPage() { + return ( + Loading...
}> + + + ) +} diff --git a/frontend/src/app/toss/success/page.tsx b/frontend/src/app/toss/success/page.tsx new file mode 100644 index 0000000..dd19c9a --- /dev/null +++ b/frontend/src/app/toss/success/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; + +// Next.js 라우터 훅 대신 표준 웹 API를 사용하도록 수정했습니다. +export default function SuccessPage() { + const [statusMessage, setStatusMessage] = useState("결제를 승인하는 중입니다..."); + const [isSuccess, setIsSuccess] = useState(null); + + useEffect(() => { + const confirmPayment = async () => { + // useSearchParams 훅 대신 URLSearchParams를 사용하여 쿼리 파라미터를 직접 파싱합니다. + const params = new URLSearchParams(window.location.search); + const paymentKey = params.get("paymentKey"); + const orderId = params.get("orderId"); + const amount = params.get("amount"); + + if (!paymentKey || !orderId || !amount) { + setStatusMessage("결제 승인에 필요한 정보가 URL에 없습니다."); + setIsSuccess(false); + return; + } + + try { + const fetchOptions = { + method: 'POST', + credentials: 'include' as RequestCredentials, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paymentKey, orderId, amount: Number(amount) }), + }; + + // 백엔드에 최종 결제 승인 요청 + const response = await fetch("/api/payment/confirm", fetchOptions); + const responseData = await response.json(); + + if (response.ok) { + setStatusMessage("결제가 성공적으로 완료되었습니다!"); + setIsSuccess(true); + } else { + // 백엔드에서 보낸 에러 메시지를 표시 + setStatusMessage(`결제 승인 실패: ${responseData.msg || '알 수 없는 오류가 발생했습니다.'}`); + setIsSuccess(false); + } + } catch (error) { + console.error("결제 승인 네트워크 오류:", error); + setStatusMessage(`결제 승인 중 오류가 발생했습니다: ${error instanceof Error ? error.message : String(error)}`); + setIsSuccess(false); + } + }; + + confirmPayment(); + }, []); // 컴포넌트가 마운트될 때 한 번만 실행 + + const handleGoHome = () => { + // useRouter 훅 대신 window.location을 사용하여 페이지를 이동합니다. + window.location.href = '/'; + }; + + return ( +
+
+ {isSuccess === null ? ( +
+ ) : isSuccess ? ( + + + + ) : ( + + + + )} +

+ {isSuccess ? "결제 완료" : "결제 실패"} +

+

{statusMessage}

+ +
+
+ ); +} diff --git a/frontend/src/components/business/ProfileMenu.tsx b/frontend/src/components/business/ProfileMenu.tsx index 6548eaf..bc5a7cc 100644 --- a/frontend/src/components/business/ProfileMenu.tsx +++ b/frontend/src/components/business/ProfileMenu.tsx @@ -20,29 +20,42 @@ export default function HomeMenu() { async function handleLogout(e: React.MouseEvent) { e.preventDefault(); - const response = await client.DELETE("/api/members/logout", { - credentials: "include", - }); + try { + // openapi-fetch 라이브러리의 표준 응답 형식에 맞춰 error 객체를 직접 구조분해합니다. + const { error } = await client.DELETE("/api/members/logout", { + credentials: "include", + }); - if (response.error) { - alert(response.error.msg); - return; - } + // API 요청 실패 시 error 객체에 정보가 담겨옵니다. + if (error) { + // error 객체에서 메시지를 추출하여 사용자에게 알립니다. + const errorMessage = (error as any).msg || '로그아웃에 실패했습니다.'; + alert(errorMessage); + return; + } + + // 에러가 없으면 성공으로 간주하고 로그아웃 처리를 진행합니다. + removeLoginMember(); + router.replace("/"); - removeLoginMember(); - router.replace("/"); + } catch (err) { + // 네트워크 문제 등 예기치 않은 오류 발생 시 처리합니다. + console.error("Logout failed:", err); + alert("로그아웃 중 예기치 않은 오류가 발생했습니다."); + } } return ( - {isLogin && ( + {/* 로그인 상태이고, loginMember 객체와 profileImageUrl이 모두 유효한 값일 때만 Image를 렌더링합니다. */} + {isLogin && loginMember?.profileImageUrl && (
프로필 이미지 Date: Thu, 14 Aug 2025 20:25:28 +0900 Subject: [PATCH 10/37] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=95=88=EB=90=98=EB=8A=94=EA=B1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/reservation/entity/Reservation.java | 7 +++++++ .../domain/course/controller/CourseControllerTest.java | 7 ++++--- .../reservation/controller/ReservationControllerTest.java | 8 ++++---- .../reservation/controller/ReservationTestHelper.java | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java index 4e087e6..2f01d29 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java @@ -37,4 +37,11 @@ public class Reservation extends BaseTime { @Column(nullable = false) private Long price; + + public Reservation(Member student, Course course, ReservationStatus status, Long price) { + this.student = student; + this.course = course; + this.status = status; + this.price = price; + } } diff --git a/backend/src/test/java/com/Catch_Course/domain/course/controller/CourseControllerTest.java b/backend/src/test/java/com/Catch_Course/domain/course/controller/CourseControllerTest.java index 851f9d9..37badf6 100644 --- a/backend/src/test/java/com/Catch_Course/domain/course/controller/CourseControllerTest.java +++ b/backend/src/test/java/com/Catch_Course/domain/course/controller/CourseControllerTest.java @@ -386,8 +386,8 @@ void modifyItem3() throws Exception { .andExpect(jsonPath("$.msg").value("존재하지 않는 강의입니다.")); } - private ResultActions writeRequest(String title, String content, long capacity) throws Exception { - Map requestBody = Map.of("title", title, "content", content, "capacity", capacity); + private ResultActions writeRequest(String title, String content, long capacity, long price) throws Exception { + Map requestBody = Map.of("title", title, "content", content, "capacity", capacity, "price", price); // Map -> Json 변환 ObjectMapper objectMapper = new ObjectMapper(); @@ -408,8 +408,9 @@ void write() throws Exception { String title = "테스트 제목"; String content = "테스트 내용"; long capacity = 100; + long price = 10000; - ResultActions resultActions = writeRequest(title, content, capacity); + ResultActions resultActions = writeRequest(title, content, capacity, price); resultActions .andExpect(status().isOk()) 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 f2834da..85dc325 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 @@ -138,12 +138,12 @@ void reserve() throws Exception { Course awaitCourse = courseService.findById(courseId); // DB 조회 Reservation reservation = reservationRepository.findByStudentAndCourse(loginedMember, awaitCourse).get(); - assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING); // RedisStream 확인 List events = notificationService.getNotifications(loginedMember.getId()); NotificationDto event = events.get(events.size() - 1); // 최신 이벤트 - assertThat(event.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + assertThat(event.getStatus()).isEqualTo(ReservationStatus.PENDING); assertThat(event.getMessage()).isEqualTo("수강 신청이 성공하였습니다."); assertThat(event.getCourseTitle()).isEqualTo(awaitCourse.getTitle()); } @@ -275,7 +275,7 @@ void cancelReservation4() throws Exception { } @Test - @DisplayName("신청 목록 조회") + @DisplayName("수강 목록 조회(결제 대기)") void getReservation() throws Exception { int page = 1; int pageSize = 5; @@ -285,7 +285,7 @@ void getReservation() throws Exception { reservationTestHelper.reserveSetUp(loginedMember, course); ResultActions resultActions = mvc.perform( - get("/api/reserve/me?page=%d&pageSize=%d".formatted(page, pageSize)) + get("/api/reserve/me/pending?page=%d&pageSize=%d".formatted(page, pageSize)) .header("Authorization", "Bearer " + token) ); diff --git a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java index 051e621..ea9e027 100644 --- a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java +++ b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java @@ -23,7 +23,7 @@ public class ReservationTestHelper { @Transactional(propagation = Propagation.REQUIRES_NEW) // 반드시 새로운 트랜잭션 생성 public void reserveSetUp(Member loginedMember, Course course) { - Reservation reservation = new Reservation(loginedMember,course, ReservationStatus.WAITING); // 대기열 등록 + Reservation reservation = new Reservation(loginedMember,course, ReservationStatus.WAITING, course.getPrice()); // 대기열 등록 reservationRepository.save(reservation); reservationService.processReservation(course.getId(), loginedMember.getId()); // 수강 신청 em.flush(); // DB에 적고 From 6facbcb42382cada63ad89b31b6dfe476a05f278 Mon Sep 17 00:00:00 2001 From: nokkae Date: Thu, 14 Aug 2025 20:38:39 +0900 Subject: [PATCH 11/37] rollback:workflows --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c705601..ce10503 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -71,7 +71,7 @@ jobs: # 2. 릴리스 : main/develop 브랜치로 Push될 때만 실행 makeTagAndRelease: name: Create Tag and Release - # if: github.event_name == 'push' + if: github.event_name == 'push' needs: backend-ci runs-on: ubuntu-latest permissions: @@ -101,7 +101,7 @@ jobs: # 3. 빌드 및 배포: main/develop 브랜치로 Push될 때만 실행 buildImageAndPush: name: 도커 이미지 빌드와 푸시 - # if: github.event_name == 'push' + if: github.event_name == 'push' needs: makeTagAndRelease runs-on: ubuntu-latest steps: From f2c8a0eb550d8182dafb65f24258006427509b68 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Fri, 15 Aug 2025 14:00:44 +0900 Subject: [PATCH 12/37] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentController.java | 14 +++++++ .../domain/payments/entity/CancelHistory.java | 26 +++++++++++++ .../repository/CancelHistoryRepository.java | 10 +++++ .../payments/service/PaymentService.java | 37 +++++++++++++++++++ .../global/payment/TossPaymentsService.java | 25 +++++++++++++ 5 files changed, 112 insertions(+) create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/entity/CancelHistory.java create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/repository/CancelHistoryRepository.java diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java index 5175e03..067a0df 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -90,4 +90,18 @@ public RsData confirmPayment(@RequestBody @Valid confirmPaymentReqBo ); } + @Operation(summary = "결제 취소") + @DeleteMapping("/{reservationId}") + public RsData getReservations(@PathVariable Long reservationId) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + PaymentDto paymentDto = paymentService.deletePayment(member, reservationId); + + return new RsData<>( + "200-1", + "결제 취소가 완료되었습니다.", + paymentDto + ); + } + } diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/CancelHistory.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/CancelHistory.java new file mode 100644 index 0000000..b3ab57d --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/CancelHistory.java @@ -0,0 +1,26 @@ +package com.Catch_Course.domain.payments.entity; + +import com.Catch_Course.global.entity.BaseTime; +import jakarta.persistence.Entity; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@SuperBuilder +public class CancelHistory extends BaseTime { + private Long paymentId; + private Long reservationId; + private String orderId; + private String memberNickname; + private String courseTitle; + private Long amount; +} + + diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/CancelHistoryRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/CancelHistoryRepository.java new file mode 100644 index 0000000..2855520 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/CancelHistoryRepository.java @@ -0,0 +1,10 @@ +package com.Catch_Course.domain.payments.repository; + +import com.Catch_Course.domain.payments.entity.CancelHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CancelHistoryRepository extends JpaRepository { + +} 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 e2cc2bb..fc125e5 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 @@ -2,8 +2,10 @@ import com.Catch_Course.domain.member.entity.Member; import com.Catch_Course.domain.payments.dto.PaymentDto; +import com.Catch_Course.domain.payments.entity.CancelHistory; import com.Catch_Course.domain.payments.entity.Payment; import com.Catch_Course.domain.payments.entity.PaymentStatus; +import com.Catch_Course.domain.payments.repository.CancelHistoryRepository; import com.Catch_Course.domain.payments.repository.PaymentRepository; import com.Catch_Course.domain.reservation.entity.Reservation; import com.Catch_Course.domain.reservation.entity.ReservationStatus; @@ -30,6 +32,7 @@ public class PaymentService { private final ReservationRepository reservationRepository; private final ReservationService reservationService; private final TossPaymentsService tossPaymentsService; + private final CancelHistoryRepository cancelHistoryRepository; public PaymentDto getPayment(Member member, Long reservationId) { @@ -124,4 +127,38 @@ public PaymentDto confirmPayment(String paymentKey, String orderId, Long amount) return new PaymentDto(paymentRepository.save(payment)); } + + @Transactional + public PaymentDto deletePayment(Member member, Long reservationId) { + // reservation 이력 조회 + Reservation reservation = reservationRepository.findByIdAndStudentAndStatus(reservationId, member, ReservationStatus.COMPLETED) + .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); + + Payment payment = paymentRepository.findByReservation(reservation) + .orElseThrow(() -> new ServiceException("404-5", "결제 정보가 없습니다.")); + + String cancelReason = "고객 요청"; + tossPaymentsService.cancel(payment.getPaymentKey(),cancelReason); + + // 상태 변경 + payment.setStatus(PaymentStatus.CANCELLED); + + // 신청 이력 삭제 + reservationService.cancelReserve(member,reservation.getCourse().getId()); + + // 삭제 이력 저장 + reservationService.saveDeleteHistory(member.getId(), reservation.getCourse().getId()); + + // 취소 이력 저장 + cancelHistoryRepository.save(CancelHistory.builder() + .paymentId(payment.getId()) + .reservationId(reservation.getId()) + .orderId(payment.getMerchantUid()) + .memberNickname(member.getNickname()) + .courseTitle(reservation.getCourse().getTitle()) + .amount(payment.getAmount()) + .build()); + + return new PaymentDto(paymentRepository.save(payment)); + } } diff --git a/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java b/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java index 503ffe4..ff06d4e 100644 --- a/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java +++ b/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java @@ -7,6 +7,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; import java.nio.charset.StandardCharsets; @@ -53,6 +54,7 @@ public void confirm(String paymentKey, String orderId, Long amount) { } private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); // Base64 인코딩 시, 생성자에서 주입받은 final secretKey를 사용합니다. String encodedKey = Base64.getEncoder().encodeToString((this.secretKey.trim() + ":").getBytes(StandardCharsets.UTF_8)); @@ -61,4 +63,27 @@ private HttpHeaders createHeaders() { headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); return headers; } + + + public void cancel(String paymentKey, String cancelReason) { + + log.info("Toss Payments 결제 취소 요청 시작: paymentKey={}", paymentKey); + + // 1. Toss Payments 취소 API URL + String url = "https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel"; + HttpHeaders headers = createHeaders(); + Map requestBody = Map.of("cancelReason", cancelReason); + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + try { + restTemplate.postForObject(url, requestEntity, Map.class); + log.info("Toss Payments 결제 취소 성공: paymentKey={}", paymentKey); + } catch (HttpClientErrorException e) { + log.error("Toss Payments API 연동 실패 (HTTP Status: {}): {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new ServiceException("400-6", "결제 취소 중 오류가 발생했습니다."); + } catch (Exception e) { + log.error("Toss Payments API 연동 중 알 수 없는 오류 발생: {}", e.getMessage()); + throw new ServiceException("500-1", "결제 취소 중 시스템 오류가 발생했습니다."); + } + } } From 9ecbb8ee6133cfb3f749601d34f779fa71499fa3 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Fri, 15 Aug 2025 16:01:03 +0900 Subject: [PATCH 13/37] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentController.java | 2 +- .../domain/payments/entity/PaymentStatus.java | 1 + .../payments/service/PaymentService.java | 64 ++++++++------ .../kafka/consumer/KafkaConsumerConfig.java | 33 +++++++ .../kafka/consumer/PaymentCancelConsumer.java | 85 +++++++++++++++++++ .../kafka/consumer/ReservationConsumer.java | 4 +- .../consumer/ReservationDeletedConsumer.java | 4 +- .../kafka/dto/PaymentCancelRequest.java | 27 ++++++ .../kafka/producer/PaymentCancelProducer.java | 22 +++++ .../producer/ReservationDeletedProducer.java | 2 +- .../kafka/producer/ReservationProducer.java | 2 +- 11 files changed, 213 insertions(+), 33 deletions(-) create mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/consumer/KafkaConsumerConfig.java create mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java create mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java create mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/producer/PaymentCancelProducer.java diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java index 067a0df..702aab8 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -99,7 +99,7 @@ public RsData getReservations(@PathVariable Long reservationId) { return new RsData<>( "200-1", - "결제 취소가 완료되었습니다.", + "결제 취소요청이 접수되었습니다.", paymentDto ); } diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java index 246f4d8..717b46f 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java @@ -3,6 +3,7 @@ public enum PaymentStatus { PENDING, // 결제 대기 PAID, // 결제 완료 + CANCEL_REQUESTED, // 결제 취소 요청 CANCELLED, // 결제 취소 FAILED, // 결제 실패 } 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 fc125e5..281dd25 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 @@ -2,21 +2,25 @@ import com.Catch_Course.domain.member.entity.Member; import com.Catch_Course.domain.payments.dto.PaymentDto; -import com.Catch_Course.domain.payments.entity.CancelHistory; import com.Catch_Course.domain.payments.entity.Payment; import com.Catch_Course.domain.payments.entity.PaymentStatus; -import com.Catch_Course.domain.payments.repository.CancelHistoryRepository; import com.Catch_Course.domain.payments.repository.PaymentRepository; import com.Catch_Course.domain.reservation.entity.Reservation; import com.Catch_Course.domain.reservation.entity.ReservationStatus; import com.Catch_Course.domain.reservation.repository.ReservationRepository; import com.Catch_Course.domain.reservation.service.ReservationService; import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.kafka.dto.PaymentCancelRequest; +import com.Catch_Course.global.kafka.producer.PaymentCancelProducer; import com.Catch_Course.global.payment.TossPaymentsService; 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; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import java.util.List; import java.util.Optional; @@ -32,7 +36,10 @@ public class PaymentService { private final ReservationRepository reservationRepository; private final ReservationService reservationService; private final TossPaymentsService tossPaymentsService; - private final CancelHistoryRepository cancelHistoryRepository; + private final PaymentCancelProducer paymentCancelProducer; + private final ApplicationEventPublisher eventPublisher; + + public PaymentDto getPayment(Member member, Long reservationId) { @@ -65,14 +72,14 @@ public PaymentDto requestPayment(Member member, Long reservationId) { Reservation reservation = reservationService.findByIdAndStudent(reservationId, member); Optional opPayment = paymentRepository.findByReservation(reservation); - if(opPayment.isPresent()) { + if (opPayment.isPresent()) { Payment payment = opPayment.get(); - if(payment.getStatus().equals(PaymentStatus.PAID)) { + if (payment.getStatus().equals(PaymentStatus.PAID)) { throw new ServiceException("409-2", "이미 처리된 결제입니다."); - } else if(payment.getStatus().equals(PaymentStatus.CANCELLED)) { + } else if (payment.getStatus().equals(PaymentStatus.CANCELLED)) { // todo: 취소된 결제는 로그 남겨서 삭제처리 - throw new ServiceException("409-2", "취소된 결제입니다."); - } else{ + throw new ServiceException("409-2", "이미 취소된 결제입니다."); + } else { // 이미 테이블에 있는 경우(FAIL 이나 PENDING 상태) payment.setStatus(PaymentStatus.PENDING); return new PaymentDto(paymentRepository.save(payment)); @@ -105,7 +112,7 @@ public PaymentDto confirmPayment(String paymentKey, String orderId, Long amount) if (payment.getAmount() != amount) { throw new ServiceException("400-4", "결제 금액이 일치하지 않습니다."); } - try{ + try { // 최종 승인 tossPaymentsService.confirm(paymentKey, orderId, amount); @@ -137,28 +144,29 @@ public PaymentDto deletePayment(Member member, Long reservationId) { Payment payment = paymentRepository.findByReservation(reservation) .orElseThrow(() -> new ServiceException("404-5", "결제 정보가 없습니다.")); - String cancelReason = "고객 요청"; - tossPaymentsService.cancel(payment.getPaymentKey(),cancelReason); - - // 상태 변경 - payment.setStatus(PaymentStatus.CANCELLED); - - // 신청 이력 삭제 - reservationService.cancelReserve(member,reservation.getCourse().getId()); + // 이미 취소 요청 중 + if (payment.getStatus() == PaymentStatus.CANCEL_REQUESTED) { + throw new ServiceException("409-4", "이미 취소 처리중인 결제입니다."); + } else if (payment.getStatus() == PaymentStatus.CANCELLED) { + throw new ServiceException("409-2", "이미 취소된 결제입니다."); + } - // 삭제 이력 저장 - reservationService.saveDeleteHistory(member.getId(), reservation.getCourse().getId()); + // 취소 요청 상태 + payment.setStatus(PaymentStatus.CANCEL_REQUESTED); - // 취소 이력 저장 - cancelHistoryRepository.save(CancelHistory.builder() - .paymentId(payment.getId()) - .reservationId(reservation.getId()) - .orderId(payment.getMerchantUid()) - .memberNickname(member.getNickname()) - .courseTitle(reservation.getCourse().getTitle()) - .amount(payment.getAmount()) - .build()); + // 메세지 직접 발행 대신 내부 이벤트로 발행 + String cancelReason = "고객 요청"; + PaymentCancelRequest request = new PaymentCancelRequest(payment, cancelReason, reservation, member); + eventPublisher.publishEvent(request); return new PaymentDto(paymentRepository.save(payment)); } + + // deletePayment 메서드가 커밋면 메세지 발행 + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.NOT_SUPPORTED) // 트랜잭션에서 제외 + public void handlePaymentCancelEvent(PaymentCancelRequest request) { + log.info("DB 트랜잭션 커밋 완료. Kafka 에 결제 취소 메시지를 발행. DTO: {}", request); + paymentCancelProducer.send(request); + } } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/KafkaConsumerConfig.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/KafkaConsumerConfig.java new file mode 100644 index 0000000..2cb145d --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/KafkaConsumerConfig.java @@ -0,0 +1,33 @@ +package com.Catch_Course.global.kafka.consumer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.util.backoff.FixedBackOff; + +@Configuration +@EnableKafka +public class KafkaConsumerConfig { + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( + ConsumerFactory consumerFactory, + KafkaTemplate kafkaTemplate + ) { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory); + + // 10초 간격으로 최대 3번 재시도, 모두 실패하면 DLT로 메시지 전송 + DefaultErrorHandler errorHandler = new DefaultErrorHandler( + new DeadLetterPublishingRecoverer(kafkaTemplate), + new FixedBackOff(10000L, 2) // (10초 간격, 최대 2번 + 최초 1번 = 총 3번 시도) + ); + factory.setCommonErrorHandler(errorHandler); + + return factory; + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java new file mode 100644 index 0000000..0dc9a5f --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java @@ -0,0 +1,85 @@ +package com.Catch_Course.global.kafka.consumer; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.member.repository.MemberRepository; +import com.Catch_Course.domain.payments.entity.CancelHistory; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.entity.PaymentStatus; +import com.Catch_Course.domain.payments.repository.CancelHistoryRepository; +import com.Catch_Course.domain.payments.repository.PaymentRepository; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.domain.reservation.service.ReservationService; +import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.kafka.dto.PaymentCancelRequest; +import com.Catch_Course.global.payment.TossPaymentsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentCancelConsumer { + private final TossPaymentsService tossPaymentsService; + private final PaymentRepository paymentRepository; + private final ReservationRepository reservationRepository; + private final MemberRepository memberRepository; + private final ReservationService reservationService; + private final CancelHistoryRepository cancelHistoryRepository; + + // 구독 + @KafkaListener(topics = "payment_cancel", groupId = "course", errorHandler = "myErrorHandler") + @Transactional + public void consume(PaymentCancelRequest paymentCancelRequest) { + log.info("결제 취소 요청 처리 시작: {}", paymentCancelRequest); + + Payment payment = paymentRepository.findById(paymentCancelRequest.getPaymentId()) + .orElseThrow(() -> new ServiceException("404-5","결제 정보가 존재하지 않습니다.")); + + if(payment.getStatus() == PaymentStatus.CANCELLED) { + log.warn("이미 취소 처리된 결제입니다. 중복 처리를 방지합니다. paymentId: {}", paymentCancelRequest.getPaymentId()); + return; + } + + // 외부 API 요청 + tossPaymentsService.cancel(payment.getPaymentKey(),paymentCancelRequest.getCancelReason()); + + // DB 후처리 로직 + cancelProcess(payment,paymentCancelRequest); + } + + public void cancelProcess(Payment payment,PaymentCancelRequest paymentCancelRequest) { + + Reservation reservation = reservationRepository.findById(paymentCancelRequest.getReservationId()) + .orElseThrow(() -> new ServiceException("404-3","수강신청 이력이 없습니다.")); + + Member member = memberRepository.findById(paymentCancelRequest.getMemberId()) + .orElseThrow(() -> new ServiceException("404-4","회원을 찾을 수 없습니다.")); + + // 상태 변경 + payment.setStatus(PaymentStatus.CANCELLED); + + // 예약 취소 + reservationService.cancelReserve(member, reservation.getCourse().getId()); + + // 결제 취소 이력 저장 + saveCancelHistory(payment, reservation, member); + + log.info("결제 취소 DB 후속 처리 완료: paymentId={}", paymentCancelRequest.getPaymentId()); + } + + public void saveCancelHistory(Payment payment, Reservation reservation, Member member) { + + cancelHistoryRepository.save(CancelHistory.builder() + .paymentId(payment.getId()) + .reservationId(reservation.getId()) + .orderId(payment.getMerchantUid()) + .memberNickname(member.getNickname()) + .courseTitle(reservation.getCourse().getTitle()) + .amount(payment.getAmount()) + .build()); + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java index b35918e..f72a733 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Component @@ -16,8 +17,9 @@ public class ReservationConsumer { // 구독 @KafkaListener(topics = "course-reservation", groupId = "course", errorHandler = "myErrorHandler") + @Transactional public void consume(ReservationRequest reservationRequest) { - log.info("Consuming reservation request: {}", reservationRequest); + log.info("수강신청 처리 시작 : {}", reservationRequest); reservationService.processReservation(reservationRequest.getCourseId(),reservationRequest.getMemberId()); } } \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java index d83c5dc..b8f5649 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Component @@ -16,9 +17,10 @@ public class ReservationDeletedConsumer { // 구독 @KafkaListener(topics = "course-reservation-deleted", groupId = "course") + @Transactional public void consume(ReservationDeletedRequest reservationDeletedRequest) { - log.info("Consuming reservation request: {}", reservationDeletedRequest); + log.info("수강 취소 이력 저장 처리 시작: {}", reservationDeletedRequest); try { // 실제 로직 호출 reservationService.saveDeleteHistory(reservationDeletedRequest.getMemberId(), reservationDeletedRequest.getCourseId()); diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java b/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java new file mode 100644 index 0000000..bb51352 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java @@ -0,0 +1,27 @@ +package com.Catch_Course.global.kafka.dto; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.reservation.entity.Reservation; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class PaymentCancelRequest { + private Long paymentId; + private String paymentKey; + private String cancelReason; + private Long reservationId; + private Long memberId; + + public PaymentCancelRequest(Payment payment, String cancelReason, Reservation reservation, Member member) { + this.paymentId = payment.getId(); + this.paymentKey = payment.getPaymentKey(); + this.cancelReason = cancelReason; + this.reservationId = reservation.getId(); + this.memberId = member.getId(); + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/PaymentCancelProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/PaymentCancelProducer.java new file mode 100644 index 0000000..2b2a8c1 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/PaymentCancelProducer.java @@ -0,0 +1,22 @@ +package com.Catch_Course.global.kafka.producer; + +import com.Catch_Course.global.kafka.dto.PaymentCancelRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PaymentCancelProducer { + + private final KafkaTemplate kafkaTemplate; + private static final String TOPIC = "payment_cancel"; + + public void send(PaymentCancelRequest paymentCancelRequest) { + String key = String.valueOf(paymentCancelRequest.getPaymentId()); + log.info("결제 취소 요청 메세지 전송: {}", paymentCancelRequest); + kafkaTemplate.send(TOPIC, key, paymentCancelRequest); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java index d0a9f4d..d104053 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java @@ -17,7 +17,7 @@ public class ReservationDeletedProducer { private static final String TOPIC = "course-reservation-deleted"; public void send(ReservationDeletedRequest request) { - log.info("Sending reservation deleted request: {}", request); + log.info("취소 이력 저장 요청 메세지 전송: {}", request); kafkaTemplate.send(TOPIC, request); } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationProducer.java index eb7e4ee..1fee99d 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationProducer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationProducer.java @@ -15,7 +15,7 @@ public class ReservationProducer { private static final String TOPIC = "course-reservation"; public void send(String key, ReservationRequest reservationRequest) { - log.info("Sending reservation request: {}", reservationRequest); + log.info("수강신청 요청 메세지 전송: {}", reservationRequest); kafkaTemplate.send(TOPIC,key, reservationRequest); } } \ No newline at end of file From 7bd98b103373e7a54cc25794f8d4656267c636a7 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Fri, 15 Aug 2025 17:41:33 +0900 Subject: [PATCH 14/37] =?UTF-8?q?fix:=20=EC=88=98=EA=B0=95=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=B7=A8=EC=86=8C=20->=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=BD?= =?UTF-8?q?=ED=97=98=20=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentController.java | 2 +- .../payments/service/PaymentService.java | 10 +-- .../controller/ReservationController.java | 4 +- .../reservation/entity/ReservationStatus.java | 3 +- .../service/ReservationService.java | 75 +++++++++---------- .../kafka/consumer/PaymentCancelConsumer.java | 10 +-- .../consumer/ReservationCancelConsumer.java | 63 ++++++++++++++++ .../consumer/ReservationDeletedConsumer.java | 33 -------- .../kafka/dto/PaymentCancelRequest.java | 2 + ...est.java => ReservationCancelRequest.java} | 3 +- ...er.java => ReservationCancelProducer.java} | 10 +-- 11 files changed, 120 insertions(+), 95 deletions(-) create mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java delete mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java rename backend/src/main/java/com/Catch_Course/global/kafka/dto/{ReservationDeletedRequest.java => ReservationCancelRequest.java} (76%) rename backend/src/main/java/com/Catch_Course/global/kafka/producer/{ReservationDeletedProducer.java => ReservationCancelProducer.java} (58%) diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java index 702aab8..ba8986e 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -95,7 +95,7 @@ public RsData confirmPayment(@RequestBody @Valid confirmPaymentReqBo public RsData getReservations(@PathVariable Long reservationId) { Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 - PaymentDto paymentDto = paymentService.deletePayment(member, reservationId); + PaymentDto paymentDto = paymentService.deletePaymentRequest(member, reservationId); return new RsData<>( "200-1", 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 281dd25..5ca4828 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 @@ -8,7 +8,6 @@ import com.Catch_Course.domain.reservation.entity.Reservation; import com.Catch_Course.domain.reservation.entity.ReservationStatus; import com.Catch_Course.domain.reservation.repository.ReservationRepository; -import com.Catch_Course.domain.reservation.service.ReservationService; import com.Catch_Course.global.exception.ServiceException; import com.Catch_Course.global.kafka.dto.PaymentCancelRequest; import com.Catch_Course.global.kafka.producer.PaymentCancelProducer; @@ -34,13 +33,10 @@ public class PaymentService { private final PaymentRepository paymentRepository; private final ReservationRepository reservationRepository; - private final ReservationService reservationService; private final TossPaymentsService tossPaymentsService; private final PaymentCancelProducer paymentCancelProducer; private final ApplicationEventPublisher eventPublisher; - - public PaymentDto getPayment(Member member, Long reservationId) { // reservation 이력 조회 @@ -69,7 +65,9 @@ public List getPayments(Member member) { @Transactional public PaymentDto requestPayment(Member member, Long reservationId) { - Reservation reservation = reservationService.findByIdAndStudent(reservationId, member); + Reservation reservation = reservationRepository.findByIdAndStudentAndStatus(reservationId, member, ReservationStatus.PENDING) + .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); + Optional opPayment = paymentRepository.findByReservation(reservation); if (opPayment.isPresent()) { @@ -136,7 +134,7 @@ public PaymentDto confirmPayment(String paymentKey, String orderId, Long amount) } @Transactional - public PaymentDto deletePayment(Member member, Long reservationId) { + public PaymentDto deletePaymentRequest(Member member, Long reservationId) { // reservation 이력 조회 Reservation reservation = reservationRepository.findByIdAndStudentAndStatus(reservationId, member, ReservationStatus.COMPLETED) .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java b/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java index d6a28f6..2ad4042 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java @@ -45,11 +45,11 @@ public RsData reserve(@RequestParam Long courseId) { public RsData cancelReservation(@RequestParam Long courseId) { Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 - ReservationDto reservationDto = reservationService.cancelReserve(member, courseId); + ReservationDto reservationDto = reservationService.cancelReserveRequest(member, courseId); return new RsData<>( "200-1", - "수강 취소되었습니다.", + "수강 취소 요청이 접수되었습니다.", reservationDto ); } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java index 8850d7d..0e1d81b 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java @@ -4,5 +4,6 @@ public enum ReservationStatus { COMPLETED, // 신청 완료 WAITING, // 대기 FAILED, // 실패 - PENDING // 결제 대기 + PENDING, // 결제 대기 + CANCELLED } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index 3e10847..1fc716b 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -6,16 +6,15 @@ import com.Catch_Course.domain.member.repository.MemberRepository; import com.Catch_Course.domain.notification.dto.NotificationDto; import com.Catch_Course.domain.notification.service.NotificationService; +import com.Catch_Course.domain.payments.service.PaymentService; import com.Catch_Course.domain.reservation.dto.ReservationDto; -import com.Catch_Course.domain.reservation.entity.DeletedHistory; import com.Catch_Course.domain.reservation.entity.Reservation; import com.Catch_Course.domain.reservation.entity.ReservationStatus; -import com.Catch_Course.domain.reservation.repository.DeleteHistoryRepository; import com.Catch_Course.domain.reservation.repository.ReservationRepository; import com.Catch_Course.global.exception.ServiceException; -import com.Catch_Course.global.kafka.dto.ReservationDeletedRequest; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; import com.Catch_Course.global.kafka.dto.ReservationRequest; -import com.Catch_Course.global.kafka.producer.ReservationDeletedProducer; +import com.Catch_Course.global.kafka.producer.ReservationCancelProducer; import com.Catch_Course.global.kafka.producer.ReservationProducer; import com.Catch_Course.global.sse.service.SseService; import lombok.RequiredArgsConstructor; @@ -38,10 +37,10 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final MemberRepository memberRepository; private final ReservationProducer reservationProducer; - private final ReservationDeletedProducer reservationDeletedProducer; - private final DeleteHistoryRepository deleteHistoryRepository; + private final ReservationCancelProducer reservationCancelProducer; private final NotificationService notificationService; private final SseService sseService; + private final PaymentService paymentService; public Reservation addToQueue(Member member, Long courseId) { Course course = courseRepository.findByIdWithPessimisticLock(courseId) // 비관적 Lock 을 걸고 조회 @@ -49,7 +48,7 @@ public Reservation addToQueue(Member member, Long courseId) { Optional optionalReservation = reservationRepository.findByStudentAndCourse(member, course); if (optionalReservation.isPresent()) { - handleDuplicateReservation(optionalReservation.get()); + handleDuplicateReservation(optionalReservation.get(), member, courseId); return optionalReservation.get(); } @@ -66,17 +65,22 @@ public Reservation addToQueue(Member member, Long courseId) { return reservationRepository.save(reservation); } - private void handleDuplicateReservation(Reservation reservation) { + private void handleDuplicateReservation(Reservation reservation, Member member, Long courseId) { ReservationStatus status = reservation.getStatus(); if (status.equals(ReservationStatus.COMPLETED) || status.equals(ReservationStatus.PENDING)) { throw new ServiceException("409-1", "이미 신청한 강의입니다."); } else if (status.equals(ReservationStatus.WAITING)) { throw new ServiceException("409-3", "이미 대기열에 등록된 신청입니다."); + } else { + // 대기열 등록 + reservation.setStatus(ReservationStatus.WAITING); + reservationProducer.send(String.valueOf(courseId), new ReservationRequest(member.getId(), courseId)); + reservationRepository.save(reservation); } } - public ReservationDto cancelReserve(Member member, Long courseId) { + public ReservationDto cancelReserveRequest(Member member, Long courseId) { Course course = courseRepository.findByIdWithPessimisticLock(courseId) // 잠금 .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); @@ -84,16 +88,20 @@ public ReservationDto cancelReserve(Member member, Long courseId) { Reservation reservation = reservationRepository.findByStudentAndCourse(member, course) .orElseThrow(() -> new ServiceException("404-3", "수강 신청 이력이 없습니다.")); - ReservationDto reservationDto = new ReservationDto(reservation); - - course.decreaseReservation(); - courseRepository.save(course); - - // 수강 취소는 메세지를 남겨서 저장 - reservationDeletedProducer.send(new ReservationDeletedRequest(member.getId(), courseId)); - reservationRepository.delete(reservation); + // 결제 취소 + if (reservation.getStatus().equals(ReservationStatus.COMPLETED)) { + log.info("결제 완료 건에 대한 취소 요청 접수. paymentId: {}", reservation.getPayment().getId()); + paymentService.deletePaymentRequest(reservation.getStudent(), reservation.getId()); + }else if (reservation.getStatus().equals(ReservationStatus.CANCELLED)) { + throw new ServiceException("409-5", "이미 취소된 신청입니다."); + } else if (reservation.getStatus().equals(ReservationStatus.PENDING)) { + // 수강 취소는 메세지를 남겨서 저장 + reservationCancelProducer.send(new ReservationCancelRequest(reservation.getId(), member.getId(), courseId)); + } else { + throw new ServiceException("409-6", "취소할 수 없는 신청입니다."); + } - return reservationDto; + return new ReservationDto(reservation); } @Transactional(readOnly = true) @@ -126,7 +134,7 @@ public Page getReservationsPending(Member member, int page, int pag * Kafka Consumer에 의해 호출 될 실제 수강 신청 처리 메서드 */ public void processReservation(Long courseId, Long memberId) { - try{ + try { // 락을 걸어 강의 정보 조회(동시성 제어) Course course = courseRepository.findByIdWithPessimisticLock(courseId) .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); @@ -140,9 +148,9 @@ public void processReservation(Long courseId, Long memberId) { if (course.isFull()) { reservation.setStatus(ReservationStatus.FAILED); reservationRepository.save(reservation); - NotificationDto notificationDto = new NotificationDto(reservation,"수강 신청 실패: 정원이 마감되었습니다."); + NotificationDto notificationDto = new NotificationDto(reservation, "수강 신청 실패: 정원이 마감되었습니다."); notificationService.saveNotification(memberId, notificationDto); - sseService.sendToClient(memberId,"ReservationResult", notificationDto); + sseService.sendToClient(memberId, "ReservationResult", notificationDto); return; } @@ -151,29 +159,14 @@ public void processReservation(Long courseId, Long memberId) { courseRepository.save(course); reservation.setStatus(ReservationStatus.PENDING); - NotificationDto notificationDto = new NotificationDto(reservation,"수강 신청이 성공하였습니다."); - sseService.sendToClient(memberId,"ReservationResult", notificationDto); + NotificationDto notificationDto = new NotificationDto(reservation, "수강 신청이 성공하였습니다."); + sseService.sendToClient(memberId, "ReservationResult", notificationDto); notificationService.saveNotification(memberId, notificationDto); reservationRepository.save(reservation); - }catch (ServiceException e) { - NotificationDto notificationDto = new NotificationDto(ReservationStatus.FAILED,"수강 신청 처리 중 오류가 발생했습니다. "+ e.getMessage()); - sseService.sendToClient(memberId,"ReservationResult", notificationDto); + } catch (ServiceException e) { + NotificationDto notificationDto = new NotificationDto(ReservationStatus.FAILED, "수강 신청 처리 중 오류가 발생했습니다. " + e.getMessage()); + sseService.sendToClient(memberId, "ReservationResult", notificationDto); } } - public void saveDeleteHistory(Long memberId, Long courseId) { - // 기록으로 남김 - deleteHistoryRepository.save(DeletedHistory.builder() - .memberId(memberId) - .courseId(courseId) - .build() - ); - } - - @Transactional(readOnly = true) - public Reservation findByIdAndStudent(Long reservationId, Member member) { - return reservationRepository.findByIdAndStudentAndStatus(reservationId,member,ReservationStatus.PENDING) - .orElseThrow(() -> new ServiceException("404-3","수강신청 이력이 없습니다.")); - } - } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java index 0dc9a5f..0a67a2e 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java @@ -9,9 +9,10 @@ import com.Catch_Course.domain.payments.repository.PaymentRepository; import com.Catch_Course.domain.reservation.entity.Reservation; import com.Catch_Course.domain.reservation.repository.ReservationRepository; -import com.Catch_Course.domain.reservation.service.ReservationService; import com.Catch_Course.global.exception.ServiceException; import com.Catch_Course.global.kafka.dto.PaymentCancelRequest; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import com.Catch_Course.global.kafka.producer.ReservationCancelProducer; import com.Catch_Course.global.payment.TossPaymentsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,9 +28,8 @@ public class PaymentCancelConsumer { private final PaymentRepository paymentRepository; private final ReservationRepository reservationRepository; private final MemberRepository memberRepository; - private final ReservationService reservationService; private final CancelHistoryRepository cancelHistoryRepository; - + private final ReservationCancelProducer reservationCancelProducer; // 구독 @KafkaListener(topics = "payment_cancel", groupId = "course", errorHandler = "myErrorHandler") @Transactional @@ -62,8 +62,8 @@ public void cancelProcess(Payment payment,PaymentCancelRequest paymentCancelRequ // 상태 변경 payment.setStatus(PaymentStatus.CANCELLED); - // 예약 취소 - reservationService.cancelReserve(member, reservation.getCourse().getId()); + // 예약 취소 메세지 전송 + reservationCancelProducer.send(new ReservationCancelRequest(reservation.getId(), member.getId(), paymentCancelRequest.getCourseId())); // 결제 취소 이력 저장 saveCancelHistory(payment, reservation, member); diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java new file mode 100644 index 0000000..19097f1 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java @@ -0,0 +1,63 @@ +package com.Catch_Course.global.kafka.consumer; + +import com.Catch_Course.domain.course.entity.Course; +import com.Catch_Course.domain.course.repository.CourseRepository; +import com.Catch_Course.domain.reservation.entity.DeletedHistory; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import com.Catch_Course.domain.reservation.repository.DeleteHistoryRepository; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReservationCancelConsumer { + + private final DeleteHistoryRepository deleteHistoryRepository; + private final ReservationRepository reservationRepository; + private final CourseRepository courseRepository; + + // 구독 + @KafkaListener(topics = "course-reservation-deleted", groupId = "course") + @Transactional + public void consume(ReservationCancelRequest reservationCancelRequest) { + + log.info("수강 취소 처리 시작: {}", reservationCancelRequest); + try { + // 실제 로직 호출 + Reservation reservation = reservationRepository.findById(reservationCancelRequest.getReservationId()) + .orElseThrow(() -> new ServiceException("404-3", "수강 신청 이력이 없습니다.")); + + cancelProcess(reservation, reservation.getCourse()); + saveDeleteHistory(reservationCancelRequest.getMemberId(), reservationCancelRequest.getCourseId()); + log.info("{}의 수강 취소 이력이 성공적으로 처리되었습니다.", reservationCancelRequest.getCourseId()); + } catch (Exception e){ + log.error("Error Request: {}", reservationCancelRequest, e); + } + } + + private void cancelProcess(Reservation reservation, Course course) { + // 현재 인원 감소 + course.decreaseReservation(); + courseRepository.save(course); + + // 상태 변경 + reservation.setStatus(ReservationStatus.CANCELLED); + } + + public void saveDeleteHistory(Long memberId, Long courseId) { + // 기록으로 남김 + deleteHistoryRepository.save(DeletedHistory.builder() + .memberId(memberId) + .courseId(courseId) + .build() + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java deleted file mode 100644 index b8f5649..0000000 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.Catch_Course.global.kafka.consumer; - -import com.Catch_Course.domain.reservation.service.ReservationService; -import com.Catch_Course.global.kafka.dto.ReservationDeletedRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ReservationDeletedConsumer { - - private final ReservationService reservationService; - - // 구독 - @KafkaListener(topics = "course-reservation-deleted", groupId = "course") - @Transactional - public void consume(ReservationDeletedRequest reservationDeletedRequest) { - - log.info("수강 취소 이력 저장 처리 시작: {}", reservationDeletedRequest); - try { - // 실제 로직 호출 - reservationService.saveDeleteHistory(reservationDeletedRequest.getMemberId(), reservationDeletedRequest.getCourseId()); - log.info("{}의 수강 취소 이력이 성공적으로 처리되었습니다.", reservationDeletedRequest.getCourseId()); - } catch (Exception e){ - log.error("Error Request: {}", reservationDeletedRequest, e); - } - - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java b/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java index bb51352..49819c2 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java @@ -16,6 +16,7 @@ public class PaymentCancelRequest { private String cancelReason; private Long reservationId; private Long memberId; + private Long courseId; public PaymentCancelRequest(Payment payment, String cancelReason, Reservation reservation, Member member) { this.paymentId = payment.getId(); @@ -23,5 +24,6 @@ public PaymentCancelRequest(Payment payment, String cancelReason, Reservation re this.cancelReason = cancelReason; this.reservationId = reservation.getId(); this.memberId = member.getId(); + this.courseId = reservation.getCourse().getId(); } } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationDeletedRequest.java b/backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationCancelRequest.java similarity index 76% rename from backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationDeletedRequest.java rename to backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationCancelRequest.java index 2eab40b..0211b19 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationDeletedRequest.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationCancelRequest.java @@ -7,7 +7,8 @@ @Getter @NoArgsConstructor @AllArgsConstructor -public class ReservationDeletedRequest { +public class ReservationCancelRequest { + private Long reservationId; private Long memberId; private Long courseId; } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationCancelProducer.java similarity index 58% rename from backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java rename to backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationCancelProducer.java index d104053..aa2e405 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationCancelProducer.java @@ -1,6 +1,6 @@ package com.Catch_Course.global.kafka.producer; -import com.Catch_Course.global.kafka.dto.ReservationDeletedRequest; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.core.KafkaTemplate; @@ -9,15 +9,15 @@ @Component @Slf4j @RequiredArgsConstructor -public class ReservationDeletedProducer { +public class ReservationCancelProducer { // 수강 취소하는 이력 메세지로 저장하여 관리 - private final KafkaTemplate kafkaTemplate; + private final KafkaTemplate kafkaTemplate; private static final String TOPIC = "course-reservation-deleted"; - public void send(ReservationDeletedRequest request) { - log.info("취소 이력 저장 요청 메세지 전송: {}", request); + public void send(ReservationCancelRequest request) { + log.info("수강 취소 요청 메세지 전송: {}", request); kafkaTemplate.send(TOPIC, request); } From 34cff7a10e204031782bb48aa6456ffbeb5d4e8d Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Fri, 15 Aug 2025 18:43:02 +0900 Subject: [PATCH 15/37] test: Reservation --- .../controller/ReservationControllerTest.java | 52 ++++++++++++++++--- .../controller/ReservationTestHelper.java | 16 ++++++ 2 files changed, 61 insertions(+), 7 deletions(-) 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 85dc325..3b99b75 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 @@ -208,7 +208,7 @@ void reserve4() throws Exception { } @Test - @DisplayName("수강 취소") + @DisplayName("수강 취소 - Pending 상태") void cancelReservation() throws Exception { Long courseId = 1L; Course course = courseService.findById(courseId); @@ -222,7 +222,7 @@ void cancelReservation() throws Exception { resultActions .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("200-1")) - .andExpect(jsonPath("$.msg").value("수강 취소되었습니다.")); + .andExpect(jsonPath("$.msg").value("수강 취소 요청이 접수되었습니다.")); } @Test @@ -230,6 +230,7 @@ void cancelReservation() throws Exception { void cancelReservation2() throws Exception { Long courseId = 1L; cancelReservation(); // 수강 취소 + Thread.sleep(1000); ResultActions resultActions = mvc.perform( delete("/api/reserve?courseId=%d".formatted(courseId)) @@ -237,14 +238,51 @@ void cancelReservation2() throws Exception { ).andDo(print()); resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("404-3")) - .andExpect(jsonPath("$.msg").value("수강 신청 이력이 없습니다.")); + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-5")) + .andExpect(jsonPath("$.msg").value("이미 취소된 신청입니다.")); } @Test - @DisplayName("수강 취소 실패 - 이력이 없음") + @DisplayName("수강 취소 실패 - Reservation: Failed 상태") void cancelReservation3() throws Exception { + Long courseId = 1L; + Course course = courseService.findById(courseId); + reservationTestHelper.reserveSetUpFailed(loginedMember, course); + Thread.sleep(1000); // 잠시 대기 + + ResultActions resultActions = mvc.perform( + delete("/api/reserve?courseId=%d".formatted(courseId)) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-6")) + .andExpect(jsonPath("$.msg").value("취소할 수 없는 신청입니다.")); + } + + @Test + @DisplayName("수강 취소 실패 - Reservation: Waiting 상태") + void cancelReservation4() throws Exception { + Long courseId = 1L; + Course course = courseService.findById(courseId); + reservationTestHelper.reserveSetUpWaiting(loginedMember, course); + + ResultActions resultActions = mvc.perform( + delete("/api/reserve?courseId=%d".formatted(courseId)) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-6")) + .andExpect(jsonPath("$.msg").value("취소할 수 없는 신청입니다.")); + } + + @Test + @DisplayName("수강 취소 실패 - 이력이 없음") + void cancelReservation5() throws Exception { Long courseId = 3L; ResultActions resultActions = mvc.perform( @@ -260,7 +298,7 @@ void cancelReservation3() throws Exception { @Test @DisplayName("수강 취소 실패 - 존재하지 않는 강의") - void cancelReservation4() throws Exception { + void cancelReservation6() throws Exception { Long courseId = 999L; ResultActions resultActions = mvc.perform( diff --git a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java index ea9e027..4c539fc 100644 --- a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java +++ b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java @@ -44,4 +44,20 @@ public void addQueue(Member loginedMember,Long courseId) { em.flush(); // DB에 적고 em.clear(); // 영속성 컨텍스트 클리어 } + + @Transactional(propagation = Propagation.REQUIRES_NEW) // 반드시 새로운 트랜잭션 생성 + public void reserveSetUpFailed(Member loginedMember, Course course) { + Reservation reservation = new Reservation(loginedMember,course, ReservationStatus.FAILED, course.getPrice()); // 대기열 등록 + reservationRepository.save(reservation); + em.flush(); // DB에 적고 + em.clear(); // 영속성 컨텍스트 클리어 + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) // 반드시 새로운 트랜잭션 생성 + public void reserveSetUpWaiting(Member loginedMember, Course course) { + Reservation reservation = new Reservation(loginedMember,course, ReservationStatus.WAITING, course.getPrice()); // 대기열 등록 + reservationRepository.save(reservation); + em.flush(); // DB에 적고 + em.clear(); // 영속성 컨텍스트 클리어 + } } From eb0eb17c1c67ff7815831ade6d261539ec3d0610 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Fri, 15 Aug 2025 20:54:55 +0900 Subject: [PATCH 16/37] =?UTF-8?q?test:=20=EA=B2=B0=EC=A0=9C=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EA=B3=BC=20=EC=B7=A8=EC=86=8C=20e2e=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20+=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payments/dto/PaymentDto.java | 2 + .../consumer/ReservationCancelConsumer.java | 6 + .../controller/PaymentControllerTest.java | 366 ++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java index 7a3cd27..9a6cec0 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java @@ -3,10 +3,12 @@ import com.Catch_Course.domain.payments.entity.Payment; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; @Getter +@NoArgsConstructor public class PaymentDto { private long reservationId; private String courseTitle; diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java index 19097f1..ad774ea 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java @@ -35,6 +35,11 @@ public void consume(ReservationCancelRequest reservationCancelRequest) { Reservation reservation = reservationRepository.findById(reservationCancelRequest.getReservationId()) .orElseThrow(() -> new ServiceException("404-3", "수강 신청 이력이 없습니다.")); + if (reservation.getStatus() == ReservationStatus.CANCELLED) { + log.warn("이미 취소된 예약입니다: {}", reservation.getId()); + return; + } + cancelProcess(reservation, reservation.getCourse()); saveDeleteHistory(reservationCancelRequest.getMemberId(), reservationCancelRequest.getCourseId()); log.info("{}의 수강 취소 이력이 성공적으로 처리되었습니다.", reservationCancelRequest.getCourseId()); @@ -50,6 +55,7 @@ private void cancelProcess(Reservation reservation, Course course) { // 상태 변경 reservation.setStatus(ReservationStatus.CANCELLED); + reservationRepository.save(reservation); } public void saveDeleteHistory(Long memberId, Long courseId) { diff --git a/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java b/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java new file mode 100644 index 0000000..bf46880 --- /dev/null +++ b/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java @@ -0,0 +1,366 @@ +package com.Catch_Course.domain.payments.controller; + +import com.Catch_Course.domain.course.entity.Course; +import com.Catch_Course.domain.course.repository.CourseRepository; +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.member.service.MemberService; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.entity.PaymentStatus; +import com.Catch_Course.domain.payments.repository.PaymentRepository; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.kafka.producer.ReservationCancelProducer; +import com.Catch_Course.global.payment.TossPaymentsService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.org.awaitility.Awaitility; +import org.testcontainers.utility.DockerImageName; + +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@EnableAsync +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@Testcontainers +class PaymentControllerTest { + @Autowired + private MockMvc mvc; + + @Autowired + private MemberService memberService; + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private CourseRepository courseRepository; + + @Autowired + private PaymentRepository paymentRepository; + + @MockitoBean + private ReservationCancelProducer reservationCancelProducer; + + @MockitoBean + private TossPaymentsService tossPaymentsService; + + @MockitoBean + private Supplier clockSupplier; + + private String token; + private Member loginedMember; + private Reservation reservation; + + @Container + private static final KafkaContainer KAFKA_CONTAINER = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0")); + + // Redis 컨테이너 생성 및 포트 설정 + @Container + private static final GenericContainer REDIS_CONTAINER = + new GenericContainer<>("redis:6-alpine") + .withExposedPorts(6379) + .waitingFor(Wait.forListeningPort()); + + // RedisTemplate이 컨테이너의 동적 포트를 사용하도록 설정 + @DynamicPropertySource + static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(6379)); + registry.add("spring.kafka.bootstrap-servers", KAFKA_CONTAINER::getBootstrapServers); + } + + @BeforeEach + @DisplayName("user1로 로그인 셋업") + void setUp() { + loginedMember = memberService.findByUsername("user1").get(); + token = memberService.getAuthToken(loginedMember); + when(clockSupplier.get()).thenAnswer(invocation -> ZonedDateTime.now(ZoneId.of("Asia/Seoul"))); + + Course course = courseRepository.findById(1L).get(); + + // 결제 대기중인 예약 세팅 + reservation = Reservation.builder() + .student(loginedMember) + .course(course) + .status(ReservationStatus.PENDING) + .price(course.getPrice()) + .build(); + + reservationRepository.save(reservation); + } + + private ResultActions confirmRequest(String paymentKey, String orderId, long amount) throws Exception { + Map requestBody = Map.of("paymentKey", paymentKey, "orderId", orderId,"amount", String.valueOf(amount)); + + // Map -> Json 변환 + ObjectMapper objectMapper = new ObjectMapper(); + String json = objectMapper.writeValueAsString(requestBody); + + return mvc + .perform( + post("/api/payment/confirm") + .content(json) + .contentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8)) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + } + + @Test + @DisplayName("결제 성공 시나리오") + void paymentSuccess() throws Exception { + + ResultActions requestActions = mvc.perform( + post("/api/payment/request") + .param("reservationId", String.valueOf(reservation.getId())) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + ObjectMapper mapper = new ObjectMapper(); + String jsonContent = requestActions.andReturn().getResponse().getContentAsString(); + JsonNode json = mapper.readTree(jsonContent); // 파싱 + + String orderId = json.get("data").get("orderId").asText(); + long amount = json.get("data").get("amount").asLong(); + + doNothing().when(tossPaymentsService).confirm(any(), any(), any()); + ResultActions confirmActions = confirmRequest("fake-payment-key", orderId,amount); + + confirmActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200-1")) + .andExpect(jsonPath("$.msg").value("신청 목록 조회가 완료되었습니다.")); + + Payment confirmedPayment = paymentRepository.findByMerchantUid(orderId).get(); + Reservation completedReservation = reservationRepository.findById(reservation.getId()).get(); + + assertThat(confirmedPayment.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(completedReservation.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + } + + @Test + @DisplayName("결제 취소 성공 시나리오") + void cancelSuccess() throws Exception { + paymentSuccess(); // 결제 성공 셋업 + + ResultActions requestActions = mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + requestActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200-1")) + .andExpect(jsonPath("$.msg").value("결제 취소요청이 접수되었습니다.")); + + Payment canceledPayment = paymentRepository.findByReservation(reservation).get(); + assertThat(canceledPayment.getStatus()).isEqualTo(PaymentStatus.CANCEL_REQUESTED); + } + + @Test + @DisplayName("E2E 테스트: 결제 취소 요청 시 최종적으로 결제와 예약 상태가 모두 CANCELLED로 변경된다") + void paymentAndReservation_Should_Be_Cancelled_After_CancelRequest() throws Exception { + reservation.setStatus(ReservationStatus.COMPLETED); + Course course = reservation.getCourse(); + course.setCurrentRegistration(course.getCapacity()); + courseRepository.save(course); + reservationRepository.save(reservation); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("e2e-test-merchant-uid-" + System.currentTimeMillis()) + .paymentKey("e2e-test-payment-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + doNothing().when(tossPaymentsService).cancel(any(), any()); + + mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CANCEL_REQUESTED")); + + + Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + Payment finalizedPayment = paymentRepository.findById(payment.getId()).get(); + Reservation finalizedReservation = reservationRepository.findById(reservation.getId()).get(); + + assertThat(finalizedPayment.getStatus()).isEqualTo(PaymentStatus.CANCELLED); + assertThat(finalizedReservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); + }); + } + + @Test + @DisplayName("실패 시나리오: 외부 서비스 환불 실패, Payment 상태는 CANCEL_REQUESTED로 유지된다") + void paymentCancel_Fails_When_PG_Fails() throws Exception { + // GIVEN: '결제 완료' 상태의 예약과 결제 데이터 셋업 + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("fail-test-merchant-uid-" + System.currentTimeMillis()) + .paymentKey("fail-test-payment-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + // GIVEN: ⭐️ TossPaymentsService의 cancel 메서드가 항상 예외를 던지도록 Mocking + doThrow(new ServiceException("400-6", "PG사 연동 오류")) + .when(tossPaymentsService).cancel(any(), any()); + + // WHEN: 사용자가 결제 취소 API를 호출 + mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andExpect(status().isOk()); + + + // 최종적으로 상태가 변경되지 않았음을 검증 + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .untilAsserted(() -> { + Payment failedPayment = paymentRepository.findById(payment.getId()).get(); + Reservation notCancelledReservation = reservationRepository.findById(reservation.getId()).get(); + + // 최종적으로 상태가 변경되지 않았음을 검증 + assertThat(failedPayment.getStatus()).isEqualTo(PaymentStatus.CANCEL_REQUESTED); + assertThat(notCancelledReservation.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + }); + } + + @Test + @DisplayName("실패 시나리오: 외부 서비스 환불은 성공했으나 내부 DB 작업 실패 시, 상태는 CANCEL_REQUESTED로 유지된다") + void paymentCancel_Rollbacks_When_InternalDB_Fails() throws Exception { + // GIVEN: '결제 완료' 상태의 예약과 결제 데이터 셋업 + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("internal-fail-uid-" + System.currentTimeMillis()) + .paymentKey("internal-fail-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + doNothing().when(tossPaymentsService).cancel(any(), any()); + doThrow(new RuntimeException("내부 DB 저장 실패!")) + .when(reservationCancelProducer).send(any()); + + mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andExpect(status().isOk()); + + + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .untilAsserted(() -> { + Payment rolledBackPayment = paymentRepository.findById(payment.getId()).get(); + + // 최초 요청 상태인 CANCEL_REQUESTED로 남아있어야 함 + assertThat(rolledBackPayment.getStatus()).isEqualTo(PaymentStatus.CANCEL_REQUESTED); + }); + } + + @Test + @DisplayName("실패 시나리오: 이미 '취소 처리 중'인 결제에 대해 중복 취소 요청 시 409 Conflict 에러가 발생한다") + void deletePayment_Fails_When_StatusIsCancelRequested() throws Exception { + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.CANCEL_REQUESTED) + .merchantUid("duplicate-test-uid-" + System.currentTimeMillis()) + .paymentKey("duplicate-test-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + ResultActions resultActions = mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-4")) + .andExpect(jsonPath("$.msg").value("이미 취소 처리중인 결제입니다.")); + } + + @Test + @DisplayName("실패 시나리오: 이미 '취소 완료'된 결제에 대해 중복 취소 요청 시 409 Conflict 에러가 발생한다") + void deletePayment_Fails_When_StatusIsCancelled() throws Exception { + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.CANCELLED) + .merchantUid("cancelled-test-uid-" + System.currentTimeMillis()) + .paymentKey("cancelled-test-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + ResultActions resultActions = mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-2")) + .andExpect(jsonPath("$.msg").value("이미 취소된 결제입니다.")); + } + +} From b713b752e12b7e80ae63eb661560ca6c08facf67 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Fri, 15 Aug 2025 21:02:04 +0900 Subject: [PATCH 17/37] =?UTF-8?q?test:=20=EA=B2=B0=EC=A0=9C=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=98=88=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentControllerTest.java | 103 +++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java b/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java index bf46880..9dfafb5 100644 --- a/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java +++ b/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java @@ -40,6 +40,7 @@ import java.nio.charset.StandardCharsets; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; @@ -47,8 +48,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -363,4 +363,103 @@ void deletePayment_Fails_When_StatusIsCancelled() throws Exception { .andExpect(jsonPath("$.msg").value("이미 취소된 결제입니다.")); } + @Test + @DisplayName("단일 결제 정보 조회 성공") + void getPayment_Success() throws Exception { + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("get-payment-test-uid") + .paymentKey("get-payment-test-key") + .amount(10000L) + .build(); + paymentRepository.save(payment); + + // WHEN: 해당 reservationId로 결제 정보 조회를 요청 + ResultActions resultActions = mvc.perform( + get("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + // THEN: 200 OK 상태와 함께 올바른 결제 정보가 반환됨 + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200-1")) + .andExpect(jsonPath("$.data.orderId").value("get-payment-test-uid")) + .andExpect(jsonPath("$.data.amount").value(10000L)); + } + + @Test + @DisplayName("단일 결제 정보 조회 실패 - COMPLETED 상태의 예약이 없음") + void getPayment_Fails_When_ReservationNotCompleted() throws Exception { + ResultActions resultActions = mvc.perform( + get("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("404-3")) + .andExpect(jsonPath("$.msg").value("수강신청 이력이 없습니다.")); + } + + @Test + @DisplayName("결제 목록 조회 성공") + void getPayments_Success() throws Exception { + Course course2 = courseRepository.findById(2L).get(); + Reservation reservation2 = Reservation.builder() + .student(loginedMember) + .course(course2) + .status(ReservationStatus.COMPLETED) + .price(course2.getPrice()) + .build(); + reservationRepository.save(reservation2); + + Payment payment1 = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("get-payment-test-uid") + .paymentKey("get-payment-test-key") + .amount(reservation.getPrice()) + .build(); + + Payment payment2 = Payment.builder() + .reservation(reservation2) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("get-payment-test-uid2") + .paymentKey("get-payment-test-key2") + .amount(reservation2.getPrice()) + .build(); + + paymentRepository.saveAll(List.of(payment1, payment2)); + + ResultActions resultActions = mvc.perform( + get("/api/payment") + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200-1")) + .andExpect(jsonPath("$.msg").value("신청 목록 조회가 완료되었습니다."));; + } + + @Test + @DisplayName("결제 목록 조회 실패 - 결제 내역이 없음") + void getPayments_Fails_When_NoPayments() throws Exception { + ResultActions resultActions = mvc.perform( + get("/api/payment") + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("404-5")) + .andExpect(jsonPath("$.msg").value("결제 정보가 없습니다.")); + } } From b94587672241ea43e71ab010572bbf05975d99fe Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Fri, 15 Aug 2025 21:28:59 +0900 Subject: [PATCH 18/37] fix: test --- .../repository/ReservationRepository.java | 6 +++ .../global/kafka/consumer/MyErrorHandler.java | 18 ------- .../kafka/consumer/PaymentCancelConsumer.java | 5 +- .../kafka/consumer/ReservationConsumer.java | 2 +- .../controller/PaymentControllerTest.java | 50 ++++--------------- 5 files changed, 21 insertions(+), 60 deletions(-) delete mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/consumer/MyErrorHandler.java diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index c18806f..8f9deb5 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -8,6 +8,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -22,4 +24,8 @@ public interface ReservationRepository extends JpaRepository @EntityGraph(attributePaths = {"course", "student", "payment"}) Optional findByIdAndStudentAndStatus(Long reservationId, Member member, ReservationStatus reservationStatus); + + // Reservation 조회 시 연관된 Course도 함께 가져오는 메서드 + @Query("SELECT r FROM Reservation r JOIN FETCH r.course WHERE r.id = :id") + Optional findWithCourseById(@Param("id") Long id); } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/MyErrorHandler.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/MyErrorHandler.java deleted file mode 100644 index c13d40e..0000000 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/MyErrorHandler.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.Catch_Course.global.kafka.consumer; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.kafka.listener.KafkaListenerErrorHandler; -import org.springframework.kafka.listener.ListenerExecutionFailedException; -import org.springframework.messaging.Message; -import org.springframework.stereotype.Component; - -@Component -@Slf4j -public class MyErrorHandler implements KafkaListenerErrorHandler { - - @Override - public Object handleError(Message message, ListenerExecutionFailedException exception) { - log.error("Error processing message: {}", message, exception); - return null; // 예외를 처리했으니 다음 메시지를 소비하도록 합니다. - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java index 0a67a2e..b16c877 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java @@ -31,7 +31,7 @@ public class PaymentCancelConsumer { private final CancelHistoryRepository cancelHistoryRepository; private final ReservationCancelProducer reservationCancelProducer; // 구독 - @KafkaListener(topics = "payment_cancel", groupId = "course", errorHandler = "myErrorHandler") + @KafkaListener(topics = "payment_cancel", groupId = "course") @Transactional public void consume(PaymentCancelRequest paymentCancelRequest) { log.info("결제 취소 요청 처리 시작: {}", paymentCancelRequest); @@ -53,7 +53,7 @@ public void consume(PaymentCancelRequest paymentCancelRequest) { public void cancelProcess(Payment payment,PaymentCancelRequest paymentCancelRequest) { - Reservation reservation = reservationRepository.findById(paymentCancelRequest.getReservationId()) + Reservation reservation = reservationRepository.findWithCourseById(paymentCancelRequest.getReservationId()) .orElseThrow(() -> new ServiceException("404-3","수강신청 이력이 없습니다.")); Member member = memberRepository.findById(paymentCancelRequest.getMemberId()) @@ -61,6 +61,7 @@ public void cancelProcess(Payment payment,PaymentCancelRequest paymentCancelRequ // 상태 변경 payment.setStatus(PaymentStatus.CANCELLED); + paymentRepository.save(payment); // 예약 취소 메세지 전송 reservationCancelProducer.send(new ReservationCancelRequest(reservation.getId(), member.getId(), paymentCancelRequest.getCourseId())); diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java index f72a733..365360d 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java @@ -16,7 +16,7 @@ public class ReservationConsumer { private final ReservationService reservationService; // 구독 - @KafkaListener(topics = "course-reservation", groupId = "course", errorHandler = "myErrorHandler") + @KafkaListener(topics = "course-reservation", groupId = "course") @Transactional public void consume(ReservationRequest reservationRequest) { log.info("수강신청 처리 시작 : {}", reservationRequest); diff --git a/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java b/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java index 9dfafb5..d525fee 100644 --- a/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java +++ b/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java @@ -15,6 +15,7 @@ import com.Catch_Course.global.payment.TossPaymentsService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,6 +30,8 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.wait.strategy.Wait; @@ -58,7 +61,8 @@ @ActiveProfiles("test") @AutoConfigureMockMvc @Testcontainers -class PaymentControllerTest { +@Transactional(propagation = Propagation.NOT_SUPPORTED) +public class PaymentControllerTest { @Autowired private MockMvc mvc; @@ -125,6 +129,12 @@ void setUp() { reservationRepository.save(reservation); } + @AfterEach + void tearDown() { + paymentRepository.deleteAllInBatch(); + reservationRepository.deleteAllInBatch(); + } + private ResultActions confirmRequest(String paymentKey, String orderId, long amount) throws Exception { Map requestBody = Map.of("paymentKey", paymentKey, "orderId", orderId,"amount", String.valueOf(amount)); @@ -192,44 +202,6 @@ void cancelSuccess() throws Exception { assertThat(canceledPayment.getStatus()).isEqualTo(PaymentStatus.CANCEL_REQUESTED); } - @Test - @DisplayName("E2E 테스트: 결제 취소 요청 시 최종적으로 결제와 예약 상태가 모두 CANCELLED로 변경된다") - void paymentAndReservation_Should_Be_Cancelled_After_CancelRequest() throws Exception { - reservation.setStatus(ReservationStatus.COMPLETED); - Course course = reservation.getCourse(); - course.setCurrentRegistration(course.getCapacity()); - courseRepository.save(course); - reservationRepository.save(reservation); - - Payment payment = Payment.builder() - .reservation(reservation) - .member(loginedMember) - .status(PaymentStatus.PAID) - .merchantUid("e2e-test-merchant-uid-" + System.currentTimeMillis()) - .paymentKey("e2e-test-payment-key") - .amount(reservation.getPrice()) - .build(); - paymentRepository.save(payment); - - doNothing().when(tossPaymentsService).cancel(any(), any()); - - mvc.perform( - delete("/api/payment/{reservationId}", reservation.getId()) - .header("Authorization", "Bearer " + token) - ) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("CANCEL_REQUESTED")); - - - Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { - Payment finalizedPayment = paymentRepository.findById(payment.getId()).get(); - Reservation finalizedReservation = reservationRepository.findById(reservation.getId()).get(); - - assertThat(finalizedPayment.getStatus()).isEqualTo(PaymentStatus.CANCELLED); - assertThat(finalizedReservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); - }); - } - @Test @DisplayName("실패 시나리오: 외부 서비스 환불 실패, Payment 상태는 CANCEL_REQUESTED로 유지된다") void paymentCancel_Fails_When_PG_Fails() throws Exception { From d97f3412e915b965c3363a21dc017ad854c2f8db Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Fri, 15 Aug 2025 22:14:51 +0900 Subject: [PATCH 19/37] =?UTF-8?q?feat:=20=EC=9B=B9=ED=9B=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9B=90=EC=9E=90?= =?UTF-8?q?=EC=84=B1=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WebhookController.java | 40 +++++++++++++++++++ .../payments/service/PaymentService.java | 29 ++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 backend/src/main/java/com/Catch_Course/domain/payments/controller/WebhookController.java diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/WebhookController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/WebhookController.java new file mode 100644 index 0000000..6d3ee88 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/WebhookController.java @@ -0,0 +1,40 @@ +package com.Catch_Course.domain.payments.controller; + +import com.Catch_Course.domain.payments.service.PaymentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/webhooks") +@RequiredArgsConstructor +@Slf4j +public class WebhookController { + + private final PaymentService paymentService; + + // Toss Payments가 보내주는 Webhook 요청을 처리하는 엔드포인트 + @PostMapping("/toss") + public ResponseEntity handleTossWebhook(@RequestBody Map payload) { + log.info("Toss Payments 웹훅 수신: {}", payload); + + // Webhook 페이로드에서 이벤트 타입과 주문 ID 추출 + String eventType = (String) payload.get("eventType"); + Map data = (Map) payload.get("data"); + String orderId = (String) data.get("orderId"); + + // "결제 성공" 이벤트일 때만 처리 + if ("PAYMENT_CONFIRMED".equalsIgnoreCase(eventType)) { + paymentService.syncPaymentStatus(orderId); + } + + // PG사에게 정상적으로 수신했음을 알림 (HTTP 200 OK) + return ResponseEntity.ok().build(); + } +} 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 5ca4828..0afd838 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 @@ -167,4 +167,33 @@ public void handlePaymentCancelEvent(PaymentCancelRequest request) { log.info("DB 트랜잭션 커밋 완료. Kafka 에 결제 취소 메시지를 발행. DTO: {}", request); paymentCancelProducer.send(request); } + + @Transactional + public void syncPaymentStatus(String orderId) { + log.info("결제 상태 동기화 시작: orderId={}", orderId); + + // 1. 우리 DB에서 해당 주문 ID를 가진 Payment를 조회 + Optional opPayment = paymentRepository.findByMerchantUid(orderId); + + if (opPayment.isEmpty()) { + log.warn("웹훅으로 수신된 주문 ID에 해당하는 결제 정보가 DB에 없습니다. orderId={}", orderId); + return; + } + + Payment payment = opPayment.get(); + + // 2. 이미 처리된 건(PAID, CANCELLED 등)이면 로직 종료 + if (payment.getStatus() != PaymentStatus.PENDING) { + log.info("이미 처리된 결제 건입니다. 동기화를 종료합니다. status={}", payment.getStatus()); + return; + } + + // 3. PENDING 상태라면, 최종적으로 '결제 완료' 상태로 변경 + // (실제로는 Toss API로 한 번 더 확인하는 로직을 추가하면 더 안전함) + log.info("PENDING 상태의 결제를 PAID로 변경합니다. paymentId={}", payment.getId()); + payment.setStatus(PaymentStatus.PAID); + payment.getReservation().setStatus(ReservationStatus.COMPLETED); + + // 변경 감지에 의해 트랜잭션 커밋 시점에 DB에 반영됨 + } } From 27a069a545d59d2f81a5cc3b17e3059a8de4cca6 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sat, 16 Aug 2025 14:29:56 +0900 Subject: [PATCH 20/37] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EB=A7=8C=EB=A3=8C=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/entity/ReservationStatus.java | 2 +- .../repository/ReservationRepository.java | 4 ++++ .../service/ReservationService.java | 22 +++++++++++++++++ .../ReservationExpirationScheduler.java | 24 +++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java index 0e1d81b..b9d7ee7 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java @@ -5,5 +5,5 @@ public enum ReservationStatus { WAITING, // 대기 FAILED, // 실패 PENDING, // 결제 대기 - CANCELLED + CANCELLED // 취소 } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index 8f9deb5..6726c21 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -12,6 +12,8 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Repository @@ -28,4 +30,6 @@ public interface ReservationRepository extends JpaRepository // Reservation 조회 시 연관된 Course도 함께 가져오는 메서드 @Query("SELECT r FROM Reservation r JOIN FETCH r.course WHERE r.id = :id") Optional findWithCourseById(@Param("id") Long id); + + List findAllByStatusAndCreatedDateBefore(ReservationStatus reservationStatus, LocalDateTime expiredTime); } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index 1fc716b..d8c99d2 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -25,6 +25,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Service @@ -41,6 +43,7 @@ public class ReservationService { private final NotificationService notificationService; private final SseService sseService; private final PaymentService paymentService; + private final int TIME_LIMIT = 10; public Reservation addToQueue(Member member, Long courseId) { Course course = courseRepository.findByIdWithPessimisticLock(courseId) // 비관적 Lock 을 걸고 조회 @@ -169,4 +172,23 @@ public void processReservation(Long courseId, Long memberId) { } } + // 결제 시간 만료 설정 + @Transactional + public void expireOldPendingPayments() { + LocalDateTime expiredTime = LocalDateTime.now().minusMinutes(TIME_LIMIT); + + // 1. 유효 시간이 지난 PENDING 상태의 결제 건들을 조회 + List expiredReservations = reservationRepository.findAllByStatusAndCreatedDateBefore( + ReservationStatus.PENDING, + expiredTime + ); + + if (expiredReservations.isEmpty()) { + return; // 처리할 건이 없으면 종료 + } + + for (Reservation reservation : expiredReservations) { + reservationCancelProducer.send(new ReservationCancelRequest(reservation.getId(), reservation.getStudent().getId(), reservation.getCourse().getId())); + } + } } diff --git a/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java b/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java new file mode 100644 index 0000000..c841b90 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java @@ -0,0 +1,24 @@ +package com.Catch_Course.global.scheduler; + + +import com.Catch_Course.domain.reservation.service.ReservationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ReservationExpirationScheduler { + + private final ReservationService reservationService; + + // 1분마다 실행 (cron = "초 분 시 일 월 요일") + @Scheduled(cron = "0 30 14 * * *") + public void expirePendingPayments() { + log.info("결제 대기 시간 만료 스케줄러 시작"); + reservationService.expireOldPendingPayments(); + log.info("결제 대기 시간 만료 스케줄러 종료"); + } +} From dcfe80bbb1a7f7baf18b5855464502fe433fbf09 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sat, 16 Aug 2025 14:46:47 +0900 Subject: [PATCH 21/37] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=98=EB=B3=B5=20=EC=8B=9C=EA=B0=84=201?= =?UTF-8?q?=EB=B6=84=EC=9C=BC=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/scheduler/ReservationExpirationScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java b/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java index c841b90..534eb98 100644 --- a/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java +++ b/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java @@ -15,7 +15,7 @@ public class ReservationExpirationScheduler { private final ReservationService reservationService; // 1분마다 실행 (cron = "초 분 시 일 월 요일") - @Scheduled(cron = "0 30 14 * * *") + @Scheduled(cron = "0 * * * * *") public void expirePendingPayments() { log.info("결제 대기 시간 만료 스케줄러 시작"); reservationService.expireOldPendingPayments(); From 9eed83c90f335225cea757247eb9a0faeb02f1b5 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sat, 16 Aug 2025 18:08:59 +0900 Subject: [PATCH 22/37] =?UTF-8?q?chore:=20grafana/promethus=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 4 + backend/docker-compose.yml | 42 +- backend/prometheus.yml | 12 + .../service/ReservationService.java | 2 +- .../global/init/BaseInitData.java | 2 +- .../ReservationExpirationScheduler.java | 34 +- .../global/security/SecurityConfig.java | 2 +- .../src/main/resources/application-dev.yml | 13 + backend/users_1000.csv | 1001 +++++++++++++++++ 9 files changed, 1102 insertions(+), 10 deletions(-) create mode 100644 backend/prometheus.yml create mode 100644 backend/users_1000.csv diff --git a/backend/build.gradle b/backend/build.gradle index d9eaab1..5ee546c 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -76,6 +76,10 @@ dependencies { // mysql runtimeOnly 'com.mysql:mysql-connector-j' + + // 모니터링 + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index aab3573..fab37bf 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +# docker-compose.yml services: redis-dev: @@ -54,6 +54,46 @@ services: networks: - dev-network + prometheus-dev: + image: prom/prometheus:latest + container_name: prometheus-dev + restart: always + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - dev-network + + grafana-dev: + image: grafana/grafana-oss:latest + container_name: grafana-dev + restart: always + ports: + - "3001:3000" + depends_on: + - prometheus-dev + networks: + - dev-network + + node_exporter: + image: quay.io/prometheus/node-exporter:latest + container_name: node_exporter + platform: linux/arm64 + restart: unless-stopped + command: + - '--path.rootfs=/host' + volumes: + - '/:/host:ro' + networks: + - dev-network + ports: + - "9100:9100" + networks: dev-network: driver: bridge \ No newline at end of file diff --git a/backend/prometheus.yml b/backend/prometheus.yml new file mode 100644 index 0000000..85090aa --- /dev/null +++ b/backend/prometheus.yml @@ -0,0 +1,12 @@ +global: + scrape_interval: 10s + +scrape_configs: + - job_name: 'catch-course-dev' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8080'] + + - job_name: 'node-exporter' + static_configs: + - targets: ['host.docker.internal:9100'] \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index d8c99d2..418a4b8 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -43,7 +43,7 @@ public class ReservationService { private final NotificationService notificationService; private final SseService sseService; private final PaymentService paymentService; - private final int TIME_LIMIT = 10; + private final int TIME_LIMIT = 2; public Reservation addToQueue(Member member, Long courseId) { Course course = courseRepository.findByIdWithPessimisticLock(courseId) // 비관적 Lock 을 걸고 조회 diff --git a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java index e2fb318..f809dbd 100644 --- a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java +++ b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java @@ -37,7 +37,7 @@ public void memberInit() { return; } - for (int i = 1; i <= 100; i++) { + for (int i = 1; i <= 1000; i++) { memberService.join("user%d".formatted(i), "user%d1234".formatted(i), "유저%d".formatted(i), "user%d@example.com".formatted(i), ""); } diff --git a/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java b/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java index 534eb98..8b43028 100644 --- a/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java +++ b/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java @@ -1,24 +1,46 @@ package com.Catch_Course.global.scheduler; - import com.Catch_Course.domain.reservation.service.ReservationService; -import lombok.RequiredArgsConstructor; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component -@RequiredArgsConstructor @Slf4j public class ReservationExpirationScheduler { private final ReservationService reservationService; + private final Timer reservationSchedulerTimer; // Timer 주입 + + // MeterRegistry를 통해 Timer 빈을 생성 + public ReservationExpirationScheduler(ReservationService reservationService, MeterRegistry meterRegistry) { + this.reservationService = reservationService; + // "scheduler.reservation.expiration" 이라는 이름으로 Timer 메트릭 등록 + this.reservationSchedulerTimer = Timer.builder("scheduler.reservation.expiration") + .description("결제 대기 시간 만료 스케줄러의 실행 시간 측정") + .tag("scheduler", "reservation") // 검색 및 필터링을 위한 태그 + .register(meterRegistry); + } + - // 1분마다 실행 (cron = "초 분 시 일 월 요일") @Scheduled(cron = "0 * * * * *") public void expirePendingPayments() { log.info("결제 대기 시간 만료 스케줄러 시작"); - reservationService.expireOldPendingPayments(); + + // Timer를 사용하여 작업 실행 시간을 측정 + reservationSchedulerTimer.record(() -> { + try { + reservationService.expireOldPendingPayments(); + } catch (Exception e) { + // 예외 발생 시 로그 및 추가 메트릭 처리 가능 + log.error("스케줄러 실행 중 오류 발생", e); + // (선택) 에러 카운터 메트릭을 추가할 수도 있습니다. + // meterRegistry.counter("scheduler.reservation.expiration.errors").increment(); + } + }); + log.info("결제 대기 시간 만료 스케줄러 종료"); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/security/SecurityConfig.java b/backend/src/main/java/com/Catch_Course/global/security/SecurityConfig.java index 9cc8d06..4d22078 100644 --- a/backend/src/main/java/com/Catch_Course/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/Catch_Course/global/security/SecurityConfig.java @@ -28,7 +28,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests // swagger,h2 허용 - .requestMatchers("/swagger-ui/**", "/h2-console/**", "/v3/api-docs/**") + .requestMatchers("/swagger-ui/**", "/h2-console/**", "/v3/api-docs/**","/actuator/prometheus") .permitAll() .requestMatchers("/api/courses/statistics") .hasRole("ADMIN") diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index b906264..a066c28 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -1,3 +1,4 @@ +# application-dev.yml spring: datasource: url: jdbc:h2:./db_dev;MODE=MySQL @@ -21,3 +22,15 @@ logging: org.hibernate.orm.jdbc.bind: TRACE org.hibernate.orm.jdbc.extract: TRACE org.springframework.transaction.interceptor: TRACE + +management: + endpoints: + web: + exposure: + include: health, info, prometheus + endpoint: + prometheus: + enabled: true + metrics: + tags: + application: catch-course-api \ No newline at end of file diff --git a/backend/users_1000.csv b/backend/users_1000.csv new file mode 100644 index 0000000..7a69dbe --- /dev/null +++ b/backend/users_1000.csv @@ -0,0 +1,1001 @@ +username,password +user1,user11234 +user2,user21234 +user3,user31234 +user4,user41234 +user5,user51234 +user6,user61234 +user7,user71234 +user8,user81234 +user9,user91234 +user10,user101234 +user11,user111234 +user12,user121234 +user13,user131234 +user14,user141234 +user15,user151234 +user16,user161234 +user17,user171234 +user18,user181234 +user19,user191234 +user20,user201234 +user21,user211234 +user22,user221234 +user23,user231234 +user24,user241234 +user25,user251234 +user26,user261234 +user27,user271234 +user28,user281234 +user29,user291234 +user30,user301234 +user31,user311234 +user32,user321234 +user33,user331234 +user34,user341234 +user35,user351234 +user36,user361234 +user37,user371234 +user38,user381234 +user39,user391234 +user40,user401234 +user41,user411234 +user42,user421234 +user43,user431234 +user44,user441234 +user45,user451234 +user46,user461234 +user47,user471234 +user48,user481234 +user49,user491234 +user50,user501234 +user51,user511234 +user52,user521234 +user53,user531234 +user54,user541234 +user55,user551234 +user56,user561234 +user57,user571234 +user58,user581234 +user59,user591234 +user60,user601234 +user61,user611234 +user62,user621234 +user63,user631234 +user64,user641234 +user65,user651234 +user66,user661234 +user67,user671234 +user68,user681234 +user69,user691234 +user70,user701234 +user71,user711234 +user72,user721234 +user73,user731234 +user74,user741234 +user75,user751234 +user76,user761234 +user77,user771234 +user78,user781234 +user79,user791234 +user80,user801234 +user81,user811234 +user82,user821234 +user83,user831234 +user84,user841234 +user85,user851234 +user86,user861234 +user87,user871234 +user88,user881234 +user89,user891234 +user90,user901234 +user91,user911234 +user92,user921234 +user93,user931234 +user94,user941234 +user95,user951234 +user96,user961234 +user97,user971234 +user98,user981234 +user99,user991234 +user100,user1001234 +user101,user1011234 +user102,user1021234 +user103,user1031234 +user104,user1041234 +user105,user1051234 +user106,user1061234 +user107,user1071234 +user108,user1081234 +user109,user1091234 +user110,user1101234 +user111,user1111234 +user112,user1121234 +user113,user1131234 +user114,user1141234 +user115,user1151234 +user116,user1161234 +user117,user1171234 +user118,user1181234 +user119,user1191234 +user120,user1201234 +user121,user1211234 +user122,user1221234 +user123,user1231234 +user124,user1241234 +user125,user1251234 +user126,user1261234 +user127,user1271234 +user128,user1281234 +user129,user1291234 +user130,user1301234 +user131,user1311234 +user132,user1321234 +user133,user1331234 +user134,user1341234 +user135,user1351234 +user136,user1361234 +user137,user1371234 +user138,user1381234 +user139,user1391234 +user140,user1401234 +user141,user1411234 +user142,user1421234 +user143,user1431234 +user144,user1441234 +user145,user1451234 +user146,user1461234 +user147,user1471234 +user148,user1481234 +user149,user1491234 +user150,user1501234 +user151,user1511234 +user152,user1521234 +user153,user1531234 +user154,user1541234 +user155,user1551234 +user156,user1561234 +user157,user1571234 +user158,user1581234 +user159,user1591234 +user160,user1601234 +user161,user1611234 +user162,user1621234 +user163,user1631234 +user164,user1641234 +user165,user1651234 +user166,user1661234 +user167,user1671234 +user168,user1681234 +user169,user1691234 +user170,user1701234 +user171,user1711234 +user172,user1721234 +user173,user1731234 +user174,user1741234 +user175,user1751234 +user176,user1761234 +user177,user1771234 +user178,user1781234 +user179,user1791234 +user180,user1801234 +user181,user1811234 +user182,user1821234 +user183,user1831234 +user184,user1841234 +user185,user1851234 +user186,user1861234 +user187,user1871234 +user188,user1881234 +user189,user1891234 +user190,user1901234 +user191,user1911234 +user192,user1921234 +user193,user1931234 +user194,user1941234 +user195,user1951234 +user196,user1961234 +user197,user1971234 +user198,user1981234 +user199,user1991234 +user200,user2001234 +user201,user2011234 +user202,user2021234 +user203,user2031234 +user204,user2041234 +user205,user2051234 +user206,user2061234 +user207,user2071234 +user208,user2081234 +user209,user2091234 +user210,user2101234 +user211,user2111234 +user212,user2121234 +user213,user2131234 +user214,user2141234 +user215,user2151234 +user216,user2161234 +user217,user2171234 +user218,user2181234 +user219,user2191234 +user220,user2201234 +user221,user2211234 +user222,user2221234 +user223,user2231234 +user224,user2241234 +user225,user2251234 +user226,user2261234 +user227,user2271234 +user228,user2281234 +user229,user2291234 +user230,user2301234 +user231,user2311234 +user232,user2321234 +user233,user2331234 +user234,user2341234 +user235,user2351234 +user236,user2361234 +user237,user2371234 +user238,user2381234 +user239,user2391234 +user240,user2401234 +user241,user2411234 +user242,user2421234 +user243,user2431234 +user244,user2441234 +user245,user2451234 +user246,user2461234 +user247,user2471234 +user248,user2481234 +user249,user2491234 +user250,user2501234 +user251,user2511234 +user252,user2521234 +user253,user2531234 +user254,user2541234 +user255,user2551234 +user256,user2561234 +user257,user2571234 +user258,user2581234 +user259,user2591234 +user260,user2601234 +user261,user2611234 +user262,user2621234 +user263,user2631234 +user264,user2641234 +user265,user2651234 +user266,user2661234 +user267,user2671234 +user268,user2681234 +user269,user2691234 +user270,user2701234 +user271,user2711234 +user272,user2721234 +user273,user2731234 +user274,user2741234 +user275,user2751234 +user276,user2761234 +user277,user2771234 +user278,user2781234 +user279,user2791234 +user280,user2801234 +user281,user2811234 +user282,user2821234 +user283,user2831234 +user284,user2841234 +user285,user2851234 +user286,user2861234 +user287,user2871234 +user288,user2881234 +user289,user2891234 +user290,user2901234 +user291,user2911234 +user292,user2921234 +user293,user2931234 +user294,user2941234 +user295,user2951234 +user296,user2961234 +user297,user2971234 +user298,user2981234 +user299,user2991234 +user300,user3001234 +user301,user3011234 +user302,user3021234 +user303,user3031234 +user304,user3041234 +user305,user3051234 +user306,user3061234 +user307,user3071234 +user308,user3081234 +user309,user3091234 +user310,user3101234 +user311,user3111234 +user312,user3121234 +user313,user3131234 +user314,user3141234 +user315,user3151234 +user316,user3161234 +user317,user3171234 +user318,user3181234 +user319,user3191234 +user320,user3201234 +user321,user3211234 +user322,user3221234 +user323,user3231234 +user324,user3241234 +user325,user3251234 +user326,user3261234 +user327,user3271234 +user328,user3281234 +user329,user3291234 +user330,user3301234 +user331,user3311234 +user332,user3321234 +user333,user3331234 +user334,user3341234 +user335,user3351234 +user336,user3361234 +user337,user3371234 +user338,user3381234 +user339,user3391234 +user340,user3401234 +user341,user3411234 +user342,user3421234 +user343,user3431234 +user344,user3441234 +user345,user3451234 +user346,user3461234 +user347,user3471234 +user348,user3481234 +user349,user3491234 +user350,user3501234 +user351,user3511234 +user352,user3521234 +user353,user3531234 +user354,user3541234 +user355,user3551234 +user356,user3561234 +user357,user3571234 +user358,user3581234 +user359,user3591234 +user360,user3601234 +user361,user3611234 +user362,user3621234 +user363,user3631234 +user364,user3641234 +user365,user3651234 +user366,user3661234 +user367,user3671234 +user368,user3681234 +user369,user3691234 +user370,user3701234 +user371,user3711234 +user372,user3721234 +user373,user3731234 +user374,user3741234 +user375,user3751234 +user376,user3761234 +user377,user3771234 +user378,user3781234 +user379,user3791234 +user380,user3801234 +user381,user3811234 +user382,user3821234 +user383,user3831234 +user384,user3841234 +user385,user3851234 +user386,user3861234 +user387,user3871234 +user388,user3881234 +user389,user3891234 +user390,user3901234 +user391,user3911234 +user392,user3921234 +user393,user3931234 +user394,user3941234 +user395,user3951234 +user396,user3961234 +user397,user3971234 +user398,user3981234 +user399,user3991234 +user400,user4001234 +user401,user4011234 +user402,user4021234 +user403,user4031234 +user404,user4041234 +user405,user4051234 +user406,user4061234 +user407,user4071234 +user408,user4081234 +user409,user4091234 +user410,user4101234 +user411,user4111234 +user412,user4121234 +user413,user4131234 +user414,user4141234 +user415,user4151234 +user416,user4161234 +user417,user4171234 +user418,user4181234 +user419,user4191234 +user420,user4201234 +user421,user4211234 +user422,user4221234 +user423,user4231234 +user424,user4241234 +user425,user4251234 +user426,user4261234 +user427,user4271234 +user428,user4281234 +user429,user4291234 +user430,user4301234 +user431,user4311234 +user432,user4321234 +user433,user4331234 +user434,user4341234 +user435,user4351234 +user436,user4361234 +user437,user4371234 +user438,user4381234 +user439,user4391234 +user440,user4401234 +user441,user4411234 +user442,user4421234 +user443,user4431234 +user444,user4441234 +user445,user4451234 +user446,user4461234 +user447,user4471234 +user448,user4481234 +user449,user4491234 +user450,user4501234 +user451,user4511234 +user452,user4521234 +user453,user4531234 +user454,user4541234 +user455,user4551234 +user456,user4561234 +user457,user4571234 +user458,user4581234 +user459,user4591234 +user460,user4601234 +user461,user4611234 +user462,user4621234 +user463,user4631234 +user464,user4641234 +user465,user4651234 +user466,user4661234 +user467,user4671234 +user468,user4681234 +user469,user4691234 +user470,user4701234 +user471,user4711234 +user472,user4721234 +user473,user4731234 +user474,user4741234 +user475,user4751234 +user476,user4761234 +user477,user4771234 +user478,user4781234 +user479,user4791234 +user480,user4801234 +user481,user4811234 +user482,user4821234 +user483,user4831234 +user484,user4841234 +user485,user4851234 +user486,user4861234 +user487,user4871234 +user488,user4881234 +user489,user4891234 +user490,user4901234 +user491,user4911234 +user492,user4921234 +user493,user4931234 +user494,user4941234 +user495,user4951234 +user496,user4961234 +user497,user4971234 +user498,user4981234 +user499,user4991234 +user500,user5001234 +user501,user5011234 +user502,user5021234 +user503,user5031234 +user504,user5041234 +user505,user5051234 +user506,user5061234 +user507,user5071234 +user508,user5081234 +user509,user5091234 +user510,user5101234 +user511,user5111234 +user512,user5121234 +user513,user5131234 +user514,user5141234 +user515,user5151234 +user516,user5161234 +user517,user5171234 +user518,user5181234 +user519,user5191234 +user520,user5201234 +user521,user5211234 +user522,user5221234 +user523,user5231234 +user524,user5241234 +user525,user5251234 +user526,user5261234 +user527,user5271234 +user528,user5281234 +user529,user5291234 +user530,user5301234 +user531,user5311234 +user532,user5321234 +user533,user5331234 +user534,user5341234 +user535,user5351234 +user536,user5361234 +user537,user5371234 +user538,user5381234 +user539,user5391234 +user540,user5401234 +user541,user5411234 +user542,user5421234 +user543,user5431234 +user544,user5441234 +user545,user5451234 +user546,user5461234 +user547,user5471234 +user548,user5481234 +user549,user5491234 +user550,user5501234 +user551,user5511234 +user552,user5521234 +user553,user5531234 +user554,user5541234 +user555,user5551234 +user556,user5561234 +user557,user5571234 +user558,user5581234 +user559,user5591234 +user560,user5601234 +user561,user5611234 +user562,user5621234 +user563,user5631234 +user564,user5641234 +user565,user5651234 +user566,user5661234 +user567,user5671234 +user568,user5681234 +user569,user5691234 +user570,user5701234 +user571,user5711234 +user572,user5721234 +user573,user5731234 +user574,user5741234 +user575,user5751234 +user576,user5761234 +user577,user5771234 +user578,user5781234 +user579,user5791234 +user580,user5801234 +user581,user5811234 +user582,user5821234 +user583,user5831234 +user584,user5841234 +user585,user5851234 +user586,user5861234 +user587,user5871234 +user588,user5881234 +user589,user5891234 +user590,user5901234 +user591,user5911234 +user592,user5921234 +user593,user5931234 +user594,user5941234 +user595,user5951234 +user596,user5961234 +user597,user5971234 +user598,user5981234 +user599,user5991234 +user600,user6001234 +user601,user6011234 +user602,user6021234 +user603,user6031234 +user604,user6041234 +user605,user6051234 +user606,user6061234 +user607,user6071234 +user608,user6081234 +user609,user6091234 +user610,user6101234 +user611,user6111234 +user612,user6121234 +user613,user6131234 +user614,user6141234 +user615,user6151234 +user616,user6161234 +user617,user6171234 +user618,user6181234 +user619,user6191234 +user620,user6201234 +user621,user6211234 +user622,user6221234 +user623,user6231234 +user624,user6241234 +user625,user6251234 +user626,user6261234 +user627,user6271234 +user628,user6281234 +user629,user6291234 +user630,user6301234 +user631,user6311234 +user632,user6321234 +user633,user6331234 +user634,user6341234 +user635,user6351234 +user636,user6361234 +user637,user6371234 +user638,user6381234 +user639,user6391234 +user640,user6401234 +user641,user6411234 +user642,user6421234 +user643,user6431234 +user644,user6441234 +user645,user6451234 +user646,user6461234 +user647,user6471234 +user648,user6481234 +user649,user6491234 +user650,user6501234 +user651,user6511234 +user652,user6521234 +user653,user6531234 +user654,user6541234 +user655,user6551234 +user656,user6561234 +user657,user6571234 +user658,user6581234 +user659,user6591234 +user660,user6601234 +user661,user6611234 +user662,user6621234 +user663,user6631234 +user664,user6641234 +user665,user6651234 +user666,user6661234 +user667,user6671234 +user668,user6681234 +user669,user6691234 +user670,user6701234 +user671,user6711234 +user672,user6721234 +user673,user6731234 +user674,user6741234 +user675,user6751234 +user676,user6761234 +user677,user6771234 +user678,user6781234 +user679,user6791234 +user680,user6801234 +user681,user6811234 +user682,user6821234 +user683,user6831234 +user684,user6841234 +user685,user6851234 +user686,user6861234 +user687,user6871234 +user688,user6881234 +user689,user6891234 +user690,user6901234 +user691,user6911234 +user692,user6921234 +user693,user6931234 +user694,user6941234 +user695,user6951234 +user696,user6961234 +user697,user6971234 +user698,user6981234 +user699,user6991234 +user700,user7001234 +user701,user7011234 +user702,user7021234 +user703,user7031234 +user704,user7041234 +user705,user7051234 +user706,user7061234 +user707,user7071234 +user708,user7081234 +user709,user7091234 +user710,user7101234 +user711,user7111234 +user712,user7121234 +user713,user7131234 +user714,user7141234 +user715,user7151234 +user716,user7161234 +user717,user7171234 +user718,user7181234 +user719,user7191234 +user720,user7201234 +user721,user7211234 +user722,user7221234 +user723,user7231234 +user724,user7241234 +user725,user7251234 +user726,user7261234 +user727,user7271234 +user728,user7281234 +user729,user7291234 +user730,user7301234 +user731,user7311234 +user732,user7321234 +user733,user7331234 +user734,user7341234 +user735,user7351234 +user736,user7361234 +user737,user7371234 +user738,user7381234 +user739,user7391234 +user740,user7401234 +user741,user7411234 +user742,user7421234 +user743,user7431234 +user744,user7441234 +user745,user7451234 +user746,user7461234 +user747,user7471234 +user748,user7481234 +user749,user7491234 +user750,user7501234 +user751,user7511234 +user752,user7521234 +user753,user7531234 +user754,user7541234 +user755,user7551234 +user756,user7561234 +user757,user7571234 +user758,user7581234 +user759,user7591234 +user760,user7601234 +user761,user7611234 +user762,user7621234 +user763,user7631234 +user764,user7641234 +user765,user7651234 +user766,user7661234 +user767,user7671234 +user768,user7681234 +user769,user7691234 +user770,user7701234 +user771,user7711234 +user772,user7721234 +user773,user7731234 +user774,user7741234 +user775,user7751234 +user776,user7761234 +user777,user7771234 +user778,user7781234 +user779,user7791234 +user780,user7801234 +user781,user7811234 +user782,user7821234 +user783,user7831234 +user784,user7841234 +user785,user7851234 +user786,user7861234 +user787,user7871234 +user788,user7881234 +user789,user7891234 +user790,user7901234 +user791,user7911234 +user792,user7921234 +user793,user7931234 +user794,user7941234 +user795,user7951234 +user796,user7961234 +user797,user7971234 +user798,user7981234 +user799,user7991234 +user800,user8001234 +user801,user8011234 +user802,user8021234 +user803,user8031234 +user804,user8041234 +user805,user8051234 +user806,user8061234 +user807,user8071234 +user808,user8081234 +user809,user8091234 +user810,user8101234 +user811,user8111234 +user812,user8121234 +user813,user8131234 +user814,user8141234 +user815,user8151234 +user816,user8161234 +user817,user8171234 +user818,user8181234 +user819,user8191234 +user820,user8201234 +user821,user8211234 +user822,user8221234 +user823,user8231234 +user824,user8241234 +user825,user8251234 +user826,user8261234 +user827,user8271234 +user828,user8281234 +user829,user8291234 +user830,user8301234 +user831,user8311234 +user832,user8321234 +user833,user8331234 +user834,user8341234 +user835,user8351234 +user836,user8361234 +user837,user8371234 +user838,user8381234 +user839,user8391234 +user840,user8401234 +user841,user8411234 +user842,user8421234 +user843,user8431234 +user844,user8441234 +user845,user8451234 +user846,user8461234 +user847,user8471234 +user848,user8481234 +user849,user8491234 +user850,user8501234 +user851,user8511234 +user852,user8521234 +user853,user8531234 +user854,user8541234 +user855,user8551234 +user856,user8561234 +user857,user8571234 +user858,user8581234 +user859,user8591234 +user860,user8601234 +user861,user8611234 +user862,user8621234 +user863,user8631234 +user864,user8641234 +user865,user8651234 +user866,user8661234 +user867,user8671234 +user868,user8681234 +user869,user8691234 +user870,user8701234 +user871,user8711234 +user872,user8721234 +user873,user8731234 +user874,user8741234 +user875,user8751234 +user876,user8761234 +user877,user8771234 +user878,user8781234 +user879,user8791234 +user880,user8801234 +user881,user8811234 +user882,user8821234 +user883,user8831234 +user884,user8841234 +user885,user8851234 +user886,user8861234 +user887,user8871234 +user888,user8881234 +user889,user8891234 +user890,user8901234 +user891,user8911234 +user892,user8921234 +user893,user8931234 +user894,user8941234 +user895,user8951234 +user896,user8961234 +user897,user8971234 +user898,user8981234 +user899,user8991234 +user900,user9001234 +user901,user9011234 +user902,user9021234 +user903,user9031234 +user904,user9041234 +user905,user9051234 +user906,user9061234 +user907,user9071234 +user908,user9081234 +user909,user9091234 +user910,user9101234 +user911,user9111234 +user912,user9121234 +user913,user9131234 +user914,user9141234 +user915,user9151234 +user916,user9161234 +user917,user9171234 +user918,user9181234 +user919,user9191234 +user920,user9201234 +user921,user9211234 +user922,user9221234 +user923,user9231234 +user924,user9241234 +user925,user9251234 +user926,user9261234 +user927,user9271234 +user928,user9281234 +user929,user9291234 +user930,user9301234 +user931,user9311234 +user932,user9321234 +user933,user9331234 +user934,user9341234 +user935,user9351234 +user936,user9361234 +user937,user9371234 +user938,user9381234 +user939,user9391234 +user940,user9401234 +user941,user9411234 +user942,user9421234 +user943,user9431234 +user944,user9441234 +user945,user9451234 +user946,user9461234 +user947,user9471234 +user948,user9481234 +user949,user9491234 +user950,user9501234 +user951,user9511234 +user952,user9521234 +user953,user9531234 +user954,user9541234 +user955,user9551234 +user956,user9561234 +user957,user9571234 +user958,user9581234 +user959,user9591234 +user960,user9601234 +user961,user9611234 +user962,user9621234 +user963,user9631234 +user964,user9641234 +user965,user9651234 +user966,user9661234 +user967,user9671234 +user968,user9681234 +user969,user9691234 +user970,user9701234 +user971,user9711234 +user972,user9721234 +user973,user9731234 +user974,user9741234 +user975,user9751234 +user976,user9761234 +user977,user9771234 +user978,user9781234 +user979,user9791234 +user980,user9801234 +user981,user9811234 +user982,user9821234 +user983,user9831234 +user984,user9841234 +user985,user9851234 +user986,user9861234 +user987,user9871234 +user988,user9881234 +user989,user9891234 +user990,user9901234 +user991,user9911234 +user992,user9921234 +user993,user9931234 +user994,user9941234 +user995,user9951234 +user996,user9961234 +user997,user9971234 +user998,user9981234 +user999,user9991234 +user1000,user10001234 From a46ef1ddf793eb1984b037c72f9b3c5d859e8801 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sat, 16 Aug 2025 19:46:21 +0900 Subject: [PATCH 23/37] =?UTF-8?q?refactor:=20=EB=A9=94=EC=84=B8=EC=A7=80?= =?UTF-8?q?=20=ED=81=90=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=EC=83=81=ED=83=9C=20reservation=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20+=20=EA=B2=B0=EC=A0=9C=EC=99=80=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docker-compose-prod.yml | 1 + backend/docker-compose.yml | 1 + .../payments/service/PaymentService.java | 4 +- .../repository/ReservationRepository.java | 13 +++- .../service/ReservationService.java | 32 +++------- .../consumer/RedisKeyExpirationConsumer.java | 62 +++++++++++++++++++ .../consumer/ReservationCancelConsumer.java | 2 +- .../producer/RedisKeyExpirationProducer.java | 21 +++++++ .../global/redis/RedisExpirationListener.java | 49 +++++++++++++++ .../ReservationExpirationScheduler.java | 46 -------------- backend/src/main/resources/application.yml | 1 + 11 files changed, 158 insertions(+), 74 deletions(-) create mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisKeyExpirationConsumer.java create mode 100644 backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisKeyExpirationProducer.java create mode 100644 backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java delete mode 100644 backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java diff --git a/backend/docker-compose-prod.yml b/backend/docker-compose-prod.yml index 6308e82..127e43c 100644 --- a/backend/docker-compose-prod.yml +++ b/backend/docker-compose-prod.yml @@ -16,6 +16,7 @@ services: redis-prod: image: redis:latest container_name: redis-prod + command: ["redis-server", "--notify-keyspace-events", "Ex"] restart: always ports: - "6380:6379" diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index fab37bf..b4ca17b 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -4,6 +4,7 @@ services: redis-dev: image: redis:latest container_name: redis-dev + command: ["redis-server", "--notify-keyspace-events", "Ex"] restart: always ports: - "6379:6379" 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 0afd838..a191a0e 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 @@ -65,7 +65,7 @@ public List getPayments(Member member) { @Transactional public PaymentDto requestPayment(Member member, Long reservationId) { - Reservation reservation = reservationRepository.findByIdAndStudentAndStatus(reservationId, member, ReservationStatus.PENDING) + Reservation reservation = reservationRepository.findByIdAndStudentAndStatusWithPessimisticLock(reservationId, member, ReservationStatus.PENDING) .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); Optional opPayment = paymentRepository.findByReservation(reservation); @@ -136,7 +136,7 @@ public PaymentDto confirmPayment(String paymentKey, String orderId, Long amount) @Transactional public PaymentDto deletePaymentRequest(Member member, Long reservationId) { // reservation 이력 조회 - Reservation reservation = reservationRepository.findByIdAndStudentAndStatus(reservationId, member, ReservationStatus.COMPLETED) + Reservation reservation = reservationRepository.findByIdAndStudentAndStatusWithPessimisticLock(reservationId, member, ReservationStatus.COMPLETED) .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); Payment payment = paymentRepository.findByReservation(reservation) diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index 6726c21..dbcc782 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -4,16 +4,16 @@ import com.Catch_Course.domain.member.entity.Member; import com.Catch_Course.domain.reservation.entity.Reservation; import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; @Repository @@ -24,6 +24,10 @@ public interface ReservationRepository extends JpaRepository @EntityGraph(attributePaths = {"course", "student"}) Page findAllByStudentAndStatus(Member member, ReservationStatus reservationStatus, Pageable pageable); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @EntityGraph(attributePaths = {"course", "student", "payment"}) + Optional findByIdAndStudentAndStatusWithPessimisticLock(Long reservationId, Member member, ReservationStatus reservationStatus); + @EntityGraph(attributePaths = {"course", "student", "payment"}) Optional findByIdAndStudentAndStatus(Long reservationId, Member member, ReservationStatus reservationStatus); @@ -31,5 +35,8 @@ public interface ReservationRepository extends JpaRepository @Query("SELECT r FROM Reservation r JOIN FETCH r.course WHERE r.id = :id") Optional findWithCourseById(@Param("id") Long id); - List findAllByStatusAndCreatedDateBefore(ReservationStatus reservationStatus, LocalDateTime expiredTime); + @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 Lock, FOR UPDATE 전부 차단 + @Query("select r from Reservation r where r.id= :id") + Optional findByIdWithPessimisticLock(@Param("id") Long id); + } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index 418a4b8..b408aab 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -22,12 +22,12 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @@ -43,6 +43,7 @@ public class ReservationService { private final NotificationService notificationService; private final SseService sseService; private final PaymentService paymentService; + private final RedisTemplate redisTemplate; private final int TIME_LIMIT = 2; public Reservation addToQueue(Member member, Long courseId) { @@ -162,8 +163,15 @@ public void processReservation(Long courseId, Long memberId) { courseRepository.save(course); reservation.setStatus(ReservationStatus.PENDING); + + // Redis에 만료 타이머 등록 + String expirationKey = "reservation:expire:" + reservation.getId(); + redisTemplate.opsForValue().set(expirationKey, "", TIME_LIMIT, TimeUnit.MINUTES); + log.info("Redis 만료 타이머 등록. Key: {}, 만료 시간: {} 분", expirationKey, TIME_LIMIT); + NotificationDto notificationDto = new NotificationDto(reservation, "수강 신청이 성공하였습니다."); sseService.sendToClient(memberId, "ReservationResult", notificationDto); + notificationService.saveNotification(memberId, notificationDto); reservationRepository.save(reservation); } catch (ServiceException e) { @@ -171,24 +179,4 @@ public void processReservation(Long courseId, Long memberId) { sseService.sendToClient(memberId, "ReservationResult", notificationDto); } } - - // 결제 시간 만료 설정 - @Transactional - public void expireOldPendingPayments() { - LocalDateTime expiredTime = LocalDateTime.now().minusMinutes(TIME_LIMIT); - - // 1. 유효 시간이 지난 PENDING 상태의 결제 건들을 조회 - List expiredReservations = reservationRepository.findAllByStatusAndCreatedDateBefore( - ReservationStatus.PENDING, - expiredTime - ); - - if (expiredReservations.isEmpty()) { - return; // 처리할 건이 없으면 종료 - } - - for (Reservation reservation : expiredReservations) { - reservationCancelProducer.send(new ReservationCancelRequest(reservation.getId(), reservation.getStudent().getId(), reservation.getCourse().getId())); - } - } } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisKeyExpirationConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisKeyExpirationConsumer.java new file mode 100644 index 0000000..f2342db --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisKeyExpirationConsumer.java @@ -0,0 +1,62 @@ +package com.Catch_Course.global.kafka.consumer; + +import com.Catch_Course.domain.course.entity.Course; +import com.Catch_Course.domain.course.repository.CourseRepository; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisKeyExpirationConsumer { + + private final ReservationRepository reservationRepository; + private final CourseRepository courseRepository; + + // 구독 + @KafkaListener(topics = "course-reservation-expired", groupId = "course") + @Transactional + public void consume(ReservationCancelRequest reservationCancelRequest) { + + log.info("결제 기한 만료 처리 시작: {}", reservationCancelRequest); + try { + // 실제 로직 호출 + Optional optionalReservation = reservationRepository.findByIdWithPessimisticLock(reservationCancelRequest.getReservationId()); + + if (optionalReservation.isEmpty()) { + log.warn("취소할 예약을 찾을 수 없습니다. ID: {}", reservationCancelRequest.getReservationId()); + return; + } + + Reservation reservation = optionalReservation.get(); + if (!reservation.getStatus().equals(ReservationStatus.PENDING)) { + log.info("예약 ID {}는 PENDING 상태가 아니므로 취소하지 않습니다. 현재 상태: {}", reservation.getId(), reservation.getStatus()); + return; + } + + cancelProcess(reservation, reservation.getCourse()); + log.info("{}의 수강 취소가 성공적으로 처리되었습니다.", reservationCancelRequest.getCourseId()); + } catch (Exception e) { + log.error("Error Request: {}", reservationCancelRequest, e); + } + } + + private void cancelProcess(Reservation reservation, Course course) { + // 현재 인원 감소 + course.decreaseReservation(); + courseRepository.save(course); + + // 상태 변경 + reservation.setStatus(ReservationStatus.CANCELLED); + reservationRepository.save(reservation); + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java index ad774ea..673ba5f 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java @@ -32,7 +32,7 @@ public void consume(ReservationCancelRequest reservationCancelRequest) { log.info("수강 취소 처리 시작: {}", reservationCancelRequest); try { // 실제 로직 호출 - Reservation reservation = reservationRepository.findById(reservationCancelRequest.getReservationId()) + Reservation reservation = reservationRepository.findByIdWithPessimisticLock(reservationCancelRequest.getReservationId()) .orElseThrow(() -> new ServiceException("404-3", "수강 신청 이력이 없습니다.")); if (reservation.getStatus() == ReservationStatus.CANCELLED) { diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisKeyExpirationProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisKeyExpirationProducer.java new file mode 100644 index 0000000..e4ee26b --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisKeyExpirationProducer.java @@ -0,0 +1,21 @@ +package com.Catch_Course.global.kafka.producer; + +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RedisKeyExpirationProducer { + + private final KafkaTemplate kafkaTemplate; + private static final String TOPIC = "course-reservation-expired"; + + public void send(ReservationCancelRequest request) { + log.info("결제 기한 만료로 인한 수강 취소 요청 메세지 전송: {}", request); + kafkaTemplate.send(TOPIC, request); + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java b/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java new file mode 100644 index 0000000..219c2c4 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java @@ -0,0 +1,49 @@ +package com.Catch_Course.global.redis; + +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import com.Catch_Course.global.kafka.producer.RedisKeyExpirationProducer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisExpirationListener implements MessageListener { + + private final ReservationRepository reservationRepository; + private final RedisKeyExpirationProducer redisKeyExpirationProducer; + + @Override + public void onMessage(Message message, byte[] pattern) { + + String expiredKey = message.toString(); + log.info("Redis Key 만료 감지: {}", expiredKey); + + if(expiredKey.startsWith("reservation:expire:")) { + String[] params = expiredKey.split(":"); + if(params.length == 3) { + try { + Long reservationId = Long.parseLong(params[2]); + + Optional reservation = reservationRepository.findByIdWithPessimisticLock(reservationId); + + if(reservation.isPresent()) { + Long memberId = reservation.get().getStudent().getId(); + Long courseId = reservation.get().getCourse().getId(); + redisKeyExpirationProducer.send(new ReservationCancelRequest(reservationId, memberId, courseId)); + } + } catch (NumberFormatException e){ + log.error("만료된 Redis 키에서 예약 ID를 파싱하는 데 실패했습니다: {}", expiredKey, e); + } + } + } + + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java b/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java deleted file mode 100644 index 8b43028..0000000 --- a/backend/src/main/java/com/Catch_Course/global/scheduler/ReservationExpirationScheduler.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.Catch_Course.global.scheduler; - -import com.Catch_Course.domain.reservation.service.ReservationService; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@Slf4j -public class ReservationExpirationScheduler { - - private final ReservationService reservationService; - private final Timer reservationSchedulerTimer; // Timer 주입 - - // MeterRegistry를 통해 Timer 빈을 생성 - public ReservationExpirationScheduler(ReservationService reservationService, MeterRegistry meterRegistry) { - this.reservationService = reservationService; - // "scheduler.reservation.expiration" 이라는 이름으로 Timer 메트릭 등록 - this.reservationSchedulerTimer = Timer.builder("scheduler.reservation.expiration") - .description("결제 대기 시간 만료 스케줄러의 실행 시간 측정") - .tag("scheduler", "reservation") // 검색 및 필터링을 위한 태그 - .register(meterRegistry); - } - - - @Scheduled(cron = "0 * * * * *") - public void expirePendingPayments() { - log.info("결제 대기 시간 만료 스케줄러 시작"); - - // Timer를 사용하여 작업 실행 시간을 측정 - reservationSchedulerTimer.record(() -> { - try { - reservationService.expireOldPendingPayments(); - } catch (Exception e) { - // 예외 발생 시 로그 및 추가 메트릭 처리 가능 - log.error("스케줄러 실행 중 오류 발생", e); - // (선택) 에러 카운터 메트릭을 추가할 수도 있습니다. - // meterRegistry.counter("scheduler.reservation.expiration.errors").increment(); - } - }); - - log.info("결제 대기 시간 만료 스케줄러 종료"); - } -} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 2f27675..d28e72f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,3 +1,4 @@ +# application.yml server: port: 8080 From 85bd2afdd026be2cbc33c96c52d12c79e4d023f1 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sat, 16 Aug 2025 20:15:16 +0900 Subject: [PATCH 24/37] =?UTF-8?q?refactor:=20=EB=8F=99=EC=8B=9C=EC=84=B1?= =?UTF-8?q?=20=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/course/controller/CourseController.java | 4 ++-- .../domain/course/service/CourseService.java | 4 ++++ .../domain/payments/repository/PaymentRepository.java | 8 ++++++++ .../reservation/repository/ReservationRepository.java | 9 +++++++-- .../global/kafka/consumer/PaymentCancelConsumer.java | 4 ++-- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java b/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java index 82d6961..6afe298 100644 --- a/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java +++ b/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java @@ -73,7 +73,7 @@ public RsData getItem(@PathVariable long id) { public RsData delete(@PathVariable long id) { Member dummyMember = rq.getDummyMember(); // 더미 유저 객체(id,username,authorities 만 있음, 필요하면 DB에서 꺼내씀) - Course course = courseService.getItem(id) + Course course = courseService.getItemWithPessimisticLock(id) .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); course.canDelete(dummyMember); @@ -100,7 +100,7 @@ public RsData modify(@PathVariable long id, @RequestBody @Valid ModifyReqB ) { Member dummyMember = rq.getDummyMember(); - Course course = courseService.getItem(id) + Course course = courseService.getItemWithPessimisticLock(id) .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); if (!course.getInstructor().getId().equals(dummyMember.getId())) { diff --git a/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java b/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java index cc82e42..2ec01ed 100644 --- a/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java +++ b/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java @@ -59,6 +59,10 @@ public Optional getItem(long courseId) { return courseRepository.findById(courseId); } + public Optional getItemWithPessimisticLock(long courseId) { + return courseRepository.findByIdWithPessimisticLock(courseId); + } + public long count() { return courseRepository.count(); } diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java index a61af37..ae6f090 100644 --- a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java @@ -4,7 +4,11 @@ import com.Catch_Course.domain.payments.entity.Payment; import com.Catch_Course.domain.payments.entity.PaymentStatus; import com.Catch_Course.domain.reservation.entity.Reservation; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -17,4 +21,8 @@ public interface PaymentRepository extends JpaRepository { Optional findByMerchantUid(String orderId); List findByMemberAndStatus(Member member, PaymentStatus paymentStatus); + + @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 Lock, FOR UPDATE 전부 차단 + @Query("select r from Reservation r where r.id= :id") + Optional findByIdWithPessimisticLock(@Param("id") Long id); } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index dbcc782..d87fccd 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -26,14 +26,19 @@ public interface ReservationRepository extends JpaRepository @Lock(LockModeType.PESSIMISTIC_WRITE) @EntityGraph(attributePaths = {"course", "student", "payment"}) - Optional findByIdAndStudentAndStatusWithPessimisticLock(Long reservationId, Member member, ReservationStatus reservationStatus); + @Query("select r from Reservation r where r.id = :reservationId and r.student = :member and r.status = :reservationStatus") + Optional findByIdAndStudentAndStatusWithPessimisticLock( + @Param("reservationId") Long reservationId, + @Param("member") Member member, + @Param("reservationStatus") ReservationStatus reservationStatus); @EntityGraph(attributePaths = {"course", "student", "payment"}) Optional findByIdAndStudentAndStatus(Long reservationId, Member member, ReservationStatus reservationStatus); // Reservation 조회 시 연관된 Course도 함께 가져오는 메서드 + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT r FROM Reservation r JOIN FETCH r.course WHERE r.id = :id") - Optional findWithCourseById(@Param("id") Long id); + Optional findWithCourseByIdWithPessimisticLock(@Param("id") Long id); @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 Lock, FOR UPDATE 전부 차단 @Query("select r from Reservation r where r.id= :id") diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java index b16c877..fd9e695 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java @@ -36,7 +36,7 @@ public class PaymentCancelConsumer { public void consume(PaymentCancelRequest paymentCancelRequest) { log.info("결제 취소 요청 처리 시작: {}", paymentCancelRequest); - Payment payment = paymentRepository.findById(paymentCancelRequest.getPaymentId()) + Payment payment = paymentRepository.findByIdWithPessimisticLock(paymentCancelRequest.getPaymentId()) .orElseThrow(() -> new ServiceException("404-5","결제 정보가 존재하지 않습니다.")); if(payment.getStatus() == PaymentStatus.CANCELLED) { @@ -53,7 +53,7 @@ public void consume(PaymentCancelRequest paymentCancelRequest) { public void cancelProcess(Payment payment,PaymentCancelRequest paymentCancelRequest) { - Reservation reservation = reservationRepository.findWithCourseById(paymentCancelRequest.getReservationId()) + Reservation reservation = reservationRepository.findWithCourseByIdWithPessimisticLock(paymentCancelRequest.getReservationId()) .orElseThrow(() -> new ServiceException("404-3","수강신청 이력이 없습니다.")); Member member = memberRepository.findById(paymentCancelRequest.getMemberId()) From 03b323611b7d609a0100344f00111d6abd810a33 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sat, 16 Aug 2025 21:38:38 +0900 Subject: [PATCH 25/37] =?UTF-8?q?rollback:=20init=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/Catch_Course/global/init/BaseInitData.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java index f809dbd..e2fb318 100644 --- a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java +++ b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java @@ -37,7 +37,7 @@ public void memberInit() { return; } - for (int i = 1; i <= 1000; i++) { + for (int i = 1; i <= 100; i++) { memberService.join("user%d".formatted(i), "user%d1234".formatted(i), "유저%d".formatted(i), "user%d@example.com".formatted(i), ""); } From 190398133347f5faa1c71f3d7741aab25d1c3df7 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sun, 17 Aug 2025 10:59:03 +0900 Subject: [PATCH 26/37] =?UTF-8?q?fix:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReservationService.java | 25 +++++++++++++++++-- .../global/init/BaseInitData.java | 2 +- ...umer.java => RedisExpirationConsumer.java} | 2 +- ...ucer.java => RedisExpirationProducer.java} | 2 +- .../global/redis/DebugRedisListener.java | 18 +++++++++++++ .../global/redis/RedisConfig.java | 12 +++++++++ .../global/redis/RedisExpirationListener.java | 20 +++------------ .../src/main/resources/application-dev.yml | 2 ++ 8 files changed, 61 insertions(+), 22 deletions(-) rename backend/src/main/java/com/Catch_Course/global/kafka/consumer/{RedisKeyExpirationConsumer.java => RedisExpirationConsumer.java} (98%) rename backend/src/main/java/com/Catch_Course/global/kafka/producer/{RedisKeyExpirationProducer.java => RedisExpirationProducer.java} (94%) create mode 100644 backend/src/main/java/com/Catch_Course/global/redis/DebugRedisListener.java diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index b408aab..099bb7b 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -14,6 +14,7 @@ import com.Catch_Course.global.exception.ServiceException; import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; import com.Catch_Course.global.kafka.dto.ReservationRequest; +import com.Catch_Course.global.kafka.producer.RedisExpirationProducer; import com.Catch_Course.global.kafka.producer.ReservationCancelProducer; import com.Catch_Course.global.kafka.producer.ReservationProducer; import com.Catch_Course.global.sse.service.SseService; @@ -43,8 +44,9 @@ public class ReservationService { private final NotificationService notificationService; private final SseService sseService; private final PaymentService paymentService; - private final RedisTemplate redisTemplate; - private final int TIME_LIMIT = 2; + private final RedisTemplate redisTemplate; + private final RedisExpirationProducer redisExpirationProducer; + private final int TIME_LIMIT = 1; public Reservation addToQueue(Member member, Long courseId) { Course course = courseRepository.findByIdWithPessimisticLock(courseId) // 비관적 Lock 을 걸고 조회 @@ -179,4 +181,23 @@ public void processReservation(Long courseId, Long memberId) { sseService.sendToClient(memberId, "ReservationResult", notificationDto); } } + + public void expireReservation(Long reservationId) { + log.info("만료 처리 서비스 시작. 예약 ID: {}", reservationId); + try { + Optional reservation = reservationRepository.findByIdWithPessimisticLock(reservationId); + + if(reservation.isPresent()) { + Long memberId = reservation.get().getStudent().getId(); + Long courseId = reservation.get().getCourse().getId(); + redisExpirationProducer.send(new ReservationCancelRequest(reservationId, memberId, courseId)); + log.info("만료 처리 Kafka 메시지 전송 성공. 예약 ID: {}", reservationId); + } + log.warn("만료 처리 시점에 예약을 찾을 수 없음. ID: {}", reservationId); + + } catch (Exception e) { + log.error("만료 처리 중 예외 발생. 예약 ID: {}", reservationId, e); + } + } + } diff --git a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java index e2fb318..f809dbd 100644 --- a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java +++ b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java @@ -37,7 +37,7 @@ public void memberInit() { return; } - for (int i = 1; i <= 100; i++) { + for (int i = 1; i <= 1000; i++) { memberService.join("user%d".formatted(i), "user%d1234".formatted(i), "유저%d".formatted(i), "user%d@example.com".formatted(i), ""); } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisKeyExpirationConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisExpirationConsumer.java similarity index 98% rename from backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisKeyExpirationConsumer.java rename to backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisExpirationConsumer.java index f2342db..834eb11 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisKeyExpirationConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisExpirationConsumer.java @@ -17,7 +17,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class RedisKeyExpirationConsumer { +public class RedisExpirationConsumer { private final ReservationRepository reservationRepository; private final CourseRepository courseRepository; diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisKeyExpirationProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisExpirationProducer.java similarity index 94% rename from backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisKeyExpirationProducer.java rename to backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisExpirationProducer.java index e4ee26b..c9ae3fe 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisKeyExpirationProducer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisExpirationProducer.java @@ -9,7 +9,7 @@ @Component @Slf4j @RequiredArgsConstructor -public class RedisKeyExpirationProducer { +public class RedisExpirationProducer { private final KafkaTemplate kafkaTemplate; private static final String TOPIC = "course-reservation-expired"; diff --git a/backend/src/main/java/com/Catch_Course/global/redis/DebugRedisListener.java b/backend/src/main/java/com/Catch_Course/global/redis/DebugRedisListener.java new file mode 100644 index 0000000..239f9a6 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/redis/DebugRedisListener.java @@ -0,0 +1,18 @@ +package com.Catch_Course.global.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class DebugRedisListener implements MessageListener { + + @Override + public void onMessage(Message message, byte[] pattern) { + log.info(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"); + log.info(">>>> DEBUG: Event Received! Key = {}", message.toString()); + log.info(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/redis/RedisConfig.java b/backend/src/main/java/com/Catch_Course/global/redis/RedisConfig.java index f3e2e8d..5a78829 100644 --- a/backend/src/main/java/com/Catch_Course/global/redis/RedisConfig.java +++ b/backend/src/main/java/com/Catch_Course/global/redis/RedisConfig.java @@ -11,6 +11,8 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -54,4 +56,14 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec return redisTemplate; } + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory connectionFactory, + RedisExpirationListener expirationListener // 리스너 Bean을 주입 + ) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(expirationListener, new PatternTopic("__keyevent@*__:expired")); + return container; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java b/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java index 219c2c4..6dc4cf7 100644 --- a/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java +++ b/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java @@ -1,24 +1,18 @@ package com.Catch_Course.global.redis; -import com.Catch_Course.domain.reservation.entity.Reservation; -import com.Catch_Course.domain.reservation.repository.ReservationRepository; -import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; -import com.Catch_Course.global.kafka.producer.RedisKeyExpirationProducer; +import com.Catch_Course.domain.reservation.service.ReservationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.MessageListener; import org.springframework.stereotype.Component; -import java.util.Optional; - @Component @RequiredArgsConstructor @Slf4j public class RedisExpirationListener implements MessageListener { - private final ReservationRepository reservationRepository; - private final RedisKeyExpirationProducer redisKeyExpirationProducer; + private final ReservationService reservationService; @Override public void onMessage(Message message, byte[] pattern) { @@ -31,19 +25,11 @@ public void onMessage(Message message, byte[] pattern) { if(params.length == 3) { try { Long reservationId = Long.parseLong(params[2]); - - Optional reservation = reservationRepository.findByIdWithPessimisticLock(reservationId); - - if(reservation.isPresent()) { - Long memberId = reservation.get().getStudent().getId(); - Long courseId = reservation.get().getCourse().getId(); - redisKeyExpirationProducer.send(new ReservationCancelRequest(reservationId, memberId, courseId)); - } + reservationService.expireReservation(reservationId); } catch (NumberFormatException e){ log.error("만료된 Redis 키에서 예약 ID를 파싱하는 데 실패했습니다: {}", expiredKey, e); } } } - } } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index a066c28..ebfbab4 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -22,6 +22,8 @@ logging: org.hibernate.orm.jdbc.bind: TRACE org.hibernate.orm.jdbc.extract: TRACE org.springframework.transaction.interceptor: TRACE + org.springframework.data.redis: DEBUG + io.lettuce.core: DEBUG management: endpoints: From 747271e83252141009094a090dda4e45297dac44 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sun, 17 Aug 2025 11:00:15 +0900 Subject: [PATCH 27/37] =?UTF-8?q?rollback:=20init=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/Catch_Course/global/init/BaseInitData.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java index f809dbd..e2fb318 100644 --- a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java +++ b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java @@ -37,7 +37,7 @@ public void memberInit() { return; } - for (int i = 1; i <= 1000; i++) { + for (int i = 1; i <= 100; i++) { memberService.join("user%d".formatted(i), "user%d1234".formatted(i), "유저%d".formatted(i), "user%d@example.com".formatted(i), ""); } From a222990cdb92dadd48c14c9a151aad3f616ea698 Mon Sep 17 00:00:00 2001 From: wonseokyoon Date: Sun, 17 Aug 2025 22:20:04 +0900 Subject: [PATCH 28/37] rollback --- .../service/ReservationService.java | 2 +- .../global/redis/DebugRedisListener.java | 18 ------------------ 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 backend/src/main/java/com/Catch_Course/global/redis/DebugRedisListener.java diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index 099bb7b..e0b4e5c 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -46,7 +46,7 @@ public class ReservationService { private final PaymentService paymentService; private final RedisTemplate redisTemplate; private final RedisExpirationProducer redisExpirationProducer; - private final int TIME_LIMIT = 1; + private final int TIME_LIMIT = 30; public Reservation addToQueue(Member member, Long courseId) { Course course = courseRepository.findByIdWithPessimisticLock(courseId) // 비관적 Lock 을 걸고 조회 diff --git a/backend/src/main/java/com/Catch_Course/global/redis/DebugRedisListener.java b/backend/src/main/java/com/Catch_Course/global/redis/DebugRedisListener.java deleted file mode 100644 index 239f9a6..0000000 --- a/backend/src/main/java/com/Catch_Course/global/redis/DebugRedisListener.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.Catch_Course.global.redis; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.connection.Message; -import org.springframework.data.redis.connection.MessageListener; -import org.springframework.stereotype.Component; - -@Component -@Slf4j -public class DebugRedisListener implements MessageListener { - - @Override - public void onMessage(Message message, byte[] pattern) { - log.info(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"); - log.info(">>>> DEBUG: Event Received! Key = {}", message.toString()); - log.info(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"); - } -} \ No newline at end of file From 553451d9a5850e0c512ab97bc4b11bda9c22c60c Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:24:58 +0900 Subject: [PATCH 29/37] fix:CI --- .github/workflows/deploy.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ce10503..5fdd74c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -56,10 +56,17 @@ jobs: - name: Docker Buildx 설치 uses: docker/setup-buildx-action@v2 - + + - name: Start services for test + run: docker-compose -f ../docker-compose.yml up -d + - name: Run tests with Gradle run: ./gradlew test + - name: Stop services after test + if: always() + run: docker-compose -f ../docker-compose.yml down + # 테스트 실패 시 리포트를 아티팩트로 업로드하여 디버깅을 돕습니다. - name: Upload test reports if: failure() From 5123f09e423d2e648602c07f89cfce250e822e03 Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:26:16 +0900 Subject: [PATCH 30/37] fix:CI --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5fdd74c..84ddb18 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -58,14 +58,14 @@ jobs: uses: docker/setup-buildx-action@v2 - name: Start services for test - run: docker-compose -f ../docker-compose.yml up -d + run: docker compose -f ../docker-compose.yml up -d - name: Run tests with Gradle run: ./gradlew test - name: Stop services after test if: always() - run: docker-compose -f ../docker-compose.yml down + run: docker compose -f ../docker-compose.yml down # 테스트 실패 시 리포트를 아티팩트로 업로드하여 디버깅을 돕습니다. - name: Upload test reports From 3dd622ddca3991794a89568c8d4a539ca57d8595 Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:28:15 +0900 Subject: [PATCH 31/37] fix:CI --- .github/workflows/deploy.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 84ddb18..a36e7a4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -56,7 +56,6 @@ jobs: - name: Docker Buildx 설치 uses: docker/setup-buildx-action@v2 - - name: Start services for test run: docker compose -f ../docker-compose.yml up -d @@ -78,7 +77,7 @@ jobs: # 2. 릴리스 : main/develop 브랜치로 Push될 때만 실행 makeTagAndRelease: name: Create Tag and Release - if: github.event_name == 'push' + # if: github.event_name == 'push' needs: backend-ci runs-on: ubuntu-latest permissions: @@ -108,7 +107,7 @@ jobs: # 3. 빌드 및 배포: main/develop 브랜치로 Push될 때만 실행 buildImageAndPush: name: 도커 이미지 빌드와 푸시 - if: github.event_name == 'push' + # if: github.event_name == 'push' needs: makeTagAndRelease runs-on: ubuntu-latest steps: From 84173509475ebc312c61efd78b6a9a934de177ee Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:31:33 +0900 Subject: [PATCH 32/37] fix:CI --- .github/workflows/deploy.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a36e7a4..322228a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,9 +13,6 @@ on: jobs: backend-ci: runs-on: ubuntu-latest - defaults: - run: - working-directory: ./backend env: APPLICATION_SECRET: ${{ secrets.APPLICATION_SECRET }} @@ -41,12 +38,11 @@ jobs: ${{ runner.os }}-gradle- - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - + run: chmod +x ./backend/gradlew + - name: application-secret.yml 생성 run: | - echo "$APPLICATION_SECRET" > src/main/resources/application-secret.yml - + echo "$APPLICATION_SECRET" > ./backend/src/main/resources/application-secret.yml - name: Docker Login uses: docker/login-action@v2 with: @@ -56,15 +52,17 @@ jobs: - name: Docker Buildx 설치 uses: docker/setup-buildx-action@v2 + - name: Start services for test - run: docker compose -f ../docker-compose.yml up -d + run: docker compose up -d - name: Run tests with Gradle run: ./gradlew test + working-directory: ./backend - name: Stop services after test if: always() - run: docker compose -f ../docker-compose.yml down + run: docker compose down # 테스트 실패 시 리포트를 아티팩트로 업로드하여 디버깅을 돕습니다. - name: Upload test reports @@ -77,7 +75,7 @@ jobs: # 2. 릴리스 : main/develop 브랜치로 Push될 때만 실행 makeTagAndRelease: name: Create Tag and Release - # if: github.event_name == 'push' + if: github.event_name == 'push' needs: backend-ci runs-on: ubuntu-latest permissions: @@ -107,7 +105,7 @@ jobs: # 3. 빌드 및 배포: main/develop 브랜치로 Push될 때만 실행 buildImageAndPush: name: 도커 이미지 빌드와 푸시 - # if: github.event_name == 'push' + if: github.event_name == 'push' needs: makeTagAndRelease runs-on: ubuntu-latest steps: From b55d7770399a015191bc71c8bcf3485928c222b7 Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:34:52 +0900 Subject: [PATCH 33/37] fix:CI --- .github/workflows/deploy.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 322228a..4f5dece 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,7 +26,6 @@ jobs: java-version: '21' distribution: 'temurin' - # Gradle 캐싱을 추가하여 빌드 속도를 개선합니다. - name: Cache Gradle packages uses: actions/cache@v4 with: @@ -39,10 +38,10 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x ./backend/gradlew - - - name: application-secret.yml 생성 + - name: Create application-secret.yml run: | echo "$APPLICATION_SECRET" > ./backend/src/main/resources/application-secret.yml + - name: Docker Login uses: docker/login-action@v2 with: @@ -50,7 +49,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker Buildx 설치 + - name: Docker Buildx setup uses: docker/setup-buildx-action@v2 - name: Start services for test @@ -75,7 +74,7 @@ jobs: # 2. 릴리스 : main/develop 브랜치로 Push될 때만 실행 makeTagAndRelease: name: Create Tag and Release - if: github.event_name == 'push' + # if: github.event_name == 'push' needs: backend-ci runs-on: ubuntu-latest permissions: @@ -105,7 +104,7 @@ jobs: # 3. 빌드 및 배포: main/develop 브랜치로 Push될 때만 실행 buildImageAndPush: name: 도커 이미지 빌드와 푸시 - if: github.event_name == 'push' + # if: github.event_name == 'push' needs: makeTagAndRelease runs-on: ubuntu-latest steps: @@ -143,7 +142,6 @@ jobs: with: context: ./backend push: true - # 이 옵션을 추가하여 Docker 빌드 캐시를 사용하지 않도록 강제합니다. no-cache: true tags: | ghcr.io/${{ env.OWNER_LC }}/catch-course:${{ needs.makeTagAndRelease.outputs.tag_name }} From fb8998985adce0fb455afabef526cb9955af20f6 Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:37:50 +0900 Subject: [PATCH 34/37] fix:CI --- .github/workflows/deploy.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4f5dece..6879c57 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,7 @@ on: jobs: backend-ci: runs-on: ubuntu-latest + # [수정] 모든 run 명령어에 working-directory를 강제하던 defaults 설정을 제거했습니다. env: APPLICATION_SECRET: ${{ secrets.APPLICATION_SECRET }} @@ -52,6 +53,9 @@ jobs: - name: Docker Buildx setup uses: docker/setup-buildx-action@v2 + - name: List files in root directory + run: ls -la + - name: Start services for test run: docker compose up -d @@ -145,4 +149,4 @@ jobs: no-cache: true tags: | ghcr.io/${{ env.OWNER_LC }}/catch-course:${{ needs.makeTagAndRelease.outputs.tag_name }} - ghcr.io/${{ env.OWNER_LC }}/catch-course:latest \ No newline at end of file + ghcr.io/${{ env.OWNER_LC }}/catch-course:latest From 107ba2a37225b1bf6a91d3f6866f9bd72aadfb96 Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:38:36 +0900 Subject: [PATCH 35/37] fix:CI --- .github/workflows/deploy.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6879c57..275fc00 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,7 +13,6 @@ on: jobs: backend-ci: runs-on: ubuntu-latest - # [수정] 모든 run 명령어에 working-directory를 강제하던 defaults 설정을 제거했습니다. env: APPLICATION_SECRET: ${{ secrets.APPLICATION_SECRET }} @@ -57,7 +56,7 @@ jobs: run: ls -la - name: Start services for test - run: docker compose up -d + run: docker compose -f docker-compose.yml up -d - name: Run tests with Gradle run: ./gradlew test @@ -65,7 +64,7 @@ jobs: - name: Stop services after test if: always() - run: docker compose down + run: docker compose -f docker-compose.yml down # 테스트 실패 시 리포트를 아티팩트로 업로드하여 디버깅을 돕습니다. - name: Upload test reports From 48cab2063b98cae6ec88ee2f554c0ac6e4c5c06a Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:42:17 +0900 Subject: [PATCH 36/37] fix:CI --- .github/workflows/deploy.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 275fc00..09cbb7e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,9 @@ on: jobs: backend-ci: runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend env: APPLICATION_SECRET: ${{ secrets.APPLICATION_SECRET }} @@ -26,6 +29,7 @@ jobs: java-version: '21' distribution: 'temurin' + # Gradle 캐싱을 추가하여 빌드 속도를 개선합니다. - name: Cache Gradle packages uses: actions/cache@v4 with: @@ -37,10 +41,10 @@ jobs: ${{ runner.os }}-gradle- - name: Grant execute permission for gradlew - run: chmod +x ./backend/gradlew - - name: Create application-secret.yml + run: chmod +x ./gradlew + - name: application-secret.yml 생성 run: | - echo "$APPLICATION_SECRET" > ./backend/src/main/resources/application-secret.yml + echo "$APPLICATION_SECRET" > src/main/resources/application-secret.yml - name: Docker Login uses: docker/login-action@v2 @@ -49,22 +53,18 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker Buildx setup + - name: Docker Buildx 설치 uses: docker/setup-buildx-action@v2 - - name: List files in root directory - run: ls -la - - name: Start services for test - run: docker compose -f docker-compose.yml up -d + run: docker compose up -d - name: Run tests with Gradle run: ./gradlew test - working-directory: ./backend - name: Stop services after test if: always() - run: docker compose -f docker-compose.yml down + run: docker compose down # 테스트 실패 시 리포트를 아티팩트로 업로드하여 디버깅을 돕습니다. - name: Upload test reports @@ -107,7 +107,7 @@ jobs: # 3. 빌드 및 배포: main/develop 브랜치로 Push될 때만 실행 buildImageAndPush: name: 도커 이미지 빌드와 푸시 - # if: github.event_name == 'push' + if: github.event_name == 'push' needs: makeTagAndRelease runs-on: ubuntu-latest steps: @@ -145,6 +145,7 @@ jobs: with: context: ./backend push: true + # 이 옵션을 추가하여 Docker 빌드 캐시를 사용하지 않도록 강제합니다. no-cache: true tags: | ghcr.io/${{ env.OWNER_LC }}/catch-course:${{ needs.makeTagAndRelease.outputs.tag_name }} From fcfd091bbb1d51aec4f75c78f26f9be49745c6b3 Mon Sep 17 00:00:00 2001 From: nokkae Date: Sun, 17 Aug 2025 22:49:58 +0900 Subject: [PATCH 37/37] fix:CI --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 09cbb7e..d74aff4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -77,7 +77,7 @@ jobs: # 2. 릴리스 : main/develop 브랜치로 Push될 때만 실행 makeTagAndRelease: name: Create Tag and Release - # if: github.event_name == 'push' + if: github.event_name == 'push' needs: backend-ci runs-on: ubuntu-latest permissions: