트랜잭션 격리 수준 — 현상(anomaly)으로 이해하고 실제 구현까지
목표: 4개 레벨 이름 암기에서 그치지 않고 "어떤 이상 현상을 허용하고 성능을 얻는가"의 눈금으로 이해하기. 그리고 표준 표(1992)가 아니라 MVCC·MySQL·PostgreSQL의 실제 동작 차이까지 내려가기.
관련: thread (락 대기 → 스레드풀 고갈) / cache (Redis 분산 락) / zset (단일 스레드 vs 락 기반 동시성) / queue (동시성 완충)
한 줄 결론(면접 오프닝):
격리 수준 = 정합성(correctness)과 동시성(performance) 사이의 트레이드오프 다이얼. "어떤 이상 현상까지 허용하고 성능을 얻을 것인가"를 표준화한 눈금이다.
§0. 왜 존재하나 — 한 문장
트랜잭션 여러 개가 동시에 돌 때, 완벽히 순차 실행한 것처럼(Serializable) 보이게 하면 안전하지만 느리다. 그래서 "어떤 이상 현상까지 허용하고 성능을 얻을 것인가"의 눈금을 표준화한 게 격리 수준이다.
이 프레임으로 답변을 시작하면 격이 달라진다. 레벨은 목적이 아니라 수단 — 진짜 본체는 "무슨 이상 현상을 막느냐"다.
§1. 이상 현상(anomaly) — 레벨보다 이게 본체
레벨은 "어떤 현상을 막느냐"의 조합일 뿐이라, 현상을 먼저 잡아야 한다.
① Dirty Read — 남이 커밋 안 한 데이터를 읽음
T1: 잔액 100 → 200으로 UPDATE (아직 커밋 전)
T2: 잔액 읽음 → 200 ← 커밋 안 된 값!
T1: ROLLBACK
T2는 "세상에 존재한 적 없는 값"을 본 것
② Non-Repeatable Read — 같은 행을 두 번 읽었는데 값이 다름
T1: SELECT 잔액 → 100
T2: 잔액 200으로 UPDATE + COMMIT
T1: SELECT 잔액 → 200 ← 같은 트랜잭션인데 값이 변함
③ Phantom Read — 같은 조건 조회인데 행 개수가 다름
②와의 차이: ②는 기존 행의 값 변경, ③은 조건에 걸리는 행의 추가/삭제.
T1: SELECT COUNT(*) WHERE 나이>=20 → 5건
T2: 나이 25 회원 INSERT + COMMIT
T1: 같은 쿼리 → 6건 ← 유령(phantom)이 나타남
④ Lost Update — 갱신 유실 (표준 4대엔 없지만 실무 1순위 ⚠️)
둘 다 읽고 → 각자 계산 → 나중에 쓴 놈이 덮어씀.
T1: 재고 읽음 → 10 T2: 재고 읽음 → 10
T1: 10-1=9 저장 T2: 10-3=7 저장 (나중에 커밋)
결과: 7 ← T1의 차감(-1)이 증발. 정답은 6
킬포인트: 웅진 회원권 배치, 티켓팅 좌석 예약이 정확히 이 현상과의 싸움이었다. 이 개인 경험 연결이 답변의 하이라이트. → §4에서 해법으로 이어짐.
⑤ Write Skew — 쓰기 스큐 (심화, 아는 사람 거의 없음)
둘이 서로 다른 행을 고쳤는데 합쳐 보면 제약 위반. 고전 예:
규칙: "당직 의사 최소 1명"
현재: 의사 A(당직), 의사 B(당직) — 2명
T1(A): 나(A) 빼도 B가 있네 → A 퇴근 처리 ← 자기 기준 정당
T2(B): 나(B) 빼도 A가 있네 → B 퇴근 처리 ← 자기 기준 정당
결과: 당직 0명 ← 제약 위반!
각 트랜잭션은 자기 기준으로 정당해서 일반 락으론 안 잡히고, Serializable(SSI)에서만 막힌다. 언급하면 확 튄다.
§2. 4개 레벨 — "무엇을 막나" 매트릭스
| 레벨 | Dirty Read | Non-Repeatable | Phantom | 한 줄 정의 |
|---|---|---|---|---|
| READ UNCOMMITTED | 허용 | 허용 | 허용 | 커밋 안 된 것도 읽음 (실무 거의 안 씀) |
| READ COMMITTED | 차단 | 허용 | 허용 | 커밋된 것만 읽음. 문장마다 스냅샷 갱신 |
| REPEATABLE READ | 차단 | 차단 | 허용* | 트랜잭션 시작 시점 스냅샷 고정 |
| SERIALIZABLE | 차단 | 차단 | 차단 | 순차 실행과 동등함을 보장 |
암기 팁: 아래로 갈수록 "읽기의 기준 시점"이 넓어진다 — 문장 단위 최신(RC) → 트랜잭션 단위 고정(RR) → 아예 순차 실행 등가(S).
* 표에 별표를 단 이유가 다음 섹션 — 실제 DB는 이 표대로 안 움직인다. 여기서부터가 차별화 구간.
§3. 실제 구현 — MVCC, 그리고 MySQL vs PostgreSQL
표준 표는 락 기반 구현을 가정한 1992년 문서고, 현대 DB는 **MVCC(다중 버전 동시성 제어)**로 구현한다.
MVCC 한 줄: 데이터를 덮어쓰는 대신 버전을 여러 개 유지하고, 각 트랜잭션은 자기 스냅샷 시점에 맞는 버전을 읽는다. 그래서 읽기가 쓰기를 안 막고, 쓰기가 읽기를 안 막는다. 락 기반이면 읽는 동안 쓰기가 대기해야 하는데, 그게 사라진 게 MVCC의 존재 이유.
MVCC 구현 방식의 차이 (단골 꼬리질문)
| PostgreSQL | MySQL (InnoDB) | |
|---|---|---|
| UPDATE 방식 | 새 튜플 삽입 + 구 튜플에 "죽음" 표시 | 행 제자리 갱신 + 이전 버전은 undo log |
| 구 버전 보관 | 테이블 안에 그대로 쌓임 | undo log에 별도 보관 |
| 부작용 | VACUUM 필요, bloat 발생 | undo log 관리 |
| 스냅샷 읽기 | 죽은 튜플 무시 | undo 체인 거슬러 재구성 |
| 롤백 비용 | 공짜 (새 튜플 무시하면 끝) | undo 재적용이라 비용 있음 |
레벨별 실제 동작 차이
READ COMMITTED
- 문장(statement) 하나 시작할 때마다 새 스냅샷. 그래서 같은 트랜잭션 안에서 두 번 읽으면 값이 달라질 수 있음(= non-repeatable 허용).
- PostgreSQL / Oracle의 기본값.
REPEATABLE READ — 여기서 두 DB가 갈린다
- 트랜잭션의 첫 읽기 시점 스냅샷을 끝까지 고정. MySQL InnoDB의 기본값.
| MySQL RR | PostgreSQL RR | |
|---|---|---|
| 정체 | 스냅샷 + 갭 락/넥스트키 락 | 사실상 Snapshot Isolation(SI) |
| 팬텀 | 잠금 읽기(FOR UPDATE)에서 조건 범위의 "틈"까지 잠가 상당 부분 차단 (표준보다 강함) |
스냅샷 고정이라 일반 SELECT에선 안 보임 |
| 충돌 시 | 락 대기로 풂 | "could not serialize access" 에러 → 앱이 재시도 |
이 차이(락 대기 vs 에러+재시도)가 실무 감각 포인트. MySQL은 기다리게 하고, PG는 실패시키고 다시 시도하게 한다.
SERIALIZABLE
- MySQL: 모든 SELECT를 잠금 읽기로 바꾸는 락 기반.
- PostgreSQL: SSI(Serializable Snapshot Isolation) — 낙관적으로 돌리다 직렬화 불가능한 의존 패턴이 감지되면 한쪽을 abort. 그래서 PG의 Serializable은 "느려지는" 게 아니라 "실패하고 재시도하는" 모델. Write Skew를 잡을 수 있는 유일한 레벨.
기본값 암기 (필수)
MySQL → REPEATABLE READ
PostgreSQL → READ COMMITTED
Oracle → READ COMMITTED
SQL Server → READ COMMITTED
"왜 다르지?" 꼬리질문 대비:
- MySQL이 RR인 이유: binlog 복제(statement 기반 시절)의 일관성 역사 때문.
- PG가 RC인 이유: RC + MVCC로 대부분 충분하다는 철학.
§4. 격리 수준이 못 지켜주는 것 — Lost Update와 애플리케이션 락
여기가 이력서와 직결되는 실무 층. RC는 물론이고 스냅샷 기반 RR에서도 read-modify-write 패턴의 lost update는 방심하면 뚫린다 (PG RR은 에러로 알려주지만, MySQL RR 일반 SELECT는 못 잡음). 그래서 애플리케이션이 개입한다.
① 비관적 락 (Pessimistic Lock)
SELECT ... FOR UPDATE -- 읽는 순간 행을 잠가 경쟁 자체를 차단
- 충돌이 잦을 때 유리.
- 대가: 락 대기가 커넥션·스레드를 점유.
- ⚠️ 웅진 장애가 정확히 이 비용이 폭발한 사례 — 락 대기 → 스레드풀 고갈 → 6천 명 미등록. → thread의 스레드풀 고갈과 연결.
② 낙관적 락 (Optimistic Lock)
-- @Version 컬럼 사용
UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?
-- 영향받은 행 0개면 → 충돌 → 재시도
- 일단 진행하고 커밋 시점에 버전 비교, 다르면 실패 + 재시도.
- 충돌이 드물 때 유리, 락 점유 없음.
- 대가: 재시도 로직 필수.
③ 원자적 UPDATE (제일 저렴한데 의외로 언급 적음)
UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty > 0
- 읽기-계산-쓰기를 한 문장으로 합쳐버리면 격리 수준과 무관하게 안전.
- DB가 행 락을 문장 실행 동안만 잡아 원자성 보장. 가장 값싼 해법.
④ 분산 락 (Redis 등)
- 경합 범위가 DB 커넥션 밖이거나 여러 인스턴스/여러 리소스에 걸칠 때.
- 웅진에서 최종적으로 간 방향. → cache의 Redis 활용과 연결.
해법 선택 매트릭스
| 상황 | 해법 |
|---|---|
| 단일 행 카운터 차감(재고/좌석 수) | 원자적 UPDATE (제일 쌈) |
| 충돌 드묾, 락 점유 싫음 | 낙관적 락 (@Version) |
| 충돌 잦음, 확실히 순서 보장 | 비관적 락 (FOR UPDATE) — 단, 락 대기 비용 주의 |
| 경합이 DB 밖 / 여러 리소스 | 분산 락 (Redis) |
이 계보로 답하면 "격리 수준 암기"가 아니라 **"동시성 제어 전체 지도를 갖고 있다"**가 된다.
§5. 면접 대응 요약 (한 장 치트시트)
- 오프닝: "격리 수준은 정합성과 동시성의 트레이드오프 다이얼" (§0)
- 현상 먼저: Dirty → Non-Repeatable → Phantom → Lost Update(실무 1순위) → Write Skew(심화) (§1)
- 레벨은 조합: 매트릭스 + "읽기 기준 시점이 넓어진다" 암기 팁 (§2)
- 표준 표는 옛날 것: 현대는 MVCC. MySQL RR(갭락) vs PG RR(SI) 차이 (§3)
- 격리로 안 되는 것: Lost Update → 비관/낙관/원자적/분산 락 (§4)
- 개인 경험 연결: 웅진 락 대기 장애, 티켓팅 좌석 예약 → 신뢰도 급상승
§6. 토론 주제 모음
Q1. RR인데 왜 MySQL은 팬텀을 (거의) 막고 PostgreSQL은 접근이 다른가? → MySQL은 갭 락/넥스트키 락으로 조건 범위의 틈까지 물리적으로 잠금. PG는 스냅샷 고정이라 일반 읽기에선 팬텀이 아예 안 보이고, 충돌 나면 에러로 알림. **"물리적 락 vs 스냅샷 + 낙관적 감지"**의 철학 차이.
Q2. 원자적 UPDATE가 그렇게 좋으면 왜 항상 안 쓰나? → 단일 행 단순 연산엔 최고지만, 여러 행에 걸친 복잡한 불변식(write skew류)이나 "읽어서 판단 후 여러 곳 갱신"엔 한 문장으로 못 접음. 그땐 락이나 Serializable 필요.
Q3. Serializable을 왜 기본으로 안 쓰나? → MySQL은 모든 읽기가 락이라 동시성 급락, PG SSI는 abort+재시도 비용. 대부분의 앱은 RC/RR + 국소적 락으로 충분. Serializable은 write skew 같은 복잡 불변식이 진짜 필요할 때만.
Q4. 낙관적 락 vs 비관적 락, 티켓팅엔 뭘? → 인기 좌석은 충돌이 잦아 낙관적 락이면 재시도 폭주 → 비관적 락/분산 락이 유리. 비인기 구간은 낙관적이 가벼움. 충돌 빈도가 선택 기준. → §1 ④, queue 대기열 설계와 연결.
Q5. PG의 "롤백은 공짜"가 왜 MySQL보다 유리한가? 반대 대가는? → PG는 새 튜플만 버리면 되지만, 대신 죽은 튜플이 쌓여 VACUUM/bloat 부담. MySQL은 롤백에 undo 재적용 비용이 있는 대신 테이블이 안 부푼다. "롤백 비용 vs 청소 비용"의 트레이드오프.
Q6. MVCC인데도 Lost Update가 왜 뚫리나? → MVCC는 **"읽기 일관성"**을 주지, **"내가 읽은 값이 쓸 때까지 안 바뀜"**은 보장 안 함. read-modify-write 사이에 남이 커밋하면 내 갱신이 덮어씀. 그래서 명시적 락이나 버전 검사가 별도로 필요. (격리 수준 ≠ 동시 갱신 방지)