From 88af25380f45058d898c0a4441a2c3c49ff507db Mon Sep 17 00:00:00 2001 From: YouSeok518 Date: Tue, 14 Oct 2025 23:11:28 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/back/domain/cart/entity/Cart.java | 3 ++ .../order/order/service/OrderService.java | 9 ++-- .../cash/service/CashChargeService.java | 9 ++-- .../cash/service/CashExchangeService.java | 12 ++--- .../repository/MoriCashBalanceRepository.java | 12 +++++ .../service/MoriCashPaymentService.java | 13 +++--- .../service/MoriCashRefundService.java | 8 ++-- .../settlement/service/SettlementService.java | 4 +- .../product/repository/ProductRepository.java | 10 ++++- .../review/controller/ReviewController.java | 13 +++--- .../dto/request/ReviewCreateRequestDto.java | 5 ++- .../dto/request/ReviewListRequestDto.java | 4 +- .../dto/request/ReviewWriteRequestDto.java | 5 ++- .../dto/response/ReviewDetailResponseDto.java | 5 ++- .../dto/response/ReviewResponseDto.java | 5 ++- .../review/repository/ReviewRepository.java | 15 +++++++ .../domain/review/service/ReviewService.java | 28 ++++++------ .../OrderServiceArtistRevenueTest.java | 2 + .../order/order/service/OrderServiceTest.java | 6 +-- .../cash/service/CashChargeServiceTest.java | 8 ++-- .../cash/service/CashExchangeServiceTest.java | 16 +++---- .../service/MoriCashPaymentServiceTest.java | 17 ++++--- .../service/MoriCashRefundServiceTest.java | 10 ++--- .../service/SettlementServiceTest.java | 12 ++--- .../controller/ReviewControllerTest.java | 31 +++++++------ .../review/service/ReviewServiceTest.java | 45 ++++++++++--------- 26 files changed, 186 insertions(+), 121 deletions(-) diff --git a/src/main/java/com/back/domain/cart/entity/Cart.java b/src/main/java/com/back/domain/cart/entity/Cart.java index 593a3582..0068a463 100644 --- a/src/main/java/com/back/domain/cart/entity/Cart.java +++ b/src/main/java/com/back/domain/cart/entity/Cart.java @@ -15,6 +15,9 @@ @Table(name = "carts") public class Cart extends BaseEntity { + @Version + private Long version; // Optimistic Lock을 위한 버전 필드 + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; diff --git a/src/main/java/com/back/domain/order/order/service/OrderService.java b/src/main/java/com/back/domain/order/order/service/OrderService.java index ad31d904..a90855eb 100644 --- a/src/main/java/com/back/domain/order/order/service/OrderService.java +++ b/src/main/java/com/back/domain/order/order/service/OrderService.java @@ -353,9 +353,9 @@ public void creditArtistRevenue(Order order) { order.getOrderItems().forEach(orderItem -> { User artist = orderItem.getProduct().getUser(); - // 작가의 모리캐시 잔액 조회 또는 생성 + // 작가의 모리캐시 잔액 조회 또는 생성 (Pessimistic Write Lock - 동시성 제어) com.back.domain.payment.moriCash.entity.MoriCashBalance balance = - moriCashBalanceRepository.findByUser(artist) + moriCashBalanceRepository.findByUserWithLock(artist) .orElseGet(() -> { com.back.domain.payment.moriCash.entity.MoriCashBalance newBalance = com.back.domain.payment.moriCash.entity.MoriCashBalance.createInitialBalance(artist); @@ -384,10 +384,11 @@ public void creditArtistRevenue(Order order) { private List createOrderItems(List orderItemRequests) { return orderItemRequests.stream() .map(itemRequest -> { - Product product = productRepository.findByProductUuid(itemRequest.productUuid()) + // Pessimistic Write Lock으로 상품 조회 (동시성 제어) + Product product = productRepository.findByProductUuidWithLock(itemRequest.productUuid()) .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); - // 재고 감소 + // 재고 검증 및 감소 int newStock = product.getStock() - itemRequest.quantity(); if (newStock < 0) { throw new IllegalArgumentException("재고가 부족합니다. (상품: " + product.getName() + ", 현재 재고: " + product.getStock() + ")"); diff --git a/src/main/java/com/back/domain/payment/cash/service/CashChargeService.java b/src/main/java/com/back/domain/payment/cash/service/CashChargeService.java index 5d60714c..c4147484 100644 --- a/src/main/java/com/back/domain/payment/cash/service/CashChargeService.java +++ b/src/main/java/com/back/domain/payment/cash/service/CashChargeService.java @@ -65,9 +65,12 @@ public CashChargeResponseDto completeCharge(Long transactionId, String paymentKe cashTransaction.getAmount() ); - // 2. 모리캐시 잔액 업데이트 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(cashTransaction.getUser()) - .orElseGet(() -> MoriCashBalance.createInitialBalance(cashTransaction.getUser())); + // 2. 모리캐시 잔액 업데이트 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(cashTransaction.getUser()) + .orElseGet(() -> { + MoriCashBalance newBalance = MoriCashBalance.createInitialBalance(cashTransaction.getUser()); + return moriCashBalanceRepository.save(newBalance); + }); balance.addBalance(cashTransaction.getAmount()); moriCashBalanceRepository.save(balance); diff --git a/src/main/java/com/back/domain/payment/cash/service/CashExchangeService.java b/src/main/java/com/back/domain/payment/cash/service/CashExchangeService.java index f005cd11..210306c9 100644 --- a/src/main/java/com/back/domain/payment/cash/service/CashExchangeService.java +++ b/src/main/java/com/back/domain/payment/cash/service/CashExchangeService.java @@ -33,8 +33,8 @@ public CashExchangeResponseDto createExchangeRequest(CashExchangeRequestDto requ throw new IllegalArgumentException("작가만 환전이 가능합니다."); } - // 2. 모리캐시 잔액 확인 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(user) + // 2. 모리캐시 잔액 확인 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(user) .orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다.")); if (balance.getAvailableBalance() < requestDto.getAmount()) { @@ -74,8 +74,8 @@ public CashExchangeResponseDto approveExchange(Long transactionId, String pgTran // 1. 거래 완료 처리 cashTransaction.completeTransaction(pgTransactionId, pgApprovalNumber, cashTransaction.getBalanceAfter()); - // 2. 모리캐시 잔액에서 실제 차감 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(cashTransaction.getUser()) + // 2. 모리캐시 잔액에서 실제 차감 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(cashTransaction.getUser()) .orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다.")); balance.deductFrozenBalance(cashTransaction.getAmount()); @@ -99,8 +99,8 @@ public void rejectExchange(Long transactionId, String rejectionReason) { // 1. 거래 실패 처리 cashTransaction.failTransaction(rejectionReason); - // 2. 모리캐시 잔액 동결 해제 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(cashTransaction.getUser()) + // 2. 모리캐시 잔액 동결 해제 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(cashTransaction.getUser()) .orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다.")); balance.unfreezeBalance(cashTransaction.getAmount()); diff --git a/src/main/java/com/back/domain/payment/moriCash/repository/MoriCashBalanceRepository.java b/src/main/java/com/back/domain/payment/moriCash/repository/MoriCashBalanceRepository.java index c7301ba9..5ed40bd4 100644 --- a/src/main/java/com/back/domain/payment/moriCash/repository/MoriCashBalanceRepository.java +++ b/src/main/java/com/back/domain/payment/moriCash/repository/MoriCashBalanceRepository.java @@ -2,7 +2,11 @@ import com.back.domain.payment.moriCash.entity.MoriCashBalance; import com.back.domain.user.entity.User; +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 java.util.Optional; @@ -12,6 +16,14 @@ public interface MoriCashBalanceRepository extends JpaRepository findByUser(User user); + + /** + * 사용자별 캐시 잔액 조회 (Pessimistic Write Lock) + * 결제/충전/환전 시 사용 - 동시성 제어 + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT m FROM MoriCashBalance m WHERE m.user = :user") + Optional findByUserWithLock(@Param("user") User user); /** * 사용자 ID로 캐시 잔액 조회 diff --git a/src/main/java/com/back/domain/payment/moriCash/service/MoriCashPaymentService.java b/src/main/java/com/back/domain/payment/moriCash/service/MoriCashPaymentService.java index aaf7384d..3523b785 100644 --- a/src/main/java/com/back/domain/payment/moriCash/service/MoriCashPaymentService.java +++ b/src/main/java/com/back/domain/payment/moriCash/service/MoriCashPaymentService.java @@ -46,9 +46,12 @@ public MoriCashPaymentResponseDto createPayment(MoriCashPaymentRequestDto reques throw new IllegalArgumentException("본인의 주문만 결제할 수 있습니다."); } - // 3. 모리캐시 잔액 확인 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(user) - .orElseGet(() -> MoriCashBalance.createInitialBalance(user)); + // 3. 모리캐시 잔액 확인 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(user) + .orElseGet(() -> { + MoriCashBalance newBalance = MoriCashBalance.createInitialBalance(user); + return moriCashBalanceRepository.save(newBalance); + }); if (balance.getAvailableBalance() < requestDto.getUsedMoriCash()) { throw new IllegalArgumentException("모리캐시 잔액이 부족합니다."); @@ -99,8 +102,8 @@ public void cancelPayment(Long paymentId, User user) { throw new IllegalArgumentException("완료된 결제만 취소할 수 있습니다."); } - // 3. 모리캐시 잔액 복원 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(user) + // 3. 모리캐시 잔액 복원 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(user) .orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다.")); balance.restoreBalance(payment.getUsedMoriCash()); diff --git a/src/main/java/com/back/domain/payment/moriCash/service/MoriCashRefundService.java b/src/main/java/com/back/domain/payment/moriCash/service/MoriCashRefundService.java index 0bbd571a..cc6d1039 100644 --- a/src/main/java/com/back/domain/payment/moriCash/service/MoriCashRefundService.java +++ b/src/main/java/com/back/domain/payment/moriCash/service/MoriCashRefundService.java @@ -53,8 +53,8 @@ public MoriCashRefundResponseDto processRefund(MoriCashRefundRequestDto requestD throw new IllegalArgumentException("환불 금액이 올바르지 않습니다."); } - // 5. 모리캐시 잔액 복원 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(user) + // 5. 모리캐시 잔액 복원 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(user) .orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다.")); balance.restoreBalance(requestDto.getRefundAmount()); @@ -83,8 +83,8 @@ public void cancelRefund(Long paymentId, String cancellationReason) { throw new IllegalArgumentException("환불 처리된 결제가 아닙니다."); } - // 2. 모리캐시 잔액에서 환불 금액 차감 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(payment.getUser()) + // 2. 모리캐시 잔액에서 환불 금액 차감 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(payment.getUser()) .orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다.")); balance.deductBalance(payment.getRefundPrice()); diff --git a/src/main/java/com/back/domain/payment/settlement/service/SettlementService.java b/src/main/java/com/back/domain/payment/settlement/service/SettlementService.java index 9ea885b8..bae065fa 100644 --- a/src/main/java/com/back/domain/payment/settlement/service/SettlementService.java +++ b/src/main/java/com/back/domain/payment/settlement/service/SettlementService.java @@ -33,8 +33,8 @@ public WithdrawalResponseDto requestWithdrawal(WithdrawalRequestDto requestDto, throw new IllegalArgumentException("작가만 환전 신청이 가능합니다."); } - // 1. 모리캐시 잔액 조회 또는 생성 - MoriCashBalance balance = moriCashBalanceRepository.findByUser(artist) + // 1. 모리캐시 잔액 조회 또는 생성 (Pessimistic Write Lock - 동시성 제어) + MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(artist) .orElseGet(() -> { log.info("모리캐시 잔액 정보 없음. 새로 생성 - 작가ID: {}", artist.getId()); MoriCashBalance newBalance = MoriCashBalance.createInitialBalance(artist); diff --git a/src/main/java/com/back/domain/product/product/repository/ProductRepository.java b/src/main/java/com/back/domain/product/product/repository/ProductRepository.java index 080dd895..3f686ecf 100644 --- a/src/main/java/com/back/domain/product/product/repository/ProductRepository.java +++ b/src/main/java/com/back/domain/product/product/repository/ProductRepository.java @@ -2,9 +2,11 @@ import com.back.domain.product.category.entity.Category; import com.back.domain.product.product.entity.Product; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,7 +18,13 @@ public interface ProductRepository extends JpaRepository, ProductCustomRepository, JpaSpecificationExecutor { Optional findByProductUuid(UUID productUuid); - boolean existsByCategoryId(Long categoryId); // category_id 필드값이 해당 categoryId인 상품이 하나라도 존재하는지 체크 + + // 재고 감소용 - Pessimistic Write Lock (동시성 제어) + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.productUuid = :productUuid") + Optional findByProductUuidWithLock(@Param("productUuid") UUID productUuid); + + boolean existsByCategoryId(Long categoryId); /** * 특정 카테고리의 상품 수 조회 diff --git a/src/main/java/com/back/domain/review/controller/ReviewController.java b/src/main/java/com/back/domain/review/controller/ReviewController.java index 03b40ec2..cf248230 100644 --- a/src/main/java/com/back/domain/review/controller/ReviewController.java +++ b/src/main/java/com/back/domain/review/controller/ReviewController.java @@ -29,6 +29,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.UUID; /** * 리뷰 Controller @@ -81,14 +82,14 @@ public ResponseEntity createReview( @Operation(summary = "리뷰 목록 조회", description = "상품의 리뷰 목록을 조회합니다.") @GetMapping public ResponseEntity getReviewList( - @Parameter(description = "상품 ID") @RequestParam Long productId, + @Parameter(description = "상품 UUID") @RequestParam UUID productUuid, @Parameter(description = "리뷰 타입 (PHOTO, GENERAL, ALL)") @RequestParam(required = false) ReviewListRequestDto.ReviewType reviewType, @Parameter(description = "페이지 번호") @RequestParam(required = false, defaultValue = "0") Integer page, @Parameter(description = "페이지 크기") @RequestParam(required = false, defaultValue = "10") Integer size, @AuthenticationPrincipal User user) { ReviewListRequestDto requestDto = ReviewListRequestDto.builder() - .productId(productId) + .productUuid(productUuid) .reviewType(reviewType != null ? reviewType : ReviewListRequestDto.ReviewType.ALL) .page(page) .size(size) @@ -180,8 +181,8 @@ public ResponseEntity writeReviewFromPopup( @Valid @RequestBody ReviewWriteRequestDto requestDto, @AuthenticationPrincipal User user) { - log.info("리뷰 작성 팝업 API 호출 - 사용자: {}, 상품ID: {}, 평점: {}, 상품옵션: {}", - user.getId(), requestDto.getProductId(), requestDto.getRating(), requestDto.getProductOption()); + log.info("리뷰 작성 팝업 API 호출 - 사용자: {}, 상품UUID: {}, 평점: {}, 상품옵션: {}", + user.getId(), requestDto.getProductUuid(), requestDto.getRating(), requestDto.getProductOption()); ReviewDetailResponseDto response = reviewService.writeReviewFromPopup(requestDto, user); @@ -197,9 +198,9 @@ public ResponseEntity writeReviewFromPopup( @Operation(summary = "상품별 리뷰 통계 조회", description = "특정 상품의 리뷰 통계 정보를 조회합니다.") @GetMapping("/stats") public ResponseEntity getReviewStats( - @Parameter(description = "상품 ID") @RequestParam Long productId) { + @Parameter(description = "상품 UUID") @RequestParam UUID productUuid) { - Product product = productRepository.findById(productId) + Product product = productRepository.findByProductUuid(productUuid) .orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다.")); ReviewStatsResponseDto response = reviewService.getReviewStats(product); diff --git a/src/main/java/com/back/domain/review/dto/request/ReviewCreateRequestDto.java b/src/main/java/com/back/domain/review/dto/request/ReviewCreateRequestDto.java index 39a9c9e1..8536f2e6 100644 --- a/src/main/java/com/back/domain/review/dto/request/ReviewCreateRequestDto.java +++ b/src/main/java/com/back/domain/review/dto/request/ReviewCreateRequestDto.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.*; import java.util.List; +import java.util.UUID; /** * 리뷰 작성 요청 DTO @@ -19,8 +20,8 @@ @Builder public class ReviewCreateRequestDto { - @NotNull(message = "상품 ID는 필수입니다") - private Long productId; // 상품 ID + @NotNull(message = "상품 UUID는 필수입니다") + private UUID productUuid; // 상품 UUID @NotNull(message = "평점은 필수입니다") @Min(value = 1, message = "평점은 1점 이상이어야 합니다") diff --git a/src/main/java/com/back/domain/review/dto/request/ReviewListRequestDto.java b/src/main/java/com/back/domain/review/dto/request/ReviewListRequestDto.java index fded23d7..3e60b2c9 100644 --- a/src/main/java/com/back/domain/review/dto/request/ReviewListRequestDto.java +++ b/src/main/java/com/back/domain/review/dto/request/ReviewListRequestDto.java @@ -5,6 +5,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.UUID; + /** * 리뷰 목록 조회 요청 DTO */ @@ -14,7 +16,7 @@ @Builder public class ReviewListRequestDto { - private Long productId; // 상품 ID + private UUID productUuid; // 상품 UUID private ReviewType reviewType; // 리뷰 타입 (PHOTO, GENERAL, ALL) diff --git a/src/main/java/com/back/domain/review/dto/request/ReviewWriteRequestDto.java b/src/main/java/com/back/domain/review/dto/request/ReviewWriteRequestDto.java index 3fdb9f16..964904dc 100644 --- a/src/main/java/com/back/domain/review/dto/request/ReviewWriteRequestDto.java +++ b/src/main/java/com/back/domain/review/dto/request/ReviewWriteRequestDto.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.*; import java.util.List; +import java.util.UUID; /** * 리뷰 작성 팝업용 요청 DTO @@ -19,8 +20,8 @@ @Builder public class ReviewWriteRequestDto { - @NotNull(message = "상품 ID는 필수입니다") - private Long productId; // 상품 ID + @NotNull(message = "상품 UUID는 필수입니다") + private UUID productUuid; // 상품 UUID @NotNull(message = "평점은 필수입니다") @Min(value = 1, message = "평점은 1점 이상이어야 합니다") diff --git a/src/main/java/com/back/domain/review/dto/response/ReviewDetailResponseDto.java b/src/main/java/com/back/domain/review/dto/response/ReviewDetailResponseDto.java index 69044243..229d2ba3 100644 --- a/src/main/java/com/back/domain/review/dto/response/ReviewDetailResponseDto.java +++ b/src/main/java/com/back/domain/review/dto/response/ReviewDetailResponseDto.java @@ -9,6 +9,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -21,7 +22,7 @@ public class ReviewDetailResponseDto { private Long reviewId; // 리뷰 ID - private Long productId; // 상품 ID + private UUID productUuid; // 상품 UUID private String productName; // 상품명 private String productOption; // 상품 옵션 (예: "상품옵션1") private Long userId; // 사용자 ID @@ -43,7 +44,7 @@ public class ReviewDetailResponseDto { public static ReviewDetailResponseDto from(Review review, boolean isLiked) { return ReviewDetailResponseDto.builder() .reviewId(review.getId()) - .productId(review.getProduct().getId()) + .productUuid(review.getProduct().getProductUuid()) .productName(review.getProduct().getName()) .productOption(review.getProductOption() != null ? review.getProductOption() : "상품옵션1") .userId(review.getUser().getId()) diff --git a/src/main/java/com/back/domain/review/dto/response/ReviewResponseDto.java b/src/main/java/com/back/domain/review/dto/response/ReviewResponseDto.java index 5e423d6e..c316634b 100644 --- a/src/main/java/com/back/domain/review/dto/response/ReviewResponseDto.java +++ b/src/main/java/com/back/domain/review/dto/response/ReviewResponseDto.java @@ -9,6 +9,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -21,7 +22,7 @@ public class ReviewResponseDto { private Long reviewId; // 리뷰 ID - private Long productId; // 상품 ID + private UUID productUuid; // 상품 UUID private String productName; // 상품명 private Long userId; // 사용자 ID private String userName; // 사용자명 @@ -43,7 +44,7 @@ public class ReviewResponseDto { public static ReviewResponseDto from(Review review, boolean isLiked) { return ReviewResponseDto.builder() .reviewId(review.getId()) - .productId(review.getProduct().getId()) + .productUuid(review.getProduct().getProductUuid()) .productName(review.getProduct().getName()) .userId(review.getUser().getId()) .userName(review.getUser().getName()) diff --git a/src/main/java/com/back/domain/review/repository/ReviewRepository.java b/src/main/java/com/back/domain/review/repository/ReviewRepository.java index b36e32c1..2625d699 100644 --- a/src/main/java/com/back/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/back/domain/review/repository/ReviewRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -163,4 +164,18 @@ List findByProductWithUserAndImagesAndLikes( @Param("product") Product product, @Param("currentUserId") Long currentUserId, Pageable pageable); + + /** + * 리뷰 좋아요 수 증가 (동시성 안전) + */ + @Modifying + @Query("UPDATE Review r SET r.likeCount = r.likeCount + 1 WHERE r.id = :reviewId") + void increaseLikeCount(@Param("reviewId") Long reviewId); + + /** + * 리뷰 좋아요 수 감소 (동시성 안전) + */ + @Modifying + @Query("UPDATE Review r SET r.likeCount = CASE WHEN r.likeCount > 0 THEN r.likeCount - 1 ELSE 0 END WHERE r.id = :reviewId") + void decreaseLikeCount(@Param("reviewId") Long reviewId); } diff --git a/src/main/java/com/back/domain/review/service/ReviewService.java b/src/main/java/com/back/domain/review/service/ReviewService.java index 2bb29a07..a3378e54 100644 --- a/src/main/java/com/back/domain/review/service/ReviewService.java +++ b/src/main/java/com/back/domain/review/service/ReviewService.java @@ -52,11 +52,11 @@ public class ReviewService { */ @Transactional public ReviewResponseDto createReview(ReviewCreateRequestDto requestDto, User user) { - log.info("리뷰 작성 요청 - 상품ID: {}, 사용자: {}, 평점: {}", - requestDto.getProductId(), user.getId(), requestDto.getRating()); + log.info("리뷰 작성 요청 - 상품UUID: {}, 사용자: {}, 평점: {}", + requestDto.getProductUuid(), user.getId(), requestDto.getRating()); // 상품 존재 확인 - Product product = productRepository.findById(requestDto.getProductId()) + Product product = productRepository.findByProductUuid(requestDto.getProductUuid()) .orElseThrow(() -> new ServiceException("E-1", "상품을 찾을 수 없습니다.")); // 이미 리뷰를 작성했는지 확인 @@ -112,10 +112,10 @@ public ReviewResponseDto createReview(ReviewCreateRequestDto requestDto, User us * 리뷰 목록 조회 */ public ReviewListResponseDto getReviewList(ReviewListRequestDto requestDto, User currentUser) { - log.info("리뷰 목록 조회 요청 - 상품ID: {}, 리뷰타입: {}", - requestDto.getProductId(), requestDto.getReviewType()); + log.info("리뷰 목록 조회 요청 - 상품UUID: {}, 리뷰타입: {}", + requestDto.getProductUuid(), requestDto.getReviewType()); - Product product = productRepository.findById(requestDto.getProductId()) + Product product = productRepository.findByProductUuid(requestDto.getProductUuid()) .orElseThrow(() -> new ServiceException("E-1", "상품을 찾을 수 없습니다.")); // 페이지 설정 @@ -211,14 +211,14 @@ public ReviewDetailResponseDto getReviewDetailForPopup(Long reviewId, User curre */ @Transactional public ReviewDetailResponseDto writeReviewFromPopup(ReviewWriteRequestDto requestDto, User user) { - log.info("리뷰 작성 팝업 요청 - 상품ID: {}, 사용자: {}, 평점: {}, 상품옵션: {}", - requestDto.getProductId(), user.getId(), requestDto.getRating(), requestDto.getProductOption()); + log.info("리뷰 작성 팝업 요청 - 상품UUID: {}, 사용자: {}, 평점: {}, 상품옵션: {}", + requestDto.getProductUuid(), user.getId(), requestDto.getRating(), requestDto.getProductOption()); // 입력 데이터 유효성 검사 validateReviewWriteRequest(requestDto); // 상품 존재 확인 - Product product = productRepository.findById(requestDto.getProductId()) + Product product = productRepository.findByProductUuid(requestDto.getProductUuid()) .orElseThrow(() -> new ServiceException("E-1", "상품을 찾을 수 없습니다.")); // 이미 리뷰를 작성했는지 확인 @@ -391,9 +391,10 @@ public boolean toggleReviewLike(Long reviewId, User user) { // 좋아요 취소 ReviewLike reviewLike = existingLike.get(); reviewLike.cancelLike(); - review.decreaseLikeCount(); reviewLikeRepository.save(reviewLike); - reviewRepository.save(review); + + // DB 쿼리로 직접 감소 (동시성 안전) + reviewRepository.decreaseLikeCount(reviewId); log.info("리뷰 좋아요 취소 - 리뷰ID: {}", reviewId); return false; @@ -403,9 +404,10 @@ public boolean toggleReviewLike(Long reviewId, User user) { .review(review) .user(user) .build(); - review.increaseLikeCount(); reviewLikeRepository.save(reviewLike); - reviewRepository.save(review); + + // DB 쿼리로 직접 증가 (동시성 안전) + reviewRepository.increaseLikeCount(reviewId); log.info("리뷰 좋아요 추가 - 리뷰ID: {}", reviewId); return true; diff --git a/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java b/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java index e08d9888..f98c1038 100644 --- a/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java +++ b/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -75,6 +76,7 @@ void setUp() { // 관리자 생성 (테스트용) admin = User.createLocalUser("admin@test.com" + uniqueSuffix, "password", "관리자" + uniqueSuffix, "010-9999-9999"); + ReflectionTestUtils.setField(admin, "role", com.back.domain.user.entity.Role.ADMIN); // 관리자 권한 부여 admin = userRepository.save(admin); // 카테고리 생성 diff --git a/src/test/java/com/back/domain/order/order/service/OrderServiceTest.java b/src/test/java/com/back/domain/order/order/service/OrderServiceTest.java index 56e3bd3c..77f244fd 100644 --- a/src/test/java/com/back/domain/order/order/service/OrderServiceTest.java +++ b/src/test/java/com/back/domain/order/order/service/OrderServiceTest.java @@ -60,14 +60,14 @@ void createOrder_Success() { Product product = createProduct(1L, productUuid); OrderRequestDto requestDto = createOrderRequestDto(productUuid); - given(productRepository.findByProductUuid(productUuid)).willReturn(Optional.of(product)); + given(productRepository.findByProductUuidWithLock(productUuid)).willReturn(Optional.of(product)); given(orderRepository.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); OrderResponseDto result = orderService.createOrder(user, requestDto); assertThat(result).isNotNull(); assertThat(result.status()).isEqualTo(OrderStatus.PAYMENT_COMPLETED); - verify(productRepository).findByProductUuid(productUuid); + verify(productRepository).findByProductUuidWithLock(productUuid); verify(orderRepository).save(any(Order.class)); verify(cartRepository).deleteByUserAndProductUuidIn(eq(user), anyList()); } @@ -78,7 +78,7 @@ void createOrder_ProductNotFound() { User user = createUser(1L); UUID productUuid = UUID.randomUUID(); OrderRequestDto requestDto = createOrderRequestDto(productUuid); - given(productRepository.findByProductUuid(productUuid)).willReturn(Optional.empty()); + given(productRepository.findByProductUuidWithLock(productUuid)).willReturn(Optional.empty()); assertThatThrownBy(() -> orderService.createOrder(user, requestDto)) .isInstanceOf(IllegalArgumentException.class) diff --git a/src/test/java/com/back/domain/payment/cash/service/CashChargeServiceTest.java b/src/test/java/com/back/domain/payment/cash/service/CashChargeServiceTest.java index 7b603baa..1616a9ba 100644 --- a/src/test/java/com/back/domain/payment/cash/service/CashChargeServiceTest.java +++ b/src/test/java/com/back/domain/payment/cash/service/CashChargeServiceTest.java @@ -90,7 +90,7 @@ void completeCharge_Success() { when(cashTransactionRepository.findById(transactionId)).thenReturn(Optional.of(transaction)); when(paymentGatewayService.approvePayment(anyString(), anyString(), anyInt())).thenReturn(pgResponse); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(user)).thenReturn(Optional.of(balance)); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(balance); // When @@ -103,7 +103,7 @@ void completeCharge_Success() { verify(cashTransactionRepository).findById(transactionId); verify(paymentGatewayService).approvePayment(paymentKey, orderId, transaction.getAmount()); - verify(moriCashBalanceRepository).findByUser(user); + verify(moriCashBalanceRepository).findByUserWithLock(user); verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); } @@ -141,7 +141,7 @@ void completeCharge_CreateNewBalance() { when(cashTransactionRepository.findById(transactionId)).thenReturn(Optional.of(transaction)); when(paymentGatewayService.approvePayment(anyString(), anyString(), anyInt())).thenReturn(pgResponse); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.empty()); + when(moriCashBalanceRepository.findByUserWithLock(user)).thenReturn(Optional.empty()); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(newBalance); // When @@ -150,7 +150,7 @@ void completeCharge_CreateNewBalance() { // Then assertThat(result).isNotNull(); verify(paymentGatewayService).approvePayment(paymentKey, orderId, transaction.getAmount()); - verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); + verify(moriCashBalanceRepository, times(2)).save(any(MoriCashBalance.class)); // orElseGet에서 1번, 이후 1번 } @Test diff --git a/src/test/java/com/back/domain/payment/cash/service/CashExchangeServiceTest.java b/src/test/java/com/back/domain/payment/cash/service/CashExchangeServiceTest.java index 086d8e0f..1443ee47 100644 --- a/src/test/java/com/back/domain/payment/cash/service/CashExchangeServiceTest.java +++ b/src/test/java/com/back/domain/payment/cash/service/CashExchangeServiceTest.java @@ -56,7 +56,7 @@ void createExchangeRequest_ArtistSuccess() { MoriCashBalance balance = createTestBalance(15000); CashTransaction savedTransaction = createTestTransaction(); - when(moriCashBalanceRepository.findByUser(artistUser)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(artistUser)).thenReturn(Optional.of(balance)); when(cashTransactionRepository.save(any(CashTransaction.class))).thenReturn(savedTransaction); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(balance); @@ -68,7 +68,7 @@ void createExchangeRequest_ArtistSuccess() { assertThat(result.getAmount()).isEqualTo(10000); assertThat(result.getStatus()).isEqualTo(CashTransactionStatus.PENDING); - verify(moriCashBalanceRepository).findByUser(artistUser); + verify(moriCashBalanceRepository).findByUserWithLock(artistUser); verify(cashTransactionRepository).save(any(CashTransaction.class)); verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); } @@ -88,7 +88,7 @@ void createExchangeRequest_InsufficientBalance() { // Given MoriCashBalance balance = createTestBalance(5000); // 요청 금액보다 적음 - when(moriCashBalanceRepository.findByUser(artistUser)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(artistUser)).thenReturn(Optional.of(balance)); // When & Then assertThatThrownBy(() -> cashExchangeService.createExchangeRequest(requestDto, artistUser)) @@ -100,7 +100,7 @@ void createExchangeRequest_InsufficientBalance() { @DisplayName("캐시 환전 신청 - 잔액 정보 없음") void createExchangeRequest_BalanceNotFound() { // Given - when(moriCashBalanceRepository.findByUser(artistUser)).thenReturn(Optional.empty()); + when(moriCashBalanceRepository.findByUserWithLock(artistUser)).thenReturn(Optional.empty()); // When & Then assertThatThrownBy(() -> cashExchangeService.createExchangeRequest(requestDto, artistUser)) @@ -127,7 +127,7 @@ void approveExchange_Success() { .build(); when(cashTransactionRepository.findById(transactionId)).thenReturn(Optional.of(transaction)); - when(moriCashBalanceRepository.findByUser(artistUser)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(artistUser)).thenReturn(Optional.of(balance)); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(balance); // When @@ -138,7 +138,7 @@ void approveExchange_Success() { assertThat(result.getStatus()).isEqualTo(CashTransactionStatus.COMPLETED); verify(cashTransactionRepository).findById(transactionId); - verify(moriCashBalanceRepository).findByUser(artistUser); + verify(moriCashBalanceRepository).findByUserWithLock(artistUser); verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); } @@ -172,7 +172,7 @@ void rejectExchange_Success() { .build(); when(cashTransactionRepository.findById(transactionId)).thenReturn(Optional.of(transaction)); - when(moriCashBalanceRepository.findByUser(artistUser)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(artistUser)).thenReturn(Optional.of(balance)); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(balance); // When @@ -180,7 +180,7 @@ void rejectExchange_Success() { // Then verify(cashTransactionRepository).findById(transactionId); - verify(moriCashBalanceRepository).findByUser(artistUser); + verify(moriCashBalanceRepository).findByUserWithLock(artistUser); verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); assertThat(transaction.getStatus()).isEqualTo(CashTransactionStatus.FAILED); diff --git a/src/test/java/com/back/domain/payment/moriCash/service/MoriCashPaymentServiceTest.java b/src/test/java/com/back/domain/payment/moriCash/service/MoriCashPaymentServiceTest.java index fc52da83..eff4338f 100644 --- a/src/test/java/com/back/domain/payment/moriCash/service/MoriCashPaymentServiceTest.java +++ b/src/test/java/com/back/domain/payment/moriCash/service/MoriCashPaymentServiceTest.java @@ -64,7 +64,7 @@ void createPayment_Success() { MoriCashPayment savedPayment = createTestPayment(); when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(user)).thenReturn(Optional.of(balance)); when(moriCashPaymentRepository.save(any(MoriCashPayment.class))).thenReturn(savedPayment); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(balance); @@ -78,7 +78,7 @@ void createPayment_Success() { assertThat(result.getStatus()).isEqualTo(MoriCashPaymentStatus.COMPLETED); verify(orderRepository).findById(1L); - verify(moriCashBalanceRepository).findByUser(user); + verify(moriCashBalanceRepository).findByUserWithLock(user); verify(moriCashPaymentRepository).save(any(MoriCashPayment.class)); verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); } @@ -126,7 +126,7 @@ void createPayment_InsufficientBalance() { MoriCashBalance balance = createTestBalance(5000); // 요청 금액보다 적음 when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(user)).thenReturn(Optional.of(balance)); // When & Then assertThatThrownBy(() -> moriCashPaymentService.createPayment(requestDto, user)) @@ -135,11 +135,14 @@ void createPayment_InsufficientBalance() { } @Test - @DisplayName("모리캐시 결제 - 잔액이 없는 경우 예외 발생") + @DisplayName("모리캐시 결제 - 잔액이 없는 경우 새로 생성 후 잔액 부족으로 실패") void createPayment_CreateNewBalance() { // Given + MoriCashBalance newBalance = createTestBalance(0); // 새로 생성된 잔액은 0원 + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.empty()); + when(moriCashBalanceRepository.findByUserWithLock(user)).thenReturn(Optional.empty()); + when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(newBalance); // When & Then assertThatThrownBy(() -> moriCashPaymentService.createPayment(requestDto, user)) @@ -156,7 +159,7 @@ void cancelPayment_Success() { MoriCashBalance balance = createTestBalance(15000); when(moriCashPaymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(user)).thenReturn(Optional.of(balance)); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(balance); // When @@ -164,7 +167,7 @@ void cancelPayment_Success() { // Then verify(moriCashPaymentRepository).findById(paymentId); - verify(moriCashBalanceRepository).findByUser(user); + verify(moriCashBalanceRepository).findByUserWithLock(user); verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); assertThat(payment.getStatus()).isEqualTo(MoriCashPaymentStatus.CANCELLED); diff --git a/src/test/java/com/back/domain/payment/moriCash/service/MoriCashRefundServiceTest.java b/src/test/java/com/back/domain/payment/moriCash/service/MoriCashRefundServiceTest.java index 6b4425ca..767b139c 100644 --- a/src/test/java/com/back/domain/payment/moriCash/service/MoriCashRefundServiceTest.java +++ b/src/test/java/com/back/domain/payment/moriCash/service/MoriCashRefundServiceTest.java @@ -56,7 +56,7 @@ void processRefund_Success() { MoriCashBalance balance = createTestBalance(10000); when(moriCashPaymentRepository.findById(1L)).thenReturn(Optional.of(payment)); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(user)).thenReturn(Optional.of(balance)); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(balance); // When @@ -68,7 +68,7 @@ void processRefund_Success() { assertThat(result.getBalanceAfter()).isEqualTo(15000); // 10000 + 5000 verify(moriCashPaymentRepository).findById(1L); - verify(moriCashBalanceRepository).findByUser(user); + verify(moriCashBalanceRepository).findByUserWithLock(user); verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); } @@ -216,7 +216,7 @@ void cancelRefund_Success() { MoriCashBalance balance = createTestBalance(15000); when(moriCashPaymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(user)).thenReturn(Optional.of(balance)); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(balance); // When @@ -224,7 +224,7 @@ void cancelRefund_Success() { // Then verify(moriCashPaymentRepository).findById(paymentId); - verify(moriCashBalanceRepository).findByUser(user); + verify(moriCashBalanceRepository).findByUserWithLock(user); verify(moriCashBalanceRepository).save(any(MoriCashBalance.class)); assertThat(payment.getRefundPrice()).isNull(); @@ -291,7 +291,7 @@ void getRefund_Success() { MoriCashBalance balance = createTestBalance(10000); when(moriCashPaymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); - when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUser(user)).thenReturn(Optional.of(balance)); // 조회 전용이라 락 없음 // When MoriCashRefundResponseDto result = moriCashRefundService.getRefund(paymentId, user); diff --git a/src/test/java/com/back/domain/payment/settlement/service/SettlementServiceTest.java b/src/test/java/com/back/domain/payment/settlement/service/SettlementServiceTest.java index 856902c0..4d7fc37b 100644 --- a/src/test/java/com/back/domain/payment/settlement/service/SettlementServiceTest.java +++ b/src/test/java/com/back/domain/payment/settlement/service/SettlementServiceTest.java @@ -69,7 +69,7 @@ void requestWithdrawal_Success() { .accountHolder("작가") .build(); - when(moriCashBalanceRepository.findByUser(artist)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(artist)).thenReturn(Optional.of(balance)); when(settlementRepository.save(any(Settlement.class))).thenReturn(settlement); // when @@ -117,7 +117,7 @@ void requestWithdrawal_Fail_InsufficientBalance() { .amount(200000) // 보유액보다 많은 금액 .build(); - when(moriCashBalanceRepository.findByUser(artist)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(artist)).thenReturn(Optional.of(balance)); // when & then assertThatThrownBy(() -> settlementService.requestWithdrawal(requestDto, artist)) @@ -138,7 +138,7 @@ void requestWithdrawal_Fail_CreateBalanceButInsufficient() { // 잔액이 없을 때 빈 잔액 생성 (0원) MoriCashBalance newBalance = MoriCashBalance.createInitialBalance(artist); - when(moriCashBalanceRepository.findByUser(artist)).thenReturn(Optional.empty()); + when(moriCashBalanceRepository.findByUserWithLock(artist)).thenReturn(Optional.empty()); when(moriCashBalanceRepository.save(any(MoriCashBalance.class))).thenReturn(newBalance); // when & then @@ -168,7 +168,7 @@ void commissionCalculation() { .accountHolder("작가") .build(); - when(moriCashBalanceRepository.findByUser(artist)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUserWithLock(artist)).thenReturn(Optional.of(balance)); when(settlementRepository.save(any(Settlement.class))).thenReturn(settlement); // when @@ -183,7 +183,7 @@ void commissionCalculation() { @DisplayName("보유 모리캐시 조회") void getMoriCashBalance() { // given - when(moriCashBalanceRepository.findByUser(artist)).thenReturn(Optional.of(balance)); + when(moriCashBalanceRepository.findByUser(artist)).thenReturn(Optional.of(balance)); // 조회 전용이라 락 없음 // when Integer result = settlementService.getMoriCashBalance(artist); @@ -196,7 +196,7 @@ void getMoriCashBalance() { @DisplayName("보유 모리캐시 조회 - 잔액 없을 때 0 반환") void getMoriCashBalance_NoBalance() { // given - when(moriCashBalanceRepository.findByUser(artist)).thenReturn(Optional.empty()); + when(moriCashBalanceRepository.findByUser(artist)).thenReturn(Optional.empty()); // 조회 전용이라 락 없음 // when Integer result = settlementService.getMoriCashBalance(artist); diff --git a/src/test/java/com/back/domain/review/controller/ReviewControllerTest.java b/src/test/java/com/back/domain/review/controller/ReviewControllerTest.java index 69c4ca72..3b5c5b31 100644 --- a/src/test/java/com/back/domain/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/back/domain/review/controller/ReviewControllerTest.java @@ -26,6 +26,7 @@ import java.time.LocalDateTime; import java.util.Arrays; +import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -53,6 +54,8 @@ class ReviewControllerTest { private MockMvc mockMvc; private ObjectMapper objectMapper; private User user; + + private static final UUID TEST_PRODUCT_UUID = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); @BeforeEach void setUp() { @@ -66,7 +69,7 @@ void setUp() { void createReview_Success() throws Exception { // Given ReviewCreateRequestDto requestDto = ReviewCreateRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다!") .images(Arrays.asList()) @@ -74,7 +77,7 @@ void createReview_Success() throws Exception { ReviewResponseDto responseDto = ReviewResponseDto.builder() .reviewId(1L) - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .productName("테스트 상품") .userId(user.getId()) .userName(user.getName()) @@ -96,7 +99,7 @@ void createReview_Success() throws Exception { .requestAttr("user", user)) .andExpect(status().isOk()) .andExpect(jsonPath("$.reviewId").value(1L)) - .andExpect(jsonPath("$.productId").value(1L)) + .andExpect(jsonPath("$.productUuid").value(TEST_PRODUCT_UUID.toString())) .andExpect(jsonPath("$.rating").value(5)) .andExpect(jsonPath("$.content").value("정말 좋은 상품입니다!")); } @@ -106,7 +109,7 @@ void createReview_Success() throws Exception { void getReviewList_Success() throws Exception { // When & Then mockMvc.perform(get("/api/reviews") - .param("productId", "1") + .param("productUuid", TEST_PRODUCT_UUID.toString()) .param("reviewType", "ALL") .param("page", "0") .param("size", "10") @@ -120,7 +123,7 @@ void getReviewDetail_Success() throws Exception { // Given ReviewResponseDto responseDto = ReviewResponseDto.builder() .reviewId(1L) - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .productName("테스트 상품") .userId(user.getId()) .userName(user.getName()) @@ -162,7 +165,7 @@ void getReviewDetailPopup_Success() throws Exception { // Given ReviewDetailResponseDto responseDto = ReviewDetailResponseDto.builder() .reviewId(1L) - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .productName("테스트 상품") .productOption("상품옵션1") .userId(user.getId()) @@ -191,7 +194,7 @@ void getReviewDetailPopup_Success() throws Exception { void writeReviewFromPopup_Success() throws Exception { // Given ReviewWriteRequestDto requestDto = ReviewWriteRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다!") .images(Arrays.asList()) @@ -202,7 +205,7 @@ void writeReviewFromPopup_Success() throws Exception { ReviewDetailResponseDto responseDto = ReviewDetailResponseDto.builder() .reviewId(1L) - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .productName("테스트 상품") .productOption("상품옵션1") .userId(user.getId()) @@ -234,7 +237,7 @@ void writeReviewFromPopup_Success() throws Exception { void getReviewStats_Success() throws Exception { // Given com.back.domain.product.product.entity.Product product = mock(com.back.domain.product.product.entity.Product.class); - when(productRepository.findById(1L)).thenReturn(java.util.Optional.of(product)); + when(productRepository.findByProductUuid(TEST_PRODUCT_UUID)).thenReturn(java.util.Optional.of(product)); com.back.domain.review.dto.response.ReviewStatsResponseDto statsDto = com.back.domain.review.dto.response.ReviewStatsResponseDto.builder() @@ -250,7 +253,7 @@ void getReviewStats_Success() throws Exception { // When & Then mockMvc.perform(get("/api/reviews/stats") - .param("productId", "1")) + .param("productUuid", TEST_PRODUCT_UUID.toString())) .andExpect(status().isOk()); } @@ -259,7 +262,7 @@ void getReviewStats_Success() throws Exception { void writeReviewFromPopup_FigmaDesign_Success() throws Exception { // Given - 피그마 디자인의 모든 요소 포함 ReviewWriteRequestDto requestDto = ReviewWriteRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다! 추천해요!") .images(Arrays.asList()) @@ -270,7 +273,7 @@ void writeReviewFromPopup_FigmaDesign_Success() throws Exception { ReviewDetailResponseDto responseDto = ReviewDetailResponseDto.builder() .reviewId(1L) - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .productName("테스트 상품") .productOption("상품옵션1") .userId(user.getId()) @@ -304,7 +307,7 @@ void writeReviewFromPopup_FigmaDesign_Success() throws Exception { void writeReviewFromPopup_ValidationError() throws Exception { // Given - 필수 필드 누락된 요청 ReviewWriteRequestDto requestDto = ReviewWriteRequestDto.builder() - .productId(null) // 필수 필드 누락 + .productUuid(null) // 필수 필드 누락 .rating(5) .content("") // 빈 내용 .productOption("") // 빈 상품옵션 @@ -324,7 +327,7 @@ void getReviewDetailPopup_FigmaDesign_Success() throws Exception { // Given - 피그마 디자인 팝업 응답 ReviewDetailResponseDto responseDto = ReviewDetailResponseDto.builder() .reviewId(1L) - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .productName("테스트 상품") .productOption("상품옵션1") .userId(user.getId()) diff --git a/src/test/java/com/back/domain/review/service/ReviewServiceTest.java b/src/test/java/com/back/domain/review/service/ReviewServiceTest.java index ca5008e0..850882fb 100644 --- a/src/test/java/com/back/domain/review/service/ReviewServiceTest.java +++ b/src/test/java/com/back/domain/review/service/ReviewServiceTest.java @@ -32,6 +32,7 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -60,6 +61,8 @@ class ReviewServiceTest { private User user; private Product product; private Review review; + + private static final UUID TEST_PRODUCT_UUID = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); @BeforeEach void setUp() { @@ -73,13 +76,13 @@ void setUp() { void createReview_Success() { // Given ReviewCreateRequestDto requestDto = ReviewCreateRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다!") .images(Arrays.asList()) .build(); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findByProductUuid(TEST_PRODUCT_UUID)).thenReturn(Optional.of(product)); when(reviewRepository.findByProductAndUserAndNotDeleted(product, user)).thenReturn(Optional.empty()); when(reviewRepository.save(any(Review.class))).thenReturn(review); @@ -91,7 +94,7 @@ void createReview_Success() { assertThat(result.getRating()).isEqualTo(5); assertThat(result.getContent()).isEqualTo("정말 좋은 상품입니다! 추천해요!"); - verify(productRepository).findById(1L); + verify(productRepository).findByProductUuid(TEST_PRODUCT_UUID); verify(reviewRepository).findByProductAndUserAndNotDeleted(product, user); verify(reviewRepository).save(any(Review.class)); } @@ -100,13 +103,14 @@ void createReview_Success() { @DisplayName("리뷰 작성 실패 - 상품을 찾을 수 없음") void createReview_ProductNotFound() { // Given + UUID nonExistentUuid = UUID.fromString("00000000-0000-0000-0000-000000000000"); ReviewCreateRequestDto requestDto = ReviewCreateRequestDto.builder() - .productId(999L) + .productUuid(nonExistentUuid) .rating(5) .content("정말 좋은 상품입니다!") .build(); - when(productRepository.findById(999L)).thenReturn(Optional.empty()); + when(productRepository.findByProductUuid(nonExistentUuid)).thenReturn(Optional.empty()); // When & Then assertThatThrownBy(() -> reviewService.createReview(requestDto, user)) @@ -119,12 +123,12 @@ void createReview_ProductNotFound() { void createReview_AlreadyExists() { // Given ReviewCreateRequestDto requestDto = ReviewCreateRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다!") .build(); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findByProductUuid(TEST_PRODUCT_UUID)).thenReturn(Optional.of(product)); when(reviewRepository.findByProductAndUserAndNotDeleted(product, user)).thenReturn(Optional.of(review)); // When & Then @@ -138,7 +142,7 @@ void createReview_AlreadyExists() { void getReviewList_Success() { // Given ReviewListRequestDto requestDto = ReviewListRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .reviewType(ReviewListRequestDto.ReviewType.ALL) .page(0) .size(10) @@ -147,7 +151,7 @@ void getReviewList_Success() { List reviews = Arrays.asList(review); Page reviewPage = new PageImpl<>(reviews, PageRequest.of(0, 10), 1); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findByProductUuid(TEST_PRODUCT_UUID)).thenReturn(Optional.of(product)); when(reviewRepository.findByProductAndNotDeleted(any(Product.class), any(Pageable.class))).thenReturn(reviewPage); when(reviewRepository.countByProductAndNotDeleted(product)).thenReturn(1L); when(reviewRepository.countPhotoReviewsByProduct(product)).thenReturn(0L); @@ -168,7 +172,7 @@ void getReviewList_Success() { assertThat(result.getTotalCount()).isEqualTo(1); assertThat(result.getAverageRating()).isEqualTo(5.0); - verify(productRepository).findById(1L); + verify(productRepository).findByProductUuid(TEST_PRODUCT_UUID); verify(reviewRepository).findByProductWithUserAndImagesAndLikes(eq(product), eq(user.getId()), any(Pageable.class)); } @@ -238,7 +242,6 @@ void toggleReviewLike_Success_AddLike() { when(reviewRepository.findById(1L)).thenReturn(Optional.of(review)); when(reviewLikeRepository.findByReviewAndUserAndNotDeleted(review, user)).thenReturn(Optional.empty()); when(reviewLikeRepository.save(any(ReviewLike.class))).thenReturn(ReviewLike.builder().build()); - when(reviewRepository.save(any(Review.class))).thenReturn(review); // When boolean result = reviewService.toggleReviewLike(1L, user); @@ -248,7 +251,7 @@ void toggleReviewLike_Success_AddLike() { verify(reviewRepository).findById(1L); verify(reviewLikeRepository).findByReviewAndUserAndNotDeleted(review, user); verify(reviewLikeRepository).save(any(ReviewLike.class)); - verify(reviewRepository).save(any(Review.class)); + verify(reviewRepository).increaseLikeCount(1L); } @Test @@ -263,7 +266,6 @@ void toggleReviewLike_Success_RemoveLike() { when(reviewRepository.findById(1L)).thenReturn(Optional.of(review)); when(reviewLikeRepository.findByReviewAndUserAndNotDeleted(review, user)).thenReturn(Optional.of(reviewLike)); when(reviewLikeRepository.save(any(ReviewLike.class))).thenReturn(reviewLike); - when(reviewRepository.save(any(Review.class))).thenReturn(review); // When boolean result = reviewService.toggleReviewLike(1L, user); @@ -273,7 +275,7 @@ void toggleReviewLike_Success_RemoveLike() { verify(reviewRepository).findById(1L); verify(reviewLikeRepository).findByReviewAndUserAndNotDeleted(review, user); verify(reviewLikeRepository).save(any(ReviewLike.class)); - verify(reviewRepository).save(any(Review.class)); + verify(reviewRepository).decreaseLikeCount(1L); } @Test @@ -281,7 +283,7 @@ void toggleReviewLike_Success_RemoveLike() { void writeReviewFromPopup_Success() { // Given - 피그마 디자인 요소들 포함 ReviewWriteRequestDto requestDto = ReviewWriteRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다! 추천해요!") .images(Arrays.asList()) @@ -290,7 +292,7 @@ void writeReviewFromPopup_Success() { .reviewType("PHOTO") .build(); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findByProductUuid(TEST_PRODUCT_UUID)).thenReturn(Optional.of(product)); when(reviewRepository.findByProductAndUserAndNotDeleted(product, user)).thenReturn(Optional.empty()); when(reviewRepository.save(any(Review.class))).thenReturn(review); @@ -303,7 +305,7 @@ void writeReviewFromPopup_Success() { assertThat(result.getContent()).isEqualTo("정말 좋은 상품입니다! 추천해요!"); assertThat(result.getProductOption()).isEqualTo("상품옵션1"); - verify(productRepository).findById(1L); + verify(productRepository).findByProductUuid(TEST_PRODUCT_UUID); verify(reviewRepository).findByProductAndUserAndNotDeleted(product, user); verify(reviewRepository).save(any(Review.class)); } @@ -313,7 +315,7 @@ void writeReviewFromPopup_Success() { void writeReviewFromPopup_InvalidHashtags() { // Given - 유효하지 않은 해시태그 (20자 초과) ReviewWriteRequestDto requestDto = ReviewWriteRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다!") .hashtags(Arrays.asList("이것은매우긴해시태그입니다20자를초과합니다")) @@ -331,7 +333,7 @@ void writeReviewFromPopup_InvalidHashtags() { void writeReviewFromPopup_TooManyImages() { // Given - 이미지 6개 (최대 5개 제한 초과) ReviewWriteRequestDto requestDto = ReviewWriteRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다!") .images(Arrays.asList( @@ -356,7 +358,7 @@ void writeReviewFromPopup_TooManyImages() { void writeReviewFromPopup_AutoPhotoReview() { // Given - 이미지가 있으면 자동으로 포토리뷰로 설정 ReviewWriteRequestDto requestDto = ReviewWriteRequestDto.builder() - .productId(1L) + .productUuid(TEST_PRODUCT_UUID) .rating(5) .content("정말 좋은 상품입니다!") .images(Arrays.asList( @@ -365,7 +367,7 @@ void writeReviewFromPopup_AutoPhotoReview() { .productOption("상품옵션1") .build(); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findByProductUuid(TEST_PRODUCT_UUID)).thenReturn(Optional.of(product)); when(reviewRepository.findByProductAndUserAndNotDeleted(product, user)).thenReturn(Optional.empty()); when(reviewRepository.save(any(Review.class))).thenAnswer(invocation -> { Review savedReview = invocation.getArgument(0); @@ -421,6 +423,7 @@ private Product createTestProduct() { .name("테스트 상품") .price(10000) .discountRate(10) + .productUuid(TEST_PRODUCT_UUID) .build(); ReflectionTestUtils.setField(product, "id", 1L); return product;