Skip to content

Commit 7f04949

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

7 files changed

Lines changed: 644 additions & 28 deletions

File tree

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
/**
@@ -97,23 +95,39 @@ public OrderInfo createOrder(String userId, List<OrderItemCommand> commands) {
9795
// - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
9896
User user = loadUserForUpdate(userId);
9997

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

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

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

119133
orderItems.add(OrderItem.of(
@@ -147,6 +161,13 @@ public OrderInfo createOrder(String userId, List<OrderItemCommand> commands) {
147161

148162
/**
149163
* 주문을 취소하고 포인트를 환불하며 재고를 원복한다.
164+
* <p>
165+
* <b>동시성 제어:</b>
166+
* <ul>
167+
* <li><b>비관적 락 사용:</b> 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용</li>
168+
* <li><b>Deadlock 방지:</b> 상품 ID를 정렬하여 일관된 락 획득 순서 보장</li>
169+
* </ul>
170+
* </p>
150171
*
151172
* @param order 주문 엔티티
152173
* @param user 사용자 엔티티
@@ -157,18 +178,41 @@ public void cancelOrder(Order order, User user) {
157178
throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다.");
158179
}
159180

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

166210
order.cancel();
167211
increaseStocksForOrderItems(order.getItems(), products);
168-
user.receivePoint(Point.of((long) order.getTotalAmount()));
212+
lockedUser.receivePoint(Point.of((long) order.getTotalAmount()));
169213

170214
products.forEach(productRepository::save);
171-
userRepository.save(user);
215+
userRepository.save(lockedUser);
172216
orderRepository.save(order);
173217
}
174218

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)