
동시에 들어온 요청 두 개가 같은 데이터를 건드리면 무슨 일이 일어나는가. 재고가 1개 남았는데 두 명이 동시에 주문하면 어떻게 되는가. 이 질문의 답은 트랜잭션 격리 수준에 달려 있다. 이 글은 격리 수준이 실제로 무엇을 막아주고 무엇은 안 막아주는지, 그리고 데드락을 줄이는 방법을 정리한다.
격리 수준이 막는 세 가지 현상
SQL 표준은 동시성에서 생길 수 있는 문제를 세 가지로 정의한다.
더티 리드(Dirty Read). 아직 커밋되지 않은 다른 트랜잭션의 변경을 읽는 것. 그 트랜잭션이 롤백되면 존재한 적 없는 값을 읽은 셈이 된다.
반복 불가능 읽기(Non-repeatable Read). 같은 행을 두 번 읽었는데 값이 다른 것. 사이에 다른 트랜잭션이 커밋했기 때문이다.
팬텀 리드(Phantom Read). 같은 조건으로 두 번 조회했는데 행 개수가 다른 것. 사이에 조건에 맞는 행이 추가됐기 때문이다.
격리 수준은 이 셋을 어디까지 막을지를 정한다.
| 격리 수준 | 더티 리드 | 반복 불가능 읽기 | 팬텀 리드 |
|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 |
| READ COMMITTED | 방지 | 발생 | 발생 |
| REPEATABLE READ | 방지 | 방지 | 발생(표준 기준) |
| SERIALIZABLE | 방지 | 방지 | 방지 |
실무에서 알아둘 점은 기본값이 데이터베이스마다 다르다는 것이다. PostgreSQL과 오라클은 READ COMMITTED가 기본이고, MySQL(InnoDB)은 REPEATABLE READ가 기본이다. 같은 코드가 데이터베이스에 따라 다르게 동작할 수 있다는 뜻이다.
PostgreSQL은 REPEATABLE READ에서 팬텀 리드도 막는다. 표준보다 강하게 구현돼 있다. 이런 차이 때문에 "이론상 이렇다"보다 쓰는 데이터베이스의 문서를 확인하는 것이 맞다.
격리 수준으로는 안 막히는 문제
여기가 핵심이다. 재고 차감 같은 작업은 격리 수준을 올려도 그것만으로는 안전하지 않다.
-- 위험한 패턴
SELECT stock FROM products WHERE id = 1; -- 애플리케이션이 1 을 읽음
-- 애플리케이션에서 1 >= 1 판단
UPDATE products SET stock = 0 WHERE id = 1;
두 트랜잭션이 동시에 SELECT하면 둘 다 1을 읽는다. 둘 다 "재고 있음"으로 판단하고 둘 다 주문을 만든다. 읽고 판단하고 쓰는 사이에 남이 끼어드는 것, 이걸 잃어버린 갱신(lost update)이라고 한다.
해법은 두 가지다.
비관적 잠금. 읽을 때 아예 잠근다.
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
다른 트랜잭션은 이 행을 읽으려다 대기한다. 확실하지만 동시성이 떨어진다.
낙관적 잠금. 버전 컬럼을 두고 갱신할 때 확인한다.
UPDATE products SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5 AND stock >= 1;
-- 영향 받은 행이 0이면 다른 사람이 먼저 바꾼 것 → 재시도
충돌이 드물면 낙관적 잠금이 효율적이다. 충돌이 잦으면 재시도가 반복되므로 비관적 잠금이 낫다.
가장 단순한 방법은 계산을 데이터베이스에서 하는 것이다.
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1;
읽고 판단하는 단계가 없으므로 끼어들 틈이 없다. 가능하면 이 형태를 먼저 고려한다.
데드락은 순서가 엇갈릴 때 생긴다
데드락은 두 트랜잭션이 서로가 가진 잠금을 기다릴 때 생긴다.

줄이는 방법은 몇 가지가 정해져 있다.
잠금 순서를 통일한다. 가장 효과가 크다. 여러 행을 건드릴 때 항상 ID 오름차순으로 접근하면, 위 그림의 교차가 생기지 않는다. 계좌 이체라면 작은 번호부터 잠근다.
트랜잭션을 짧게 유지한다. 트랜잭션 안에서 외부 API를 호출하거나 파일을 쓰지 않는다. 잠금을 오래 들고 있을수록 충돌 확률이 올라간다. 트랜잭션을 열기 전에 필요한 데이터를 모아두는 편이 낫다.
건드리는 행을 줄인다. 인덱스가 없어서 넓은 범위를 잠그는 경우가 있다. MySQL은 인덱스가 없으면 갭 잠금 범위가 넓어진다. 인덱스 설계가 동시성에도 영향을 준다.
재시도를 준비한다. 데드락은 완전히 없앨 수 없다. 데드락 예외를 잡아 짧은 대기 후 재시도하는 코드를 넣어두면, 사용자에게는 아무 일도 없었던 것처럼 보인다.
격리 수준을 올리는 것이 답은 아니다
SERIALIZABLE로 올리면 모든 문제가 사라질 것 같지만, 대가가 있다.
PostgreSQL의 SERIALIZABLE은 직렬화 이상을 감지하면 트랜잭션을 실패시킨다. 즉 애플리케이션이 재시도를 반드시 구현해야 한다. 이걸 안 하면 사용자에게 오류가 그대로 노출된다.
MySQL의 SERIALIZABLE은 모든 SELECT에 공유 잠금을 걸어서 동시성이 크게 떨어진다. 읽기가 많은 서비스라면 체감이 크다.
그래서 일반적인 권장은 기본 격리 수준을 쓰되, 정확성이 필요한 지점에만 명시적으로 잠금을 거는 것이다. 전체를 조이는 대신 필요한 곳만 조인다. 재고 차감, 잔액 변경, 좌석 예약처럼 지점이 명확한 경우가 대부분이다.
한 가지 더. 이런 문제는 테스트로 잡기 어렵다. 순차적으로 도는 테스트에서는 동시성 버그가 안 나온다. 재고 하나를 두고 동시 요청 100개를 보내는 테스트를 한 번이라도 짜두면, 구현이 맞는지 확인할 수 있다. 운영에서 발견하는 것보다 훨씬 싸다.
참고 자료
- PostgreSQL: Transaction Isolation — 격리 수준별 동작과 직렬화 실패 처리
- MySQL: InnoDB Locking and Transaction Model — 잠금 종류, 갭 잠금, 데드락 감지
- PostgreSQL: Explicit Locking —
FOR UPDATE등 명시적 잠금
'IT' 카테고리의 다른 글
| 환경 변수와 시크릿 관리, 어디까지 해야 하나 (0) | 2026.10.24 |
|---|---|
| 메시지 큐를 언제 써야 하고 언제 쓰면 안 되나 (0) | 2026.10.23 |
| API 버저닝, 언제 올리고 언제 안 올리나 (0) | 2026.10.21 |
| 타임아웃과 재시도, 서킷 브레이커까지 (0) | 2026.10.20 |
| 헬스체크와 무중단 배포, 프로브 제대로 쓰기 (0) | 2026.10.19 |
