트랜잭션 격리 수준
여러 트랜잭션이 같은 데이터를 동시에 건드릴 때, 서로를 얼마나 격리할지 정하는 설정입니다.
높일수록 정확해지고 느려집니다. 낮출수록 빨라지고 이상한 값을 보게 됩니다. 그래서 "제일 높은 걸 쓰면 되지 않나"의 답은 "그러면 동시성이 죽는다"입니다.
먼저, 무엇이 잘못될 수 있나
격리 수준은 아래 세 가지 현상을 각각 허용하거나 막습니다. 이름부터 보는 게 빠릅니다.
Dirty read — 커밋 안 된 값을 읽음
T1: BEGIN
T1: UPDATE accounts SET balance = 0 WHERE id = 1; -- 아직 커밋 안 함
T2: SELECT balance FROM accounts WHERE id = 1; -- 0을 읽음
T1: ROLLBACK -- 없던 일이 됨
T2는 존재한 적 없는 값을 읽었습니다. 그걸로 판단을 내렸다면 그 판단은 근거가 없습니다.
Non-repeatable read — 같은 행을 두 번 읽었는데 값이 다름
T1: SELECT balance FROM accounts WHERE id = 1; -- 100
T2: UPDATE accounts SET balance = 70 WHERE id = 1; COMMIT;
T1: SELECT balance FROM accounts WHERE id = 1; -- 70
T1은 아직 안 끝났는데 세상이 바뀌었습니다. 읽은 값으로 계산하고 있었다면 앞뒤가 안 맞습니다.
Phantom read — 같은 조건으로 두 번 읽었는데 행 개수가 다름
T1: SELECT count(*) FROM orders WHERE user_id = 1; -- 3건
T2: INSERT INTO orders (user_id) VALUES (1); COMMIT;
T1: SELECT count(*) FROM orders WHERE user_id = 1; -- 4건
행이 바뀐 게 아니라 없던 행이 생겼습니다. "주문 3건 이하면 쿠폰 지급" 같은 규칙이 여기서 무너집니다.
네 단계
| 격리 수준 | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ UNCOMMITTED | 허용 | 허용 | 허용 |
| READ COMMITTED | 막음 | 허용 | 허용 |
| REPEATABLE READ | 막음 | 막음 | 허용 (아래 참고) |
| SERIALIZABLE | 막음 | 막음 | 막음 |
READ UNCOMMITTED
남이 커밋하지 않은 값까지 봅니다. 롤백되면 내가 본 값은 없던 게 됩니다. 실무에서 쓸 이유가 사실상 없습니다. PostgreSQL은 이 수준을 요청해도 READ COMMITTED로 처리합니다.
READ COMMITTED
커밋된 값만 봅니다. 단, 문장 하나하나가 각자 최신 스냅샷을 봅니다. 그래서 한 트랜잭션 안에서 같은 SELECT를 두 번 하면 결과가 달라질 수 있습니다.
PostgreSQL과 Oracle의 기본값입니다. 대부분의 웹 요청은 짧고 단순해서 이걸로 충분합니다.
REPEATABLE READ
트랜잭션이 시작한 순간의 스냅샷을 끝까지 봅니다. 중간에 남이 뭘 커밋하든 내 눈에는 안 보입니다. 같은 SELECT는 몇 번을 해도 같은 결과입니다.
MySQL(InnoDB)의 기본값입니다. 여러 번 읽어서 하나의 결론을 내야 하는 작업 — 잔액 재계산, 정산, 리포트 — 은 최소 이 수준이 필요합니다.
표에는 팬텀을 "허용"으로 적었지만, 이건 표준 명세 기준입니다. MySQL InnoDB는 넥스트키 락으로 팬텀도 대부분 막습니다. 표만 보고 "MySQL에서 팬텀이 난다"고 단정하면 틀립니다.
SERIALIZABLE
동시에 실행됐지만 하나씩 차례로 실행한 것과 같은 결과를 보장합니다. 가장 안전하고 가장 비쌉니다.
PostgreSQL은 이걸 락이 아니라 충돌 감지로 구현합니다(SSI). 그래서 대기 대신 에러를 던지고 재시도를 요구합니다. 애플리케이션에 재시도 로직이 없으면 그냥 실패로 보입니다.
그래서 뭘 쓰나
대부분의 요청은 기본값 그대로 둡니다. 격리 수준을 올리는 건 여러 번 읽어서 하나의 판단을 내리는 작업에 한정하는 게 좋습니다.
- 단건 조회·수정 → 기본값 (READ COMMITTED / REPEATABLE READ)
- 잔액 재계산, 정산, 집계 리포트 → REPEATABLE READ 이상
- 재고 차감, 좌석 예약처럼 "확인하고 쓰는" 작업 → SERIALIZABLE, 또는 더 흔하게는 원자 연산이나 명시적 락
마지막 항목이 중요합니다. 격리 수준을 올리는 게 유일한 답이 아닙니다. 조건부 원자 갱신 한 줄이 더 싸고 명확할 때가 많습니다.
UPDATE seats SET taken = 1
WHERE id = ? AND taken = 0; -- 영향 행이 0이면 이미 남이 가져감
MongoDB는 이름이 다릅니다
MongoDB에는 READ COMMITTED 같은 격리 수준 설정이 없습니다. 대신 readConcern과 writeConcern으로 나눠 표현합니다.
| readConcern | 무엇을 보나 |
|---|---|
local | 이 노드가 가진 최신 값. 롤백될 수 있음 |
majority | 과반 노드에 반영되어 되돌아가지 않을 값 |
snapshot | 트랜잭션 시작 시점의 일관된 스냅샷 (트랜잭션 안에서) |
관계형과 대응시키면 대략 이렇습니다. local이 READ UNCOMMITTED에 가깝고(롤백 가능성), majority가 READ COMMITTED, 트랜잭션 안의 snapshot이 REPEATABLE READ 역할을 합니다.
그리고 단일 문서 연산은 격리 수준과 무관하게 항상 원자적입니다. $inc 하나로 끝나는 일에 트랜잭션을 여는 건 비용만 늘립니다. 여러 문서를 함께 바꿔야 할 때만 트랜잭션이 필요합니다.
흔한 함정
격리 수준은 읽기 일관성만 보장합니다. "읽은 값이 안 바뀐다"와 "내가 쓸 때까지 남이 못 쓴다"는 다른 이야기입니다. 후자가 필요하면 SELECT ... FOR UPDATE나 버전 기반 조건부 갱신이 필요합니다.
높은 격리 수준은 락을 오래 잡습니다. 트랜잭션 안에서 외부 API를 호출하면 그 왕복 시간만큼 다른 요청이 줄을 섭니다.
DB마다 같은 이름이 다르게 동작합니다. MySQL의 REPEATABLE READ와 PostgreSQL의 REPEATABLE READ는 팬텀 처리도, 충돌 시 동작도 다릅니다. 이름이 아니라 쓰는 DB의 문서를 기준으로 판단해야 합니다.
코인 잔액이 자꾸 틀어졌다에서 재계산이 스스로 오판하던 문제가 정확히 여기 걸립니다. 저장된 잔액과 히스토리 합계를 서로 다른 순간에 읽으면 멀쩡한 값이 불일치로 보입니다. 두 읽기가 같은 스냅샷 안에 있어야 하고, 그래서 최소 REPEATABLE READ가 필요합니다.