핵심 한 줄

읽고 → 판단하고 → 저장한다는 순진한 재고 차감은 두 스레드가 같은 “100”을 읽는 순간 무너진다(check-then-act). 해법은 셋 — 비관적 락·낙관적 락·원자적 UPDATE — 이고, 셋의 공통 철학은 하나다: 판단과 변경 사이의 틈을 없애고 원자적 지점 하나로 모은다. 정답은 없고 경합 빈도가 선택을 정한다.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


1. 혼자 실행하면 맞는 코드

재고 100. 주문이 하나씩 온다면 아래 코드는 완벽하다.

ProductStock stock = repository.findById(id).orElseThrow();
stock.decrease(amount);   // 부족하면 예외
repository.save(stock);

decrease는 도메인 불변식까지 지킨다. 재고가 모자라면 스스로 거절한다.

public void decrease(int amount) {
    if (quantity < amount) {
        throw new IllegalArgumentException("재고 부족: 남은 " + quantity + ", 요청 " + amount);
    }
    this.quantity -= amount;
}

테스트도 통과한다. 그런데 이 코드는 혼자 실행될 때만 맞다. L3의 첫 질문이 여기서 시작된다 — “동시에 오면?“

2. check-then-act — 틈이 있는 코드

재고 100에 주문 150건이 동시에 쏟아진다고 하자. 위 코드는 세 걸음이다.

  1. 읽는다 (check): 재고를 읽어 100을 얻는다
  2. 판단한다: 100 >= 1, 통과
  3. 저장한다 (act): 99로 쓴다

문제는 1과 3 사이에 이 있다는 것이다. 스레드 A가 100을 읽고 아직 저장하기 전에, 스레드 B도 100을 읽는다. 둘 다 “충분하다”고 판단하고, 둘 다 저장한다. 두 건이 팔렸는데 재고는 99. 이게 check-then-act 함정이다 — 검사한 값이 행동하는 순간엔 이미 옛날 값이다.

150건이 동시에 몰리면 재고는 음수로 내려가거나, 100개보다 훨씬 많이 팔린다. 초과판매. 목표는 단순하다: 초과판매 0.

셋 다 노리는 곳은 같다. 저 틈을 없애는 것. 다만 없애는 방법이 다르고, 그 차이가 곧 트레이드오프다.

3. ① 비관적 락 — 아예 못 건드리게 잠근다

가장 직관적인 답: 읽을 때 그 행을 잠근다. “내가 다 끝낼 때까지 아무도 이 행 못 읽어.”

@Transactional
public void decrease(Long id, int amount) {
    ProductStock stock = repository.findByIdForUpdate(id)   // SELECT ... FOR UPDATE
            .orElseThrow(() -> new IllegalArgumentException("not enough stock"));
    stock.decrease(amount);
    repository.save(stock);
}

findByIdForUpdateSELECT ... FOR UPDATE를 날린다. DB가 그 행에 락을 건다. 스레드 A가 읽어서 잠근 순간, 스레드 B는 A가 커밋할 때까지 2단계에서 대기한다. A가 99로 커밋하면, 그제서야 B는 99를 읽는다. check-then-act의 틈이 락으로 메워졌다. 접근을 아예 직렬화하는 것이다.

확실하다. 대신 값을 치른다 — 모두가 줄을 선다. 경합이 잦으면 락 대기가 쌓여 throughput이 떨어진다. 최악엔 데드락도 본다. 그래서 비관적 락은 경합이 정말 잦을 때, 충돌을 낙관하는 게 사치일 때의 선택이다.

4. ② 낙관적 락 — 잠그지 않고, 부딪히면 다시

반대 도박: 대부분 안 부딪힌다고 낙관한다. 잠그지 않고, 저장하는 순간에만 “그새 누가 바꿨나?”를 검사한다. 검사 수단이 @Version이다.

@Version
private Long version;

읽을 때 version=7이었다면, 저장은 이렇게 나간다 — UPDATE ... SET quantity=?, version=8 WHERE id=? AND version=7. 그새 다른 트랜잭션이 먼저 커밋해 version8이 됐다면, WHERE version=7에 걸리는 행이 없다. 0행. JPA가 OptimisticLockingFailureException을 던진다. 그러면 다시 읽고 재시도한다.

public void decreaseOptimistic(Long id, int amount) {
    int maxRetry = 100;
    for (int attempt = 1; attempt <= maxRetry; attempt++) {
        try {
            optimisticCommand.decreaseOnce(id, amount);   // 매번 새 트랜잭션
            return;
        } catch (OptimisticLockingFailureException conflict) {
            // 다른 트랜잭션이 먼저 바꿈 → 루프 돌아 다시 읽고 시도
        }
    }
    throw new IllegalStateException("낙관적 락 재시도 초과: id=" + id);
}

여기서 이 편의 함정 하나가 나온다. 왜 decreaseOnce를 같은 클래스 메서드가 아니라 별도 빈(OptimisticStockCommand)으로 뺐을까?

@Component
public class OptimisticStockCommand {
    @Transactional
    public void decreaseOnce(Long id, int amount) {
        ProductStock stock = repository.findById(id).orElseThrow();
        stock.decrease(amount);
        repository.save(stock);   // 커밋 시 UPDATE ... WHERE version=? → 충돌이면 예외
    }
}

스프링의 @Transactional프록시로 동작한다. 같은 빈 안에서 자기 @Transactional 메서드를 부르면 프록시를 안 거쳐서 새 트랜잭션이 안 열린다(self-invocation 함정). 재시도가 같은 트랜잭션 안에 머물면, 다시 읽어도 영속성 컨텍스트에 캐시된 옛날 version을 본다 — 영원히 같은 충돌을 반복한다. 그래서 decreaseOnce를 별도 빈으로 두어, 매 재시도가 진짜 새 트랜잭션으로 열리고 최신 version을 다시 읽게 만든다. 재시도 단위와 트랜잭션 경계가 일치해야 낙관적 락이 성립한다.

낙관적 락은 락 대기가 없어 경합이 드물 때 빠르다. 대신 경합이 잦으면 재시도가 폭증한다 — 도박이 어긋나는 만큼 비싸진다.

5. ③ 원자적 UPDATE — 판단을 DB 한 줄에 넘긴다

세 번째는 발상을 바꾼다. 재고를 자바로 읽어와 판단하지 말고, 판단까지 DB에게 한 문장으로 시킨다.

UPDATE product_stock
SET quantity = quantity - :amount
WHERE id = :id AND quantity >= :amount

WHERE quantity >= :amount가 판단이고 SET quantity = quantity - :amount가 차감이다. 둘이 한 문장 안에 원자적으로 붙어 있어 틈 자체가 없다. 재고가 부족하면 WHERE에 걸리는 행이 없어 0행이 갱신된다. 서비스는 반환 행수만 본다.

@Transactional
public void decreaseAtomic(Long id, int amount) {
    int updated = repository.modifyQuantity(id, amount);
    if (updated == 0) {
        throw new IllegalArgumentException("재고 부족 또는 없음 : id =" + id);
    }
}

읽기도, 재시도 루프도, 락 대기도 없다. 단 한 방. 한 줄 산술 차감에는 셋 중 가장 단순하고 가장 강력하다.

한계는 명확하다. quantity - amount 같은 산술이라 한 줄로 표현되는 것이다. “회원 등급이 VIP이고 이번 달 구매액이 얼마 이하면서 재고가…” 같은 복잡한 도메인 판단은 한 줄 WHERE로 안 접힌다. 그럴 땐 판단을 자바 도메인 객체로 끌고 와야 하고, 그러면 다시 ①/②의 세계다.

6. 검증 — 동시성은 말이 아니라 재현이다

“괜찮을 것이다”는 동시성에서 가장 위험한 말이다. 이 편은 통합 테스트로 경합을 실제로 만든다. ExecutorService로 스레드 150개를 띄우고, CountDownLatch로 출발선에 세웠다가 동시에 풀어 같은 재고를 때린다. 그리고 끝난 뒤 재고를 확인한다 — 음수인가? 100개 넘게 팔렸나? 세 방법 모두 초과판매 0이어야 통과다. 서문의 원칙 그대로, “그럴 것이다”가 아니라 “이렇게 나왔다”로 말한다.


세 해법을 한 장으로:

방법틈을 없애는 법유리할 때대가
비관적 락읽을 때 행을 잠금(FOR UPDATE)경합이 잦을락 대기 → throughput↓
낙관적 락저장 시 version 충돌 검사 + 재시도경합이 드물충돌 시 재시도 폭증
원자적 UPDATE판단+차감을 한 문장에단순 산술 차감복잡한 도메인 판단엔 부적합

정답은 없다. 셋의 뿌리는 하나 — check-then-act를 없애고 원자적 지점 하나로 모은다. 다른 건 그 지점을 어디에(락·version·SQL 한 줄) 두느냐, 그리고 그 선택이 경합 빈도와 맞느냐다. 이게 L3다. 답이 하나가 아니라 트레이드오프가 되는 지점.

동시성은 “동시에 오면”이었다. 다음 질문은 “두 번 오면”이다 — 같은 취소 요청이 네트워크 재시도로 두 번 도착해도 딱 한 번만 처리되게 하는 것, 멱등성.

04.두 번 취소해도 한 번만