본문 바로가기

데이터베이스/Postgres

[PostgreSQL] MVCC 완전 정복

설계 배경 — 읽기와 쓰기가 서로를 막지 않아야 한다

전통적인 동시성 제어 방식인 2PL(Two-Phase Locking)은 명쾌하다. 읽기는 공유 락을, 쓰기는 배타 락을 잡는다. 충돌하는 연산은 서로를 기다리게 만들어 실행 순서를 강제로 직렬화한다. 그러나 실제 운영 환경에서는 치명적인 문제가 드러난다. 읽기가 쓰기를 막고, 쓰기가 읽기를 막으면서 트래픽이 조금만 몰려도 대기 체인이 폭발적으로 늘어난다. 오래 걸리는 리포트 쿼리 하나가 OLTP(Online Transaction Processing, 짧고 빈번한 쓰기·읽기가 뒤섞인 실시간 거래 처리) 쓰기 전체를 멈추게 하는 장애가 반복됐다.

 

PostgreSQL의 전신인 Berkeley POSTGRES는 1980년대 후반 이 문제에 근본적으로 다른 해답을 제시했다. 해답은  MVCC(Multi-Version Concurrency Control)이다. 데이터를 제자리에서 덮어쓰지 않고 새 버전을 나란히 저장함으로써 읽기와 쓰기가 서로 다른 버전을 동시에 바라볼 수 있게 하는 방식이다. 핵심 결과는 단순하고 강력하다.

PostgreSQL에서 SELECT는 어떤 락도 잡지 않는다.

 

읽기 트랜잭션이 아무리 오래 실행돼도 쓰기를 막지 않는다. 쓰기 트랜잭션이 진행 중이어도 읽기는 그 이전 버전을 조용히 읽어간다. 대신 MVCC는 트레이드오프를 동반한다. UPDATEDELETE가 실행될 때마다 구버전 튜플이 힙 페이지에 남는다. 이를 dead tuple이라 부르며, 주기적으로 회수하지 않으면 테이블이 끝없이 부풀어 오른다. 이 회수 작업이 VACUUM이고, dead tuple이 어떻게 만들어지는지가 이번 편의 핵심이다.

 

참고로 InnoDB(MySQL)도 MVCC를 사용하지만 구현 방식이 다르다. InnoDB는 언두 로그(undo log)에 이전 버전을 기록하고, 최신 버전만 테이블 파일에 유지한다. 언두 로그를 읽어야 이전 스냅샷을 재구성할 수 있다. PostgreSQL은 모든 버전을 힙 파일 안에 나란히 저장한다. 이 차이가 VACUUM이 필요한 이유이기도 하고, PostgreSQL이 롤백을 거의 즉시 완료하는 이유이기도 하다. InnoDB의 롤백은 언두 로그를 역으로 적용해야 하므로 변경량에 비례하는 시간이 든다.

 

MVCC 구현 방식은 세 가지 대표 축으로 나뉜다.

  • PostgreSQL 방식(append-only heap): 모든 버전을 힙에 저장, VACUUM 필요.
  • InnoDB 방식(delta store): 최신 버전만 테이블에, 이전 버전은 언두 로그.
  • MSSQL/Oracle 방식: 별도 버전 저장소(tempdb/undo tablespace).

PostgreSQL은 가장 단순한 구현 덕분에 읽기 경로가 직관적이지만, dead tuple 관리라는 운영 부담을 동반한다.


핵심 개념과 용어 정의

MVCC의 가시성 판단은 몇 개의 숫자 비교로 이루어진다. 그 숫자들을 정확히 이해해야 한다.

트랜잭션 ID(XID)

PostgreSQL은 트랜잭션이 데이터를 변경하기 시작할 때 단조 증가하는 32비트 정수를 부여한다. 이것이 트랜잭션 ID(XID, Transaction ID)다. 단순한 읽기 트랜잭션에는 XID가 할당되지 않는다. BEGIN; SELECT ...; COMMIT;처럼 쓰기가 없는 트랜잭션은 내부적으로 가상 XID(virtual XID)로 동작한다. 가상 XID는 프로세스 ID + 시퀀스 번호 형태로 실제 XID 공간을 소모하지 않는다.

-- 읽기 전용 트랜잭션은 XID가 할당되지 않음
BEGIN;
SELECT pg_current_xact_id_if_assigned();  -- NULL
SELECT 1;
COMMIT;

-- 쓰기가 포함되면 XID가 할당됨
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (99, 100);
SELECT pg_current_xact_id_if_assigned();  -- 실제 XID 반환
ROLLBACK;

실제 XID는 힙 튜플의 xmin / xmax 헤더 필드에 기록되어 MVCC 가시성 판단의 기준이 된다. 가상 XID는 이 헤더에 기록되지 않는다.

 

XID 공간과 예약 값

 

32비트이므로 최대 약 42억 개의 XID가 존재한다. 하위 3개는 특수 목적으로 예약되어 있다.

 

예약된 XID 값은 다음과 같다.

XID 이름 용도
0 InvalidTransactionId 유효하지 않음을 표시하는 sentinel 값
1 BootstrapTransactionId initdb 시 시스템 카탈로그 초기화에만 사용
2 FrozenTransactionId FREEZE 처리된 튜플의 xmin에 기록되는 특수 값. 항상 과거로 판단됨

일반 트랜잭션은 XID 3부터 시작한다.

 

모듈러 산술과 순환(wraparound)

PostgreSQL은 이 공간을 순환(wraparound)해서 재사용한다. 이 때문에 "이 XID가 현재보다 과거인가 미래인가"를 단순 크기 비교(tgt > cur)로 판단할 수 없다. XID가 42억을 넘어 0으로 돌아온 뒤에는 작은 숫자가 오히려 미래일 수 있기 때문이다.

 

PostgreSQL은  "이 XID가 현재보다 과거인가 미래인가"를 판단할 때 단순 크기 비교가 아닌 모듈러 산술(modular arithmetic: 수 공간을 원형으로 취급해 "이전/이후"를 절댓값이 아닌 상대적 거리로 판단하는 방식)을 사용해 문제를 해결한다.

 

XID 공간을 원형으로 취급해, 현재 XID(C)에서 시계 방향으로 2^31(약 21억) 이내에 있으면 미래, 반시계 방향 2^31이내이면 과거로 판단한다. 절댓값이 아닌 상대적 위치로 결정하므로 wraparound 이후에도 올바르게 동작한다.

실제 PostgreSQL 소스(transam/transam.c)의 판단 로직은 다음과 같다.

/* id1이 id2보다 이전(과거)인가? */
static inline bool TransactionIdPrecedes(TransactionId id1, TransactionId id2)
{
    int32 diff = (int32)(id1 - id2);
    return (diff < 0);
}

diff < 0이면 id1이 id2의 과거다. 부호 있는 32비트 정수의 오버플로우를 그대로 이용해 모듈러 산술을 구현한다. wraparound 직후 xmin=100, 현재 XID=2,147,483,748인 상황에서는 (int32)(100 - 2147483748)이 양수로 오버플로우되므로 diff > 0 -> false가 반환되어 id1(xmin=100)이 미래로 판단된다. 이 때 FREEZE가 필요해진다.

 

 

FREEZE

wraparound 문제

XID는 42억에 도달하면 다시 3으로 돌아온다. 이 순환이 문제가 되는 시점은 현재 XID와 오래된 튜플의 xmin 사이 거리가 2³¹(약 21억)을 초과할 때다.

테이블 생성 직후: xmin = 100 (과거로 판단 → 정상 조회)

XID가 계속 증가해서 현재 XID = 2,147,483,748 (= 100 + 2³¹ + 100)
→ 모듈러 산술 기준으로 xmin=100이 갑자기 "미래"처럼 보임
→ SELECT 쿼리에서 해당 row가 사라져 보이는 데이터 유실 발생

이것이 XID wraparound 문제다. 실제로 2019년 Cloudflare에서 이 문제로 약 27시간 장애가 발생했다.

 

아래 그림은 wraparound가 발생하는 순간 xmin=100 튜플의 가시성이 뒤집히는 과정을 보여준다.

FrozenTransactionId(=2)

VACUUM FREEZE는 오래된 튜플의 xmin을 FrozenTransactionId(=2)로 교체한다. XID 2는 모든 일반 트랜잭션(XID ≥ 3)보다 항상 과거로 판단되도록 PostgreSQL 내부적으로 하드코딩된 특수 값이다. 한 번 Freeze된 튜플은 이후 어떤 XID가 현재가 되어도 가시성이 뒤집히지 않는다.

-- Freeze 전후 xmin 확인
SELECT ctid, xmin, xmax FROM my_table WHERE id = 1;
-- xmin: 100 (일반 XID)

VACUUM FREEZE my_table;

SELECT ctid, xmin, xmax FROM my_table WHERE id = 1;
-- xmin: 2 (FrozenTransactionId)

VACUUM이 Freeze를 수행하는 시점

VACUUM은 두 가지 GUC 파라미터로 Freeze 대상을 결정한다.

파라미터 기본값 의미
vacuum_freeze_min_age 50,000,000 이 age보다 오래된 튜플만 Freeze 대상
vacuum_freeze_table_age 150,000,000 테이블의 relfrozenxid age가 이 값을 초과하면 테이블 전체 스캔

age는 현재 XID - 해당 XID로 계산된다. pg_stat_user_tables의 relfrozenxid는 "이 테이블에서 이 XID보다 작은 xmin은 모두 Freeze 완료"를 의미하는 하이워터마크다.

 

아래는 VACUUM Freeze의 판단 흐름이다.

Freeze 상태 모니터링

운영 환경에서는 relfrozenxid의 age를 주기적으로 확인해야 한다.

-- 테이블별 relfrozenxid age 확인
SELECT
    schemaname,
    relname,
    age(relfrozenxid)              AS xid_age,
    pg_size_pretty(pg_total_relation_size(relid)) AS size
FROM pg_stat_user_tables
ORDER BY age(relfrozenxid) DESC
LIMIT 20;

-- 데이터베이스 전체에서 가장 오래된 XID
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;

경보 기준은 대체로 다음과 같이 잡는다.

age 범위 상태 대응
< 1억 안전 정상 운영
1억 ~ 15억 주의 VACUUM 스케줄 점검
15억 ~ 20억 경고 즉시 VACUUM FREEZE 수동 실행
> 20억 위험 PostgreSQL이 자동으로 쓰기 트랜잭션 차단 시작

PostgreSQL은 age가 약 **20억(autovacuum_freeze_max_age)**을 초과하면 해당 테이블에 강제로 autovacuum을 실행한다. 그래도 처리가 늦으면 약 21억에서 새 트랜잭션을 아예 거부하고 다음 오류를 발생시킨다.

ERROR: database is not accepting commands to avoid wraparound data loss
HINT: Stop the postmaster and vacuum that database in single-user mode.

 

이 상태가 되면 읽기 전용 쿼리만 허용되며, 단일 사용자 모드로 VACUUM FREEZE를 완료해야 복구된다.

서브트랜잭션과 SAVEPOINT

SAVEPOINT로 시작되는 서브트랜잭션(subtransaction)은 부모 트랜잭션과 별개의 XID를 부여받는다. 서브트랜잭션의 상태는 $PGDATA/pg_subtrans/에 별도로 기록된다.

BEGIN;
SELECT pg_current_xact_id();  -- 예: 800 (부모 XID)

SAVEPOINT sp1;
INSERT INTO orders (user_id, amount) VALUES (10, 500);
SELECT pg_current_xact_id();  -- 801 (서브트랜잭션 XID)

SAVEPOINT sp2;
INSERT INTO orders (user_id, amount) VALUES (11, 600);
SELECT pg_current_xact_id();  -- 802 (또 다른 서브트랜잭션 XID)

ROLLBACK TO SAVEPOINT sp1;
-- 서브트랜잭션 801, 802가 ABORTED 처리됨
-- 부모 트랜잭션 800은 유지됨

COMMIT;

ROLLBACK TO SAVEPOINT sp1은 서브트랜잭션 801과 802의 상태를 ABORTED로 표시한다. 이후 이 서브트랜잭션들이 삽입한 튜플(xmin이 각각 801, 802인 튜플)은 가시성 판단에서 "삽입 트랜잭션이 중단됨"으로 처리되어 보이지 않는다. 부모 트랜잭션 XID 800은 COMMITTED로 유지되므로, sp1 이전의 변경은 커밋 후 보인다.

 

서브트랜잭션이 깊이 중첩되거나 많아지면 pg_subtrans 조회 비용이 늘어난다. PostgreSQL은 최근 서브트랜잭션 상태를 공유 메모리에 캐싱하지만, 중첩 깊이가 지나치게 깊어지면 성능에 영향을 줄 수 있다. 실제로 PL/pgSQL에서 EXCEPTION 블록을 사용하면 내부적으로 SAVEPOINT가 생성되어 서브트랜잭션 XID를 소모한다. EXCEPTION 블록이 반복문 안에 있으면 루프 한 번마다 XID가 소모되므로 주의가 필요하다.

 

가시성 판단에서 서브트랜잭션 XID의 처리는 일반 XID와 동일하다. pg_subtrans에서 부모 XID를 확인하고, 부모 트랜잭션이 COMMITTED이면 서브트랜잭션도 커밋된 것으로 판단한다(서브트랜잭션 자체가 롤백되지 않은 경우).

 

튜플 헤더의 트랜잭션 시스템 컬럼

앞선 포스팅에서 살펴본 HeapTupleHeaderData 구조체에는 네 개의 트랜잭션 관련 필드가 박혀 있다.

컬럼 저장 크기 의미
xmin 4바이트 이 튜플 버전을 생성한 트랜잭션의 XID
xmax 4바이트 이 튜플 버전을 삭제하거나 갱신한 트랜잭션의 XID. 유효하지 않으면 0
cmin 4바이트 같은 트랜잭션 내에서 이 튜플을 생성한 명령의 순번(0부터 시작)
cmax 4바이트 같은 트랜잭션 내에서 이 튜플을 삭제·갱신한 명령의 순번

 

실제 구조체에서 cmincmaxt_field3이라는 단일 4바이트 필드를 공유한다. 하나의 튜플이 같은 트랜잭션 내에서 생성과 삭제가 동시에 발생하는 경우는 없다고 가정하기 때문이다. 트랜잭션 안에서 삽입 후 다시 삭제한 경우(예: INSERT 후 같은 트랜잭션에서 DELETE) 처럼 두 값이 모두 필요한 특수한 경우에는 combocid 해시 테이블에 (cmin, cmax) 쌍을 별도로 인코딩한다. 서브트랜잭션 없이도 발생할 수 있다.

 

xmax에 XID가 기록돼 있다고 해서 반드시 "삭제·갱신이 완료된 버전"인 것은 아니다. 그 트랜잭션이 롤백됐다면 xmax에 XID가 있어도 이 튜플은 여전히 유효한 버전이다. 가시성 판단에서 커밋 여부 확인이 반드시 필요한 이유다.

스냅샷(Snapshot)

트랜잭션이 데이터를 읽을 때 PostgreSQL은 스냅샷(Snapshot)을 기준으로 각 튜플 버전의 가시성을 판단한다. 스냅샷은 "이 순간에 어떤 트랜잭션이 완료됐는가"를 기록한 메타데이터다.

SnapshotData 구조체의 핵심은 세 값이다.

  • xmin: 스냅샷 생성 시점에 실행 중인 트랜잭션들 중 가장 오래된(가장 작은) XID. 이보다 작은 XID의 트랜잭션은 이미 완료(커밋 또는 중단)됐다. 커밋된 것이라면 그 튜플은 보인다.
  • xmax: 스냅샷 생성 시점에 아직 시작되지 않은 트랜잭션의 첫 XID. 이보다 크거나 같은 XID가 만든 튜플은 절대 안 보인다.
  • xip[]: xmin 이상 xmax 미만 범위에서 아직 진행 중인 트랜잭션들의 XID 목록. 이 목록에 있는 XID의 변경은 안 보인다.

READ COMMITTED에서는 매 쿼리 실행마다 GetTransactionSnapshot()이 새 스냅샷을 찍는다. REPEATABLE READSERIALIZABLE에서는 트랜잭션의 첫 번째 데이터 접근 시점에 FirstSnapshotSet 플래그가 설정되고, 이후 모든 읽기가 같은 스냅샷을 공유한다.

커밋 로그(pg_xact)

각 XID의 커밋 상태(IN_PROGRESS / COMMITTED / ABORTED / SUB_COMMITTED)는 $PGDATA/pg_xact/ 디렉터리의 파일에 XID당 2비트로 저장된다. XID를 2비트로 인코딩하므로 하나의 8KB 페이지에 32768개 XID의 상태가 들어간다. 최근에 사용된 페이지들은 공유 메모리의 CLOG 버퍼(Commit Log 버퍼의 약칭. pg_xact 파일의 내용을 빠르게 읽기 위한 공유 메모리 캐시로, PostgreSQL 10 이전 버전에서는 디렉터리 이름 자체가 pg_clog였다)에 캐싱된다.

 

한번 확인한 커밋 상태는 튜플 헤더의 hint bit(infomask 비트)에도 기록해둔다. 다음 접근 때는 infomask를 먼저 보고, hint bit가 설정되어 있으면 pg_xact를 조회하지 않아도 된다. hint bit 기록은 트랜잭션 없이 페이지를 dirty로 만드는 유일한 경우여서 WAL(Write-Ahead Log — 데이터 변경 전에 로그를 디스크에 먼저 기록해 내구성을 보장하는 메커니즘)을 별도로 쓰지 않는다. 단, full_page_writes=on(체크포인트 이후 처음 수정되는 페이지를 WAL에 통째로 기록해 부분 기록 장애를 방지하는 설정) 환경에서 체크포인트(더티 페이지를 디스크에 flush해 복구 기점을 확정하는 작업) 후 첫 hint bit 기록 시에는 전체 페이지가 WAL에 쓰인다.


내부 구조 — 버전 체인과 infomask

튜플 버전 체인

UPDATE가 실행되면 PostgreSQL은 기존 튜플의 xmax를 현재 XID로 채우고 힙 페이지에 새 튜플을 추가한다. 두 버전은 헤더의 t_ctid 포인터로 연결되어 버전 체인(version chain)을 형성한다. DELETE의 경우 새 버전 삽입 없이 기존 튜플의 xmax만 채운다. 최신 버전의 t_ctid는 자기 자신을 가리킨다.

v1과 v2는 dead tuple이다. 이들을 볼 수 있는 스냅샷이 더 이상 존재하지 않으면 VACUUM이 회수한다. 그전까지는 물리적으로 페이지를 점유하고, 순차 스캔 시마다 가시성 판단 코드를 통과시켜야 하는 CPU 오버헤드를 유발한다.

infomask — 가시성 정보 캐시

각 튜플 헤더에는 16비트 t_infomask 필드가 있다. 이 필드는 여러 목적으로 사용되며, 그중 중요한 비트들은 다음과 같다.

비트 (hex) 이름 의미
0x0100 HEAP_XMIN_COMMITTED xmin이 커밋됐음이 확인됨(hint bit)
0x0200 HEAP_XMIN_INVALID xmin이 중단됐거나 무효임(hint bit)
0x0400 HEAP_XMAX_COMMITTED xmax가 커밋됐음이 확인됨(hint bit)
0x0800 HEAP_XMAX_INVALID xmax가 무효임 — 삭제·갱신 없음(hint bit)
0x2000 HEAP_UPDATED 이 튜플이 UPDATE의 결과로 생성됨
0x0080 HEAP_XMAX_LOCK_ONLY xmax가 실제 삭제·갱신이 아닌 락 전용

 

HEAP_XMAX_INVALID가 설정된 튜플은 xmax가 0이거나 중단된 경우다 — 가시성 판단에서 pg_xact 조회 없이 "보임"으로 빠르게 결정할 수 있다. HEAP_XMIN_COMMITTEDHEAP_XMAX_COMMITTED가 모두 설정된 튜플은 스냅샷과 xmax를 비교해 가시성을 최종 확정한다. 이 두 hint bit가 있으면 pg_xact 조회는 생략되지만, 스냅샷 비교 자체는 여전히 필요하다.

스냅샷의 물리적 구조

xip[]에 들어있는 XID(210, 230, 280)는 스냅샷이 찍힌 이후 커밋되더라도 이 스냅샷을 보유한 트랜잭션에게는 보이지 않는다. 스냅샷은 찍힌 순간의 세계를 고정한다. 활성 트랜잭션이 많을수록 xip[] 크기가 커지고, 스냅샷 생성과 비교에 드는 비용이 증가한다. 이것이 동시 연결이 매우 많을 때 스냅샷 오버헤드가 문제가 되는 이유다.

 

GetSnapshotData() 함수(src/backend/storage/ipc/procarray.c)가 스냅샷을 생성할 때, 공유 메모리의 ProcArray(각 백엔드 프로세스의 XID, backend_xmin 등 상태를 담은 공유 배열)를 순회하며 모든 활성 백엔드의 XID를 수집한다. 이 과정에서 ProcArrayLock(ProcArray 접근을 직렬화하는 경량 잠금, LWLock)을 공유 모드로 잡는다. 동시 활성 백엔드가 수백 개 이상으로 많아지면 이 락의 경쟁으로 스냅샷 생성 지연이 눈에 띄게 늘어날 수 있다. PgBouncer 같은 커넥션 풀러를 써서 실제 백엔드 수를 줄이는 것이 중요한 이유 중 하나다.

Visibility Map과 MVCC의 연결

MVCC가 만들어내는 dead tuple 상황을 효율적으로 관리하기 위해 PostgreSQL은 힙 파일과 나란히 Visibility Map(VM) 파일을 유지한다. VM은 각 힙 페이지에 대해 두 비트를 기록한다. "이 페이지의 모든 튜플이 현재 살아있는 모든 스냅샷에 보이는가(all-visible)" 그리고 "이 페이지의 모든 튜플이 FREEZE 처리됐는가(all-frozen)"이다.

 

all-visible 비트가 설정된 페이지는 가시성 판단 없이 바로 데이터를 읽을 수 있어 순차 스캔이 빨라진다. 더 중요하게는 Index-Only Scan이 가능해진다. 인덱스가 커버링 인덱스(covering index — 쿼리가 필요로 하는 모든 컬럼을 포함해 힙 페이지 접근 없이 결과를 반환할 수 있는 인덱스)라면 힙을 아예 읽지 않고 인덱스만으로 답을 낼 수 있는데, 이때 각 행이 현재 트랜잭션에 보이는지를 힙 방문 없이 VM으로 확인한다. VM은 VACUUM이 페이지를 청소할 때 갱신된다.


동작 원리 — 가시성 판단 알고리즘

힙 스캔 도중 각 튜플에 대해 HeapTupleSatisfiesVisibility() 함수가 호출된다. 격리 수준과 목적에 따라 여러 변형이 있다.

함수명 사용 시점
HeapTupleSatisfiesMVCC() 일반 SELECT — 스냅샷 기반
HeapTupleSatisfiesNow() 트리거, RI(Referential Integrity, 외래키 참조 무결성) 체크 — 현재 커밋 상태 기준
HeapTupleSatisfiesDirty() 잠금 충돌 감지 — 아직 커밋 안 된 변경도 봄
HeapTupleSatisfiesVacuum() VACUUM — 회수 가능 여부 판단
HeapTupleSatisfiesSelf() 같은 트랜잭션의 자기 변경 판단

 

핵심 로직은 src/backend/access/heap/heapam_visibility.c에 있다. HeapTupleSatisfiesMVCC()는 매 호출마다 전달받은 SnapshotData *를 기준으로 판단하며, 같은 스냅샷 객체를 재사용할 경우 결과가 일관되게 유지된다. HeapTupleSatisfiesDirty()는 스냅샷 없이 호출되며, 진행 중인 트랜잭션의 변경도 "보이는 것"으로 처리해 락 충돌을 감지하는 데 사용한다.

 

성능 경로에서는 infomask hint bit를 최우선으로 확인해 pg_xact 조회를 최소화한다. 두 번째로 TransactionIdIsCurrentTransactionId()를 통해 자기 트랜잭션인지 확인한다(cmin/cmax 분기). 그 다음 pg_xact 조회 순서로 진행된다. 대부분의 튜플은 hint bit가 설정돼 있어 pg_xact까지 내려가지 않는다.

 

이 흐름에서 핵심적인 설계 포인트 세 가지를 짚는다.

  • 롤백이 빠른 이유: PostgreSQL에는 언두 로그가 없다. 롤백 시 pg_xact의 해당 XID를 ABORTED로 표시하는 것이 전부다. 힙 페이지의 튜플은 그대로 남는다. 100만 행을 삽입하다가 롤백해도 XID 상태 기록 하나로 즉시 완료된다. 대신 ABORTED 상태의 dead tuple은 나중에 VACUUM이 치워야 한다. 반면 InnoDB는 롤백 시 언두 로그를 역으로 적용해야 하므로 변경량에 비례하는 시간이 소요된다.

  • 진행 중인 삭제 트랜잭션: xmax에 XID가 기록됐어도, 그 XID가 아직 COMMITTED가 아니면 "삭제가 일어나지 않은 것"으로 간주한다. 두 트랜잭션이 같은 행을 동시에 UPDATE하려 할 때 이 지점에서 충돌이 감지되고 락 대기가 발생한다.

  • 가시성 판단의 일관성 보장: 스냅샷은 불변(immutable)이다. 같은 스냅샷으로 같은 튜플을 아무 번 읽어도 결과가 동일하다. 이 성질이 REPEATABLE READ의 "재현 가능한 읽기"를 보장하는 근본 원리다. 스냅샷이 고정된 이상, 다른 트랜잭션의 커밋 여부와 관계없이 결과가 바뀌지 않는다.

cmin/cmax — 같은 트랜잭션 내 명령 순서 구분

XID가 같으면 "내 트랜잭션이 이 명령 이전에 삽입했는가"를 구분할 수 없다. 같은 트랜잭션의 INSERT 결과를 같은 트랜잭션의 후속 SELECT에서 볼 수 있어야 하지만, DELETE 중에 그 DELETE 명령이 지우고 있는 행은 안 보여야 한다.

가시성 판단에서 xmin/xmax가 자기 XID일 때의 판단 규칙을 요약하면 다음과 같다.

상황 판단
xmin == 내 XID AND cmin < 현재 cmd 보임 (내가 이전 명령에서 삽입함)
xmin == 내 XID AND cmin >= 현재 cmd 안 보임 (이 명령에서 삽입 중)
xmax == 내 XID AND cmax < 현재 cmd 안 보임 (내가 이전 명령에서 삭제함)
xmax == 내 XID AND cmax >= 현재 cmd 보임 (이 명령이 삭제 중 — 현재 스캔 대상)

 

이 규칙 덕분에 UPDATE t SET ... WHERE id IN (SELECT id FROM t WHERE ...)처럼 수정 대상을 서브쿼리로 지정하는 패턴도 예측 가능하게 동작한다. 서브쿼리가 실행되는 명령 카운터 N 시점에서 보인 행들이, 이후 UPDATE 명령(명령 카운터 N+1)에 의해 xmax=현재XID, cmax=N+1로 채워지더라도 이미 서브쿼리 스캔이 끝난 뒤의 일이므로 스캔 결과에는 영향을 주지 않는다.

 

명령 카운터(CommandCounter)는 CommandCounterIncrement()가 호출될 때마다 증가한다. 이 함수는 각 최상위 SQL 명령이 끝날 때 호출되므로, 하나의 UPDATE가 내부적으로 실행하는 힙 탐색과 갱신은 같은 명령 카운터를 공유한다.

HOT Update — 버전 체인의 특수 최적화

일반 UPDATE는 새 버전을 힙 페이지에 추가하면서 동시에 인덱스에도 새 항목을 추가해야 한다. 인덱스가 10개라면 10번의 인덱스 업데이트가 필요하다. 그러나 인덱스 컬럼이 변경되지 않는 UPDATE라면 새 버전이 같은 페이지 안에 들어갈 수 있는 경우, PostgreSQL은 인덱스를 건드리지 않고 힙만 업데이트하는 HOT(Heap Only Tuple) Update를 수행한다.

 

HOT 조건은 두 가지다.

  1. 변경된 컬럼이 어떤 인덱스에도 포함되어 있지 않을 것
  2. 새 튜플 버전이 기존 버전과 같은 힙 페이지에 들어갈 것 (여유 공간 필요)

HOT Update가 일어나면 새 버전의 t_infomask2HEAP_ONLY_TUPLE 비트(0x8000)가 설정되고, 기존 버전의 t_infomask2HEAP_HOT_UPDATED 비트(0x4000)가 켜진다. 인덱스는 여전히 구버전의 ctid를 가리키지만, 힙 접근 시 버전 체인을 따라가면 최신 버전을 찾을 수 있다. 이 체인 탐색을 HOT chain 추적이라 한다.

-- HOT Update 여부 확인 (pageinspect 사용)
-- HEAP_ONLY_TUPLE  = 0x8000 (infomask2), HEAP_HOT_UPDATED = 0x4000 (infomask2)
SELECT lp, t_xmin, t_xmax, t_ctid,
       (t_infomask2 & x'8000'::int) > 0 AS is_heap_only_tuple,
       (t_infomask2 & x'4000'::int) > 0 AS is_hot_updated
FROM heap_page_items(get_raw_page('orders', 0));

HOT Update는 fillfactor 설정에 민감하다. 기본값인 fillfactor=100으로 테이블을 생성하면 페이지가 가득 찬 상태라 새 버전을 같은 페이지에 넣을 수 없어 HOT가 적용되지 않는다. 업데이트가 많은 테이블은 fillfactor=70~80으로 설정해 여유 공간을 미리 확보하는 것이 좋다. HOT Update의 세부 동작은 10편에서 더 깊이 다룬다.


SQL로 직접 관찰하기

준비: 관찰용 테이블 구성

CREATE TABLE orders (
    id      serial PRIMARY KEY,
    user_id int    NOT NULL,
    amount  numeric(10,2) NOT NULL,
    status  text   NOT NULL DEFAULT 'pending'
);

INSERT INTO orders (user_id, amount) VALUES
    (1, 15000),
    (2, 32000),
    (3,  8500);

1. xmin/xmax 시스템 컬럼 직접 조회

PostgreSQL은 시스템 컬럼을 SELECT *에 포함시키지 않는다. 명시적으로 지정해야 한다.

SELECT xmin, xmax, cmin, cmax, ctid, id, amount, status
FROM orders;
 xmin | xmax | cmin | cmax | ctid  | id | amount   | status
------+------+------+------+-------+----+----------+---------
  741 |    0 |    0 |    0 | (0,1) |  1 | 15000.00 | pending
  741 |    0 |    0 |    0 | (0,2) |  2 | 32000.00 | pending
  741 |    0 |    0 |    0 | (0,3) |  3 |  8500.00 | pending

세 행 모두 xmin=741(삽입한 트랜잭션의 XID), xmax=0(아직 삭제·갱신 없음)이다. ctid=(0,1)은 0번 페이지의 1번 아이템 포인터 위치다. cmin=0은 세 행이 같은 트랜잭션의 첫 번째 INSERT 명령에서 생성됐음을 뜻한다.

 

age(xmin)을 계산하면 이 튜플이 얼마나 오래됐는지 XID 차이로 확인할 수 있다. age가 클수록 FREEZE가 필요해지는 시점에 가까워진다.

SELECT id, age(xmin) AS xmin_age, xmin
FROM orders;
 id | xmin_age | xmin
----+----------+------
  1 |        1 |  741
  2 |        1 |  741
  3 |        1 |  741

age(xmin) = 1은 현재 XID(742)와 xmin(741)의 차이다. 일반적으로 autovacuum_freeze_max_age(기본값: 2억)에 도달하면 FREEZE 처리가 강제된다.

2. UPDATE 후 버전 분기 관찰

UPDATE orders SET status = 'confirmed' WHERE id = 1;

SELECT xmin, xmax, cmin, cmax, ctid, id, status
FROM orders;
 xmin | xmax | cmin | cmax | ctid  | id | status
------+------+------+------+-------+----+-----------
  742 |    0 |    0 |    0 | (0,4) |  1 | confirmed
  741 |    0 |    0 |    0 | (0,2) |  2 | pending
  741 |    0 |    0 |    0 | (0,3) |  3 | pending

id=1xmin이 742로 바뀌고 ctid(0,4)다. 새 버전이 페이지 슬롯 4에 생성됐다. 기존 슬롯 1의 dead tuple(xmin=741, xmax=742)은 페이지에 물리적으로 존재하지만, 현재 스냅샷 기준으로 xmax=742가 이미 커밋됐으므로 가시성 필터에서 걸러진다.

3. pageinspect로 dead tuple 확인

SELECT는 가시성 필터 이후의 결과만 보여준다. pageinspect 확장을 이용하면 페이지 원본에 직접 접근할 수 있다.

CREATE EXTENSION IF NOT EXISTS pageinspect;

SELECT lp, lp_flags,
       t_xmin, t_xmax, t_ctid,
       lpad(to_hex(t_infomask),  4, '0') AS infomask_hex,
       lpad(to_hex(t_infomask2), 4, '0') AS infomask2_hex
FROM heap_page_items(get_raw_page('orders', 0));
 lp | lp_flags | t_xmin | t_xmax | t_ctid | infomask_hex | infomask2_hex
----+----------+--------+--------+--------+--------------+---------------
  1 |        1 |    741 |    742 | (0,4)  | 0502         | 4004
  2 |        1 |    741 |      0 | (0,2)  | 0902         | 0004
  3 |        1 |    741 |      0 | (0,3)  | 0902         | 0004
  4 |        1 |    742 |      0 | (0,4)  | 2902         | 8004

슬롯 1번의 t_xmax=742는 "XID 742가 이 튜플을 무효화했다"는 표시다. t_ctid=(0,4)는 새 버전의 위치를 가리키는 포인터다. infomask_hex=0x0502를 분해하면:

  • 0x0400: HEAP_XMAX_COMMITTED — xmax가 커밋됐음이 hint bit에 기록됨
  • 0x0100: HEAP_XMIN_COMMITTED — xmin이 커밋됐음이 hint bit에 기록됨
  • 0x0002: HEAP_HASVARWIDTH — 가변 폭 컬럼 존재

슬롯 1의 infomask2_hex=0x4004에서 0x4000HEAP_HOT_UPDATED 인덱스를 거치지 않고 힙에만 삽입된 HOT 새 버전임을 의미한다(status 컬럼에 인덱스가 없어 HOT 조건 충족).

이 튜플이 HOT Update에 의해 갱신됐고 새 버전이 같은 페이지(슬롯 4)에 존재함을 나타낸다. 0x0004는 컬럼 수(4개, HEAP_NATTS_MASK)다. 슬롯 4번(새 버전)의 infomask2=0x8004에서 0x8000은 HEAP_ONLY_TUPLE 

 

슬롯 4번의 t_infomask=0x2902를 분해하면 0x2000(HEAP_UPDATED)은 이 튜플이 UPDATE로 새로 생성됐음을(INSERT가 아닌 UPDATE의 결과), 0x0800(HEAP_XMAX_INVALID)은 아직 삭제가 없음을 나타낸다. 슬롯 1의 dead tuple은 VACUUM이 실행될 때까지 페이지를 점유한다.

4. pg_current_snapshot()으로 스냅샷 구조 직접 출력

두 세션을 열어 스냅샷의 xip[] 동작을 확인한다.

세션 A — 트랜잭션을 열고 대기:

BEGIN;
SELECT pg_current_xact_id();   -- 예: 750
-- 이 상태로 대기

세션 B — 현재 스냅샷 확인:

SELECT pg_current_snapshot();
 pg_current_snapshot
----------------------
 748:751:750

형식은 xmin:xmax:xip_list다. 748:751:750은 "XID 748 미만은 모두 완료됨(커밋 또는 중단됨), 751 이상은 아직 시작되지 않은 미래 트랜잭션, 현재 진행 중인 트랜잭션은 XID 750"을 의미한다.

SELECT
    pg_snapshot_xmin(pg_current_snapshot()) AS snap_xmin,
    pg_snapshot_xmax(pg_current_snapshot()) AS snap_xmax,
    pg_snapshot_xip(pg_current_snapshot())  AS in_progress_xids;
 snap_xmin | snap_xmax | in_progress_xids
-----------+-----------+------------------
       748 |       751 | {750}

세션 A(XID 750)가 커밋되기 전에 세션 B가 찍는 어떤 스냅샷이든 xip[]에 750을 포함한다. 따라서 세션 A가 변경한 데이터는 해당 스냅샷 기준으로 보이지 않는다.

5. READ COMMITTED vs REPEATABLE READ 스냅샷 비교

-- READ COMMITTED: 각 쿼리마다 새 스냅샷 획득
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT pg_current_snapshot() AS snap1;

-- 다른 세션의 XID 760이 snap1 취득 후 커밋됐다고 가정

SELECT pg_current_snapshot() AS snap2;
-- snap1과 snap2가 다름: 새로운 커밋이 반영됨
COMMIT;

-- REPEATABLE READ: 첫 쿼리 이후 스냅샷 고정
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_current_snapshot() AS snap3;

-- 다른 세션에서 XID 770이 커밋해도

SELECT pg_current_snapshot() AS snap4;
-- snap3과 snap4가 같음: 처음 찍은 스냅샷 유지
COMMIT;

READ COMMITTED에서는 GetTransactionSnapshot()이 매 쿼리마다 호출돼 최신 커밋 상태를 반영한다. REPEATABLE READ에서는 FirstSnapshotSet 플래그가 설정된 이후 동일한 SnapshotData 포인터를 반환한다.

 

이 차이는 실제 애플리케이션에서 의외의 버그를 만드는 원인이 된다. 예를 들어 READ COMMITTED 트랜잭션 안에서 두 번의 SELECT가 서로 다른 결과를 반환하거나, 집계 쿼리와 상세 조회 쿼리가 일치하지 않는 현상이 나타날 수 있다. 이를 Non-Repeatable Read(같은 트랜잭션 안에서 동일한 쿼리를 두 번 실행했을 때 결과가 달라지는 현상)라 한다. 이 현상을 막으려면 REPEATABLE READ로 격리 수준을 올리거나, 하나의 쿼리 안에서 필요한 모든 집계와 상세를 처리하도록 쿼리를 설계해야 한다.

6. pg_stat_activity로 오래된 스냅샷의 영향 파악

SELECT pid,
       state,
       xact_start,
       now() - xact_start              AS xact_duration,
       backend_xmin,
       left(query, 60)                  AS query_snippet
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY backend_xmin;
  pid  | state               | xact_start             | xact_duration | backend_xmin | query_snippet
-------+---------------------+------------------------+---------------+--------------+------------------------
 12345 | idle in transaction | 2026-05-09 10:00:00+09 | 00:32:17      |          748 | BEGIN
 23456 | active              | 2026-05-09 10:31:05+09 | 00:01:12      |          760 | SELECT count(*) FROM ..

backend_xmin=748인 프로세스가 32분째 트랜잭션을 열어두고 있다. VACUUM은 xmax < 748인 dead tuple만 회수할 수 있다. 748 이후에 생성된 dead tuple은 이 세션이 종료될 때까지 회수되지 못하고 테이블 bloat를 유발한다.

이것이 "idle in transaction"(트랜잭션을 열어 둔 채 아무 쿼리도 실행하지 않고 대기 중인 상태 — pg_stat_activity.state 값이 idle in transaction으로 표시됨) 세션이 DBA에게 위험 신호인 이유다. idle_in_transaction_session_timeout 파라미터로 이런 세션을 자동 종료할 수 있다.

7. EXPLAIN으로 가시성 필터링 비용 관찰

VACUUM ANALYZE orders;

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE status = 'confirmed';
Seq Scan on orders  (cost=0.00..1.04 rows=1 width=52)
                    (actual time=0.021..0.025 rows=1 loops=1)
  Filter: (status = 'confirmed'::text)
  Rows Removed by Filter: 2
  Buffers: shared hit=1
Planning Time: 0.086 ms
Execution Time: 0.042 ms

Rows Removed by Filter: 2는 가시성 검사를 통과했으나 WHERE 조건에서 걸러진 행 수다. Dead tuple은 힙 스캔 레이어에서 가시성 필터가 먼저 처리하기 때문에 이 숫자에 잡히지 않는다. VACUUM 전에 dead tuple이 많으면 가시성 판단 자체가 CPU를 소모한다. 특히 pg_xact를 읽어야 하는 hint bit 미설정 튜플이 많을수록 오버헤드가 커진다.

 

페이지 내 dead tuple이 많은 상태에서 다시 실행하면 어떻게 다른지 확인해보자.

-- dead tuple을 대량 생성
UPDATE orders SET amount = amount + 1;
UPDATE orders SET amount = amount + 1;
UPDATE orders SET amount = amount + 1;

-- VACUUM 없이 EXPLAIN
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'confirmed';
Seq Scan on orders  (cost=0.00..1.10 rows=1 width=52)
                    (actual time=0.035..0.041 rows=1 loops=1)
  Filter: (status = 'confirmed'::text)
  Rows Removed by Filter: 2
  Buffers: shared hit=1
Planning Time: 0.091 ms
Execution Time: 0.058 ms

행 수가 적어 차이가 미미하지만, 수백만 행 규모 테이블에서 dead tuple 비율이 높으면 Seq Scan 실행 시간이 수 배까지 늘어난다. 이것이 autovacuum을 주기적으로 실행해야 하는 핵심 이유다.

8. 스냅샷 내보내기 — pg_export_snapshot

병렬로 실행되는 여러 세션이 정확히 같은 스냅샷을 공유해야 하는 경우가 있다. 예컨대 pg_dump는 여러 워커 프로세스가 동시에 테이블을 덤프하면서도 일관된 시점의 데이터를 읽어야 한다. 이때 pg_export_snapshot()이 사용된다.

-- 세션 A: 스냅샷 내보내기
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_export_snapshot();
 pg_export_snapshot
---------------------
 00000003-00000001-1
-- 세션 B: 같은 스냅샷 가져와 사용
BEGIN ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION SNAPSHOT '00000003-00000001-1';
-- 이제 세션 B는 세션 A와 완전히 동일한 스냅샷으로 데이터를 읽음

SELECT xmin, xmax, amount FROM orders;
-- 세션 A의 스냅샷 기준 결과 반환

내보낸 스냅샷은 원본 트랜잭션(세션 A)이 커밋하거나 롤백할 때까지만 유효하다. pg_dump --jobs=N 옵션이 내부적으로 이 메커니즘을 사용해 N개의 병렬 덤프 프로세스가 동일한 시점 기준으로 데이터를 읽는다. 스냅샷 ID는 pg_stat_activity.query 컬럼에서 SET TRANSACTION SNAPSHOT 명령을 통해 추적할 수 있으며, 공유된 스냅샷을 보유한 세션이 얼마나 오랫동안 살아있는지 모니터링하는 것이 중요하다 — 장시간 열린 세션이 OldestXmin을 붙들기 때문이다.

 

스냅샷 공유 기능은 논리 복제(Logical Decoding — WAL에서 변경 내용을 논리적 이벤트 스트림으로 변환해 다른 시스템으로 전달하는 메커니즘, 22편)에서도 활용된다. 초기 테이블 동기화 시 스냅샷을 내보내고 WAL 수신과 일관된 시점을 맞추는 데 사용된다.


출처 및 참고 자료

이 글의 내용은 다음 자료를 기준으로 작성됐다.

PostgreSQL 공식 문서 (PostgreSQL 16)

PostgreSQL 소스코드 (PostgreSQL 16.x)

  • src/include/access/htup_details.hHeapTupleHeaderData 구조체, infomask/infomask2 비트 정의 (HEAP_XMIN_COMMITTED, HEAP_UPDATED, HEAP_XMAX_LOCK_ONLY 등)
  • src/backend/access/heap/heapam_visibility.cHeapTupleSatisfiesMVCC() 등 가시성 판단 함수 구현
  • src/backend/storage/ipc/procarray.cGetSnapshotData() 스냅샷 생성 로직
  • src/backend/utils/time/combocid.c — combocid 인코딩 구현

관련 논문

  • Bernstein, P. A., & Goodman, N. (1983). Multiversion Concurrency Control — Theory and Algorithms. ACM Transactions on Database Systems, 8(4), 465–483. — MVCC 이론의 원형

참고 도서 및 블로그

  • Momjian, B. (2021). MVCC Unmasked — PostgreSQL 핵심 기여자 Bruce Momjian의 MVCC 상세 슬라이드
  • Hironobu Suzuki, The Internals of PostgreSQL (interdb.jp), Chapter 5: Concurrency Control — 가시성 판단 알고리즘과 스냅샷 구조를 소스코드 수준에서 설명
  • PostgreSQL Wiki: MVCC — 커뮤니티 정리 문서