diff --git a/roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java b/roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java index d9030b81..622bb60f 100644 --- a/roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java +++ b/roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java @@ -15,4 +15,7 @@ public class TossPaymentInfo { private final String status; // DONE, CANCELED, READY, IN_PROGRESS, EXPIRED, ABORTED 등 private final int totalAmount; private final LocalDateTime approvedAt; // Toss가 확정한 승인 시각 (미승인 상태면 null) + private final String method; // 결제 수단 + private final String receiptUrl; // 영수증 URL + private final String approveNo; // PG(카드사) 승인 번호 } 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 5f664cd1..8c640510 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 @@ -20,9 +20,10 @@ public class Payment extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + // 회원 탈퇴 시에도 결제 원장은 법적 보존을 위해 남기고 user 참조만 끊으므로 nullable @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; // 결제한 사용자 + @JoinColumn(name = "user_id") + private User user; // 결제한 사용자 (탈퇴 후 비식별화되면 null) @Column(unique = true) private String paymentKey; // 결제 성공 시 반환되는 키 @@ -43,6 +44,11 @@ public class Payment extends BaseTimeEntity { private LocalDateTime approvedAt; // 결제 승인(완결) 시각 - 환불 기한 산정의 기준 private LocalDateTime canceledAt; // 결제 취소 시각 + // PG 승인 메타데이터 (best-effort로 채워지므로 nullable) + private String method; // 결제 수단 (카드, 가상계좌 등) + private String receiptUrl; // 영수증 URL + private String approveNo; // PG(카드사) 승인 번호 + @Version private Long version; // 낙관적 락 - 동시 상태 변경(중복 완결/취소) 방지 @@ -53,6 +59,18 @@ public void markApproved(String paymentKey, LocalDateTime approvedAt) { this.approvedAt = approvedAt; } + // PG 승인 메타데이터 기록 (승인 완결과 함께 호출) + public void applyPgDetails(String method, String receiptUrl, String approveNo) { + this.method = method; + this.receiptUrl = receiptUrl; + this.approveNo = approveNo; + } + + // 회원 탈퇴 시 개인 식별 참조만 끊어 결제 원장을 비식별 보존 + public void detachUser() { + this.user = null; + } + // 결제 실패 처리 (PENDING -> FAILED) public void markFailed() { transitionTo(PaymentStatus.FAILED); diff --git a/roome/src/main/java/com/roome/domain/payment/entity/PaymentLog.java b/roome/src/main/java/com/roome/domain/payment/entity/PaymentLog.java index 6431a492..10451e11 100644 --- a/roome/src/main/java/com/roome/domain/payment/entity/PaymentLog.java +++ b/roome/src/main/java/com/roome/domain/payment/entity/PaymentLog.java @@ -18,9 +18,10 @@ public class PaymentLog { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + // 회원 탈퇴 시에도 결제 이력은 보존하고 user 참조만 끊으므로 nullable @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; // 결제한 사용자 + @JoinColumn(name = "user_id") + private User user; // 결제한 사용자 (탈퇴 후 비식별화되면 null) // 이 로그가 속한 결제 건 (로그를 Payment와 매칭하기 위한 연결) // (기존 로그 호환을 위해 nullable, 신규 로그는 항상 설정) @@ -47,4 +48,9 @@ public class PaymentLog { protected void onCreate() { this.createdAt = LocalDateTime.now(); } + + // 회원 탈퇴 시 개인 식별 참조만 끊어 결제 이력을 비식별 보존 + public void detachUser() { + this.user = null; + } } diff --git a/roome/src/main/java/com/roome/domain/payment/repository/PaymentLogRepository.java b/roome/src/main/java/com/roome/domain/payment/repository/PaymentLogRepository.java index bc6bbac6..936653d9 100644 --- a/roome/src/main/java/com/roome/domain/payment/repository/PaymentLogRepository.java +++ b/roome/src/main/java/com/roome/domain/payment/repository/PaymentLogRepository.java @@ -24,7 +24,4 @@ public interface PaymentLogRepository extends JpaRepository { // 특정 결제 키(paymentKey)로 결제 내역 조회 List findByPaymentKey(String paymentKey); - - // 특정 사용자(userId)의 결제 로그 삭제 - void deleteByUserId(Long userId); } diff --git a/roome/src/main/java/com/roome/domain/payment/repository/PaymentRepository.java b/roome/src/main/java/com/roome/domain/payment/repository/PaymentRepository.java index 94ff3b13..d9358916 100644 --- a/roome/src/main/java/com/roome/domain/payment/repository/PaymentRepository.java +++ b/roome/src/main/java/com/roome/domain/payment/repository/PaymentRepository.java @@ -26,7 +26,4 @@ public interface PaymentRepository extends JpaRepository { // 특정 사용자(userId)의 성공한 결제 내역 조회 List findByUserIdAndStatus(Long userId, PaymentStatus status); - - // 특정 사용자(userId)의 결제 데이터 삭제 - void deleteByUserId(Long userId); } 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 d62e13fa..a509ba05 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 @@ -80,7 +80,7 @@ private void reconcile(Long paymentId, String orderId) { orderId, payment.getAmount(), info.getTotalAmount()); return; } - paymentService.completePayment(payment, info.getPaymentKey(), info.getApprovedAt()); + paymentService.completePayment(payment, info.getPaymentKey(), info); log.warn("[대사 복구] 승인됐지만 미완결이던 결제를 완결: orderId={}, amount={}, points={}", orderId, payment.getAmount(), payment.getPurchasedPoints()); } 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 328d36f5..962d4f2b 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 @@ -1,11 +1,11 @@ package com.roome.domain.payment.service; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.roome.domain.payment.dto.PaymentLogResponseDto; import com.roome.domain.payment.dto.PaymentRequestDto; import com.roome.domain.payment.dto.PaymentResponseDto; import com.roome.domain.payment.dto.PaymentVerifyDto; +import com.roome.domain.payment.dto.TossPaymentInfo; import com.roome.domain.payment.entity.Payment; import com.roome.domain.payment.entity.PaymentLog; import com.roome.domain.payment.entity.PaymentStatus; @@ -126,7 +126,7 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto) throw new BusinessException(ErrorCode.PAYMENT_AMOUNT_MISMATCH); } - LocalDateTime approvedAt; + TossPaymentInfo tossInfo; try { // 응답 전문에는 카드 및 구매자 정보가 포함될 수 있어 body를 로깅 x ResponseEntity response = tossPaymentClient.requestConfirm(verifyDto); @@ -134,24 +134,18 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto) throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED); } - JsonNode jsonResponse = new ObjectMapper().readTree(response.getBody()); - if (!"DONE".equals(jsonResponse.path("status").asText())) { + // confirm 응답을 파싱해 상태, 금액 검증 및 원장 메타데이터에 사용 + tossInfo = TossPaymentClient.parsePaymentInfo(new ObjectMapper().readTree(response.getBody())); + if (!"DONE".equals(tossInfo.getStatus())) { log.warn("결제 상태가 DONE이 아님: orderId={}, status={}", - verifyDto.getOrderId(), jsonResponse.path("status").asText()); + verifyDto.getOrderId(), tossInfo.getStatus()); throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED); } - - // 승인 결과의 원본인 confirm 응답으로 금액 검증 - // confirm 응답 자체가 승인 사실의 신뢰 원천이므로 재조회는 지연만 늘리고 불일치 위험을 키울 수 있음 - int approvedAmount = jsonResponse.path("totalAmount").asInt(-1); - if (approvedAmount != payment.getAmount()) { + if (tossInfo.getTotalAmount() != payment.getAmount()) { log.warn("승인 금액 불일치: orderId={}, 저장 금액={}, Toss 승인 금액={}", - verifyDto.getOrderId(), payment.getAmount(), approvedAmount); + verifyDto.getOrderId(), payment.getAmount(), tossInfo.getTotalAmount()); throw new BusinessException(ErrorCode.PAYMENT_AMOUNT_MISMATCH); } - - // Toss가 확정한 승인 시각을 원장에 기록하기 위해 추출 (없으면 null로 두고 완결 시 서버 시각으로 대체) - approvedAt = TossPaymentClient.parseTossDateTime(jsonResponse.path("approvedAt").asText(null)); } catch (BusinessException e) { throw e; } catch (Exception e) { @@ -160,7 +154,7 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto) } // 결제 완결 처리 (상태 변경 + 포인트 지급 + 로그 저장) - completePayment(payment, verifyDto.getPaymentKey(), approvedAt); + completePayment(payment, verifyDto.getPaymentKey(), tossInfo); log.info("결제 성공 및 포인트 지급 완료: orderId={}, userId={}, pointsAdded={}", verifyDto.getOrderId(), userId, payment.getPurchasedPoints()); @@ -181,14 +175,18 @@ private PaymentResponseDto toResponse(Payment payment) { // 승인이 확인된 결제를 완결 처리한다 (상태 변경 + 포인트 지급 + 로그 저장) // verifyPayment(사용자 콜백)와 PaymentReconciliationService(대사 배치)가 공유하는 단일 완결 경로 @Transactional - public void completePayment(Payment payment, String paymentKey, LocalDateTime approvedAt) { + public void completePayment(Payment payment, String paymentKey, TossPaymentInfo tossInfo) { if (payment.getStatus() == PaymentStatus.SUCCESS) { log.warn("이미 완결된 결제 - 중복 완결 방지: orderId={}", payment.getOrderId()); return; } // 승인 시각은 Toss가 확정한 값을 원장에 기록하고, 값이 없으면 서버 시각으로 대체 - payment.markApproved(paymentKey, approvedAt != null ? approvedAt : LocalDateTime.now()); + LocalDateTime approvedAt = tossInfo.getApprovedAt() != null + ? tossInfo.getApprovedAt() : LocalDateTime.now(); + payment.markApproved(paymentKey, approvedAt); + // PG 승인 메타데이터를 원장에 기록 + payment.applyPgDetails(tossInfo.getMethod(), tossInfo.getReceiptUrl(), tossInfo.getApproveNo()); // Toss가 승인한 결제 금액(payment.amount)을 기준으로 카탈로그에서 지급 사유를 파생 PointProduct product = PointProduct.findByPrice(payment.getAmount()) diff --git a/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java b/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java index 4370197b..3d433081 100644 --- a/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java +++ b/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java @@ -115,13 +115,7 @@ public Optional findPaymentByOrderId(String orderId) { ); JsonNode json = objectMapper.readTree(response.getBody()); - return Optional.of(TossPaymentInfo.builder() - .paymentKey(json.path("paymentKey").asText(null)) - .status(json.path("status").asText(null)) - // 금액이 없으면 -1로 두어 어떤 결제 금액과도 일치하지 않게 함 - .totalAmount(json.path("totalAmount").asInt(-1)) - .approvedAt(parseTossDateTime(json.path("approvedAt").asText(null))) - .build()); + return Optional.of(parsePaymentInfo(json)); } catch (HttpClientErrorException.NotFound e) { return Optional.empty(); } catch (Exception e) { @@ -130,6 +124,20 @@ public Optional findPaymentByOrderId(String orderId) { } } + // Toss 결제 객체 JSON을 TossPaymentInfo로 파싱 + public static TossPaymentInfo parsePaymentInfo(JsonNode json) { + return TossPaymentInfo.builder() + .paymentKey(json.path("paymentKey").asText(null)) + .status(json.path("status").asText(null)) + // 금액이 없으면 -1로 두어 어떤 결제 금액과도 일치하지 않게 함 + .totalAmount(json.path("totalAmount").asInt(-1)) + .approvedAt(parseTossDateTime(json.path("approvedAt").asText(null))) + .method(json.path("method").asText(null)) + .receiptUrl(json.path("receipt").path("url").asText(null)) + .approveNo(json.path("card").path("approveNo").asText(null)) + .build(); + } + // Toss 결제 취소 요청 public boolean cancelPayment(String paymentKey, String cancelReason, Integer cancelAmount) { String requestUrl = TOSS_API_URL + "/" + paymentKey + "/cancel"; diff --git a/roome/src/main/java/com/roome/domain/user/service/UserService.java b/roome/src/main/java/com/roome/domain/user/service/UserService.java index 6c35cde7..b370c20b 100644 --- a/roome/src/main/java/com/roome/domain/user/service/UserService.java +++ b/roome/src/main/java/com/roome/domain/user/service/UserService.java @@ -14,6 +14,8 @@ import com.roome.domain.mycd.entity.MyCd; import com.roome.domain.mycd.repository.MyCdCountRepository; import com.roome.domain.mycd.repository.MyCdRepository; +import com.roome.domain.payment.entity.Payment; +import com.roome.domain.payment.entity.PaymentLog; import com.roome.domain.payment.repository.PaymentLogRepository; import com.roome.domain.payment.repository.PaymentRepository; import com.roome.domain.point.repository.PointHistoryRepository; @@ -233,12 +235,15 @@ private void deletePointData(Long userId) { } private void deletePaymentData(Long userId) { - // 결제 로그 삭제 - paymentLogRepository.deleteByUserId(userId); - log.debug("[회원탈퇴] 결제 로그 삭제 완료: userId={}", userId); + // 전자상거래법상 대금결제 기록은 보존 의무가 있으므로 물리 삭제 x + // 개인 식별 참조(user)만 끊어 비식별 상태로 보존 + List payments = paymentRepository.findByUserId(userId); + payments.forEach(Payment::detachUser); - // 결제 정보 삭제 - paymentRepository.deleteByUserId(userId); - log.debug("[회원탈퇴] 결제 정보 삭제 완료: userId={}", userId); + List paymentLogs = paymentLogRepository.findByUserId(userId); + paymentLogs.forEach(PaymentLog::detachUser); + + log.debug("[회원탈퇴] 결제 기록 비식별화 보존 완료: 결제 {}건, 로그 {}건, userId={}", + payments.size(), paymentLogs.size(), userId); } } \ No newline at end of file diff --git a/roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java b/roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java index 5f859be8..0c79d8d0 100644 --- a/roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java +++ b/roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java @@ -70,15 +70,16 @@ void reconcile_TossDone_CompletesPayment() { Payment payment = pendingPayment(1L, "order1"); givenStalePayments(payment); when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment)); - LocalDateTime tossApprovedAt = LocalDateTime.of(2026, 7, 15, 10, 0); - when(tossPaymentClient.findPaymentByOrderId("order1")) - .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000, tossApprovedAt))); + TossPaymentInfo tossInfo = TossPaymentInfo.builder() + .paymentKey("pk1").status("DONE").totalAmount(5_000) + .approvedAt(LocalDateTime.of(2026, 7, 15, 10, 0)).build(); + when(tossPaymentClient.findPaymentByOrderId("order1")).thenReturn(Optional.of(tossInfo)); // when reconciliationService.reconcilePendingPayments(); - // then: Toss가 확정한 승인 시각이 완결 처리로 그대로 전달되어야 한다 - verify(paymentService).completePayment(payment, "pk1", tossApprovedAt); + // then: Toss 조회 정보가 완결 처리로 그대로 전달되어야 한다 + verify(paymentService).completePayment(payment, "pk1", tossInfo); } @Test @@ -89,7 +90,7 @@ void reconcile_AmountMismatch_LeftForManualReview() { givenStalePayments(payment); when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment)); when(tossPaymentClient.findPaymentByOrderId("order1")) - .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 30_000, null))); + .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 30_000, null, null, null, null))); // when reconciliationService.reconcilePendingPayments(); @@ -124,7 +125,7 @@ void reconcile_TossCanceled_MarksCanceled() { givenStalePayments(payment); when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment)); when(tossPaymentClient.findPaymentByOrderId("order1")) - .thenReturn(Optional.of(new TossPaymentInfo("pk1", "CANCELED", 5_000, null))); + .thenReturn(Optional.of(new TossPaymentInfo("pk1", "CANCELED", 5_000, null, null, null, null))); // when reconciliationService.reconcilePendingPayments(); @@ -144,7 +145,7 @@ void reconcile_AlreadyCompleted_Skipped() { .status(PaymentStatus.SUCCESS).build(); when(paymentRepository.findById(1L)).thenReturn(Optional.of(completed)); when(tossPaymentClient.findPaymentByOrderId("order1")) - .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000, null))); + .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000, null, null, null, null))); // when reconciliationService.reconcilePendingPayments(); @@ -164,7 +165,7 @@ void reconcile_OneFailure_DoesNotBlockOthers() { when(tossPaymentClient.findPaymentByOrderId("order1")) .thenThrow(new RuntimeException("Toss 조회 실패")); when(tossPaymentClient.findPaymentByOrderId("order2")) - .thenReturn(Optional.of(new TossPaymentInfo("pk2", "DONE", 5_000, null))); + .thenReturn(Optional.of(new TossPaymentInfo("pk2", "DONE", 5_000, null, null, null, null))); when(paymentRepository.findById(2L)).thenReturn(Optional.of(second)); // when diff --git a/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java b/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java index b67361f5..781c181a 100644 --- a/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java +++ b/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java @@ -14,6 +14,7 @@ import com.roome.domain.payment.dto.PaymentRequestDto; import com.roome.domain.payment.dto.PaymentResponseDto; import com.roome.domain.payment.dto.PaymentVerifyDto; +import com.roome.domain.payment.dto.TossPaymentInfo; import com.roome.domain.payment.entity.Payment; import com.roome.domain.payment.entity.PaymentStatus; import com.roome.domain.payment.repository.PaymentLogRepository; @@ -369,14 +370,21 @@ void completePayment_Success() { .build(); LocalDateTime tossApprovedAt = LocalDateTime.of(2026, 7, 15, 10, 0); + TossPaymentInfo info = TossPaymentInfo.builder() + .paymentKey("pk123").status("DONE").totalAmount(5_000).approvedAt(tossApprovedAt) + .method("카드").receiptUrl("https://receipt/pk123").approveNo("00012345") + .build(); // when - paymentService.completePayment(payment, "pk123", tossApprovedAt); + paymentService.completePayment(payment, "pk123", info); - // then: 승인 시각은 Toss가 확정한 값으로 기록된다 + // then: 승인 시각·PG 메타데이터가 원장에 기록된다 assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); assertThat(payment.getPaymentKey()).isEqualTo("pk123"); assertThat(payment.getApprovedAt()).isEqualTo(tossApprovedAt); + assertThat(payment.getMethod()).isEqualTo("카드"); + assertThat(payment.getReceiptUrl()).isEqualTo("https://receipt/pk123"); + assertThat(payment.getApproveNo()).isEqualTo("00012345"); verify(pointService).earnPoints(testUser, PointReason.POINT_PURCHASE_550); verify(paymentLogRepository).save(any()); } @@ -393,8 +401,12 @@ void completePayment_NullApprovedAt_FallsBackToServerTime() { .status(PaymentStatus.PENDING) .build(); + // Toss가 승인 시각을 주지 않은 경우 (approvedAt = null) + TossPaymentInfo info = TossPaymentInfo.builder() + .paymentKey("pk123").status("DONE").totalAmount(5_000).approvedAt(null).build(); + // when - paymentService.completePayment(payment, "pk123", null); + paymentService.completePayment(payment, "pk123", info); // then assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); @@ -408,7 +420,8 @@ void completePayment_AlreadyCompleted_NoDoubleEarn() { Payment payment = successPayment("pk123"); // when - paymentService.completePayment(payment, "pk123", LocalDateTime.now()); + paymentService.completePayment(payment, "pk123", + TossPaymentInfo.builder().paymentKey("pk123").status("DONE").totalAmount(5_000).build()); // then verify(pointService, never()).earnPoints(any(), any()); diff --git a/roome/src/test/java/com/roome/domain/user/service/UserServiceTest.java b/roome/src/test/java/com/roome/domain/user/service/UserServiceTest.java index dff3303e..d844cd80 100644 --- a/roome/src/test/java/com/roome/domain/user/service/UserServiceTest.java +++ b/roome/src/test/java/com/roome/domain/user/service/UserServiceTest.java @@ -23,14 +23,22 @@ import com.roome.domain.mycd.entity.MyCd; import com.roome.domain.mycd.repository.MyCdCountRepository; import com.roome.domain.mycd.repository.MyCdRepository; +import com.roome.domain.payment.entity.Payment; +import com.roome.domain.payment.entity.PaymentLog; +import com.roome.domain.payment.repository.PaymentLogRepository; +import com.roome.domain.payment.repository.PaymentRepository; import com.roome.domain.point.repository.PointHistoryRepository; import com.roome.domain.point.repository.PointRepository; +import com.roome.domain.recommendedUser.repository.RecommendedUserRepository; import com.roome.domain.room.entity.Room; import com.roome.domain.room.repository.RoomRepository; +import com.roome.domain.room.repository.RoomThemeUnlockRepository; import com.roome.domain.user.entity.User; import com.roome.domain.user.repository.UserRepository; +import com.roome.domain.userGenrePreference.repository.UserGenrePreferenceRepository; import com.roome.global.exception.BusinessException; import com.roome.global.exception.ErrorCode; +import com.roome.global.service.RedisService; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -77,6 +85,18 @@ class UserServiceTest { private PointRepository pointRepository; @Mock private MyBookCountRepository myBookCountRepository; // 추가 + @Mock + private PaymentRepository paymentRepository; + @Mock + private PaymentLogRepository paymentLogRepository; + @Mock + private RecommendedUserRepository recommendedUserRepository; + @Mock + private UserGenrePreferenceRepository userGenrePreferenceRepository; + @Mock + private RoomThemeUnlockRepository roomThemeUnlockRepository; + @Mock + private RedisService redisService; private static final Long USER_ID = 1L; private static final Long ROOM_ID = 1L; @@ -111,10 +131,16 @@ void deleteUserSuccess() { when(furnitureRepository.findByRoomId(ROOM_ID)).thenReturn(furnitures); when(myBookRepository.findAllByUserId(USER_ID)).thenReturn(myBooks); when(myCdRepository.findByUserId(USER_ID)).thenReturn(myCds); - when(cdCommentRepository.findAllByUserId(USER_ID)).thenReturn(comments); + when(cdCommentRepository.findByMyCdIdIn(any())).thenReturn(comments); when(guestbookRepository.findAllByRoomOrUserId(testRoom, USER_ID)).thenReturn(guestbooks); when(housemateRepository.deleteByUserIdOrAddedId(USER_ID, USER_ID)).thenReturn(3); + // 결제 원장은 삭제하지 않고 user 참조만 끊어 보존 + Payment payment = mock(Payment.class); + PaymentLog paymentLog = mock(PaymentLog.class); + when(paymentRepository.findByUserId(USER_ID)).thenReturn(List.of(payment)); + when(paymentLogRepository.findByUserId(USER_ID)).thenReturn(List.of(paymentLog)); + // when userService.deleteUser(USER_ID); @@ -130,6 +156,10 @@ void deleteUserSuccess() { } verify(roomRepository, times(1)).delete(testRoom); verify(userRepository, times(1)).delete(testUser); + + // 결제 원장은 물리 삭제 대신 user 참조만 끊어 법적 보존 + verify(payment, times(1)).detachUser(); + verify(paymentLog, times(1)).detachUser(); } @Test