PostgreSQL의 MVCC(다중 버전 동시성 제어)는 한 행(row)을 수정할 때 원본을 덮어쓰지 않고 새 버전을 하나 더 만들어, 각 트랜잭션이 자기 시점에 맞는 버전을 보게 하는 방식입니다. 덕분에 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않습니다. 이 글은 그 마법이 실제로 어떤 시스템 컬럼과 스냅샷으로 굴러가는지, 그리고 그 대가로 무엇을 치러야 하는지를 다룹니다.
솔직히 처음 PostgreSQL을 만졌을 때 "UPDATE 한 번 했는데 왜 테이블이 계속 커지지?"가 제일 이해가 안 갔습니다. 그 답이 전부 MVCC 안에 있었습니다.
모든 행에는 숨은 컬럼이 있다
테이블을 만들면 눈에 보이지 않는 시스템 컬럼이 붙습니다. 핵심은 두 개입니다. xmin은 이 행 버전을 삽입한 트랜잭션의 ID, xmax는 이 버전을 삭제하거나 갱신한 트랜잭션의 ID입니다. 직접 조회할 수 있습니다.
CREATE TABLE accounts (id int primary key, balance int);
INSERT INTO accounts VALUES (1, 100);
SELECT xmin, xmax, ctid, * FROM accounts;
-- xmin | xmax | ctid | id | balance
-- 742 | 0 | (0,1) | 1 | 100
UPDATE accounts SET balance = 90 WHERE id = 1;
SELECT xmin, xmax, ctid, * FROM accounts;
-- xmin | xmax | ctid | id | balance
-- 743 | 0 | (0,2) | 1 | 90
여기서 핵심을 봅니다. UPDATE는 제자리 수정이 아닙니다. 내부적으로는 "옛 버전에 xmax를 찍어 죽었다고 표시 + 새 버전을 새 위치에 INSERT"입니다. 그래서 ctid(행의 물리적 위치)가 (0,1)에서 (0,2)로 바뀌었죠. 옛날 (0,1) 버전은 여전히 데이터 파일에 남아 있습니다. 이게 바로 데드 튜플(dead tuple)이고, VACUUM이 나중에 청소해야 할 대상입니다.
스냅샷 — 누가 무엇을 보는가
트랜잭션은 시작(또는 문장 시작) 시점에 스냅샷을 뜹니다. 스냅샷은 대략 "그 순간에 커밋이 끝난 트랜잭션들"의 목록입니다. 어떤 행 버전이 보이려면 대략 이 조건을 만족해야 합니다.
xmin이 이미 커밋되었고 내 스냅샷 기준으로 과거일 것 (즉 이 버전이 확정 삽입됨)xmax가 없거나(0), 있더라도 아직 커밋 안 됨/미래일 것 (즉 아직 안 죽음)
그래서 트랜잭션 A가 잔액을 읽는 중에 트랜잭션 B가 UPDATE+커밋을 해도, A는 자기 스냅샷에 맞는 옛 버전을 계속 봅니다. 잠금 없이, 서로 안 막고. 이게 PostgreSQL의 READ COMMITTED/REPEATABLE READ가 동작하는 실제 메커니즘입니다.
공짜가 아니다 — 블로트와 wraparound
이 구조의 청구서는 두 가지로 날아옵니다.
| 문제 | 원인 | 대응 |
|---|---|---|
| 테이블 블로트 | 데드 튜플이 쌓임 | (auto)VACUUM으로 공간 회수·재사용 |
| 긴 트랜잭션 | 오래된 스냅샷이 데드 튜플 청소를 막음 | 장기 트랜잭션·유휴 트랜잭션 제거 |
| XID wraparound | 트랜잭션 ID가 32비트라 순환 | VACUUM이 오래된 행을 freeze |
특히 세 번째가 무섭습니다. 트랜잭션 ID는 32비트라 약 21억을 넘으면 되돌아옵니다. 그래서 VACUUM은 충분히 오래된 행의 xmin을 frozen 상태로 표시해 "이건 항상 과거"라고 못 박습니다. autovacuum을 꺼두거나 너무 느슨하게 두면 언젠가 DB가 wraparound 방어를 위해 강제로 멈춰 서는(그 유명한 "database is not accepting commands") 사고가 납니다. 저도 한 번 데인 뒤에야 모니터링에 age(datfrozenxid)를 넣었습니다.
-- 데이터베이스별 XID 나이(오래될수록 위험)
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database ORDER BY xid_age DESC;
-- 어떤 트랜잭션이 청소를 막고 있는지(오래된 것부터)
SELECT pid, state, age(backend_xmin) AS xmin_age, query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY xmin_age DESC;
자주 묻는 질문
MVCC 때문에 UPDATE가 DELETE+INSERT라면 인덱스도 매번 갱신되나요?
기본적으로는 그렇습니다. 새 버전이 다른 위치에 생기니 모든 인덱스가 그 위치를 가리키도록 갱신돼야 하죠. 다만 갱신하는 컬럼이 인덱스에 없고 같은 페이지에 여유가 있으면 HOT(Heap-Only Tuple) 최적화가 인덱스 갱신을 건너뜁니다. 이것만 따로 정리한 글도 있습니다.
xmin, xmax를 애플리케이션에서 낙관적 락처럼 써도 되나요?
임시방편으로는 되지만 권장하진 않습니다. 이 값들은 내부 구현이고 wraparound로 값이 재사용될 수 있습니다. 낙관적 잠금이 필요하면 명시적인 version 컬럼이나 xmax 대신 xmin 비교보다 전용 버전 필드를 두는 편이 안전합니다.
VACUUM FULL을 돌리면 블로트가 사라지는데 왜 자주 안 하나요?
VACUUM FULL은 테이블을 통째로 다시 써서 ACCESS EXCLUSIVE 락을 겁니다. 그동안 그 테이블은 읽기조차 막힙니다. 평상시에는 일반 autovacuum으로 공간을 재사용하게 두고, 정말 블로트가 심할 때만 pg_repack 같은 온라인 도구를 쓰는 게 안전합니다.
다른 DB(예: Oracle)의 MVCC와 뭐가 다른가요?
Oracle이나 MySQL InnoDB는 옛 버전을 별도 undo 영역에 두고 필요할 때 재구성합니다. PostgreSQL은 옛 버전을 테이블 안에 그대로 남깁니다. 그래서 롤백은 매우 싸지만(그냥 안 보이게 두면 됨) 청소(VACUUM)라는 숙제가 생기는 트레이드오프입니다.

댓글 0