- Published on
Database MVCC & Isolation Levels 완전 가이드 2025: Read Phenomena, Snapshot Isolation, SSI, PostgreSQL/InnoDB 내부 구조
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며: 왜 격리 수준이 헷갈리는가?
공식 문서의 함정
SQL 표준은 네 개의 격리 수준을 정의한다: Read Uncommitted, Read Committed, Repeatable Read, Serializable. 그리고 세 가지 이상 현상(anomaly)으로 구분한다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | 허용 | 허용 | 허용 |
| Read Committed | 방지 | 허용 | 허용 |
| Repeatable Read | 방지 | 방지 | 허용 |
| Serializable | 방지 | 방지 | 방지 |
이 표를 외우고 나면 안다고 착각한다. 그런데 실제 데이터베이스는 이 표대로 동작하지 않는다:
- PostgreSQL의 Repeatable Read는 Phantom Read를 방지한다 (표에 따르면 허용해야 함).
- MySQL InnoDB의 Repeatable Read는 Snapshot Isolation이며, Next-Key Lock으로 Phantom도 막는다.
- Oracle의 Serializable은 실제론 Snapshot Isolation이다.
- Write Skew라는 이상 현상은 SQL 표준에 없지만 Snapshot Isolation의 핵심 문제다.
즉, "Repeatable Read"라는 이름이 같아도 DB마다 의미가 다르다. 이 글에서는 각 DB가 실제로 어떻게 작동하는지 들여다본다.
1. 트랜잭션과 ACID 복습
ACID의 진짜 의미
- Atomicity: 모두 성공하거나 모두 실패.
- Consistency: 트랜잭션 후에도 데이터베이스 불변식 유지.
- Isolation: 동시 실행되는 트랜잭션들이 서로 영향을 주지 않는 것처럼 보임.
- Durability: 커밋된 데이터는 영속.
이 중에서 Isolation이 가장 어렵고 미묘하다. 완벽한 Isolation(= Serializable)은 트랜잭션을 직렬로 실행하는 것과 같은 결과를 보장하지만, 그러면 성능이 끔찍하다. 그래서 다양한 약한 격리 수준이 제공된다.
격리 수준의 본질
격리 수준은 "어떤 이상 현상(anomaly)을 허용할 것인가" 의 선택이다. 약한 격리 수준은 빠르지만 프로그래머가 버그를 피하기 위해 추가 노력을 해야 한다. 강한 격리 수준은 안전하지만 성능이 떨어진다.
Serializable ←────── 안전, 느림
↑
Snapshot Isolation
↑
Repeatable Read
↑
Read Committed
↑
Read Uncommitted ←── 위험, 빠름
2. Read Phenomena 완전 정복
Dirty Read: 커밋 안 된 데이터 읽기
예시:
-- 초기 상태: account = 100
-- T1
BEGIN;
UPDATE account SET balance = 200; -- 아직 커밋 안 함
-- 갑자기 ROLLBACK
-- T2 (T1의 UPDATE 이후, ROLLBACK 이전)
BEGIN;
SELECT balance FROM account; -- 200 반환! (dirty read)
T1이 결국 롤백되므로 T2가 읽은 200은 존재한 적 없는 값이다. 이는 매우 위험하다.
실전: 거의 모든 최신 DB가 Read Uncommitted를 실제로 지원하지 않는다. 설정해도 Read Committed처럼 동작하는 경우가 많다 (예: PostgreSQL).
Non-repeatable Read: 같은 행이 달라짐
-- T1
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 100
-- T2
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;
-- T1 계속
SELECT balance FROM account WHERE id = 1; -- 200 (다른 값!)
COMMIT;
한 트랜잭션 내에서 같은 SELECT가 다른 결과를 반환한다. 복잡한 리포트 쿼리에서 일관성이 깨진다.
Phantom Read: 같은 WHERE 결과가 달라짐
-- T1
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 5; -- 10
-- T2
BEGIN;
INSERT INTO orders (user_id, ...) VALUES (5, ...);
COMMIT;
-- T1 계속
SELECT COUNT(*) FROM orders WHERE user_id = 5; -- 11 (유령 행!)
COMMIT;
존재하지 않던 행이 갑자기 나타났다. 이를 Phantom이라 부른다. Non-repeatable read와 유사하지만, 대상이 개별 행이 아니라 범위 쿼리 결과라는 점이 다르다.
Lost Update: 쓴 내용이 사라짐
-- 초기: counter = 0
-- T1과 T2가 동시에:
BEGIN;
SELECT counter FROM stats; -- 둘 다 0
-- 애플리케이션에서 +1 계산
UPDATE stats SET counter = 1;
COMMIT;
-- 결과: counter = 1 (하나의 업데이트가 사라짐!)
두 트랜잭션이 같은 값을 읽고 각자 업데이트하면, 나중에 쓴 것이 먼저 쓴 것을 덮어쓴다.
해결책:
SELECT ... FOR UPDATE(비관적 락)UPDATE stats SET counter = counter + 1(원자적 연산)- Optimistic locking (
WHERE version = ?)
Write Skew: Snapshot의 치명적 약점
-- 병원 스케줄링: 최소 1명의 의사가 당직이어야 함
-- 초기 상태: 의사 Alice와 Bob 둘 다 당직 중
-- T1 (Alice가 오프 신청)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2
-- 2명이니 1명 빠져도 괜찮다고 판단
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;
-- T2 (Bob이 오프 신청, 동시에 진행)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2 (Alice 변경 전 스냅샷)
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
-- 결과: 둘 다 오프! 당직 의사 0명!
두 트랜잭션이 각자 다른 행을 수정했기 때문에 쓰기 충돌이 없다. 하지만 결과적으로 불변식(최소 1명 당직)이 깨진다. 이를 Write Skew라 한다.
Write Skew는 Snapshot Isolation에서 발생할 수 있으며, 진짜 Serializable에서만 완전히 방지된다.
Read Skew: 일관성 없는 집계
-- 계좌 A, B 각각 100원
-- T1 (읽기)
BEGIN;
SELECT balance FROM accounts WHERE id = 'A'; -- 100
-- T2 (송금 A → B)
BEGIN;
UPDATE accounts SET balance = 50 WHERE id = 'A';
UPDATE accounts SET balance = 150 WHERE id = 'B';
COMMIT;
-- T1 계속
SELECT balance FROM accounts WHERE id = 'B'; -- 150
COMMIT;
T1은 A=100, B=150을 읽어서 총합 250원이라는 잘못된 값을 얻는다. 실제로는 A=50, B=150이거나 A=100, B=100이어야 한다.
Snapshot Isolation은 이를 완벽히 해결한다: T1이 시작된 시점의 스냅샷을 보기 때문에 A=100, B=100을 읽는다.
3. MVCC: Multi-Version Concurrency Control
MVCC의 기본 아이디어
전통적 DB는 락으로 동시성을 관리했다. 읽기도 락을 걸어서 쓰기를 막고, 쓰기도 락을 걸어서 읽기를 막았다. 이는 "Readers block writers, writers block readers" 라는 유명한 문제를 낳았다.
MVCC는 이 문제를 해결한다:
"각 행의 여러 버전을 유지하고, 각 트랜잭션은 자신에게 맞는 버전을 읽는다."
결과: 읽기는 쓰기를 막지 않고, 쓰기도 읽기를 막지 않는다.
MVCC의 핵심 구조
각 행에 메타데이터 추가:
- xmin: 이 버전을 생성한 트랜잭션 ID.
- xmax: 이 버전을 삭제한 트랜잭션 ID (없으면 0 또는 무한대).
가시성 규칙:
- 트랜잭션 T가 행 버전 V를 볼 수 있다 ↔
xmin <= TAND (xmax == 0ORxmax > T).
즉, T 시작 시점에 이미 존재했고 아직 살아있는 버전만 T에게 보인다.
4. PostgreSQL의 MVCC 심층 분석
행의 실제 저장 구조
PostgreSQL에서 모든 행(튜플)은 다음 헤더를 가진다:
typedef struct HeapTupleHeaderData {
TransactionId t_xmin; // 생성한 트랜잭션
TransactionId t_xmax; // 삭제/업데이트한 트랜잭션
CommandId t_cmin; // 같은 트랜잭션 내 커맨드 ID
CommandId t_cmax;
ItemPointerData t_ctid; // 이 튜플의 위치(또는 다음 버전 포인터)
// ...
};
UPDATE의 실제 동작
PostgreSQL에서 UPDATE는 제자리 갱신이 아니다:
- 기존 행에 xmax를 설정 (삭제 표시).
- 새로운 행을 삽입하고 xmin = 현재 트랜잭션.
- 기존 행의
ctid가 새 행을 가리키도록 연결.
결과: 페이지에 여러 버전의 같은 논리적 행이 공존한다.
Before UPDATE:
[xmin=100, xmax=0, id=1, balance=100]
After UPDATE by tx 150:
[xmin=100, xmax=150, id=1, balance=100] ← 구 버전
[xmin=150, xmax=0, id=1, balance=200] ← 신 버전
스냅샷 (Snapshot)
PostgreSQL 트랜잭션은 시작 시 스냅샷을 얻는다. 스냅샷은:
xmin: 활성 중인 가장 오래된 트랜잭션 ID.xmax: 아직 할당되지 않은 가장 큰 트랜잭션 ID.xip[]: 현재 진행 중인 트랜잭션 ID 목록.
튜플 T가 보이는지 판단:
def is_visible(tuple_xmin, tuple_xmax, snapshot):
# 아직 커밋되지 않은 생성자
if tuple_xmin in snapshot.xip:
return False
if tuple_xmin >= snapshot.xmax:
return False
# 이미 삭제된 경우
if tuple_xmax != 0 and tuple_xmax not in snapshot.xip:
if tuple_xmax < snapshot.xmax:
return False # 삭제 커밋됨
return True
VACUUM: 쓰레기 수집
MVCC의 부작용은 구 버전 행들이 계속 쌓인다는 것이다. 이를 정리하는 것이 VACUUM이다:
- VACUUM: 모든 트랜잭션이 더 이상 볼 수 없는 dead tuple을 회수.
- VACUUM FULL: 테이블을 재구성해서 공간도 회수 (AccessExclusive lock 필요).
- Autovacuum: 자동 트리거.
VACUUM을 게을리하면 테이블이 부풀어 오르고(bloat), 성능이 급격히 저하된다.
PostgreSQL 격리 수준의 진실
PostgreSQL은 Read Uncommitted를 지원하지 않는다. 설정해도 Read Committed처럼 동작한다. 실제 구현:
| 설정 | 실제 동작 |
|---|---|
| Read Uncommitted | = Read Committed |
| Read Committed (기본) | 각 쿼리마다 새 스냅샷 |
| Repeatable Read | 트랜잭션 시작 시 스냅샷 고정 (= Snapshot Isolation) |
| Serializable | SSI (Serializable Snapshot Isolation) |
주의: PostgreSQL의 Repeatable Read는 표준보다 강하다. Phantom Read도 방지한다. 하지만 Write Skew는 여전히 발생 가능하다. 이를 완전히 막으려면 Serializable로 올려야 한다.
5. MySQL InnoDB의 MVCC
Undo Log 기반 MVCC
PostgreSQL이 새 버전을 제자리에 추가하는 방식이라면, InnoDB는 in-place update + undo log 방식이다:
- 실제 데이터 페이지에는 최신 버전만 저장.
- 이전 버전은 undo log에 저장.
- 오래된 트랜잭션이 구 버전을 읽어야 할 때, undo log를 따라가 재구성.
데이터 페이지:
[id=1, balance=200, DB_TRX_ID=150, DB_ROLL_PTR=→undo]
│
▼
Undo Log:
[id=1, balance=100, DB_TRX_ID=100]
장단점 비교
| 항목 | PostgreSQL | InnoDB |
|---|---|---|
| UPDATE 방식 | 새 행 삽입 + 구 행 표시 | 제자리 갱신 + undo log |
| VACUUM 필요 | 예 (bloat 관리) | 아니오 (undo log 자동 정리) |
| 인덱스 영향 | 모든 인덱스 업데이트 (HOT 제외) | 인덱스 값 안 바뀌면 클러스터 인덱스만 |
| 구 버전 조회 | 즉시 (같은 페이지) | undo log 따라가야 함 |
| 긴 트랜잭션 영향 | bloat 유발 | undo log 폭증 |
InnoDB Repeatable Read = Snapshot Isolation
InnoDB의 기본 격리 수준은 Repeatable Read이며, 트랜잭션 시작 시 consistent read view를 생성한다. 이는 Snapshot Isolation과 사실상 동일하다.
Next-Key Lock: Phantom 방지
InnoDB는 Next-Key Lock으로 Repeatable Read에서도 phantom을 방지한다:
-- id 컬럼 인덱스: 5, 10, 20
-- T1
BEGIN;
SELECT * FROM t WHERE id > 8 AND id < 15 FOR UPDATE;
-- 이 쿼리가 (5, 10], (10, 20) 범위에 락을 건다
-- T2
INSERT INTO t VALUES (12); -- 대기! (범위 락 때문)
Next-Key Lock은 인덱스 엔트리 + 그 앞의 갭에 락을 건다. 이를 통해 Repeatable Read + phantom 방지를 달성한다.
Gap Lock, Record Lock, Next-Key Lock
- Record Lock: 특정 인덱스 엔트리에 락.
- Gap Lock: 인덱스 엔트리들 사이의 "간격"에 락.
- Next-Key Lock: Record Lock + 그 앞의 Gap Lock.
이 셋의 조합으로 다양한 격리 수준을 구현한다.
6. Snapshot Isolation과 그 한계
Snapshot Isolation의 매력
Snapshot Isolation(SI)은 많은 DB의 "Repeatable Read" 또는 심지어 "Serializable"로 판매된다. 왜?
- 읽기가 빠르다: 스냅샷만 있으면 락 불필요.
- Read Skew 방지: 일관된 시점의 데이터를 본다.
- 구현 단순: MVCC 위에 바로 얹을 수 있다.
First-committer-wins (FCW) 규칙
SI에서 동일한 행을 여러 트랜잭션이 수정하려 하면, 먼저 커밋한 쪽이 이긴다. 다른 트랜잭션은 "쓰기 충돌" 에러로 반드시 롤백되어야 한다.
-- T1, T2 둘 다 같은 행 수정 시도
-- T1: UPDATE row SET ... WHERE id = 1
-- T2: UPDATE row SET ... WHERE id = 1
-- T1 먼저 커밋 → 성공
-- T2 커밋 시도 → ERROR: could not serialize access
애플리케이션은 이 에러를 잡아서 트랜잭션을 재시도해야 한다.
Write Skew: SI의 치명적 결함
앞서 본 의사 당직 예시가 바로 Write Skew다. 두 트랜잭션이 서로 다른 행을 수정하면 FCW도 적용되지 않는다. 결과적으로 불변식이 깨진다.
Write Skew의 공통 패턴:
- 트랜잭션이 데이터를 읽어서 조건을 확인.
- 조건에 따라 다른 행을 수정.
- 다른 트랜잭션이 같은 조건을 확인하고 같은 로직으로 수정.
- 두 트랜잭션 모두 성공하지만 전체 조건이 깨짐.
Phantom과 Write Skew의 관계
Phantom은 Write Skew의 특수한 경우다:
- 일반 Write Skew: 이미 존재하는 행을 읽음.
- Phantom Write Skew: 조건에 맞는 행이 "없음"을 읽음.
-- 예: 사용자명 고유성 보장
-- T1: 사용자명 "alice"가 없음을 확인 → 추가
-- T2: 사용자명 "alice"가 없음을 확인 → 추가
-- 결과: 같은 이름 두 번 삽입 (SI에서 가능!)
이를 방지하려면 실제 Serializable이 필요하거나, 유니크 제약조건에 의존해야 한다.
7. Serializable Snapshot Isolation (SSI)
문제 제기
Snapshot Isolation은 빠르지만 Write Skew를 허용한다. 진짜 Serializable은 안전하지만 락 때문에 느리다. 더 좋은 방법은 없을까?
2008년 Michael Cahill, Uwe Röhm, Alan Fekete의 논문 "Serializable Isolation for Snapshot Databases" 는 답을 제시했다: SSI (Serializable Snapshot Isolation).
SSI의 핵심 아이디어
"SI처럼 동작하되, 직렬화 불가능한 패턴을 감지해서 롤백한다."
트랜잭션 간 읽기/쓰기 의존성 그래프를 추적하고, 순환(cycle)이 감지되면 한쪽을 취소한다.
Dangerous Structure
Cahill 등은 "rw-dependency의 이중 사이클" 이 serializability를 깨는 유일한 패턴임을 증명했다:
T1 --rw--> T2 --rw--> T3
두 개의 연속된 rw 의존성(T1이 T2가 쓴 것을 읽지 않았는데, T2가 T1이 읽은 것을 썼다)이 있으면 위험하다. SSI는 이를 감지한다.
PostgreSQL의 SSI 구현
PostgreSQL 9.1부터 SERIALIZABLE = SSI로 구현되었다:
- 트랜잭션 시작 시 SI 스냅샷 획득.
- 읽기/쓰기 연산마다 predicate lock을 추적.
- 충돌 패턴 감지 시 한쪽을
ERROR: could not serialize access로 롤백. - 롤백된 트랜잭션은 애플리케이션이 재시도.
장점: 성능은 SI에 가깝고, 안전성은 Serializable. 단점: 롤백 처리 로직 필요, 직관과 다른 시점에 에러 발생.
사용 예시
-- PostgreSQL
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 의사 당직 예시
SELECT COUNT(*) FROM doctors WHERE on_call = true;
UPDATE doctors SET on_call = false WHERE name = 'Alice';
-- 동시 실행되는 다른 트랜잭션이 Bob을 수정하면
-- 커밋 시 둘 중 하나는 롤백된다:
-- ERROR: could not serialize access due to read/write dependencies
COMMIT;
애플리케이션 패턴:
def do_doctor_off(doctor_name):
while True:
try:
with db.transaction(isolation="serializable"):
count = db.execute("SELECT COUNT(*) FROM doctors WHERE on_call = true")
if count > 1:
db.execute("UPDATE doctors SET on_call = false WHERE name = %s", doctor_name)
else:
raise ValueError("Cannot go off, you're the only one on call")
return
except SerializationError:
continue # 재시도
8. 격리 수준별 실전 사용 가이드
Read Committed (기본값으로 충분한가?)
대부분의 웹 애플리케이션에서 기본값이다. 개별 쿼리가 일관성 있으면 OK인 경우:
- 단순 CRUD
- 사용자 프로필 조회/수정
- 블로그 포스팅
주의: Non-repeatable read가 허용되므로, 한 트랜잭션에서 같은 행을 두 번 읽는 코드는 조심해야 한다.
Repeatable Read / Snapshot Isolation
보고서 생성, 복잡한 조회에 적합:
- 대시보드 집계
- 긴 트랜잭션 동안 일관된 뷰가 필요할 때
- Read-heavy 분석 쿼리
주의: Write Skew 가능성을 인지하고, 중요한 불변식이 관련된다면 명시적 락이나 Serializable 고려.
Serializable (SSI)
정합성이 절대적으로 중요한 경우:
- 금융 거래
- 의사 당직, 비행기 예약 같은 리소스 할당
- 유니크 제약의 복잡한 조건
트레이드오프:
- 성능 저하 (롤백 오버헤드)
- 재시도 로직 필수
- 높은 동시성 환경에서 경쟁 격화
어떻게 선택할까?
- 기본적으로 Read Committed로 시작.
- 불일치 버그가 보이기 시작하면 해당 트랜잭션만 Serializable로 올림.
- 전체를 Serializable로 하지 말고, 핵심 경로만 선택적 적용.
9. Optimistic vs Pessimistic Locking
격리 수준 외에도 동시성 제어 전략이 있다.
Pessimistic Locking (비관적 락)
"충돌이 자주 일어날 것이다. 미리 막자."
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 락 획득
-- 계산...
UPDATE account SET balance = ... WHERE id = 1;
COMMIT; -- 락 해제
장점: 충돌 없음, 단순한 로직. 단점: 락 경쟁, 데드락, 긴 락 유지 시 성능 저하.
Optimistic Locking (낙관적 락)
"충돌은 드물다. 감지해서 재시도하자."
-- 읽기
SELECT balance, version FROM account WHERE id = 1; -- balance=100, version=5
-- 애플리케이션에서 계산
new_balance = 100 - 30
-- 쓰기 (version 체크)
UPDATE account
SET balance = 70, version = version + 1
WHERE id = 1 AND version = 5;
-- affected rows == 0이면 다른 트랜잭션이 먼저 수정 → 재시도
장점: 락 오버헤드 없음, 고동시성에 유리. 단점: 애플리케이션 재시도 로직 필요, 충돌 많으면 오히려 느림.
선택 가이드
| 상황 | 추천 |
|---|---|
| 읽기 많음, 쓰기 적음 | Optimistic |
| 쓰기 많음, 충돌 자주 | Pessimistic |
| 사용자 대기 시간 중요 | Optimistic |
| 재시도 로직 만들기 어려움 | Pessimistic |
| 분산 시스템 | Optimistic (락 전파 비용) |
10. 분산 트랜잭션과 격리
단일 DB 격리의 한계
지금까지 이야기는 단일 DB 내의 격리였다. 여러 DB나 서비스 간 트랜잭션은 어떻게 할까?
Two-Phase Commit (2PC)
전통적인 분산 트랜잭션 프로토콜:
- Prepare Phase: Coordinator가 모든 참여자에게 "준비됐니?" 질문.
- Commit Phase: 모두 준비됐으면 "커밋해" 명령, 아니면 "롤백" 명령.
문제점:
- Coordinator 장애 시 블로킹.
- 성능 저하 (2번의 네트워크 왕복).
- 참여자 수만큼 비가용성 증가.
Saga 패턴
2PC의 대안. 보상 트랜잭션(compensating transaction) 으로 실패를 복구:
Order Service: create_order(ID)
Payment Service: charge(ID)
Inventory: reserve(ID)
실패 시 역순으로:
Inventory: unreserve(ID)
Payment Service: refund(ID)
Order Service: cancel_order(ID)
장점: 블로킹 없음, 서비스 독립성. 단점: 보상 로직 구현 부담, 격리 수준 낮음 (중간 상태가 외부에 보일 수 있음).
Percolator / Spanner
Google의 분산 DB들은 진짜 Serializable을 위해:
- Percolator (Bigtable 위): 2PC + MVCC로 범용 분산 트랜잭션.
- Spanner: TrueTime API로 전역 시계 동기화 + 2PC + Paxos.
TiDB는 Percolator 모델을 오픈소스로 구현했다.
11. 실전 트러블슈팅
문제 1: "deadlock detected"
원인: 두 트랜잭션이 서로의 락을 기다림.
해결:
- 항상 같은 순서로 락을 획득 (예: id 오름차순).
- 락 유지 시간 단축 (짧은 트랜잭션).
SELECT ... FOR UPDATE SKIP LOCKED로 건너뛰기.- 재시도 로직 구현.
문제 2: "could not serialize access"
원인: SSI 롤백.
해결:
- 애플리케이션에서 재시도.
- 지수 백오프.
- 경쟁이 심한 경로는 명시적 락으로 전환 고려.
문제 3: 긴 트랜잭션으로 인한 bloat (PostgreSQL)
증상: 디스크 사용량 급증, 쿼리 느려짐.
원인: 오래 열려있는 트랜잭션이 VACUUM을 막음.
해결:
-- 오래 실행 중인 트랜잭션 확인
SELECT pid, state, query, xact_start, now() - xact_start AS duration
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY xact_start;
-- 수동 VACUUM
VACUUM (ANALYZE, VERBOSE) table_name;
예방:
idle_in_transaction_session_timeout설정.- 트랜잭션을 짧게 유지.
- 애플리케이션이 트랜잭션을 열어두고 유휴 상태로 두지 않기.
문제 4: Lost Update in Web Applications
증상: 사용자가 동시에 편집하면 한 명의 변경사항이 사라짐.
해결:
version컬럼 + optimistic locking.- 또는 CRDT 기반 협업 편집.
- 또는 사용자 피드백 ("누군가 먼저 수정했습니다. 병합하시겠습니까?").
퀴즈로 복습하기
Q1. PostgreSQL의 Repeatable Read와 SQL 표준의 Repeatable Read의 차이는?
A. SQL 표준에서 Repeatable Read는 Phantom Read를 허용한다. 그러나 PostgreSQL의 Repeatable Read는 Snapshot Isolation으로 구현되어 있어 Phantom도 방지한다. 단, Write Skew는 여전히 발생 가능하다. 이를 완전히 막으려면 Serializable로 올려야 한다.
Q2. Write Skew가 Lost Update와 다른 점은?
A. Lost Update는 같은 행을 두 트랜잭션이 수정해서 하나의 업데이트가 사라지는 것이다. First-committer-wins 규칙이나 원자적 UPDATE로 막을 수 있다. Write Skew는 서로 다른 행을 수정해서 쓰기 충돌은 없지만, 읽은 데이터에 기반한 결정이 전체적으로 불변식을 깨는 경우다. Snapshot Isolation으로는 막을 수 없으며 Serializable이 필요하다.
Q3. PostgreSQL과 InnoDB의 MVCC 구현상 가장 큰 차이는?
A. PostgreSQL은 새 버전을 테이블 자체에 추가한다(copy-on-write). 그래서 dead tuple이 쌓이고 VACUUM으로 정리해야 한다. InnoDB는 제자리 갱신 + undo log를 사용한다. 최신 버전은 데이터 페이지에, 과거 버전은 undo log에 저장된다. InnoDB는 VACUUM이 필요 없지만, 긴 트랜잭션이 undo log 폭증을 유발할 수 있다.
Q4. SSI (Serializable Snapshot Isolation)이 어떻게 성능을 유지하면서 Serializable을 달성하는가?
A. SSI는 기본적으로 Snapshot Isolation처럼 동작한다 (락 없음, 빠른 읽기). 대신 트랜잭션들의 읽기/쓰기 의존성을 추적한다. "직렬화 불가능한 이중 rw 사이클"이 감지되면 한 트랜잭션을 롤백시킨다. 이는 낙관적 접근이다: 대부분의 경우 충돌이 없으면 SI의 성능을 유지하고, 충돌 시에만 페널티가 발생한다.
Q5. SELECT ... FOR UPDATE가 MVCC 환경에서 왜 여전히 유용한가?
A. MVCC는 읽기 일관성을 제공하지만 쓰기 직전의 상태 확인은 보장하지 않는다. FOR UPDATE는 현재 커밋된 최신 버전에 행 락을 걸고 읽어온다. 이후 UPDATE가 이 버전에 기반해 이뤄지므로 Lost Update를 방지한다. Snapshot Isolation에서 Write Skew를 수동으로 막거나, Lost Update를 명시적으로 방지할 때 필요하다.
마치며: Isolation Level의 함정과 해법
핵심 교훈
-
"Repeatable Read"라는 이름은 믿지 마라. DB마다 다르게 구현되어 있다. 문서와 실제 동작을 확인하자.
-
Snapshot Isolation이 만능이 아니다. Write Skew는 미묘하지만 치명적이다. 불변식에 민감한 로직은 Serializable을 고려하자.
-
완벽한 Serializable은 비싸다. 성능이 중요하면 전체를 SI로 하고 위험 경로만 Serializable로 승격하는 전략이 실용적이다.
-
MVCC는 공짜가 아니다. PostgreSQL은 VACUUM, InnoDB는 undo log. 장기 트랜잭션은 양쪽 모두에 독이다.
-
격리 수준은 방어선의 한 겹. 유니크 제약, 외래 키, 애플리케이션 검증을 함께 활용하자.
실무 체크리스트
- 우리 팀 DB의 기본 격리 수준이 무엇인가?
- Write Skew가 발생할 수 있는 로직이 있는가?
- 긴 트랜잭션이 VACUUM/undo log에 부담을 주고 있는가?
- 재시도 로직이 필요한 격리 수준을 쓰고 있는가?
- 데드락 발생 시 감지 및 재시도가 준비되어 있는가?
분산 시스템의 어려움 중 많은 부분이 "데이터 일관성"이다. Isolation Level은 그 한복판에 있다. 이름 외우기보단 실제 동작을 이해하는 것이 버그를 막는다.
참고 자료
- A Critique of ANSI SQL Isolation Levels (Berenson et al., 1995) - 격리 수준의 허점
- Serializable Isolation for Snapshot Databases (Cahill et al., 2008) - SSI 원 논문
- PostgreSQL Concurrency Control - 공식 문서
- MySQL InnoDB Locking and Transaction Model
- Designing Data-Intensive Applications, Ch.7 - Martin Kleppmann의 명저
- Jepsen: PostgreSQL Analysis - 검증 리포트
- Hermitage: Isolation Level Test Suite - 각 DB의 실제 동작 비교