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

같은 상품을 동시에 주문하면 벌어지는 일
작은 커머스 서비스의 백엔드를 맡고 있던 재작년 겨울, 한정 수량 이벤트를 열었던 날의 이야기입니다. 오픈 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을 손안에서 재현할 수 있었습니다.
격리 수준이 막아 주는 것과 못 막는 것

사고 다음 날, 저는 트랜잭션 격리 수준(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은 지금도 제 커밋 메시지 속에 교훈으로 남아 있습니다. 지금이라면 재고처럼 경합이 뻔한 로직은 처음부터 데이터베이스에 판정을 맡기겠습니다.
