MVCC와 트랜잭션 관리
"읽기는 쓰기를 막지 않는다"
PostgreSQL의 동시성 전략을 한 문장으로 표현하면 이렇다. Multi-Version Concurrency Control(MVCC) 덕분에 SELECT는 UPDATE를 기다리지 않고, UPDATE는 SELECT를 막지 않는다. MySQL InnoDB도 MVCC를 쓰지만 구현 방식이 다르다. PostgreSQL은 변경된 행의 이전 버전을 테이블 파일 안에 직접 남기고, InnoDB는 언두 로그(undo log)에 따로 보관한다. 이 차이가 VACUUM이라는 고유한 운영 부담을 낳는다.
튜플 버전 관리: xmin, xmax, ctid
PostgreSQL에서 테이블의 행(row)은 내부적으로 튜플(tuple) 이라고 불린다. 각 튜플은 다음 숨겨진 시스템 컬럼을 갖는다.
| 컬럼 | 의미 |
|---|---|
xmin | 이 튜플을 삽입한 트랜잭션 ID |
xmax | 이 튜플을 삭제하거나 갱신하여 무효화한 트랜잭션 ID (없으면 0) |
ctid | 테이블 내 물리적 위치 (블록번호, 오프셋) |
SELECT xmin, xmax, ctid, * FROM orders LIMIT 3; 으로 직접 확인할 수 있다.
UPDATE = DELETE + INSERT
PostgreSQL은 행을 갱신할 때 기존 튜플을 제자리에서 덮어쓰지 않는다. 대신 이렇게 동작한다.
- 기존 튜플의
xmax에 현재 트랜잭션 ID를 기록한다 (논리적 삭제). - 새 값을 담은 튜플을 테이블 뒤쪽에 INSERT한다 (새로운
xmin). - 기존 튜플은 자리를 차지한 채로 dead tuple이 된다.
xmin=100 xmax=0
name='Alice'
xmin=100 xmax=200
name='Alice'
xmin=200 xmax=0
name='Bob'
xmin=200 xmax=0
name='Bob'
이 방식의 장점은 과거 스냅샷을 보고 있는 트랜잭션이 락 없이 이전 버전을 읽을 수 있다는 점이다. 단점은 dead tuple이 쌓여 테이블 bloat(부풀림)이 발생한다는 것이다.
스냅샷 격리
트랜잭션이 시작되면 PostgreSQL은 스냅샷(Snapshot) 을 찍는다. 스냅샷에는 "현재 진행 중인 트랜잭션 ID 목록"이 담긴다. 이를 기준으로 각 튜플의 가시성을 판단한다.
xmin< 스냅샷 시작 시점 ANDxmin이 커밋됨 → 보인다xmax> 0 ANDxmax< 스냅샷 시작 시점 ANDxmax가 커밋됨 → 안 보인다 (dead)xmax> 스냅샷 시작 시점 ORxmax가 진행 중 → 여전히 보인다
이 규칙 덕분에 다른 커넥션이 UPDATE를 진행 중이어도 SELECT는 갱신 전 버전을 일관되게 읽는다.
PostgreSQL의 격리 수준
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
READ COMMITTED (기본) | 없음 | 가능 | 가능 |
REPEATABLE READ | 없음 | 없음 | 없음* |
SERIALIZABLE | 없음 | 없음 | 없음 |
*PostgreSQL의 REPEATABLE READ는 SQL 표준보다 강하다. 팬텀 리드도 방지한다.
VACUUM — 죽은 튜플을 청소하는 유지보수
dead tuple은 트랜잭션이 모두 끝난 뒤에야 재사용 가능한 공간이 된다. VACUUM이 이를 회수한다.
-- 수동 실행 (블로킹 없음, 테이블 공간을 OS에 반환하지는 않음)
VACUUM orders;
-- 공간을 OS까지 반환하려면 (잠깐 배타 락 필요)
VACUUM FULL orders;
-- 통계 정보 갱신 포함
VACUUM ANALYZE orders;autovacuum은 PostgreSQL이 자동으로 실행하는 백그라운드 VACUUM이다. pg_stat_user_tables 뷰에서 n_dead_tup 값이 크면 autovacuum이 뒤처진 신호다.
SELECT relname, n_live_tup, n_dead_tup,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;autovacuum 튜닝 포인트
| 파라미터 | 기본값 | 의미 |
|---|---|---|
autovacuum_vacuum_scale_factor | 0.2 | dead tuple이 테이블의 20 % 를 넘으면 발동 |
autovacuum_vacuum_threshold | 50 | 최소 50행 이상일 때 발동 |
autovacuum_vacuum_cost_delay | 2 ms | I/O 조절을 위한 슬롯당 대기 (빠르게 하려면 낮춤) |
대형 테이블에서는 autovacuum_vacuum_scale_factor를 테이블 단위로 낮게 설정한다.
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01);트랜잭션 ID 랩어라운드 — 가장 위험한 PostgreSQL 특수 문제
PostgreSQL의 트랜잭션 ID(XID)는 32비트 정수다. 약 21억 개 트랜잭션이 지나면 XID가 "과거"처럼 보이기 시작하는 랩어라운드(wraparound) 문제가 발생한다. 이 상태가 되면 가시성 판단이 뒤집혀 데이터가 사라진 것처럼 보일 수 있다.
PostgreSQL은 XID 소진까지 약 4천만 트랜잭션이 남으면 로그에 경고를 남기고, 약 1천만이 남으면 데이터베이스를 읽기 전용 모드로 전환한다.
예방 방법:
-- age가 높은 테이블 확인
SELECT relname, age(relfrozenxid) AS xid_age, pg_size_pretty(pg_total_relation_size(oid))
FROM pg_class
WHERE relkind = 'r'
ORDER BY xid_age DESC LIMIT 10;age(relfrozenxid)가 1억을 넘기 시작하면 해당 테이블에 대한 VACUUM FREEZE 실행을 계획해야 한다.
MySQL MVCC와의 핵심 차이
| 항목 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 이전 버전 저장 위치 | 테이블 파일 내 (heap) | undo log 별도 파일 |
| 공간 회수 방법 | VACUUM (명시적) | purge thread (자동) |
| VACUUM 운영 부담 | 별도 모니터링·튜닝 필요 | 상대적으로 적음 |
| 랩어라운드 문제 | XID 32비트, FREEZE 필요 | 없음 |
| UPDATE 비용 | INSERT + 논리삭제 (더 높음) | In-place update 가능 |
DBA 체크리스트
n_dead_tup과last_autovacuum을 주기적으로 모니터링한다.- 대량 DELETE/UPDATE 직후에는 수동
VACUUM ANALYZE를 실행해 통계를 최신으로 유지한다. autovacuum_freeze_max_age(기본 2억) 이전에 FREEZE가 완료되는지 확인한다.VACUUM FULL은 배타 락이 걸리므로 프로덕션에서는pg_repack같은 온라인 툴을 검토한다.
References
- https://www.postgresql.org/docs/current/mvcc-intro.html
- https://www.postgresql.org/docs/current/routine-vacuuming.html
- https://www.percona.com/blog/basic-understanding-bloat-vacuum-postgresql-mvcc/
- https://skylinecodes.substack.com/p/postgresql-dead-tuples-mvcc-autovacuum
- https://singhajit.com/postgresql-mvcc-autovacuum/
- https://oneuptime.com/blog/post/2026-01-19-postgresql-mvcc-concurrency/view