Skip to content

Commit 71f7aa9

Browse files
authored
동시성 제어 구현 (#361)
1 parent 779f141 commit 71f7aa9

File tree

26 files changed

+186
-121
lines changed

26 files changed

+186
-121
lines changed

src/main/java/com/back/domain/cart/entity/Cart.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
@Table(name = "carts")
1616
public class Cart extends BaseEntity {
1717

18+
@Version
19+
private Long version; // Optimistic Lock을 위한 버전 필드
20+
1821
@ManyToOne(fetch = FetchType.LAZY)
1922
@JoinColumn(name = "user_id", nullable = false)
2023
private User user;

src/main/java/com/back/domain/order/order/service/OrderService.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,9 @@ public void creditArtistRevenue(Order order) {
353353
order.getOrderItems().forEach(orderItem -> {
354354
User artist = orderItem.getProduct().getUser();
355355

356-
// 작가의 모리캐시 잔액 조회 또는 생성
356+
// 작가의 모리캐시 잔액 조회 또는 생성 (Pessimistic Write Lock - 동시성 제어)
357357
com.back.domain.payment.moriCash.entity.MoriCashBalance balance =
358-
moriCashBalanceRepository.findByUser(artist)
358+
moriCashBalanceRepository.findByUserWithLock(artist)
359359
.orElseGet(() -> {
360360
com.back.domain.payment.moriCash.entity.MoriCashBalance newBalance =
361361
com.back.domain.payment.moriCash.entity.MoriCashBalance.createInitialBalance(artist);
@@ -384,10 +384,11 @@ public void creditArtistRevenue(Order order) {
384384
private List<OrderItem> createOrderItems(List<OrderRequestDto.OrderItemRequestDto> orderItemRequests) {
385385
return orderItemRequests.stream()
386386
.map(itemRequest -> {
387-
Product product = productRepository.findByProductUuid(itemRequest.productUuid())
387+
// Pessimistic Write Lock으로 상품 조회 (동시성 제어)
388+
Product product = productRepository.findByProductUuidWithLock(itemRequest.productUuid())
388389
.orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다."));
389390

390-
// 재고 감소
391+
// 재고 검증 및 감소
391392
int newStock = product.getStock() - itemRequest.quantity();
392393
if (newStock < 0) {
393394
throw new IllegalArgumentException("재고가 부족합니다. (상품: " + product.getName() + ", 현재 재고: " + product.getStock() + ")");

src/main/java/com/back/domain/payment/cash/service/CashChargeService.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,12 @@ public CashChargeResponseDto completeCharge(Long transactionId, String paymentKe
6565
cashTransaction.getAmount()
6666
);
6767

68-
// 2. 모리캐시 잔액 업데이트
69-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(cashTransaction.getUser())
70-
.orElseGet(() -> MoriCashBalance.createInitialBalance(cashTransaction.getUser()));
68+
// 2. 모리캐시 잔액 업데이트 (Pessimistic Write Lock - 동시성 제어)
69+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(cashTransaction.getUser())
70+
.orElseGet(() -> {
71+
MoriCashBalance newBalance = MoriCashBalance.createInitialBalance(cashTransaction.getUser());
72+
return moriCashBalanceRepository.save(newBalance);
73+
});
7174

7275
balance.addBalance(cashTransaction.getAmount());
7376
moriCashBalanceRepository.save(balance);

src/main/java/com/back/domain/payment/cash/service/CashExchangeService.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ public CashExchangeResponseDto createExchangeRequest(CashExchangeRequestDto requ
3333
throw new IllegalArgumentException("작가만 환전이 가능합니다.");
3434
}
3535

36-
// 2. 모리캐시 잔액 확인
37-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(user)
36+
// 2. 모리캐시 잔액 확인 (Pessimistic Write Lock - 동시성 제어)
37+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(user)
3838
.orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다."));
3939

4040
if (balance.getAvailableBalance() < requestDto.getAmount()) {
@@ -74,8 +74,8 @@ public CashExchangeResponseDto approveExchange(Long transactionId, String pgTran
7474
// 1. 거래 완료 처리
7575
cashTransaction.completeTransaction(pgTransactionId, pgApprovalNumber, cashTransaction.getBalanceAfter());
7676

77-
// 2. 모리캐시 잔액에서 실제 차감
78-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(cashTransaction.getUser())
77+
// 2. 모리캐시 잔액에서 실제 차감 (Pessimistic Write Lock - 동시성 제어)
78+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(cashTransaction.getUser())
7979
.orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다."));
8080

8181
balance.deductFrozenBalance(cashTransaction.getAmount());
@@ -99,8 +99,8 @@ public void rejectExchange(Long transactionId, String rejectionReason) {
9999
// 1. 거래 실패 처리
100100
cashTransaction.failTransaction(rejectionReason);
101101

102-
// 2. 모리캐시 잔액 동결 해제
103-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(cashTransaction.getUser())
102+
// 2. 모리캐시 잔액 동결 해제 (Pessimistic Write Lock - 동시성 제어)
103+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(cashTransaction.getUser())
104104
.orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다."));
105105

106106
balance.unfreezeBalance(cashTransaction.getAmount());

src/main/java/com/back/domain/payment/moriCash/repository/MoriCashBalanceRepository.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
import com.back.domain.payment.moriCash.entity.MoriCashBalance;
44
import com.back.domain.user.entity.User;
5+
import jakarta.persistence.LockModeType;
56
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Lock;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
610

711
import java.util.Optional;
812

@@ -12,6 +16,14 @@ public interface MoriCashBalanceRepository extends JpaRepository<MoriCashBalance
1216
* 사용자별 캐시 잔액 조회
1317
*/
1418
Optional<MoriCashBalance> findByUser(User user);
19+
20+
/**
21+
* 사용자별 캐시 잔액 조회 (Pessimistic Write Lock)
22+
* 결제/충전/환전 시 사용 - 동시성 제어
23+
*/
24+
@Lock(LockModeType.PESSIMISTIC_WRITE)
25+
@Query("SELECT m FROM MoriCashBalance m WHERE m.user = :user")
26+
Optional<MoriCashBalance> findByUserWithLock(@Param("user") User user);
1527

1628
/**
1729
* 사용자 ID로 캐시 잔액 조회

src/main/java/com/back/domain/payment/moriCash/service/MoriCashPaymentService.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@ public MoriCashPaymentResponseDto createPayment(MoriCashPaymentRequestDto reques
4646
throw new IllegalArgumentException("본인의 주문만 결제할 수 있습니다.");
4747
}
4848

49-
// 3. 모리캐시 잔액 확인
50-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(user)
51-
.orElseGet(() -> MoriCashBalance.createInitialBalance(user));
49+
// 3. 모리캐시 잔액 확인 (Pessimistic Write Lock - 동시성 제어)
50+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(user)
51+
.orElseGet(() -> {
52+
MoriCashBalance newBalance = MoriCashBalance.createInitialBalance(user);
53+
return moriCashBalanceRepository.save(newBalance);
54+
});
5255

5356
if (balance.getAvailableBalance() < requestDto.getUsedMoriCash()) {
5457
throw new IllegalArgumentException("모리캐시 잔액이 부족합니다.");
@@ -99,8 +102,8 @@ public void cancelPayment(Long paymentId, User user) {
99102
throw new IllegalArgumentException("완료된 결제만 취소할 수 있습니다.");
100103
}
101104

102-
// 3. 모리캐시 잔액 복원
103-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(user)
105+
// 3. 모리캐시 잔액 복원 (Pessimistic Write Lock - 동시성 제어)
106+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(user)
104107
.orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다."));
105108

106109
balance.restoreBalance(payment.getUsedMoriCash());

src/main/java/com/back/domain/payment/moriCash/service/MoriCashRefundService.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ public MoriCashRefundResponseDto processRefund(MoriCashRefundRequestDto requestD
5353
throw new IllegalArgumentException("환불 금액이 올바르지 않습니다.");
5454
}
5555

56-
// 5. 모리캐시 잔액 복원
57-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(user)
56+
// 5. 모리캐시 잔액 복원 (Pessimistic Write Lock - 동시성 제어)
57+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(user)
5858
.orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다."));
5959

6060
balance.restoreBalance(requestDto.getRefundAmount());
@@ -83,8 +83,8 @@ public void cancelRefund(Long paymentId, String cancellationReason) {
8383
throw new IllegalArgumentException("환불 처리된 결제가 아닙니다.");
8484
}
8585

86-
// 2. 모리캐시 잔액에서 환불 금액 차감
87-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(payment.getUser())
86+
// 2. 모리캐시 잔액에서 환불 금액 차감 (Pessimistic Write Lock - 동시성 제어)
87+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(payment.getUser())
8888
.orElseThrow(() -> new IllegalArgumentException("모리캐시 잔액 정보를 찾을 수 없습니다."));
8989

9090
balance.deductBalance(payment.getRefundPrice());

src/main/java/com/back/domain/payment/settlement/service/SettlementService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ public WithdrawalResponseDto requestWithdrawal(WithdrawalRequestDto requestDto,
3333
throw new IllegalArgumentException("작가만 환전 신청이 가능합니다.");
3434
}
3535

36-
// 1. 모리캐시 잔액 조회 또는 생성
37-
MoriCashBalance balance = moriCashBalanceRepository.findByUser(artist)
36+
// 1. 모리캐시 잔액 조회 또는 생성 (Pessimistic Write Lock - 동시성 제어)
37+
MoriCashBalance balance = moriCashBalanceRepository.findByUserWithLock(artist)
3838
.orElseGet(() -> {
3939
log.info("모리캐시 잔액 정보 없음. 새로 생성 - 작가ID: {}", artist.getId());
4040
MoriCashBalance newBalance = MoriCashBalance.createInitialBalance(artist);

src/main/java/com/back/domain/product/product/repository/ProductRepository.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import com.back.domain.product.category.entity.Category;
44
import com.back.domain.product.product.entity.Product;
5+
import jakarta.persistence.LockModeType;
56
import org.springframework.data.domain.Pageable;
67
import org.springframework.data.jpa.repository.JpaRepository;
78
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
9+
import org.springframework.data.jpa.repository.Lock;
810
import org.springframework.data.jpa.repository.Query;
911
import org.springframework.data.repository.query.Param;
1012

@@ -16,7 +18,13 @@
1618

1719
public interface ProductRepository extends JpaRepository<Product, Long>, ProductCustomRepository, JpaSpecificationExecutor<Product> {
1820
Optional<Product> findByProductUuid(UUID productUuid);
19-
boolean existsByCategoryId(Long categoryId); // category_id 필드값이 해당 categoryId인 상품이 하나라도 존재하는지 체크
21+
22+
// 재고 감소용 - Pessimistic Write Lock (동시성 제어)
23+
@Lock(LockModeType.PESSIMISTIC_WRITE)
24+
@Query("SELECT p FROM Product p WHERE p.productUuid = :productUuid")
25+
Optional<Product> findByProductUuidWithLock(@Param("productUuid") UUID productUuid);
26+
27+
boolean existsByCategoryId(Long categoryId);
2028

2129
/**
2230
* 특정 카테고리의 상품 수 조회

src/main/java/com/back/domain/review/controller/ReviewController.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.springframework.web.bind.annotation.*;
3030

3131
import java.util.List;
32+
import java.util.UUID;
3233

3334
/**
3435
* 리뷰 Controller
@@ -81,14 +82,14 @@ public ResponseEntity<ReviewResponseDto> createReview(
8182
@Operation(summary = "리뷰 목록 조회", description = "상품의 리뷰 목록을 조회합니다.")
8283
@GetMapping
8384
public ResponseEntity<ReviewListResponseDto> getReviewList(
84-
@Parameter(description = "상품 ID") @RequestParam Long productId,
85+
@Parameter(description = "상품 UUID") @RequestParam UUID productUuid,
8586
@Parameter(description = "리뷰 타입 (PHOTO, GENERAL, ALL)") @RequestParam(required = false) ReviewListRequestDto.ReviewType reviewType,
8687
@Parameter(description = "페이지 번호") @RequestParam(required = false, defaultValue = "0") Integer page,
8788
@Parameter(description = "페이지 크기") @RequestParam(required = false, defaultValue = "10") Integer size,
8889
@AuthenticationPrincipal User user) {
8990

9091
ReviewListRequestDto requestDto = ReviewListRequestDto.builder()
91-
.productId(productId)
92+
.productUuid(productUuid)
9293
.reviewType(reviewType != null ? reviewType : ReviewListRequestDto.ReviewType.ALL)
9394
.page(page)
9495
.size(size)
@@ -180,8 +181,8 @@ public ResponseEntity<ReviewDetailResponseDto> writeReviewFromPopup(
180181
@Valid @RequestBody ReviewWriteRequestDto requestDto,
181182
@AuthenticationPrincipal User user) {
182183

183-
log.info("리뷰 작성 팝업 API 호출 - 사용자: {}, 상품ID: {}, 평점: {}, 상품옵션: {}",
184-
user.getId(), requestDto.getProductId(), requestDto.getRating(), requestDto.getProductOption());
184+
log.info("리뷰 작성 팝업 API 호출 - 사용자: {}, 상품UUID: {}, 평점: {}, 상품옵션: {}",
185+
user.getId(), requestDto.getProductUuid(), requestDto.getRating(), requestDto.getProductOption());
185186

186187
ReviewDetailResponseDto response = reviewService.writeReviewFromPopup(requestDto, user);
187188

@@ -197,9 +198,9 @@ public ResponseEntity<ReviewDetailResponseDto> writeReviewFromPopup(
197198
@Operation(summary = "상품별 리뷰 통계 조회", description = "특정 상품의 리뷰 통계 정보를 조회합니다.")
198199
@GetMapping("/stats")
199200
public ResponseEntity<ReviewStatsResponseDto> getReviewStats(
200-
@Parameter(description = "상품 ID") @RequestParam Long productId) {
201+
@Parameter(description = "상품 UUID") @RequestParam UUID productUuid) {
201202

202-
Product product = productRepository.findById(productId)
203+
Product product = productRepository.findByProductUuid(productUuid)
203204
.orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다."));
204205

205206
ReviewStatsResponseDto response = reviewService.getReviewStats(product);

0 commit comments

Comments
 (0)