Skip to content

Commit 579c6c0

Browse files
committed
Feature/concurrency purchasing (#18)
* test: 주문 동시성 테스트 로직 추가 * test: 주문 흐름의 원자성을 검증하는 테스트 코드 추가 * feat: 비관적 락 적용하여 주문 동시성 이슈 발생하지 않도록 함 * refactor: deadlock 문제 수정
1 parent 337c8cb commit 579c6c0

File tree

7 files changed

+644
-28
lines changed

7 files changed

+644
-28
lines changed

apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@
2121
import org.springframework.stereotype.Component;
2222

2323
import java.util.ArrayList;
24-
import java.util.HashSet;
2524
import java.util.List;
2625
import java.util.Map;
2726
import java.util.Objects;
28-
import java.util.Set;
2927
import java.util.stream.Collectors;
3028

3129
/**
@@ -100,23 +98,39 @@ public OrderInfo createOrder(String userId, List<OrderItemCommand> commands) {
10098
// - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
10199
User user = loadUserForUpdate(userId);
102100

103-
Set<Long> productIds = new HashSet<>();
104-
List<Product> products = new ArrayList<>();
105-
List<OrderItem> orderItems = new ArrayList<>();
101+
// ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
102+
// 여러 상품을 주문할 때, 항상 동일한 순서로 락을 획득하여 deadlock 방지
103+
List<Long> sortedProductIds = commands.stream()
104+
.map(OrderItemCommand::productId)
105+
.distinct()
106+
.sorted()
107+
.toList();
106108

107-
for (OrderItemCommand command : commands) {
108-
if (!productIds.add(command.productId())) {
109-
throw new CoreException(ErrorType.BAD_REQUEST,
110-
String.format("상품이 중복되었습니다. (상품 ID: %d)", command.productId()));
111-
}
109+
// 중복 상품 검증
110+
if (sortedProductIds.size() != commands.size()) {
111+
throw new CoreException(ErrorType.BAD_REQUEST, "상품이 중복되었습니다.");
112+
}
113+
114+
// 정렬된 순서대로 상품 락 획득 (Deadlock 방지)
115+
Map<Long, Product> productMap = new java.util.HashMap<>();
112116

117+
for (Long productId : sortedProductIds) {
113118
// 비관적 락을 사용하여 상품 조회 (재고 차감 시 동시성 제어)
114119
// - id는 PK 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용)
115120
// - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
116121
// - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
117-
Product product = productRepository.findByIdForUpdate(command.productId())
122+
// - ✅ 정렬된 순서로 락 획득하여 deadlock 방지
123+
Product product = productRepository.findByIdForUpdate(productId)
118124
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
119-
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", command.productId())));
125+
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
126+
productMap.put(productId, product);
127+
}
128+
129+
// OrderItem 생성
130+
List<Product> products = new ArrayList<>();
131+
List<OrderItem> orderItems = new ArrayList<>();
132+
for (OrderItemCommand command : commands) {
133+
Product product = productMap.get(command.productId());
120134
products.add(product);
121135

122136
orderItems.add(OrderItem.of(
@@ -150,6 +164,13 @@ public OrderInfo createOrder(String userId, List<OrderItemCommand> commands) {
150164

151165
/**
152166
* 주문을 취소하고 포인트를 환불하며 재고를 원복한다.
167+
* <p>
168+
* <b>동시성 제어:</b>
169+
* <ul>
170+
* <li><b>비관적 락 사용:</b> 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용</li>
171+
* <li><b>Deadlock 방지:</b> 상품 ID를 정렬하여 일관된 락 획득 순서 보장</li>
172+
* </ul>
173+
* </p>
153174
*
154175
* @param order 주문 엔티티
155176
* @param user 사용자 엔티티
@@ -160,18 +181,41 @@ public void cancelOrder(Order order, User user) {
160181
throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다.");
161182
}
162183

163-
List<Product> products = order.getItems().stream()
164-
.map(item -> productRepository.findById(item.getProductId())
184+
// ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장
185+
// createOrder: User 락 → Product 락 (정렬됨)
186+
// cancelOrder: User 락 → Product 락 (정렬됨) - 동일한 순서로 락 획득
187+
User lockedUser = userRepository.findByUserIdForUpdate(user.getUserId());
188+
if (lockedUser == null) {
189+
throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
190+
}
191+
192+
// ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
193+
List<Long> sortedProductIds = order.getItems().stream()
194+
.map(OrderItem::getProductId)
195+
.distinct()
196+
.sorted()
197+
.toList();
198+
199+
// 정렬된 순서대로 상품 락 획득 (Deadlock 방지)
200+
Map<Long, Product> productMap = new java.util.HashMap<>();
201+
for (Long productId : sortedProductIds) {
202+
Product product = productRepository.findByIdForUpdate(productId)
165203
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
166-
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId()))))
204+
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
205+
productMap.put(productId, product);
206+
}
207+
208+
// OrderItem 순서대로 Product 리스트 생성
209+
List<Product> products = order.getItems().stream()
210+
.map(item -> productMap.get(item.getProductId()))
167211
.toList();
168212

169213
order.cancel();
170214
increaseStocksForOrderItems(order.getItems(), products);
171-
user.receivePoint(Point.of((long) order.getTotalAmount()));
215+
lockedUser.receivePoint(Point.of((long) order.getTotalAmount()));
172216

173217
products.forEach(productRepository::save);
174-
userRepository.save(user);
218+
userRepository.save(lockedUser);
175219
orderRepository.save(order);
176220
}
177221

apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
*/
1616
@Getter
1717
@EqualsAndHashCode
18-
@Embeddable
1918
public class OrderItem {
2019
private Long productId;
2120
private String name;

apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,23 @@ public interface UserRepository {
2525
* @return 조회된 사용자, 없으면 null
2626
*/
2727
User findByUserId(String userId);
28+
29+
/**
30+
* 사용자 ID로 사용자를 조회합니다. (비관적 락)
31+
* <p>
32+
* 동시성 제어가 필요한 경우 사용합니다. (예: 포인트 차감)
33+
* </p>
34+
* <p>
35+
* <b>Lock 전략:</b>
36+
* <ul>
37+
* <li><b>PESSIMISTIC_WRITE:</b> SELECT ... FOR UPDATE 사용</li>
38+
* <li><b>Lock 범위:</b> UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락 (최소화)</li>
39+
* <li><b>사용 목적:</b> 포인트 차감 시 Lost Update 방지</li>
40+
* </ul>
41+
* </p>
42+
*
43+
* @param userId 조회할 사용자 ID
44+
* @return 조회된 사용자, 없으면 null
45+
*/
46+
User findByUserIdForUpdate(String userId);
2847
}

apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package com.loopers.infrastructure.user;
22

33
import com.loopers.domain.user.User;
4+
import jakarta.persistence.LockModeType;
45
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Lock;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
9+
510
import java.util.Optional;
611

712
/**
@@ -22,4 +27,34 @@ public interface UserJpaRepository extends JpaRepository<User, Long> {
2227
* @return 조회된 사용자를 담은 Optional
2328
*/
2429
Optional<User> findByUserId(String userId);
30+
31+
/**
32+
* 사용자 ID로 사용자를 조회합니다. (비관적 락)
33+
* <p>
34+
* SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다.
35+
* </p>
36+
* <p>
37+
* <b>Lock 전략:</b>
38+
* <ul>
39+
* <li><b>PESSIMISTIC_WRITE 선택 근거:</b> 포인트 차감 시 Lost Update 방지</li>
40+
* <li><b>Lock 범위 최소화:</b> UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락</li>
41+
* <li><b>인덱스 활용:</b> UNIQUE 제약조건으로 인덱스가 자동 생성되어 Lock 범위 최소화</li>
42+
* </ul>
43+
* </p>
44+
* <p>
45+
* <b>동작 원리:</b>
46+
* <ol>
47+
* <li>SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정</li>
48+
* <li>다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)</li>
49+
* <li>포인트 차감 후 트랜잭션 커밋 → 락 해제</li>
50+
* <li>대기 중이던 트랜잭션이 최신 값을 읽어 처리</li>
51+
* </ol>
52+
* </p>
53+
*
54+
* @param userId 조회할 사용자 ID
55+
* @return 조회된 사용자를 담은 Optional
56+
*/
57+
@Lock(LockModeType.PESSIMISTIC_WRITE)
58+
@Query("SELECT u FROM User u WHERE u.userId = :userId")
59+
Optional<User> findByUserIdForUpdate(@Param("userId") String userId);
2560
}

apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,12 @@ public User save(User user) {
3535
public User findByUserId(String userId) {
3636
return userJpaRepository.findByUserId(userId).orElse(null);
3737
}
38+
39+
/**
40+
* {@inheritDoc}
41+
*/
42+
@Override
43+
public User findByUserIdForUpdate(String userId) {
44+
return userJpaRepository.findByUserIdForUpdate(userId).orElse(null);
45+
}
3846
}

0 commit comments

Comments
 (0)