본문 바로가기
개발/개발

[Java / Spring Boot] 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일-2편

by 글쓰는 개발자 2026. 3. 6.

배경

재고가 1개뿐인 상품에 10명이 동시에 주문을 넣었다.

결과는 10명 전부 주문 성공이었다. 에러도 없이. 재고는 -9가 됐다. 음.....?


뭐가 문제인지 보기 전엔 몰랐다

재고 차감 코드는 단순해 보인다.

@Transactional
public void order(Long optionId, int quantity) {
    Stock stock = stockRepository.findByOptionId(optionId); // 1. 재고 읽기

    if (stock.getQuantity() < quantity) { // 2. 재고 확인
        throw new InsufficientStockException();
    }

    stock.decrease(quantity); // 3. 재고 차감
}

@Transactional도 붙어있고, 재고 확인도 하고 있으니 괜찮은 거 아닌가?

문제는 2번 재고 확인 시점에서 생긴다.


왜 이런 일이 생기나: Lost Update

10개 트랜잭션이 동시에 실행될 때 실제로 어떤 일이 벌어지는지 따라가보자.

T1: 재고 읽음 = 1  ← 스냅샷
T2: 재고 읽음 = 1  ← 똑같은 스냅샷 (T1이 아직 커밋 안 했으니까)

T1: 재고 확인 → 1 >= 1 → 조건 통과 → 재고 0으로 차감 → 커밋
T2: 재고 확인 → 1 >= 1 → 조건 통과 → 재고 0으로 차감 → 커밋

결과: 재고 -1

1편에서 다뤘던 REPEATABLE READ가 여기서 문제를 일으킨다.

- [Java / Spring Boot] @Transactional 붙이면 동시성이 다 해결될 줄 알았다

각 트랜잭션은 자신이 시작된 시점의 스냅샷을 읽는다. T1이 아직 커밋하지 않은 상태에서 T2가 읽으면, T2도 재고가 1개라고 읽는다. 둘 다 조건을 통과하고, 둘 다 차감한다.

이게 Lost Update(갱신 손실) 다. T1이 바꾼 값을 T2가 덮어써버린다.

10개 트랜잭션이 동시에 실행되면 결국 재고가 -9가 된다. @Transactional이 있어도.


직접 재현해보기

동시 요청을 테스트하려면 두 가지 도구가 필요하다.

ExecutorService — Java 기본 기능(java.util.concurrent). 스레드 풀을 한 줄로 만들어준다.

CountDownLatch — "모든 스레드가 끝날 때까지 기다려줘"를 구현하는 Java 기본 기능.

@Test
void 동시_주문_재고_초과_실험() throws InterruptedException {
    // given: 재고 1개
    stockRepository.save(new Stock(optionId, 1));

    int threadCount = 10;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);
    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    // when: 10명이 동시에 주문
    for (int i = 0; i < threadCount; i++) {
        executor.submit(() -> {
            try {
                orderService.order(optionId, 1);
                successCount.incrementAndGet();
            } catch (InsufficientStockException e) {
                failCount.incrementAndGet();
            } finally {
                latch.countDown(); // 하나 완료 신호
            }
        });
    }

    latch.await(); // 10개 전부 완료될 때까지 대기

    Stock result = stockRepository.findByOptionId(optionId);
    System.out.println("성공: " + successCount.get()); // 기대: 1
    System.out.println("실패: " + failCount.get());    // 기대: 9
    System.out.println("남은 재고: " + result.getQuantity()); // 기대: 0
}

실제 결과 (비관적 락 적용 전):

성공: 10
실패: 0
남은 재고: -9

에러 하나 없이 10명 전부 성공했다.


해결: 비관적 락 (Pessimistic Lock)

문제의 핵심은 "읽는 시점이 겹친다"는 거다. T1이 읽고 커밋하기 전에 T2도 읽어버리니까 둘 다 재고가 있다고 판단한다.

해결책은 간단하다. 읽는 시점에 락을 걸어서 순서를 만들면 된다.

락 있이:
T1 읽음 + 락 획득
          T2 대기 (T1이 락 가지고 있으니까)
T1 차감 → 커밋 → 락 해제
                   T2 읽음 (이제 0) → 재고 부족 → 실패

"어차피 충돌날 거니까, 읽을 때부터 잠가버리자"는 전략이다. 이게 비관적 락이다.

트랜잭션이랑 락은 다른 거다

락 얘기가 나오면 "그럼 트랜잭션은 왜 걸어? 락만 걸면 되는 거 아닌가?" 하는 의문이 생길 수 있다.

역할이 다르다.

트랜잭션: "이 작업들을 하나의 단위로 묶어줘. 실패하면 다 되돌려줘."
락:       "내가 이 데이터 쓰는 동안 다른 애들 못 건드리게 막아줘."

둘 중 하나만 있으면 이런 일이 생긴다.

락만 있고 트랜잭션 없으면:
T1: 락 획득 → 재고 차감 → 주문 저장 실패
→ 재고는 줄었는데 주문은 없는 상태 (데이터 망가짐)

트랜잭션만 있고 락 없으면:
→ 오늘 실험한 것처럼 Lost Update 발생

둘 다 있으면:
락 → 동시 접근 차단
트랜잭션 → 작업 묶음 보장 + 실패 시 롤백

락이 꼭 JPA 꺼일 필요는 없다

락을 거는 방법이 여러 가지다.

1. JPA @Lock          → JPA가 FOR UPDATE 쿼리 자동 생성
2. 직접 SQL 쿼리      → SELECT ... FOR UPDATE 직접 작성
3. Redis 분산 락      → 서버가 여러 대일 때 (Redisson 등)
4. DB 네임드 락       → GET_LOCK() 함수 사용

JPA @Lock은 그냥 가장 편한 방법이라 많이 쓰는 거다. 내부적으로는 결국 DB의 FOR UPDATE로 변환된다.

FOR UPDATE가 수정 쿼리도 아닌데 왜 락이냐

FOR UPDATE를 처음 보면 "SELECT인데 왜 UPDATE가 붙어있지?" 싶다.

FOR UPDATE는 실제로 수정을 하는 게 아니다. "내가 이 행 곧 수정할 거니까 다른 애들 손대지 마" 라고 DB에 미리 예약을 거는 거다.

DB 락에는 두 종류가 있다.

공유 락 (Shared Lock, S-Lock)
→ 일반 SELECT 할 때
→ "나 읽는 중이야, 다른 사람도 읽어도 되는데 수정은 안 돼"

배타 락 (Exclusive Lock, X-Lock)
→ UPDATE, DELETE, SELECT FOR UPDATE 할 때
→ "나 쓰는 중이야, 다른 사람 읽기/쓰기 둘 다 안 돼"

FOR UPDATE는 SELECT인데 배타 락을 미리 거는 거다. 곧 수정할 거니까 읽는 시점에 선점하는 것이다.

T1: SELECT FOR UPDATE → 배타 락 획득 (아직 수정 안 함)
T2: SELECT FOR UPDATE → 대기 (T1이 배타 락 가지고 있음)
T1: UPDATE stock SET quantity = 0
T1: COMMIT → 락 해제
T2: 이제 진입 → SELECT → quantity = 0 읽음 → 재고 부족 → 실패

JPA에서는 @Lock 어노테이션 하나로 이 쿼리를 자동으로 만들어준다.

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
    @Query("SELECT s FROM Stock s WHERE s.optionId = :optionId")
    Optional<Stock> findByOptionIdWithLock(@Param("optionId") Long optionId);
}
-- JPA가 내부적으로 생성하는 쿼리
SELECT * FROM stock WHERE option_id = ? FOR UPDATE

서비스 코드는 조회 메서드만 바꾸면 된다.

@Transactional
public void order(Long optionId, int quantity) {
    // 락을 잡고 조회 → 다른 트랜잭션은 이 락이 풀릴 때까지 대기
    Stock stock = stockRepository.findByOptionIdWithLock(optionId)
        .orElseThrow(() -> new StockNotFoundException(optionId));

    if (stock.getQuantity() < quantity) {
        throw new InsufficientStockException();
    }

    stock.decrease(quantity);
} // 트랜잭션 종료 시 락 해제 → 다음 트랜잭션 진입

실제 결과 (비관적 락 적용 후):

성공: 1
실패: 9
남은 재고: 0

10개 중 1개만 성공하고, 나머지 9개는 정확하게 InsufficientStockException을 받았다.


비관적 락의 트레이드오프

비관적 락이 좋은 것만은 아니다.

락을 걸면 한 명씩 순차적으로 처리된다. 동시에 1000명이 주문하면 999명이 줄을 서서 기다린다. 처리량(Throughput)이 줄어들고 병목현상이 생긴다.

최악의 경우 데드락도 발생한다.

T1: A 락 획득 → B 락 기다리는 중
T2: B 락 획득 → A 락 기다리는 중
→ 둘 다 영원히 대기 (교착 상태)

항목 내용

장점 충돌 확실히 방지, 구현 단순
단점 한 명씩 순차 처리 → 처리량 감소
단점 대기가 쌓이면 병목현상
단점 여러 자원을 동시에 잠글 때 데드락 위험

그럼 언제 비관적 락을 써야 하나

재고에 비관적 락을 선택한 이유는 두 가지다.

첫째, 돈과 연결된 데이터다.

재고 차감은 결제와 직결된다. 주문이 안 되는 것보다 주문했는데 나중에 취소되는 게 사용자 입장에서 훨씬 기분 나쁘다. 틀리면 절대 안 된다.

둘째, 충돌이 자주 일어난다.

인기 상품일수록 동시 요청이 몰린다. 충돌이 드문 상황이라면 다른 전략이 더 유리할 수 있다.

충돌이 자주 일어나는가? + 틀리면 절대 안 되는가?
→ YES → 비관적 락
→ NO  → 다른 전략 고려

충돌이 드문 상황에서 더 효율적인 전략이 있다. 그 이야기는 다음 편에서 다룬다.


번외: Django에서는 어떻게 하나

Django ORM에도 똑같은 기능이 있다. select_for_update()로 비관적 락을 걸 수 있다.

from django.db import transaction

@transaction.atomic  # Spring의 @Transactional
def order(option_id, quantity):
    # SELECT ... FOR UPDATE
    stock = Stock.objects.select_for_update().get(option_id=option_id)

    if stock.quantity < quantity:
        raise InsufficientStockException()

    stock.quantity -= quantity
    stock.save()

Spring이랑 1:1 대응이다.

Spring                        Django
─────────────────────────────────────────────
@Transactional            →   @transaction.atomic
@Lock(PESSIMISTIC_WRITE)  →   .select_for_update()
SELECT ... FOR UPDATE     →   SELECT ... FOR UPDATE (DB 쿼리는 동일)

결국 락은 DB가 처리하는 거라, 언어나 프레임워크가 달라도 DB에 내리는 쿼리는 똑같다. Spring @Lock이든 Django select_for_update()든 내부적으로는 동일한 FOR UPDATE로 변환된다.

시리즈 목차
  1. @Transactional 붙이면 동시성이 다 해결될 줄 알았다 
  2. 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일 ← 현재 글
  3. 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나
  4. 쿠폰 사용에 대한 처리는 어떤 락이 적절할까
  5. 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!"
반응형