From 652fff01d66e69dc3ed359b35c65f172e2a57c13 Mon Sep 17 00:00:00 2001 From: soyeon <102381857+soyeon1806@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:43:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9C=A0=ED=9A=A8=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=EC=83=81=ED=83=9C=20=EC=A0=84=EC=9D=B4=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=83=81=ED=83=9C=20=EB=A8=B8=EC=8B=A0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roome/domain/payment/entity/Payment.java | 31 +++-- .../domain/payment/entity/PaymentStatus.java | 14 ++- .../service/PaymentReconciliationService.java | 6 +- .../payment/service/PaymentService.java | 2 +- .../com/roome/global/exception/ErrorCode.java | 1 + .../entity/PaymentStateMachineTest.java | 109 ++++++++++++++++++ 6 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 roome/src/test/java/com/roome/domain/payment/entity/PaymentStateMachineTest.java diff --git a/roome/src/main/java/com/roome/domain/payment/entity/Payment.java b/roome/src/main/java/com/roome/domain/payment/entity/Payment.java index f92a5d8a..5f664cd1 100644 --- a/roome/src/main/java/com/roome/domain/payment/entity/Payment.java +++ b/roome/src/main/java/com/roome/domain/payment/entity/Payment.java @@ -2,6 +2,8 @@ import com.roome.domain.user.entity.User; import com.roome.global.entity.BaseTimeEntity; +import com.roome.global.exception.BusinessException; +import com.roome.global.exception.ErrorCode; import jakarta.persistence.*; import java.time.LocalDateTime; import lombok.*; @@ -44,24 +46,29 @@ public class Payment extends BaseTimeEntity { @Version private Long version; // 낙관적 락 - 동시 상태 변경(중복 완결/취소) 방지 - public void updateStatus(PaymentStatus status) { - this.status = status; - } - - public void updatePaymentKey(String paymentKey) { - this.paymentKey = paymentKey; - } - - // 결제 완결(승인 확인) 처리 (승인 시각 기록) + // 결제 완결(승인 확인) 처리 (PENDING -> SUCCESS) (승인 시각 기록) public void markApproved(String paymentKey, LocalDateTime approvedAt) { - this.status = PaymentStatus.SUCCESS; + transitionTo(PaymentStatus.SUCCESS); this.paymentKey = paymentKey; this.approvedAt = approvedAt; } - // 결제 취소 처리(취소 시각 기록) + // 결제 실패 처리 (PENDING -> FAILED) + public void markFailed() { + transitionTo(PaymentStatus.FAILED); + } + + // 결제 취소 처리 - PENDING or SUCCESS -> CANCELED (취소 시각 기록) public void markCanceled(LocalDateTime canceledAt) { - this.status = PaymentStatus.CANCELED; + transitionTo(PaymentStatus.CANCELED); this.canceledAt = canceledAt; } + + // 상태 머신: 허용되지 않은 전이는 거부 (원장 오염 방지) + private void transitionTo(PaymentStatus target) { + if (!this.status.canTransitionTo(target)) { + throw new BusinessException(ErrorCode.INVALID_PAYMENT_STATUS_TRANSITION); + } + this.status = target; + } } diff --git a/roome/src/main/java/com/roome/domain/payment/entity/PaymentStatus.java b/roome/src/main/java/com/roome/domain/payment/entity/PaymentStatus.java index dc61bab2..59d78eb8 100644 --- a/roome/src/main/java/com/roome/domain/payment/entity/PaymentStatus.java +++ b/roome/src/main/java/com/roome/domain/payment/entity/PaymentStatus.java @@ -1,5 +1,17 @@ package com.roome.domain.payment.entity; public enum PaymentStatus { - PENDING, SUCCESS, FAILED, CANCELED + PENDING, SUCCESS, FAILED, CANCELED; + + // 허용된 상태 전이 규칙 (결제 원장의 상태 머신) + // PENDING -> SUCCESS (승인 완결) / FAILED(실패 or 미완료) / CANCELED (승인 전 PG 취소) + // SUCCESS -> CANCELED (환불) + // FAILED, CANCELED = 종료 상태 (전이 불가) + public boolean canTransitionTo(PaymentStatus target) { + return switch (this) { + case PENDING -> target == SUCCESS || target == FAILED || target == CANCELED; + case SUCCESS -> target == CANCELED; + case FAILED, CANCELED -> false; + }; + } } diff --git a/roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java b/roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java index 0435d9a2..d62e13fa 100644 --- a/roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java +++ b/roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java @@ -65,7 +65,7 @@ private void reconcile(Long paymentId, String orderId) { // Toss에 기록 자체가 없음 = 사용자가 결제창까지 도달하지 못함 → 실패 처리 if (tossInfo.isEmpty()) { - payment.updateStatus(PaymentStatus.FAILED); + payment.markFailed(); log.info("[대사] Toss 기록 없음, 실패 처리: orderId={}", orderId); return; } @@ -85,7 +85,7 @@ private void reconcile(Long paymentId, String orderId) { orderId, payment.getAmount(), payment.getPurchasedPoints()); } case "CANCELED", "PARTIAL_CANCELED" -> { - payment.updateStatus(PaymentStatus.CANCELED); + payment.markCanceled(LocalDateTime.now()); log.info("[대사] Toss에서 취소 확인, 취소 처리: orderId={}", orderId); } case "WAITING_FOR_DEPOSIT" -> { @@ -94,7 +94,7 @@ private void reconcile(Long paymentId, String orderId) { } default -> { // READY, IN_PROGRESS, EXPIRED, ABORTED 등: 임계 시간이 지나도록 승인에 도달하지 못함 - payment.updateStatus(PaymentStatus.FAILED); + payment.markFailed(); log.info("[대사] 미완료 상태({}), 실패 처리: orderId={}", info.getStatus(), orderId); } } diff --git a/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java b/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java index 3c968747..bfc0205c 100644 --- a/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java +++ b/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java @@ -250,7 +250,7 @@ public void failPayment(Long userId, String orderId) { throw new BusinessException(ErrorCode.PAYMENT_ALREADY_PROCESSED); } - payment.updateStatus(PaymentStatus.FAILED); + payment.markFailed(); log.warn("결제 실패: orderId={}", orderId); } diff --git a/roome/src/main/java/com/roome/global/exception/ErrorCode.java b/roome/src/main/java/com/roome/global/exception/ErrorCode.java index 27e9dd86..f9e7869b 100644 --- a/roome/src/main/java/com/roome/global/exception/ErrorCode.java +++ b/roome/src/main/java/com/roome/global/exception/ErrorCode.java @@ -75,6 +75,7 @@ public enum ErrorCode { PAYMENT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "이미 처리된 결제입니다."), ORDER_ID_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 사용 중인 주문 ID입니다."), PARTIAL_CANCEL_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "부분 취소는 지원하지 않습니다. 전액 취소만 가능합니다."), + INVALID_PAYMENT_STATUS_TRANSITION(HttpStatus.CONFLICT, "허용되지 않은 결제 상태 전이입니다."), POINT_PURCHASE_NOT_FOUND(HttpStatus.NOT_FOUND, "포인트 결제 정보를 찾을 수 없습니다."), // 서평 관련 예외 MY_BOOK_REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "서평을 찾을 수 없습니다."), diff --git a/roome/src/test/java/com/roome/domain/payment/entity/PaymentStateMachineTest.java b/roome/src/test/java/com/roome/domain/payment/entity/PaymentStateMachineTest.java new file mode 100644 index 00000000..dc35159d --- /dev/null +++ b/roome/src/test/java/com/roome/domain/payment/entity/PaymentStateMachineTest.java @@ -0,0 +1,109 @@ +package com.roome.domain.payment.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.roome.domain.user.entity.User; +import com.roome.global.exception.BusinessException; +import com.roome.global.exception.ErrorCode; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PaymentStateMachineTest { + + @Nested + @DisplayName("PaymentStatus.canTransitionTo") + class TransitionRules { + + @Test + @DisplayName("PENDING은 SUCCESS/FAILED/CANCELED로 전이할 수 있다.") + void pendingTransitions() { + assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.SUCCESS)).isTrue(); + assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.FAILED)).isTrue(); + assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.CANCELED)).isTrue(); + } + + @Test + @DisplayName("SUCCESS는 CANCELED로만 전이할 수 있다.") + void successTransitions() { + assertThat(PaymentStatus.SUCCESS.canTransitionTo(PaymentStatus.CANCELED)).isTrue(); + assertThat(PaymentStatus.SUCCESS.canTransitionTo(PaymentStatus.FAILED)).isFalse(); + assertThat(PaymentStatus.SUCCESS.canTransitionTo(PaymentStatus.PENDING)).isFalse(); + } + + @Test + @DisplayName("FAILED와 CANCELED는 종료 상태로 어떤 전이도 불가능하다.") + void terminalStates() { + for (PaymentStatus target : PaymentStatus.values()) { + assertThat(PaymentStatus.FAILED.canTransitionTo(target)).isFalse(); + assertThat(PaymentStatus.CANCELED.canTransitionTo(target)).isFalse(); + } + } + } + + @Nested + @DisplayName("Payment 상태 전이 메서드") + class EntityTransitions { + + @Test + @DisplayName("PENDING 결제는 승인 완결할 수 있다.") + void markApproved_FromPending() { + Payment payment = pending(); + payment.markApproved("pk1", LocalDateTime.now()); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getPaymentKey()).isEqualTo("pk1"); + } + + @Test + @DisplayName("SUCCESS 결제를 다시 승인하면 예외가 발생한다.") + void markApproved_FromSuccess_Rejected() { + Payment payment = withStatus(PaymentStatus.SUCCESS); + assertThatThrownBy(() -> payment.markApproved("pk1", LocalDateTime.now())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.INVALID_PAYMENT_STATUS_TRANSITION.getMessage()); + } + + @Test + @DisplayName("SUCCESS 결제를 실패로 덮어쓸 수 없다.") + void markFailed_FromSuccess_Rejected() { + Payment payment = withStatus(PaymentStatus.SUCCESS); + assertThatThrownBy(payment::markFailed) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.INVALID_PAYMENT_STATUS_TRANSITION.getMessage()); + } + + @Test + @DisplayName("SUCCESS 결제는 취소(환불)할 수 있다.") + void markCanceled_FromSuccess() { + Payment payment = withStatus(PaymentStatus.SUCCESS); + payment.markCanceled(LocalDateTime.now()); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.CANCELED); + assertThat(payment.getCanceledAt()).isNotNull(); + } + + @Test + @DisplayName("이미 취소된 결제는 다시 취소할 수 없다.") + void markCanceled_FromCanceled_Rejected() { + Payment payment = withStatus(PaymentStatus.CANCELED); + assertThatThrownBy(() -> payment.markCanceled(LocalDateTime.now())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.INVALID_PAYMENT_STATUS_TRANSITION.getMessage()); + } + + private Payment pending() { + return withStatus(PaymentStatus.PENDING); + } + + private Payment withStatus(PaymentStatus status) { + return Payment.builder() + .user(User.builder().id(1L).build()) + .orderId("order1") + .amount(5_000) + .purchasedPoints(550) + .status(status) + .build(); + } + } +}