본문 바로가기
카테고리 없음

재고가 마이너스가 된 날, 트랜잭션 격리 수준을 배웠습니다

by 테크구루스 2026. 7. 4.

재고가 마이너스가 된 날, 트랜잭션 격리 수준을 배웠습니다—동시 요청이 겹치며 벌어진 사고에서 출발해 격리 수준의 개념과 실전 대처를 정리한 글입니다.

 

재고가 마이너스가 된 날, 트랜잭션 격리 수준을 배웠습니다
재고가 마이너스가 된 날, 트랜잭션 격리 수준을 배웠습니다

같은 상품을 동시에 주문하면 벌어지는 일

작은 커머스 서비스의 백엔드를 맡고 있던 재작년 겨울, 한정 수량 이벤트를 열었던 날의 이야기입니다. 오픈 3분 만에 재고 테이블을 확인했더니 어떤 상품의 qty 컬럼이 -1을 가리키고 있었습니다. 처음엔 눈을 의심했죠. 재고가 음수라니, 물리적으로 존재할 수 없는 값이 데이터베이스에 버젓이 앉아 있었으니까요.

당시 재고 차감 코드는 부끄러울 만큼 순진했습니다. 대략 이런 모양이었습니다.

// 재고를 조회 후 애플리케이션에서 판정
int stock = repo.findQtyById(productId);
if (stock > 0) {
    repo.updateQty(productId, stock - 1); // 감소된 값으로 덮어쓰기
    createOrder(userId, productId);
}

혼자 로컬에서 눌러 볼 때는 아무 문제가 없었습니다. 문제는 재고가 딱 1개 남은 순간에 동시 주문 2건이 밀려 들어왔을 때 드러났죠. 두 요청이 거의 같은 시각에 findQtyById를 호출했고, 둘 다 stock = 1을 읽어 갔습니다. 각자 1 > 0이 참이니 둘 다 주문을 만들고, 각자 updateQty(productId, 0)을 실행했습니다. 그런데 재고 차감은 한 번만 반영되어야 하는데 두 번 일어난 셈이라, 실제 팔린 수량은 2개인데 재고는 그만큼 빠지지 못했습니다. 이후 반품 롤백 로직까지 얽히며 결국 -1이라는 값이 남았습니다.

로그를 뜯어보니 정황이 선명했습니다. 두 트랜잭션의 커밋 시각 차이가 8ms 남짓이었습니다.

시각(ms) 트랜잭션 A 트랜잭션 B
t+0 qty=1 읽음  
t+2   qty=1 읽음
t+5 qty=0 저장  
t+13   qty=0 저장

전형적인 lost update, 즉 갱신 손실이었습니다. B가 저장한 값이 A가 이미 반영한 결과를 통째로 덮어써 버린 것이죠. 두 트랜잭션이 같은 재고 값을 읽고 각자의 계산으로 덮어쓰는 이 상황을 경쟁 상태(race condition)라고 부릅니다. 저는 그날 밤 재고표를 손으로 고치면서 이 개념을 몸으로 배웠습니다.

당시엔 이게 왜 로컬에서 안 잡혔는지도 한참 헤맸습니다. 이유는 단순했죠. 부하 없이 요청을 하나씩 보내면 조회와 저장 사이에 다른 트랜잭션이 끼어들 틈이 없으니, if (stock > 0) 판정은 언제나 옳게 동작한 것처럼 보였습니다. 이런 결함은 트래픽이 얇을 때는 숨어 있다가 동시성이 올라가는 바로 그 순간에만 터져 나오니, 재현 자체가 까다로웠습니다. 결국 로컬에서 스레드 20개로 같은 상품을 동시에 두드리는 스크립트를 짜고 나서야 -1을 손안에서 재현할 수 있었습니다.

 

격리 수준이 막아 주는 것과 못 막는 것

격리수준 4단계
격리수준 4단계

 

사고 다음 날, 저는 트랜잭션 격리 수준(isolation level) 문서를 처음으로 진지하게 읽었습니다. SQL 표준은 네 단계를 정의하고, 각 단계는 동시성에서 생길 수 있는 이상 현상(anomaly)을 어디까지 막아 주는지로 구분됩니다.

  • Read Uncommitted: 커밋되지 않은 남의 변경까지 읽습니다. dirty read가 발생하죠.
  • Read Committed: 커밋된 값만 읽어 dirty read를 막습니다. 다만 한 트랜잭션 안에서 같은 행을 두 번 읽을 때 값이 달라지는 non-repeatable read는 남습니다.
  • Repeatable Read: 트랜잭션 시작 시점의 스냅샷을 유지해 같은 행을 다시 읽어도 값이 안 바뀝니다. 하지만 조건에 맞는 행 집합이 중간에 늘어나는 phantom read는 표준상 허용됩니다.
  • Serializable: 마치 트랜잭션들을 한 줄로 세워 하나씩 실행한 것처럼 보장합니다. 팬텀까지 막지만 그만큼 대가가 큽니다.

세 가지 이상 현상을 표로 정리하면 이렇게 됩니다.

격리 수준 dirty read non-repeatable read phantom read
Read Uncommitted 발생 발생 발생
Read Committed 방지 발생 발생
Repeatable Read 방지 방지 발생
Serializable 방지 방지 방지

 

여기서 제가 크게 착각했던 지점을 고백하겠습니다. 처음엔 "격리 수준을 Serializable로 확 올리면 만사형통 아닌가" 하고 생각했죠. 하지만 격리 수준을 높일수록 락 경합과 재시도가 늘어 처리량(throughput)이 떨어지고, 응답 지연도 커집니다. 트래픽이 몰리는 이벤트에서 모든 주문을 직렬화하면 그 자체가 또 다른 장애가 되겠죠.

더 중요한 함정이 있었습니다. 제 사고의 본질인 lost update는 격리 수준을 올린다고 언제나 자동으로 막히지는 않는다는 점입니다. 갱신 손실은 "읽고 → 애플리케이션에서 계산하고 → 다시 쓰는" 패턴 자체에서 생기기 때문입니다. 격리 수준은 어디까지나 읽기 일관성을 다루는 축이라, 이 read-modify-write 흐름을 그대로 두면 MySQL InnoDB의 기본값인 Repeatable Read에서도 두 세션이 각자 스냅샷을 읽고 나란히 덮어쓸 여지가 남습니다. 결국 저에게 필요했던 건 격리 수준 조정이 아니라, 쓰기 자체를 원자적으로 만드는 일이었습니다.

물론 격리 수준을 이해한 것이 헛수고는 아니었습니다. 어떤 이상 현상이 왜 생기는지 알아야 대처의 방향을 고를 수 있으니까요. 다만 "격리 수준"과 "동시 쓰기 정합성"은 서로 겹치면서도 다른 문제라는 사실을, 저는 이 사고를 통해 분리해서 바라보게 되었습니다.

그래서 이렇게 막았습니다

핵심은 조건 판정과 감소를 애플리케이션이 아니라 데이터베이스의 한 문장 안에서 원자적으로 처리하는 것이었습니다. 저는 read-modify-write를 걷어내고 단일 UPDATE로 바꿨습니다.

-- 조건 검사와 감소를 한 문장으로 원자적 처리
UPDATE stock
SET qty = qty - 1
WHERE id = ? AND qty > 0;

이 한 줄이 우아한 이유는, 행에 대한 잠금과 조건 검사와 감소가 데이터베이스 엔진 안에서 분리 불가능하게 묶여 실행되기 때문입니다. 성공 판정은 반환된 영향받은 행 수(affected rows)로 합니다.

int affected = repo.decreaseStock(productId); // 위 UPDATE 실행
if (affected == 1) {
    createOrder(userId, productId); // 정상 주문
} else {
    throw new SoldOutException(); // 이미 품절
}

qty가 0이면 WHERE 조건이 걸러 내 affected rows가 0으로 돌아오니, 그 요청만 깔끔하게 품절 처리됩니다. 동시 요청이 백 건이 몰려도 각 UPDATE는 행 잠금을 순차로 잡으며 지나가므로 재고가 음수로 떨어질 길이 사라졌습니다.

또 다른 선택지는 비관적 락(pessimistic lock)입니다.

SELECT qty FROM stock WHERE id = ? FOR UPDATE; -- 행을 잠그고 읽기

FOR UPDATE로 행을 먼저 잠근 뒤 애플리케이션에서 판정하고 UPDATE하는 방식이죠. 재고 차감처럼 여러 테이블에 걸친 복잡한 검증이 필요할 때 유용합니다. 다만 잠금을 쥔 채 로직이 길어지면 대기 행렬이 늘고 데드락 위험도 커집니다.

개인적으로는 단순 재고 차감이라면 원자적 UPDATE를 먼저 택합니다. 코드가 짧고, 별도 락 관리 없이도 정확하며, 성능 손해가 가장 작았거든요. 실제로 전환 후 같은 규모의 이벤트를 다시 열었을 때 음수 재고는 단 한 건도 나오지 않았습니다. 그날의 -1은 지금도 제 커밋 메시지 속에 교훈으로 남아 있습니다. 지금이라면 재고처럼 경합이 뻔한 로직은 처음부터 데이터베이스에 판정을 맡기겠습니다.

원자적감소 해결
원자적감소 해결