Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/main/java/com/back/domain/cart/entity/Cart.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -384,10 +384,11 @@ public void creditArtistRevenue(Order order) {
private List<OrderItem> createOrderItems(List<OrderRequestDto.OrderItemRequestDto> 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() + ")");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -12,6 +16,14 @@ public interface MoriCashBalanceRepository extends JpaRepository<MoriCashBalance
* 사용자별 캐시 잔액 조회
*/
Optional<MoriCashBalance> findByUser(User user);

/**
* 사용자별 캐시 잔액 조회 (Pessimistic Write Lock)
* 결제/충전/환전 시 사용 - 동시성 제어
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM MoriCashBalance m WHERE m.user = :user")
Optional<MoriCashBalance> findByUserWithLock(@Param("user") User user);

/**
* 사용자 ID로 캐시 잔액 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("모리캐시 잔액이 부족합니다.");
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,7 +18,13 @@

public interface ProductRepository extends JpaRepository<Product, Long>, ProductCustomRepository, JpaSpecificationExecutor<Product> {
Optional<Product> 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<Product> findByProductUuidWithLock(@Param("productUuid") UUID productUuid);

boolean existsByCategoryId(Long categoryId);

/**
* 특정 카테고리의 상품 수 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

/**
* 리뷰 Controller
Expand Down Expand Up @@ -81,14 +82,14 @@ public ResponseEntity<ReviewResponseDto> createReview(
@Operation(summary = "리뷰 목록 조회", description = "상품의 리뷰 목록을 조회합니다.")
@GetMapping
public ResponseEntity<ReviewListResponseDto> 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)
Expand Down Expand Up @@ -180,8 +181,8 @@ public ResponseEntity<ReviewDetailResponseDto> 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);

Expand All @@ -197,9 +198,9 @@ public ResponseEntity<ReviewDetailResponseDto> writeReviewFromPopup(
@Operation(summary = "상품별 리뷰 통계 조회", description = "특정 상품의 리뷰 통계 정보를 조회합니다.")
@GetMapping("/stats")
public ResponseEntity<ReviewStatsResponseDto> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import jakarta.validation.constraints.*;
import java.util.List;
import java.util.UUID;

/**
* 리뷰 작성 요청 DTO
Expand All @@ -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점 이상이어야 합니다")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.UUID;

/**
* 리뷰 목록 조회 요청 DTO
*/
Expand All @@ -14,7 +16,7 @@
@Builder
public class ReviewListRequestDto {

private Long productId; // 상품 ID
private UUID productUuid; // 상품 UUID

private ReviewType reviewType; // 리뷰 타입 (PHOTO, GENERAL, ALL)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import jakarta.validation.constraints.*;
import java.util.List;
import java.util.UUID;

/**
* 리뷰 작성 팝업용 요청 DTO
Expand All @@ -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점 이상이어야 합니다")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

/**
Expand All @@ -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
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

/**
Expand All @@ -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; // 사용자명
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -163,4 +164,18 @@ List<Review> 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);
}
Loading