본문 바로가기

웹개발

비관적 락과 데드락 — 정렬 한 줄로 막은 교착 상태

오늘 배운 것

여러 사용자가 동시에 같은 상품을 주문하면 재고가 꼬인다. 이걸 막는 방법 중 하나가 비관적 락(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 분산 락으로 처리하는 방식