1편에서 본 t_xmin, t_xmax가 어떻게 동시성 제어에 쓰이는지, 왜 PostgreSQL은 행을 덮어쓰지 않고 여러 버전을 남겨두는지 알아보자.
이 글에서 다루는 것:
- MVCC (INSERT · UPDATE · DELETE의 실제 동작)
- CLOG(Commit Log), hint bits
- 이상현상 (dirty read · nonrepeatable read · phantom read · serialization anomaly), 쓰기 스큐 (write skew)
- 격리 수준 (Read Committed · Repeatable Read · Serializable)
MVCC의 핵심 아이디어
MVCC(Multi-Version Concurrency Control)는 한 문장으로 정리된다.
데이터를 덮어쓰지 말고, 새 버전을 만들고, 누구에게 어느 버전을 보여줄지는 트랜잭션 ID로 판단한다.
INSERT, UPDATE, DELETE가 실제로 무엇을 하는지 보자. 1편에서 본 demo 테이블 기준이다.
| 작업 | 실제 동작 |
| INSERT | 새 튜플을 추가. xmin = 현재 트랜잭션 ID, xmax = 0 |
| DELETE | 기존 튜플의 xmax에 현재 트랜잭션 ID를 기록. 물리적 삭제 아님 |
| UPDATE | 기존 튜플의 xmax를 채우고, 새 튜플을 추가. DELETE + INSERT와 동등 |
핵심은 어느 작업도 기존 데이터를 덮어쓰지 않는다는 점이다. 같은 행의 여러 버전이 같은 페이지 안에 공존한다. 1편에서 봤던 그 결과가 바로 이거다.

다른 데이터베이스(예: MySQL InnoDB)는 옛 버전을 별도의 undo log에 보관하고 테이블에는 최신 버전만 둔다. PostgreSQL은 옛 버전도 같은 테이블에 그대로 둔다. 이 선택은 읽기 성능에는 유리하지만 (옛 버전 재구성 비용 없음), dead tuple이 누적되어 VACUUM이라는 청소 과정이 필요해진다 — 이건 3편의 주제다.
스냅샷 — "지금 시점에 무엇이 보이는가"
옛 버전을 안 지운다면, 트랜잭션이 SELECT를 했을 때 어떤 버전을 보여줘야 할까? 답은 "이 트랜잭션이 시작된 시점에 커밋된 것까지만." 이 시점 정보를 스냅샷(snapshot) 이라고 부른다.
스냅샷은 사실 페이지 복사본 같은 게 아니라, 단순히 세 개의 숫자다.
| 필드 | 의미 |
| xmin | 가장 오래된 활성(아직 안 끝난) 트랜잭션 ID |
| xmax | 다음에 발급될 트랜잭션 ID (이보다 큰 트랜잭션은 "미래") |
| xip[] | xmin과 xmax 사이에서 아직 진행 중인 트랜잭션 ID 목록 |
쿼리로 직접 볼 수 있다.
SELECT pg_current_snapshot();

xmin=255869, xmax=255869, xip=[]
이 스냅샷을 가진 트랜잭션이 SELECT를 하면, 다음 규칙으로 각 튜플의 가시성을 판단한다.
튜플이 보이려면 두 조건을 모두 만족해야 한다:
1. 생성 확인: t_xmin이 커밋됐고, 스냅샷에 "끝난 트랜잭션"으로 들어와 있다.
2. 삭제 안 됨: t_xmax가 0이거나, 스냅샷 시점에 아직 커밋되지 않았다.
스냅샷이 255869 : 255869 :이라면:
- xmin= 255869 보다 작은 트랜잭션 → 모두 끝남 (이미 커밋되었거나 롤백됨)
- xmax= 255869 이상의 트랜잭션 → 미래의 일, 보이지 않음
- 그 사이에서는 xip에 포함되면 진행 중, 아니면 끝남 (현재 빈 값 => 진행 중인 트랜잭션이 없음)
그래서 어떤 튜플의 t_xmin = 255870 이라면 "그 트랜잭션은 아직 진행 중이니 이 튜플은 보이지 않는다"가 된다.
t_xmin = 255868 라면 "이미 끝났고 커밋됐다면 보인다"가 된다.
CLOG(Commit Log) — 트랜잭션의 커밋 여부를 기록하는 곳
튜플 헤더의 xmin/xmax는 트랜잭션 ID만 알려줄 뿐, 그 트랜잭션이 커밋됐는지 롤백됐는지는 기록하지 않는다.
이 정보를 저장하는 곳이 CLOG(Commit Log)다. 디스크의 pg_xact 디렉터리에 파일로 저장되며, 모든 트랜잭션 ID에 대해 2비트씩 상태를 기록한다.
| 상태 | 의미 |
| IN_PROGRESS | 아직 실행 중 |
| COMMITTED | 커밋됨 |
| ABORTED | 롤백됨 |
| SUB_COMMITTED | 서브트랜잭션이 커밋됨 (부모 트랜잭션의 최종 상태에 따라 결정) |
트랜잭션 ID를 입력하면 해당 2비트를 읽어 상태를 반환하는, 단순한 배열 구조다. CLOG는 shared memory에도 캐시되어 매번 디스크를 읽지는 않지만, 트래픽이 많은 시스템에서 모든 튜플마다 CLOG를 조회하면 여전히 부담이 된다.
PostgreSQL은 이 부담을 줄이기 위해 hint bits를 쓴다.
Hint Bits — CLOG 조회를 건너뛰는 캐시

어떤 튜플에 대해 한 번 CLOG를 확인해 커밋/롤백 여부를 알아내면, 그 결과를 튜플 헤더의 t_infomask에 비트로 기록한다. 이게 hint bits다.
| Hint Bit | 의미 |
| HEAP_XMIN_COMMITTED | xmin 트랜잭션이 커밋됐다 |
| HEAP_XMIN_INVALID | xmin 트랜잭션이 롤백됐다 |
| HEAP_XMAX_COMMITTED | xmax 트랜잭션이 커밋됐다 |
| HEAP_XMAX_INVALID | xmax 트랜잭션이 롤백됐다 (또는 xmax = 0) |
다음번에 같은 튜플을 읽을 땐 이 비트만 보면 된다. CLOG 조회를 건너뛸 수 있다.
함정이 하나 있다. hint bit를 쓰는 것 자체가 페이지를 dirty로 만든다. 단순한 SELECT가 디스크에 쓰기를 유발할 수 있다는 뜻이다. 처음 SELECT할 때 디스크 I/O가 예상보다 많이 발생하는 이유 중 하나다.
격리 수준 — 무엇을 막을 것인가
여기까지가 MVCC의 메커니즘이다. SQL 표준의 격리 수준(isolation level)은 사실 PostgreSQL 입장에서는 "스냅샷을 언제 찍을 것인가" + "추가 검증을 얼마나 할 것인가" 의 차이다.
격리 수준을 이해하려면 먼저 무엇을 막으려고 하는가 — 동시 트랜잭션이 만들어내는 네 가지 이상현상(phenomenon)을 알아야 한다. SQL 표준이 정의하는 네 가지는 다음과 같다.
| 이상 현상 | 정의 |
| dirty read | 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는다 |
| nonrepeatable read | 같은 행을 다시 읽었을 때 값이 바뀌어 있다 (다른 트랜잭션이 그 사이 커밋함) |
| phantom read | 같은 조건으로 다시 조회했을 때 행 집합이 달라져 있다 (새 행이 추가/삭제됨) |
| serialization anomaly | 여러 트랜잭션이 모두 커밋된 결과가, 어떤 직렬 실행 순서로도 만들어질 수 없다 |
격리 수준은 이 중 어떤 것을 허용하고 어떤 것을 막는지의 약속이다.
| 격리 수준 | dirty read | nonrepeatable read | phantom read | serialization anomaly |
| Read Uncommitted | 표준상 허용, Postgres에서는 막힘 |
가능 | 가능 | 가능 |
| Read Committed (Postgres 기본) |
막힘 | 가능 | 가능 | 가능 |
| Repeatable Read | 막힘 | 막힘 | 표준상 허용, Postgres에서는 막힘 |
가능 |
| Serializable | 막힘 | 막힘 | 막힘 | 막힘 |
PostgreSQL의 특이점 두 가지:
- Read Uncommitted를 요청해도 Read Committed처럼 동작한다. MVCC 구조상 dirty read를 만들어낼 방법이 애초에 없다. 옛 버전을 따로 보지, 미커밋 새 버전이 보일 일이 없으니까.
- Repeatable Read는 표준이 허용하는 phantom read도 막는다. 표준은 "최소한 무엇을 막아야 한다"만 정의하므로, 더 강하게 막는 건 허용된다.
각 격리 수준의 동작 방식을 알아보자.
Read Committed — 매 문(statement)마다 새 스냅샷
기본값. 매 SQL 문을 실행할 때마다 새 스냅샷을 찍는다. 그래서 같은 트랜잭션 안에서 같은 SELECT를 두 번 해도 결과가 다를 수 있다 — 그 사이에 다른 트랜잭션이 커밋했다면. 이게 nonrepeatable read다.
-- 트랜잭션 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 100
-- (이때 트랜잭션 B가 balance를 200으로 UPDATE하고 COMMIT)
SELECT balance FROM accounts WHERE id = 1; -- 200 ← 같은 트랜잭션인데 값이 다름
COMMIT;
대부분의 OLTP 워크로드에서는 이 정도면 충분하고, 락 경합도 적어서 가장 빠르다.
Repeatable Read — 트랜잭션 시작 시 한 번만 스냅샷
트랜잭션의 첫 문에서 스냅샷을 한 번만 찍고, 트랜잭션 내내 그것만 쓴다. 같은 SELECT는 항상 같은 결과를 반환한다. nonrepeatable read와 phantom read 모두 막힌다.
대신 쓰기에서 충돌이 생기면 트랜잭션이 롤백된다. 같은 행을 다른 트랜잭션이 이미 수정해 커밋했다면, 다음 에러가 난다.
ERROR: could not serialize access due to concurrent update
이 에러를 받으면 애플리케이션이 트랜잭션 전체를 재시도해야 한다. Repeatable Read 이상을 쓰려면 재시도 로직은 필수다.
자세한 예시는 바로 아래 "쓰기 스큐 (Write Skew) — Repeatable Read로는 못 막는 이상현상" 에서 다룬다.
Serializable — Repeatable Read + 직렬화 검증
Repeatable Read와 같은 스냅샷 정책을 쓰지만, 추가로 트랜잭션 간 읽기/쓰기 의존성을 추적하는 SSI(Serializable Snapshot Isolation)를 돌린다. 어떤 트랜잭션 조합이 직렬 실행으로는 만들 수 없는 결과를 만들고 있다면, 한 트랜잭션을 다음 에러로 롤백한다.
ERROR: could not serialize access due to read/write dependencies among transactions
여기서 막히는 게 바로 serialization anomaly다. 가장 대표적인 사례가 쓰기 스큐(write skew) 다.
쓰기 스큐 (Write Skew) — Repeatable Read로는 못 막는 이상현상
쓰기 스큐는 두 트랜잭션이 각자 다른 행을 수정하지만, 둘 다 같은 데이터를 읽고 그 데이터에 의존해서 결정을 내리는 상황에서 발생한다. 락도 안 걸리고, 같은 행을 동시에 수정하지도 않으니, Repeatable Read는 둘 다 통과시킨다. 그런데 결과가 직렬 실행으로는 불가능한 상태가 된다.
예시: 응급실 당직 의사
응급실 규칙 — "최소 한 명의 의사는 항상 당직(on-call) 상태여야 한다." 현재 Alice와 Bob 두 명이 당직 중이다.
CREATE TABLE doctors (name text, on_call boolean);
INSERT INTO doctors VALUES ('Alice', true), ('Bob', true);
Alice와 Bob이 동시에 "다른 사람이 있으니 나는 빠지겠다"고 판단하는 시나리오:
-- 트랜잭션 A (Alice)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true; -- 2명, 괜찮음
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;
-- 트랜잭션 B (Bob) — A와 거의 동시에
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true; -- 2명, 괜찮음 (A는 아직 미커밋)
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
두 트랜잭션은 서로 다른 행(Alice vs Bob)을 수정한다. 같은 행을 수정한 게 아니므로 Repeatable Read는 둘 다 통과시킨다.
결과: 당직 의사 0명. 어떤 직렬 순서로 돌렸더라도 이 결과는 나올 수 없다. 두 번째로 도는 트랜잭션은 count를 1로 봤을 테니.
여기서 격리 수준을 SERIALIZABLE로 바꾸면 PostgreSQL이 두 트랜잭션이 같은 데이터(on_call = true 행 집합)를 읽고 서로의 결정에 영향을 주는 상황을 감지하고, 둘 중 하나를 롤백한다.
ERROR: could not serialize access due to read/write dependencies among transactions
내부적으로는 predicate locking이 동작한다. SELECT가 읽은 행/페이지에 SIRead lock(다른 트랜잭션을 차단하지 않는 가벼운 추적용 lock)을 걸어두고, 그 범위에 다른 트랜잭션이 쓰기를 했는지를 본다. pg_locks에서 mode = 'SIReadLock'으로 확인할 수 있다.
격리 수준 선택 가이드
- Read Committed (기본): 단순 OLTP, 행 단위 갱신이 대부분. 락 경합 적고 빠름.
- Repeatable Read: 같은 트랜잭션 안에서 일관된 스냅샷이 필요한 리포트성 쿼리. 재시도 로직 필요.
- Serializable: 읽기-결정-쓰기 패턴이 있는 비즈니스 규칙(잔액, 재고, 당직, 예약 등). 재시도 로직 + SSI 오버헤드 감수.
Serializable은 락 없이 격리를 보장한다는 점이 매력이지만, 공짜는 아니다. 직렬화 충돌이 발생하면 트랜잭션이 롤백되니, 모든 트랜잭션이 재시도 가능하도록 설계되어야 하고, 충돌 빈도가 높으면 처리량이 오히려 떨어진다. "이 비즈니스 규칙을 명시적 락이나 SELECT FOR UPDATE 없이 선언적으로 보장하고 싶다"가 Serializable의 진짜 목적이다.
남는 문제: dead tuple은 누가 치우나
여기까지가 MVCC의 작동 원리다. 정리하면 PostgreSQL은:
- 행을 덮어쓰지 않고 새 버전을 만든다.
- 각 트랜잭션은 스냅샷을 기준으로 어느 버전이 보이는지 판단한다.
- 커밋 여부는 CLOG에 기록되고, hint bits로 캐싱된다.
- 격리 수준에 따라 스냅샷을 찍는 시점이 다르다.
그런데 옛 버전들은 계속 쌓인다. 시간이 지나면 어떤 트랜잭션도 그 옛 버전을 더 이상 보지 않게 되는데, PostgreSQL은 이걸 알면서도 즉시 지우지 않는다. 누가, 언제, 어떻게 지울까?
답은 VACUUM이다. 4편에서 다룬다.
다음 편(3편)에서는 MVCC만으로 동시성 제어가 불가능한 상황을 알아보고, 이를 해결하는 Locking을 다룬다.
'데이터베이스 > PostgreSQL 내부 구조' 카테고리의 다른 글
| PostgreSQL 내부구조 (6) ㅡ Index (0) | 2026.07.18 |
|---|---|
| PostgreSQL 내부 구조 (5) — WAL과 Checkpoint, 장애 복구의 핵심 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 (4) — VACUUM, 옛 버전은 누가 치우는가 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 (3) — Locking, 동시성의 또 다른 축 (1) | 2026.04.18 |
| PostgreSQL 내부 구조 (1) — 페이지와 Shared Buffers (1) | 2026.04.18 |
| PostgreSQL 기본 (0) — 연결과 프로세스 구조 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 — 시리즈를 시작하며 (0) | 2026.04.16 |