오늘 배운 것
여러 사용자가 동시에 같은 상품을 주문하면 재고가 꼬인다. 이걸 막는 방법 중 하나가 비관적 락(Pessimistic Lock) 이다.
그런데 락을 쓰면 새로운 문제가 생긴다. 데드락(교착 상태) 이다.
두 트랜잭션이 서로가 가진 락을 기다리며 영원히 멈추는 상황이다.
오늘 배운 핵심은 이거다.
락을 획득하는 순서를 모두가 동일하게 맞추면 데드락은 발생하지 않는다.
왜 사용하는가
1단계 — 락이 없으면 재고가 음수가 된다
재고가 1개 남은 상품을 A와 B가 동시에 주문한다고 하자.
시각 A 트랜잭션 B 트랜잭션
───────────────────────────────────────────────
t1 재고 조회 → 1개
t2 재고 조회 → 1개 ← 아직 A가 안 줄임
t3 "1개 있네, 주문 가능"
t4 "1개 있네, 주문 가능"
t5 재고 = 0 저장
t6 재고 = 0 저장 ← A의 차감이 덮어써짐
───────────────────────────────────────────────
결과: 2개가 팔렸는데 재고는 1개만 줄었다 ❌
이걸 갱신 손실(Lost Update) 이라고 한다.
조회와 수정 사이에 다른 트랜잭션이 끼어들어서 생긴다.
2단계 — 비관적 락으로 줄을 세운다
비관적 락은 "조회할 때부터 아예 잠가버리는" 방식이다. SQL로는 SELECT ... FOR UPDATE가 된다.
시각 A 트랜잭션 B 트랜잭션
──────────────────────────────────────────────────────
t1 재고 조회 + 잠금 🔒 → 1개
t2 재고 조회 시도 → 대기... ⏳
t3 재고 = 0 저장
t4 커밋 → 잠금 해제 🔓
t5 재고 조회 → 0개
t6 "재고 없음" 예외 ✅
──────────────────────────────────────────────────────
결과: 정확히 1개만 팔렸다
B는 A가 끝날 때까지 기다린다. 느려지지만 정확하다.
비관적 락 vs 낙관적 락
낙관적 락: 일단 진행하고, 커밋할 때 충돌했으면 실패 → 재시도
비관적 락: 처음부터 잠그고 진행 → 충돌 자체가 없음
재고는 충돌이 자주 일어나고, 실패하면 사용자 경험이 나쁘다(주문 버튼을 다시 눌러야 함). 그래서 비관적 락을 골랐다.
3단계 — 그런데 데드락이 생긴다
상품을 여러 개 주문할 때 문제가 터진다.
A: 사과 + 바나나 주문
B: 바나나 + 사과 주문 ← 장바구니 순서가 다름
시각 A 트랜잭션 B 트랜잭션
─────────────────────────────────────────────────
t1 사과 잠금 🔒
t2 바나나 잠금 🔒
t3 바나나 잠금 시도 → 대기 ⏳
t4 사과 잠금 시도 → 대기 ⏳
─────────────────────────────────────────────────
A는 B가 가진 바나나를 기다리고
B는 A가 가진 사과를 기다린다 → 영원히 멈춤 💀
이게 데드락이다. MySQL이 감지해서 한쪽을 강제 종료시키지만, 그쪽 사용자는 주문에 실패한다.
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
Deadlock found when trying to get lock; try restarting transaction
4단계 — 순서를 맞추면 사라진다
모두가 같은 순서로 락을 잡으면 데드락은 원천적으로 불가능해진다.
상품 ID 순서(사과=1, 바나나=2)로 통일해보자.
시각 A 트랜잭션 B 트랜잭션
─────────────────────────────────────────────────
t1 상품1 잠금 🔒
t2 상품1 잠금 시도 → 대기 ⏳
t3 상품2 잠금 🔒
t4 커밋 → 전부 해제 🔓
t5 상품1 잠금 🔒
t6 상품2 잠금 🔒
t7 커밋 ✅
─────────────────────────────────────────────────
결과: B가 잠깐 기다렸을 뿐, 둘 다 성공
왜 이게 통할까? 데드락은 "A는 B를 기다리고 B는 A를 기다리는" 순환이 생겨야 발생한다.
모두가 번호가 작은 것부터 잡으면, 기다림의 방향이 항상 한쪽이라 순환이 만들어질 수 없다.
예제
리포지토리 — 비관적 락 선언
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // ← SELECT ... FOR UPDATE
@Query("SELECT p FROM Product p WHERE p.id IN :ids")
List<Product> findAllByIdForUpdate(@Param("ids") List<Long> ids);
}
@Lock(LockModeType.PESSIMISTIC_WRITE) 한 줄이면 JPA가 알아서 FOR UPDATE를 붙여준다.
데드락이 나는 코드 (나쁜 예)
List<Long> productIds = cartItems.stream()
.map(CartItem::getProductId)
.distinct()
.toList(); // ← 장바구니에 담긴 순서 그대로
사용자마다 장바구니 순서가 다르므로 락 획득 순서도 제각각이다.
데드락이 없는 코드 (좋은 예)
List<Long> productIds = cartItems.stream()
.map(CartItem::getProductId)
.distinct()
.sorted() // ← 이 한 줄이 데드락을 막는다
.toList();
.sorted() 하나로 모든 트랜잭션의 락 순서가 상품 ID 오름차순으로 통일된다.
프로젝트 적용
OrderFacade.createOrder()에서 주문 생성 시 재고를 선차감한다.
// 재고 검증 > 선차감 > 주문생성 > 결제생성 + PaymentId 채번
@Transactional
public OrderCheckoutResponse createOrder(Long memberId, OrderCheckoutRequest request) {
List<CartItem> cartItems = getValidateCartItems(memberId, cartItemIds);
// 상품에 비관적 락을 걸고 일괄 조회
// 순서대로 잡아야 데드락이 없으니 정렬
List<Long> productIds = cartItems.stream()
.map(CartItem::getProductId)
.distinct()
.sorted() // ★ 데드락 방지
.toList();
Map<Long, Product> lockedProducts = productRepository.findAllByIdForUpdate(productIds)
.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
// 재고 선차감
for (CartItem cartItem : cartItems) {
Product product = lockedProducts.get(cartItem.getProductId());
product.deductStock(cartItem.getQuantity()); // 재고 부족하면 여기서 예외 → 전체 롤백
...
}
}
여기서 같이 배운 것 2가지
① 락은 한 번에 모아서 잡는다
반복문 안에서 상품을 하나씩 조회하면 락도 하나씩 잡힌다. IN 절로 한 방에 조회해서 락을 동시에 확보하는 게 낫다.
쿼리 수도 줄고(N+1 방지), 락을 잡고 있는 시간도 짧아진다.
② 재고 복구는 트랜잭션이 알아서 한다
product.deductStock(cartItem.getQuantity()); // 3번째 상품에서 재고 부족 → 예외
앞의 1, 2번 상품은 이미 재고를 깎았다. 하지만 예외가 나면 트랜잭션 전체가 롤백되므로 직접 되돌릴 필요가 없다.
수동 복구 코드를 짜면 오히려 버그가 생긴다.
포인트에도 같은 방식을 적용했다
// 비관적 락으로 조회. 없으면 동시 요청이 모두 잔액 검증을 통과해 초과 사용된다.
private Member lockMember(Long memberId) {
return memberRepository.findByIdForUpdate(memberId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
}
포인트는 회원 1명당 1행이라 여러 개를 동시에 잠글 일이 없다. 그래서 정렬이 필요 없다.
데드락 대비는 여러 행을 잠글 때만 필요하다는 것도 알게 됐다.
느낀 점
동시성 문제는 혼자 테스트할 때 절대 안 나온다는 게 제일 무서웠다. 브라우저에서 주문 버튼을 아무리 눌러도 순서대로 처리되니까 문제가 없어 보인다. 실제로는 사용자가 몰리는 순간에만 터진다.
.sorted() 한 줄이 데드락을 막는다는 게 신기하면서도, 원리를 모르면 절대 떠올릴 수 없는 해결책이라는 생각이 들었다. 데드락의 조건이 "순환 대기"라는 걸 알아야 "순환을 못 만들게 하면 되겠다"는 발상이 나온다. 코드를 외우는 것과 원리를 아는 것의 차이를 체감했다.
또 하나 인상적이었던 건 트랜잭션을 믿는 것이다. 재고를 깎다가 실패하면 앞의 것들을 되돌려야 한다고 생각해서 복구 코드를 짜려고 했는데, 트랜잭션이 이미 해주는 일이었다. 프레임워크가 보장하는 걸 다시 구현하는 게 오히려 위험하다는 걸 배웠다.
아직 확신이 안 서는 건 성능이다. 비관적 락은 대기가 생기니까 사용자가 많아지면 느려질 텐데, 어느 정도까지 버틸 수 있는지 감이 없다.
다음에 공부할 것
- [ ] 동시성 테스트 작성 — ExecutorService + CountDownLatch로 동시 요청 재현
- [ ] 정렬 전후로 데드락이 실제로 나는지/안 나는지 테스트로 증명하기
- [ ] 낙관적 락(@Version) 직접 써보고 비관적 락과 성능 비교
- [ ] 락 대기 타임아웃 설정 — @QueryHints, javax.persistence.lock.timeout
- [ ] MySQL의 넥스트키 락(Next-Key Lock) — IN 절 조회가 어디까지 잠그는지
- [ ] 재고를 DB 락 말고 Redis 분산 락으로 처리하는 방식
'웹개발' 카테고리의 다른 글
| 포인트 원장 설계 — 부호를 Enum이 소유하게 만들기 (0) | 2026.08.26 |
|---|---|
| 트랜잭션 전파 속성 — 포인트만 빠져나가는 사고를 막은 MANDATORY (0) | 2026.08.25 |
| TIL | Record 클래스는 무엇일까? (0) | 2026.08.07 |
| TIL | 오류 메시지에만 매몰되지 않기 (0) | 2026.08.06 |
| TIL | AWS EC2 파헤쳐보기 (0) | 2026.08.05 |