트랜잭션 격리 수준과 이상 현상

"READ COMMITTED면 충분한가?" — 동시성·정합성·처리량을 둘러싼 4인 토론
백엔드 > DB & 스토리지 > 트랜잭션 격리 수준 깊이: 실무 2026-06-28 페르소나 4인

지도이 토론의 길

동시 트랜잭션이 만드는 문제 → 이상 현상 3종 → 격리 수준이 무엇을 막나 → 내부 구현(MVCC) → 강한 격리의 함정과 비용 → 실무 결정.

동시성여러 트랜잭션이 같은 데이터를 동시에 건드림
이상 현상dirty / non-repeatable / phantom + lost update / write skew
격리 수준RU·RC·RR·SERIALIZABLE — 무엇까지 막느냐
MVCC락 없이 스냅샷으로 읽기를 격리하는 구현
비용/함정SERIALIZABLE 비용, snapshot isolation의 write skew

출연토론자

🛡️

박정합

금융권 시니어 DBA · 정합성 신봉
"데이터가 틀리면 시스템은 존재 이유가 없다. 격리는 협상 대상이 아니다."
강한 격리 옹호

이처리

대규모 커머스 백엔드 시니어 · 처리량 우선
"RC + 앱 레벨 멱등/락으로 99%는 해결된다. SERIALIZABLE은 처리량을 죽인다."
실용·성능 우선
🔬

최내부

DB 엔진 개발 경험 · 디테일/회의론
"표준 격리 수준 이름은 절반은 거짓말이다. 너희 DB가 실제로 뭘 하는지 알아?"
구현 디테일
🐤

김주니

백엔드 2년차 · 질문자
"근데… non-repeatable read랑 phantom read가 뭐가 다른 거예요?"
전제를 드러냄

1왜 격리가 필요한가 — 동시성이라는 압력

🐤
김주니
트랜잭션이 ACID의 I, Isolation이잖아요. 그냥 "트랜잭션끼리 서로 안 보이게" 하면 되는 거 아니에요? 왜 수준이 4개나 있죠?
이처리
완벽히 안 보이게 하려면 트랜잭션을 한 줄로 세워서(직렬) 하나씩 실행하면 돼. 정합성은 완벽하지. 근데 그러면 동시 처리량이 1이야. 초당 수만 건 들어오는 서비스에서 그건 자살이고.
🛡️
박정합
그래서 DB는 "겉보기엔 직렬로 실행한 것 같은(serializable) 결과"를 동시 실행으로 흉내 내려 한다. 격리 수준은 곧 "어디까지 흉내 낼지"의 다이얼이야. 다이얼을 낮추면 빨라지는 대신 이상한 현상이 새어 나온다.
🔬
최내부
핵심은 이거야. 격리 수준은 "무엇을 보장하느냐"가 아니라 "어떤 이상 현상을 허용하느냐"로 정의된다. SQL 표준은 3가지 현상(dirty/non-repeatable/phantom)을 기준으로 수준을 나눴어. 그런데 그 정의 자체가 1990년대 락 기반 구현을 전제로 해서, MVCC 시대엔 안 맞는 구석이 많아. 그 얘긴 뒤에.
📌 한 줄 정리: 격리 수준 = 동시성(처리량)을 얼마나 포기하는 대가로 어떤 이상 현상을 막을지 고르는 다이얼.

2문제 재현 — 이상 현상 3종 시뮬레이터

두 트랜잭션 T1·T2가 같은 데이터를 동시에 건드릴 때 무슨 일이 나는지 직접 단계 진행해 보자. 시나리오를 고르고 다음 단계를 눌러 인터리빙을 재생한다.

🔵 T1
🟠 T2

3현상 해부 — 셋은 어떻게 다른가

🐤
김주니
non-repeatable read랑 phantom read… 둘 다 "두 번 읽었더니 달라졌다"인데 뭐가 달라요?
🔬
최내부
대상이 달라. Non-repeatable read이미 읽은 그 행(row)의 값이 두 번째 읽을 때 바뀐 것 — UPDATE가 범인. Phantom read같은 조건(WHERE)으로 다시 SELECT 했더니 없던 행이 생기거나 사라진 것 — INSERT/DELETE가 범인. 하나는 "내가 보던 게 변했다", 하나는 "집합의 멤버가 변했다".
🛡️
박정합
그래서 막는 방법도 다르다. 단일 행은 그 행에만 락을 걸면 되는데, phantom은 "아직 존재하지 않는 행"을 막아야 해. 존재하지 않는 걸 어떻게 잠그나? 그래서 범위(range)에 락을 걸어야 한다. MySQL InnoDB의 gap lock, next-key lock이 그거야.
이처리
그리고 그 gap lock이 운영에서 의외의 데드락을 만드는 주범이지. RR에서 INSERT 충돌나는 거 디버깅해 본 사람은 안다… 그래서 난 기본을 RC로 깔자는 거고.
🔬
최내부
여기서 함정 하나. "dirty read는 거의 안 쓴다"는 맞는데, PostgreSQL은 READ UNCOMMITTED를 요청해도 내부적으로 READ COMMITTED로 올려버려. 애초에 커밋 안 된 버전을 보여줄 메커니즘이 없거든(MVCC라). 그러니 "우리 DB는 RU 지원함"이라는 말부터 의심해야 해.

4해결 — 격리 수준이 무엇을 막나 (다이얼을 돌려보자)

슬라이더로 격리 수준을 올리면 어떤 이상 현상이 차단되는지 표가 갱신된다. 위로 갈수록 안전하지만 동시성은 줄어든다.

이상 현상차단 여부막는 메커니즘

5내부 동작 — MVCC는 어떻게 락 없이 읽기를 격리하나

🐤
김주니
읽을 때마다 락을 걸면 느릴 텐데, PostgreSQL은 "읽기는 쓰기를 막지 않는다"고 하잖아요. 어떻게요?
🔬
최내부
MVCC(다중 버전 동시성 제어). 한 행을 UPDATE하면 기존 행을 덮어쓰는 게 아니라 새 버전을 만들어. 각 버전엔 "이 버전을 만든 트랜잭션 ID(xmin)"와 "이 버전을 죽인 트랜잭션 ID(xmax)"가 붙어. 트랜잭션은 시작 시점의 스냅샷을 보고, "내 스냅샷 기준으로 보여야 할 버전"만 골라 읽는다. 락이 필요 없지.

아래는 한 행(balance)의 버전 체인. 슬라이더로 "읽는 트랜잭션의 스냅샷 시점"을 바꾸면, 그 시점에 보이는 버전이 초록으로 강조된다.

🧹 대가: 죽은 버전(아무 스냅샷도 더는 안 보는 dead tuple)이 쌓인다. PostgreSQL은 VACUUM으로 청소한다. 청소가 밀리면 테이블이 부풀고(bloat), 트랜잭션 ID가 고갈되면(wraparound) DB가 멈출 수도 있다. 공짜 격리는 없다.

6강한 격리의 함정 — Snapshot Isolation은 직렬화가 아니다

🐤
김주니
그럼 PostgreSQL에서 REPEATABLE READ 쓰면 거의 다 막히는 거네요? 안심?
🔬
최내부
아니. PostgreSQL의 RR은 사실 Snapshot Isolation(SI)이야. SI는 dirty/non-repeatable/phantom을 다 막지만, write skew라는 이상은 못 막는다. 표준 3현상엔 없는 놈이지.
🛡️
박정합
교과서 예시: 병원에 의사가 최소 1명은 on-call이어야 한다는 규칙. 의사 둘(Alice, Bob)이 동시에 "지금 on-call이 2명이네, 나 빠져도 1명 남으니 OK" 하고 각자 자기를 뺀다. 둘 다 자기 스냅샷에선 상대가 아직 on-call이라 검증을 통과해. 커밋하면? on-call 0명. 제약이 깨졌다.
이처리
이게 무서운 게, 두 트랜잭션이 서로 다른 행을 쓰니까 쓰기 충돌이 안 나. SI는 "같은 행 쓰기"만 감시하거든. 그래서 조용히 통과하고 데이터만 틀어진다.
🛡️
박정합
그래서 진짜로 막으려면 SERIALIZABLE이 필요하다. PostgreSQL은 SSI(Serializable Snapshot Isolation)로 구현해 — SI에 "위험한 읽기-쓰기 의존성"을 추적하는 감시를 얹어서, 직렬화 불가능한 패턴이 보이면 한쪽을 중단(serialization failure, 40001)시킨다.
이처리
바로 그 "중단"이 비용이야. SERIALIZABLE을 켜면 앱은 40001 에러를 받고 트랜잭션을 통째로 재시도할 줄 알아야 해. 재시도 로직 없이 SERIALIZABLE 켜면 운영에서 에러 폭발한다. 그래서 난 "꼭 필요한 트랜잭션만 골라서" 격리를 올리자는 입장.
🛡️
박정합
동의하는 부분 있어. 전역으로 SERIALIZABLE을 까는 건 과하다. 다만 돈·재고·예약처럼 불변식이 걸린 트랜잭션은 SERIALIZABLE이나 명시적 락(SELECT … FOR UPDATE)으로 반드시 보호해야 한다. "기본은 RC, 위험한 곳만 격상" — 거기까진 합의.

6.5직접 보기 — Write Skew (on-call 의사)

두 트랜잭션이 각자 스냅샷에서 "on-call 2명"을 확인하고 자기를 뺀다. 격리 수준을 바꿔 결과를 비교하라.

on-call 의사 수
2
불변식 (≥1)
OK

7종합 트레이드오프

격리 수준막는 이상 현상동시성/처리량대가·주의전형적 용도
READ UNCOMMITTED거의 없음 (dirty read 허용)최고커밋 안 된 데이터를 읽음. PG는 사실상 RC로 승격근사 통계, 거의 안 씀
READ COMMITTEDdirty read높음같은 트랜잭션 내 두 읽기가 다를 수 있음(lost update 위험)대부분 OLTP의 기본값
REPEATABLE READ / SI+ non-repeatable, (PG는 phantom도)중간write skew 미차단. MySQL은 gap lock 데드락리포트, 일관된 다중 읽기
SERIALIZABLE전부 (write skew 포함)낮음40001 직렬화 실패 → 재시도 로직 필수돈·재고·예약 등 불변식
⚠️ 같은 이름, 다른 동작: "REPEATABLE READ"는 DB마다 의미가 다르다. PostgreSQL RR = Snapshot Isolation(phantom까지 막음). MySQL InnoDB RR = 스냅샷 읽기 + gap lock(대체로 phantom 막지만 일부 케이스 다름). Oracle엔 RR이 없고 SERIALIZABLE이 SI에 가깝다. 이름 믿지 말고 문서를 봐라.

8의사결정 가이드

상황선택이유
일반 CRUD, 조회 위주 APIREAD COMMITTED (기본)처리량이 중요하고 단일 문장 정합성이면 충분
한 트랜잭션에서 여러 번 읽고 그 일관성이 중요 (리포트·집계)REPEATABLE READ / SI스냅샷 고정으로 읽기 일관성 확보
잔액·재고·좌석 — 읽고 검증 후 쓰기(read-modify-write)SELECT … FOR UPDATE (비관적 락) 또는 버전 컬럼(낙관적 락)lost update를 행 락/버전 충돌로 직접 차단. RC에서도 안전
서로 다른 행이지만 합쳐서 불변식이 걸림 (on-call, 중복 예약, 한도 합계)SERIALIZABLE + 재시도write skew는 SSI만 막는다. 명시적 락으로 우회 가능하나 설계 어려움
대량 동시 결제·주문 트래픽RC + 멱등성 키 + 낙관적 락, 핫 로우만 격상전역 SERIALIZABLE은 처리량을 죽임. 위험 지점만 국소적으로 보호
🎯 합의된 결론: "기본은 READ COMMITTED, 불변식이 걸린 소수의 트랜잭션만 SERIALIZABLE 또는 명시적 락으로 격상한다. 격상하면 재시도/충돌 처리를 반드시 함께 설계한다." — 박정합·이처리 둘 다 여기서 만남.

9코드로 보기

① lost update를 막는 두 방법

-- 비관적 락: 읽을 때 행을 잠근다 (RC에서도 안전)
BEGIN;
SELECT balance FROM account WHERE id = 42 FOR UPDATE;  -- 다른 tx는 여기서 대기
UPDATE account SET balance = balance - 100 WHERE id = 42;
COMMIT;

-- 낙관적 락: 버전 컬럼으로 충돌을 감지, 충돌 시 재시도
UPDATE account SET balance = 900, version = version + 1
WHERE id = 42 AND version = 7;  -- 영향받은 행 0이면 누가 먼저 바꾼 것 → 재시도

② SERIALIZABLE 재시도 패턴 (의사 코드)

for attempt in 1..MAX:
    try:
        tx.begin(isolation="SERIALIZABLE")
        # ... read-modify-write with invariant check ...
        tx.commit()
        break
    except SerializationFailure:   # SQLSTATE 40001
        tx.rollback()
        sleep(backoff(attempt))    # 지수 백오프 후 재시도

+더 파고들 키워드

MVCC & VACUUM

버전 체인, dead tuple, autovacuum, TXID wraparound — 격리의 청구서.

SSI (Serializable Snapshot Isolation)

읽기-쓰기 의존성 그래프로 위험한 순환을 탐지하는 PG의 SERIALIZABLE.

Gap / Next-key Lock

MySQL InnoDB가 phantom을 막는 범위 락. 데드락의 단골 원인.

Lost Update vs Write Skew

같은 행 vs 다른 행. 막는 메커니즘이 다르다.

낙관적 vs 비관적 락

충돌이 드물면 낙관적, 잦으면 비관적. 처리량 트레이드오프.

2PL vs MVCC

락 기반 직렬화와 다중 버전. 표준 격리 정의의 역사적 배경.

TechTalk · 생성 모드 · 페르소나 토론 창작 · 사실 검증 기준 2026-06

용어집