PostgreSQL 트랜잭션 ID 랩어라운드: VACUUM이 행을 동결하는 이유와 age(datfrozenxid) 읽는 법

PostgreSQL의 32비트 트랜잭션 ID가 한 바퀴 돌면 무슨 일이 생기는지, VACUUM의 동결과 relfrozenxid·datfrozenxid, 랩어라운드 방지 autovacuum, 경고와 쓰기 거부 단계, 복구 순서를 PostgreSQL 18 공식 문서로 정리했습니다.

개발데이터베이스PostgreSQL

모니터링 대시보드에 age(datfrozenxid)라는 숫자가 있고, 아무것도 하지 않았는데 매일 조금씩 커진다고 해 보겠습니다. 이 숫자가 무엇을 세는지, 계속 커지면 어디서 무슨 일이 생기는지, 그 전에 PostgreSQL이 스스로 무엇을 하는지가 이 글의 주제입니다. 기준은 PostgreSQL 18 문서의 24.1 Routine Vacuuming, 그중 24.1.5 Preventing Transaction ID Wraparound Failures와 24.1.6 The Autovacuum Daemon입니다. 2026년 10월 19일에 확인했을 때 이 문서 페이지는 18을 현재 버전으로, 19를 개발 버전으로 표시하고 있었습니다. 다른 버전의 동작은 다루지 않으며, 설정값은 문서에 적힌 것만 옮겼습니다.

32비트 카운터를 원으로 비교한다

PostgreSQL의 MVCC는 트랜잭션 ID(XID)를 비교해서 행 버전이 보이는지 정합니다. 삽입한 XID가 현재 트랜잭션의 XID보다 큰 행 버전은 “미래”의 것이므로 보이지 않아야 합니다. 각 문장이 어떤 행 버전을 보는지는 READ COMMITTED와 스냅샷에서 따로 다뤘습니다.

문제는 XID가 32비트라는 점입니다. 문서에 따르면 오래 운영한 클러스터가 40억 개가 넘는 트랜잭션을 처리하면 XID 카운터가 0으로 돌아가고, 과거였던 트랜잭션이 갑자기 미래처럼 보이게 됩니다. 그러면 그 트랜잭션이 쓴 행이 보이지 않습니다. 문서는 이를 “치명적인 데이터 손실”이라고 부르면서, 데이터는 실제로 남아 있지만 꺼낼 수 없다면 위안이 되지 않는다고 덧붙입니다.

일반 XID는 2³² 모듈로 연산으로 비교합니다. 그래서 어떤 일반 XID든 그보다 “오래된” XID가 20억 개, “새로운” XID가 20억 개 있습니다. 문서의 표현으로는 일반 XID 공간은 끝점이 없는 원입니다.

                 현재 트랜잭션의 XID
                         │
   ◀── 약 20억: 과거 ────┼──── 약 20억: 미래 ──▶
   (원이므로 양 끝이 이어져 있다)

어떤 행 버전이 특정 XID로 만들어지면 그 뒤 20억 개의 트랜잭션 동안은 과거로 보입니다. 그 행이 20억 트랜잭션을 넘게 그대로 남아 있으면 갑자기 미래로 넘어갑니다. 고정 폭 정수가 넘치면 값이 한 바퀴 돈다는 점은 정수 오버플로 글과 같지만, 여기서는 계산 결과가 아니라 행이 보이는지 여부가 뒤집힙니다.

동결: 영원히 과거인 XID

해결책이 VACUUM의 동결(freeze)입니다. 동결된 행은 삽입한 트랜잭션이 충분히 오래전에 커밋되어 지금과 앞으로의 모든 트랜잭션에 보인다는 표시를 받습니다.

PostgreSQL은 이를 위해 특별한 XID인 FrozenTransactionId를 예약해 둡니다. 이 XID는 일반 비교 규칙을 따르지 않고 모든 일반 XID보다 항상 오래된 것으로 취급됩니다. 동결된 행 버전은 삽입 XID가 FrozenTransactionId인 것처럼 다뤄지므로, 랩어라운드와 상관없이 삭제될 때까지 계속 과거에 있습니다.

구현에 관해 문서가 적어 둔 사항도 있습니다. 9.4보다 전 버전은 행의 삽입 XID를 실제로 FrozenTransactionId(값 2)로 바꿨고 이 값이 xmin 시스템 열에 보였습니다. 그 이후 버전은 플래그 비트만 세우고 원래 xmin은 사후 조사에 쓸 수 있도록 남겨 둡니다. 그래서 9.4 이전에서 pg_upgrade한 데이터베이스에는 xmin이 2인 행이 아직 있을 수 있습니다. 시스템 카탈로그에는 initdb 첫 단계에서 들어간 xmin = 1(BootstrapTransactionId)인 행도 있고, 이것도 모든 일반 XID보다 오래된 것으로 취급됩니다.

결국 각 테이블은 20억 트랜잭션이 지나기 전에 VACUUM이 오래된 행을 동결해 줘야 합니다. 문서는 모든 데이터베이스의 모든 테이블을 최소한 20억 트랜잭션마다 한 번은 VACUUM해야 한다고 적습니다.

relfrozenxid와 datfrozenxid가 세는 것

VACUUM은 아직 동결되지 않은 가장 오래된 XID를 추적하려고 두 시스템 테이블에 값을 남깁니다.

  • pg_class.relfrozenxid: 이 값을 실제로 전진시킨 가장 최근 VACUUM(보통 가장 최근의 적극적 VACUUM)이 끝났을 때 테이블에 남아 있던 가장 오래된 미동결 XID.
  • pg_database.datfrozenxid: 그 데이터베이스에 나타나는 미동결 XID의 하한. 데이터베이스 안 테이블들의 relfrozenxid 중 최솟값입니다.

age()는 이 기준 XID에서 현재 트랜잭션의 XID까지 몇 트랜잭션이 지났는지를 돌려줍니다. 처음에 말한 대시보드 숫자가 바로 이것입니다. 문서가 예로 드는 쿼리는 다음 두 가지입니다.

SELECT c.oid::regclass as table_name,
       greatest(age(c.relfrozenxid),age(t.relfrozenxid)) as age
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relkind IN ('r', 'm');

SELECT datname, age(datfrozenxid) FROM pg_database;

첫 쿼리는 테이블별로, reltoastrelid로 연결된 TOAST 테이블과 비교해 더 큰 쪽의 나이를 보여 줍니다. 둘째 쿼리는 데이터베이스 단위입니다. 데이터베이스 숫자가 크면 첫 쿼리로 어느 테이블이 끌어올리는지 찾으면 됩니다.

VACUUM은 언제 relfrozenxid를 전진시키나

VACUUM을 자주 돌려도 이 나이가 줄지 않을 수 있습니다. VACUUM은 가시성 맵(visibility map)을 보고 스캔할 페이지를 고르고, 보통은 죽은 행 버전이 없는 페이지를 건너뜁니다. 그 페이지에 오래된 XID가 남아 있어도 마찬가지입니다. 그래서 일반 VACUUM이 테이블의 오래된 행을 전부 동결한다는 보장은 없고, 결국 적극적(aggressive) VACUUM이 필요해집니다. 적극적 VACUUM은 all-visible이지만 all-frozen은 아닌 페이지까지 포함해, 동결할 수 있는 미동결 XID와 MXID를 모두 동결합니다.

PostgreSQL 18 문서는 그 중간 단계도 설명합니다. all-visible이지만 all-frozen이 아닌 페이지가 쌓이고 있으면 일반 VACUUM도 건너뛸 수 있는 페이지 일부를 일부러 스캔해 동결을 시도할 수 있고, 이를 eager scan이라고 부릅니다. vacuum_max_eager_freeze_failure_rate를 높이면 더 많이 시도하게 할 수 있습니다. 여기서 동결에 성공한 페이지는 다음 적극적 VACUUM이 건너뛸 수 있지만, 문서는 대부분의 테이블에 여전히 주기적인 적극적 VACUUM이 필요하다고 적습니다.

relfrozenxid는 미동결 XID가 있을 수 있는 모든 페이지를 스캔했을 때만 전진합니다. 문서가 드는 경우는 세 가지입니다.

  • relfrozenxid가 vacuum_freeze_table_age보다 오래되었을 때
  • VACUUM의 FREEZE 옵션을 썼을 때
  • 아직 all-frozen이 아닌 페이지가 마침 모두 죽은 행 정리 때문에 스캔 대상이 되었을 때

이렇게 전체를 스캔하고 나면 age(relfrozenxid)는 그때 쓴 vacuum_freeze_min_age보다 조금 큰 값이 됩니다. VACUUM이 시작된 뒤 시작된 트랜잭션 수만큼 더 큽니다. VACUUM은 테이블에 남은 가장 오래된 XID를 relfrozenxid로 기록하므로 결과가 필요 이상으로 최근 값일 수도 있습니다.

관련 설정 두 가지의 역할은 이렇습니다.

  • vacuum_freeze_min_age: XID가 얼마나 오래되어야 그 XID를 가진 행을 동결할지. 높이면 곧 다시 수정될 행을 동결하는 헛일을 줄일 수 있고, 낮추면 다음에 꼭 VACUUM해야 하는 시점까지의 여유가 늘어납니다.
  • vacuum_freeze_table_age: 언제 적극적 VACUUM을 할지. 0으로 두면 VACUUM이 항상 적극적 전략을 씁니다. 실효 최댓값은 autovacuum_freeze_max_age의 0.95배이고 더 큰 값은 그로 제한됩니다.

랩어라운드 방지 autovacuum

테이블이 VACUUM 없이 버틸 수 있는 최대 기간은 문서에 따르면 20억 트랜잭션에서 마지막 적극적 VACUUM 때의 vacuum_freeze_min_age를 뺀 만큼입니다. 이를 넘기지 않도록 PostgreSQL은 autovacuum_freeze_max_age보다 오래된 미동결 XID를 가질 수 있는 테이블에 autovacuum을 실행합니다. 24.1.6절의 표현으로는 relfrozenxid가 autovacuum_freeze_max_age보다 오래된 테이블은 항상 VACUUM됩니다. 문서는 이것이 autovacuum을 꺼 두었어도 일어난다고 명시합니다.

이 autovacuum은 일반 autovacuum과 두 가지가 다릅니다.

  1. 일반 autovacuum은 SHARE UPDATE EXCLUSIVE와 충돌하는 잠금을 다른 프로세스가 요청하면 중단됩니다. 랩어라운드를 막으려고 도는 autovacuum은 자동으로 중단되지 않습니다.
  2. pg_stat_activity에서 이 autovacuum의 쿼리 이름은 (to prevent wraparound)로 끝납니다.
SELECT pid, datname, query
FROM pg_stat_activity
WHERE query LIKE '%(to prevent wraparound)';

이 쿼리는 문서의 설명을 그대로 조건으로 옮긴 것입니다. 이 표시가 붙은 작업은 갱신량이 아니라 테이블의 나이 때문에 시작된 것입니다. 문서는 업데이트나 삭제 없이 삽입만 받는 테이블처럼 공간 회수를 위한 VACUUM이 필요 없는 정적 테이블이라도 대략 autovacuum_freeze_max_age에서 vacuum_freeze_min_age를 뺀 트랜잭션마다 한 번씩 이 autovacuum이 돈다고 설명합니다.

한편 문서는 경고도 하나 둡니다. SHARE UPDATE EXCLUSIVE와 충돌하는 잠금을 잡는 명령(예: ANALYZE)을 정기적으로 실행하면 autovacuum이 끝까지 가지 못할 수 있습니다.

autovacuum_freeze_max_age를 올리는 대가

문서가 드는 유일한 단점은 저장 공간입니다. autovacuum_freeze_max_age(와 함께 vacuum_freeze_table_age)를 높이면 그 범위까지의 모든 트랜잭션에 대한 커밋 상태를 pg_xact에, track_commit_timestamp가 켜져 있으면 타임스탬프를 pg_commit_ts에 보관해야 합니다.

autovacuum_freeze_max_age pg_xact pg_commit_ts
기본값 2억 약 50MB 약 2GB
허용 최댓값 20억 약 0.5GB 약 20GB

문서는 이 크기가 전체 데이터베이스에 비해 사소하다면 최댓값을 권장하고, 그렇지 않다면 이 저장 공간을 얼마나 허용할지에 따라 정하라고 적습니다. 반대로 vacuum_freeze_min_age를 너무 낮추면 곧 다시 수정될 행을 동결하는 헛일이 생깁니다. 어느 쪽도 모든 환경에 맞는 숫자는 문서에 없습니다.

경고, 그리고 쓰기 거부

autovacuum이 어떤 이유로 오래된 XID를 치우지 못하면 데이터베이스의 가장 오래된 XID가 랩어라운드 지점까지 4천만 트랜잭션 남았을 때부터 경고가 나옵니다.

WARNING:  database "mydb" must be vacuumed within 39985967 transactions
HINT:  To avoid XID assignment failures, execute a database-wide VACUUM in that database.

이 단계에서는 힌트대로 수동 VACUUM으로 해결할 수 있습니다. 다만 문서는 그 VACUUM을 슈퍼유저가 실행해야 한다고 적습니다. 그렇지 않으면 시스템 카탈로그를 처리하지 못해 datfrozenxid를 전진시킬 수 없습니다.

경고를 무시해서 남은 트랜잭션이 3백만 개 아래로 내려가면 새 XID 할당을 거부합니다.

ERROR:  database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database "mydb"
HINT:  Execute a database-wide VACUUM in that database.

이때 이미 진행 중인 트랜잭션은 계속할 수 있지만 새로 시작할 수 있는 것은 읽기 전용 트랜잭션뿐입니다. 레코드를 수정하거나 릴레이션을 TRUNCATE하는 작업은 실패합니다. VACUUM 명령은 평소처럼 실행됩니다.

쓰기 거부 상태에서 복구하는 순서

PostgreSQL 18 문서는 예전 릴리스에서 권하던 것과 달리 postmaster를 멈추거나 단일 사용자 모드로 들어갈 필요가 없고, 바람직하지도 않다고 적습니다. 대신 다음 순서를 따릅니다.

  1. 오래된 prepared transaction을 정리합니다. pg_prepared_xacts에서 age(transactionid)가 큰 행을 찾아 커밋하거나 롤백합니다.
  2. 오래 열려 있는 트랜잭션을 끝냅니다. pg_stat_activity에서 age(backend_xid)나 age(backend_xmin)이 큰 행을 찾아 커밋이나 롤백을 하거나, pg_terminate_backend로 세션을 종료합니다.
  3. 오래된 복제 슬롯을 삭제합니다. pg_replication_slots에서 age(xmin)이나 age(catalog_xmin)이 큰 슬롯을 찾습니다. 문서는 이런 슬롯이 이미 없어졌거나 오래 내려가 있는 서버를 위해 만든 경우가 많다고 하면서, 아직 존재하고 그 슬롯에 다시 붙으려 할 수 있는 서버의 슬롯을 지우면 그 복제본을 다시 구축해야 할 수 있다고 경고합니다.
  4. 대상 데이터베이스에서 데이터베이스 전체 VACUUM을 실행합니다. VACUUM FULL은 XID가 필요하고, VACUUM FREEZE는 정상 운영 회복에 필요한 최소한보다 많은 일을 하므로 쓰지 말라고 합니다.

정상으로 돌아온 뒤에는 대상 데이터베이스의 autovacuum 설정을 다시 확인하라는 것이 문서의 마지막 단계입니다. 1~3번을 VACUUM보다 먼저 하는 이유를 문서가 따로 설명하지는 않습니다. 다만 멀티트랜잭션 절에서 “트랜잭션 ID 랩어라운드와 달리 복제 슬롯은 멀티트랜잭션 정리를 직접 붙잡지 않는다”고 쓴 것을 보면, 적어도 복제 슬롯은 XID 쪽에서 정리를 붙잡는 요인으로 다뤄진다고 읽을 수 있습니다.

문서는 단일 사용자 모드가 시스템을 내려야 하고, 데이터 손실을 막는 랩어라운드 안전장치를 끄기 때문에 더 위험하다고 설명합니다. 쓸 이유가 있다면 불필요한 테이블을 TRUNCATE나 DROP해서 VACUUM할 필요 자체를 없애려는 경우뿐이며, 3백만 트랜잭션의 여유는 관리자가 그 작업을 할 수 있도록 남겨 둔 것이라고 합니다.

멀티트랜잭션 ID도 같은 구조다

여러 트랜잭션이 한 행을 동시에 잠그면 그 정보는 멀티트랜잭션 ID(MXID)로 인코딩됩니다. MXID도 32비트 카운터라서 같은 종류의 관리가 필요합니다. 테이블별로는 pg_class.relminmxid가 가장 오래된 MXID를 담고, 나이는 mxid_age()로 봅니다. 경고는 랩어라운드 지점까지 4천만 남았을 때, 새 MXID 생성 거부는 3백만 아래로 내려갔을 때라는 기준도 XID와 같습니다.

차이도 있습니다. XID가 고갈되면 모든 쓰기 트랜잭션이 막히지만, MXID가 고갈되면 MXID가 필요한 행 잠금을 쓰는 쓰기만 막힙니다. 복구 순서도 거의 같지만 몇 가지가 빠집니다. 실행 중인 트랜잭션과 prepared transaction은 멀티트랜잭션에 나타날 가능성이 없으면 무시해도 되고, 복제 슬롯은 멀티트랜잭션 정리를 직접 붙잡지 않으므로 슬롯 삭제는 보통 관련이 없습니다. MXID 정보는 pg_stat_activity 같은 뷰에 직접 보이지 않지만, 문서는 오래된 XID를 찾는 것이 원인 트랜잭션을 찾는 좋은 방법이라고 적습니다.

평소에 볼 것

문서에서 나온 것만 모으면 다음 정도입니다.

  • 위의 두 쿼리로 데이터베이스와 테이블별 age()를 추적합니다. 판단 기준이 되는 숫자는 문서에 있는 것, 곧 autovacuum_freeze_max_age(강제 autovacuum 시작), 랩어라운드까지 4천만(경고), 3백만(쓰기 거부)입니다.
  • pg_stat_activity에서 (to prevent wraparound) autovacuum이 언제 어느 테이블에 도는지 봅니다.
  • VACUUM (VERBOSE)나 log_autovacuum_min_duration으로 남는 autovacuum 로그에는 relfrozenxid와 relminmxid가 얼마나 전진했는지, 새로 동결된 페이지가 몇 개인지가 나옵니다.
  • 복구 순서에서 먼저 정리하라고 한 세 가지, 곧 pg_prepared_xacts, pg_stat_activity, pg_replication_slots의 age() 값은 문제가 생기기 전에도 같은 방식으로 조회할 수 있습니다.

autovacuum_freeze_max_age의 기본값 2억은 문서가 pg_xact 크기를 설명하며 든 값입니다. 이 값을 올릴지 말지는 저장 공간과, 랩어라운드 방지 autovacuum이 얼마나 자주 돌아도 괜찮은지를 놓고 각자 판단할 문제입니다.

참고한 문서

링크와 내용은 2026년 10월 19일에 확인했습니다.

블로그로