isolation

트랜잭션 격리 수준 — 현상(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

REPEATABLE READ — 여기서 두 DB가 갈린다

MySQL RR PostgreSQL RR
정체 스냅샷 + 갭 락/넥스트키 락 사실상 Snapshot Isolation(SI)
팬텀 잠금 읽기(FOR UPDATE)에서 조건 범위의 "틈"까지 잠가 상당 부분 차단 (표준보다 강함) 스냅샷 고정이라 일반 SELECT에선 안 보임
충돌 시 락 대기로 풂 "could not serialize access" 에러 → 앱이 재시도

이 차이(락 대기 vs 에러+재시도)가 실무 감각 포인트. MySQL은 기다리게 하고, PG는 실패시키고 다시 시도하게 한다.

SERIALIZABLE

기본값 암기 (필수)

MySQL          → REPEATABLE READ
PostgreSQL     → READ COMMITTED
Oracle         → READ COMMITTED
SQL Server     → READ COMMITTED

"왜 다르지?" 꼬리질문 대비:


§4. 격리 수준이 못 지켜주는 것 — Lost Update와 애플리케이션 락

여기가 이력서와 직결되는 실무 층. RC는 물론이고 스냅샷 기반 RR에서도 read-modify-write 패턴의 lost update는 방심하면 뚫린다 (PG RR은 에러로 알려주지만, MySQL RR 일반 SELECT는 못 잡음). 그래서 애플리케이션이 개입한다.

① 비관적 락 (Pessimistic Lock)

SELECT ... FOR UPDATE   -- 읽는 순간 행을 잠가 경쟁 자체를 차단

② 낙관적 락 (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

④ 분산 락 (Redis 등)

해법 선택 매트릭스

상황 해법
단일 행 카운터 차감(재고/좌석 수) 원자적 UPDATE (제일 쌈)
충돌 드묾, 락 점유 싫음 낙관적 락 (@Version)
충돌 잦음, 확실히 순서 보장 비관적 락 (FOR UPDATE) — 단, 락 대기 비용 주의
경합이 DB 밖 / 여러 리소스 분산 락 (Redis)

이 계보로 답하면 "격리 수준 암기"가 아니라 **"동시성 제어 전체 지도를 갖고 있다"**가 된다.


§5. 면접 대응 요약 (한 장 치트시트)

  1. 오프닝: "격리 수준은 정합성과 동시성의 트레이드오프 다이얼" (§0)
  2. 현상 먼저: Dirty → Non-Repeatable → Phantom → Lost Update(실무 1순위) → Write Skew(심화) (§1)
  3. 레벨은 조합: 매트릭스 + "읽기 기준 시점이 넓어진다" 암기 팁 (§2)
  4. 표준 표는 옛날 것: 현대는 MVCC. MySQL RR(갭락) vs PG RR(SI) 차이 (§3)
  5. 격리로 안 되는 것: Lost Update → 비관/낙관/원자적/분산 락 (§4)
  6. 개인 경험 연결: 웅진 락 대기 장애, 티켓팅 좌석 예약 → 신뢰도 급상승

§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 사이에 남이 커밋하면 내 갱신이 덮어씀. 그래서 명시적 락이나 버전 검사가 별도로 필요. (격리 수준 ≠ 동시 갱신 방지)