PostgreSQL MVCC와 XID Wraparound - 튜플 가시성, XID 비교, VACUUM FREEZE의 동작 원리

Share

Last Updated on 8월 15, 2026 by Jade(정현호)

PostgreSQL의 MVCC를 이해하려면 튜플 버전, xmin과 xmax, Snapshot, 트랜잭션 ID(XID)의 관계를 함께 살펴봐야 합니다.

특히 XID는 32비트 범위에서 순환하기 때문에 단순한 숫자 비교만으로는 트랜잭션의 과거와 미래를 안전하게 구분할 수 없습니다. PostgreSQL은 XID의 상대적인 선후 관계를 비교하고, VACUUM FREEZE를 통해 오래된 튜플을 frozen 상태로 표시해 MVCC 가시성을 유지합니다.

이번 글에서는 다음 내용을 중심으로 PostgreSQL의 MVCC 가시성 판단과 XID wraparound 방지 원리를 살펴보겠습니다.

  • UPDATE 후 기존 튜플이 남아 있는 이유
  • xmin, xmax Snapshot을 이용한 튜플 가시성 판단
  • XID의 32비트 구조와 wraparound
  • 원형 XID 공간과 TransactionIdPrecedes()
  • VACUUM FREEZE가 필요한 이유

1. PostgreSQL MVCC

1.1 MVCC 개념

PostgreSQL은 여러 트랜잭션이 같은 데이터를 동시에 읽고 수정할 수 있도록 MVCC(Multi-Version Concurrency Control)를 사용합니다.

PostgreSQL에서의 MVCC의 핵심은 데이터를 수정할 때 기존 값을 즉시 덮어쓰지 않고, 새로운 튜플 버전을 생성하는 것입니다. 트랜잭션마다 적절한 데이터 버전을 선택해 읽기 때문에 읽기 작업과 쓰기 작업이 서로 불필요하게 차단되는 상황을 줄일 수 있습니다.

이와 같이 PostgreSQL의 MVCC는 데이터의 여러 버전을 관리하고 트랜잭션마다 조회 가능한 버전을 구분하여, 읽기와 쓰기 작업이 동시에 수행되는 환경에서 동시성을 확보하기 위해 사용됩니다.

1.2 UPDATE 시 튜플 버전 생성

다음과 같은 레코드가 있다고 가정하겠습니다.

id = 1, balance = 100

어떤 트랜잭션이 balance를 200으로 변경하면, 논리적으로는 하나의 레코드가 변경되지만 테이블 내부에는 다음과 같은 튜플 버전이 남을 수 있습니다.

기존 튜플: balance = 100
새 튜플:   balance = 200

기존 튜플은 즉시 물리적으로 제거되지 않습니다. 현재 트랜잭션의 Snapshot에 따라 이전 버전이 계속 보여야 할 수 있기 때문입니다.

따라서 PostgreSQL에서 하나의 논리적인 레코드는 내부적으로 여러 튜플 버전으로 존재할 수 있습니다. UPDATE나 DELETE로 더 이상 필요하지 않게 된 튜플은 이후 VACUUM이 정리할 수 있습니다.

2. 튜플의 xmin, xmax와 가시성 확인

2.1 튜플 헤더의 xmin과 xmax

PostgreSQL은 각 튜플 버전의 헤더에 트랜잭션과 관련된 정보를 기록합니다.

xmin: 해당 튜플 버전을 생성한 트랜잭션의 XID
xmax: 해당 튜플을 삭제하거나 갱신한 트랜잭션의 XID

UPDATE가 발생하면 일반적으로 다음과 같은 관계가 만들어집니다.

  • 기존 튜플의 xmax 에는 UPDATE를 수행한 트랜잭션의 XID가 기록됩니다.
  • 새 튜플의 xmin 에는 새 튜플을 생성한 트랜잭션의 XID가 기록됩니다.

PostgreSQL은 이 정보를 Snapshot 및 트랜잭션 커밋 상태와 함께 확인해 현재 트랜잭션에서 어떤 튜플 버전이 보여야 하는지 판단합니다.

2.2 Snapshot 구성

Snapshot은 특정 시점에 어떤 트랜잭션이 완료되었고 어떤 트랜잭션이 진행 중이었는지를 나타냅니다. 개념적으로 Snapshot은 다음 정보를 포함합니다.

  • xmin: Snapshot 생성 시점에 확인된 가장 이른 활성 트랜잭션 ID
  • xmax: Snapshot 생성 시점에 아직 완료되지 않은 트랜잭션의 경계
  • 진행 중인 트랜잭션 목록

일반적으로 Snapshot을 기준으로 다음과 같은 점을 확인합니다.

  1. 튜플을 생성한 트랜잭션이 커밋되었는가
  2. 해당 트랜잭션이 Snapshot 생성 시점에 아직 실행 중이었는가
  3. 생성 XID가 Snapshot의 과거·미래 범위에서 어디에 위치하는가
  4. 튜플이 다른 트랜잭션에 의해 삭제되거나 갱신되었는가

따라서 튜플의 xmin 값이 현재 XID보다 작다고 해서 무조건 visible한 것은 아닙니다. PostgreSQL은 XID의 상대적인 선후 관계, Snapshot, pg_xact 에 기록된 커밋 상태 등을 함께 확인합니다.

3. XID의 구조와 XID Wraparound

3.1 XID 할당 시점

PostgreSQL의 일반적인 XID는 모든 세션에 트랜잭션 시작과 동시에 할당되지 않습니다. 트랜잭션이 데이터베이스에 처음으로 쓰기 작업을 수행할 때 할당됩니다.

따라서 SELECT만 수행하는 읽기 전용 트랜잭션은 일반 XID를 할당받지 않을 수 있습니다. 반면 데이터 변경 작업을 수행한 트랜잭션은 일반 XID를 사용하게 됩니다.

일반 XID는 특정 데이터베이스에만 종속되지 않습니다. PostgreSQL 클러스터 전체에서 사용하는 전역 카운터를 통해 할당되므로, 여러 데이터베이스의 쓰기 트랜잭션이 같은 XID 공간을 함께 소비합니다.

3.2 XID 32비트 구조

튜플에 저장되는 일반적인 XID는 32비트 값입니다.

0 ~ 4,294,967,295

튜플 헤더에 저장되는 XID를 고정된 크기로 유지하면 튜플마다 추가되는 관리 정보의 크기를 일정하게 유지할 수 있고, Snapshot과 XID를 비교하는 작업도 단순한 정수 연산으로 처리할 수 있습니다.

대신 32비트 공간은 유한하므로 약 43억 개의 XID가 사용되면 카운터가 순환합니다. 이를 XID wraparound라고 합니다.

PostgreSQL은 장기간의 번호 관리를 위해 epoch를 포함하는 더 넓은 내부 표현과 xid8도 사용합니다. 그러나 튜플 가시성 및 저장 형식과 관련된 일반 XID의 핵심 문제는 여전히 32비트 공간의 순환입니다.

3.3 특수 XID

일부 XID 값은 일반 트랜잭션을 나타내지 않는 특수한 의미로 예약되어 있습니다.

XID 의미
0 InvalidTransactionId
1 BootstrapTransactionId
2 FrozenTransactionId

XID 0~2는 일반 트랜잭션에 할당되는 값이 아닙니다.

  • XID 0은 유효하지 않은 트랜잭션을 의미합니다.
  • XID 1은 데이터베이스 초기화 시점에 사용되는 값입니다.
  • XID 2는 FrozenTransactionId로, freeze된 튜플을 영원히 과거의 튜플로 처리할 때 사용됩니다.

일반 트랜잭션은 보통 3번에 해당하는 FirstNormalTransactionId 부터 시작합니다.

4. XID 비교와 TransactionIdPrecedes()

4.1 XID 숫자 비교의 한계

XID가 순환하면 숫자의 크기만으로는 트랜잭션의 실제 선후 관계를 판단할 수 없습니다.

예를 들어 다음과 같은 시간 순서를 가정하겠습니다.

4,200,000,000
...
4,294,967,295
3

현재 XID가 3 일 때 레코드의 XID가 4,200,000,000 이라면, 일반적인 숫자 비교에서는 레코드 XID가 현재 XID보다 훨씬 큰 미래의 값처럼 보입니다. 그러나 실제로는 랩어라운드 직전에 생성된 과거의 XID일 수 있습니다.

따라서 PostgreSQL은 XID 공간을 직선이 아니라 끝과 시작이 연결된 원형 구조로 보고 상대적인 거리를 계산합니다.

4.2 XID 비교 범위

일반 XID는 modulo-2³² 방식으로 비교됩니다. 두 XID 사이의 상대적 거리가 2³¹보다 작은 범위에서는 어느 XID가 과거이고 미래인지 안정적으로 구분할 수 있습니다.

반대로 이 범위를 넘어가면 같은 XID 숫자가 과거처럼 보일 수도 있고 미래처럼 보일 수도 있습니다. VACUUM FREEZE가 필요한 이유도 오래된 튜플이 이 안전한 비교 범위를 넘어가지 않도록 하기 위해서 입니다.

4.3 TransactionIdPrecedes() 함수

PostgreSQL은 XID의 숫자 크기만 비교하지 않고  TransactionIdPrecedes() 와 같은 비교 로직을 사용해 두 XID의 상대적인 선후 관계를 판단합니다.

개념적으로 비교 로직은 다음과 같습니다.

static inline bool
TransactionIdPrecedes(TransactionId id1, TransactionId id2)
{
    int32 diff;

    if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
        return (id1 < id2);

    diff = (int32) (id1 - id2);
    return (diff < 0);
}

두 XID가 일반 XID이면 먼저 32비트 범위에서 뺄셈한 뒤, 그 결과를 signed int32 로 해석합니다. 결과가 음수이면 첫 번째 XID가 두 번째 XID보다 앞선 것으로 판단합니다.

실제 소스 코드는 PostgreSQL 버전에 따라 세부 구현이나 매크로 형태가 달라질 수 있습니다. 여기서는 XID 비교의 핵심 원리를 설명하기 위해 개념적인 형태로 제시합니다.

4.4 XID 비교 사례

예시 1: Wraparound 이전 XID

다음과 같이 가정합니다.

  • 현재 XID: 3
  • 레코드 XID: 4,200,000,000
diff = 4,200,000,000 - 3
     = 4,199,999,997

이 값은 unsigned 32비트 범위 안에 있지만 signed int32의 최대값인 2,147,483,647보다 큽니다. 따라서 signed int32로 해석하면 음수가 됩니다.

4,199,999,997 - 4,294,967,296 = -94,967,299

결과적으로 다음 비교는 true가 됩니다.

TransactionIdPrecedes(4,200,000,000, 3)

PostgreSQL은 이 범위에서 레코드 XID를 현재 XID보다 과거로 판단합니다.

예시 2: 현재 XID 이후의 XID

현재 XID가 3이고 레코드 XID가 10 이라면 다음과 같습니다.

diff = 10 - 3
     = 7

결과가 음수가 아니므로 다음 비교는 false입니다.

TransactionIdPrecedes(10, 3)

이 결과는 XID 10 이 현재 XID 3 보다 과거가 아니라고 판단되었음을 의미합니다. 다만 이것만으로 튜플이 최종적으로 invisible하다고 단정할 수는 없습니다.

5. 튜플 가시성 확인

TransactionIdPrecedes()는 튜플 가시성 판단 전체가 아니라 XID의 선후 관계를 확인하는 단계입니다. 실제 가시성 판단은 Snapshot과 트랜잭션 상태를 함께 확인하는 과정입니다.

개념적인 흐름은 다음과 같습니다.

튜플의 xmin/xmax 확인
        ↓
생성·삭제 트랜잭션의 XID 선후 관계 확인
        ↓
Snapshot의 xmin, xmax, 진행 중인 XID 확인
        ↓
pg_xact의 커밋 상태 확인
        ↓
현재 트랜잭션에서 visible 여부 결정

         

5.1 튜플 생성 트랜잭션 확인

튜플의 xmin 에 기록된 트랜잭션이 커밋되었고 현재 Snapshot 기준으로 과거에 해당하면, 해당 튜플 버전은 visible 후보가 됩니다.

반대로 다음과 같은 튜플은 일반적으로 현재 Snapshot에서 보이지 않습니다.

  • 아직 커밋되지 않은 트랜잭션이 생성한 튜플
  • 롤백된 트랜잭션이 생성한 튜플
  • Snapshot 생성 시점에 진행 중이었던 트랜잭션이 생성한 튜플
  • 현재 Snapshot 기준으로 미래에 해당하는 튜플

5.2 튜플 삭제 및 갱신 여부 확인

튜플의 xmax 에는 해당 튜플을 삭제하거나 갱신한 트랜잭션의 XID가 기록될 수 있습니다.

PostgreSQL은 xmax 에 기록된 트랜잭션의 커밋 여부와 Snapshot과의 관계를 확인해 다음을 판단합니다.

  • 기존 튜플을 계속 보여줄 것인가
  • 기존 튜플을 숨기고 새 튜플 버전을 보여줄 것인가
  • 삭제된 튜플로 처리할 것인가

결국 튜플의 visible 여부는 단순히 xmin 이나 현재 XID 하나만으로 결정되지 않습니다. 튜플 헤더, Snapshot, 트랜잭션 커밋 상태, 삭제·갱신 상태를 함께 확인한 결과입니다.

6. XID Wraparound을 막는 VACUUM FREEZE

6.1 오래된 튜플과 Wraparound

일반 XID는 modulo-2³² 방식으로 비교되므로, 튜플이 생성된 뒤 약 2³¹개의 XID가 지나면 과거와 미래를 구분하는 안전한 범위를 벗어날 수 있습니다.

이 상태에서 오래된 튜플이 계속 unfrozen 상태로 남아 있으면, 실제로는 과거에 커밋된 튜플이 미래에 생성된 튜플처럼 판단될 수 있습니다. 그러면 정상적으로 존재하는 데이터가 현재 Snapshot에서 보이지 않는 문제가 발생할 수 있습니다.

6.2 Freeze 처리

PostgreSQL은 충분히 오래된 튜플을 frozen 상태로 표시합니다. Frozen 튜플은 생성 트랜잭션을 FrozenTransactionId 로 간주하며, 모든 일반 트랜잭션보다 오래된 튜플로 처리됩니다.

FrozenTransactionId는 일반 XID 비교 규칙을 따르지 않는 특수 XID입니다. 따라서 일반 XID가 wraparound하더라도 frozen 튜플은 계속 과거의 튜플로 판단할 수 있습니다.

현대 PostgreSQL에서는 일반적으로 튜플의 xmin 값을 실제로 2 로 덮어쓰기보다, 튜플 헤더의 freeze 관련 플래그를 설정하면서 기존 xmin 정보를 보존합니다. 다만 구버전에서 업그레이드된 데이터베이스에는 xmin = 2 인 튜플이 남아 있을 수 있습니다.

6.3 VACUUM 역할

VACUUM은 UPDATE나 DELETE로 더 이상 필요하지 않은 dead tuple을 정리하는 작업으로 잘 알려져 있습니다. 그러나 XID 관점에서는 다음과 같은 역할도 수행합니다.

  • 오래된 튜플을 frozen 상태로 표시
  • 테이블의 relfrozenxid 를 전진
  • XID wraparound 위험 완화
  • Visibility Map 갱신
  • 이후 인덱스 전용 스캔 등의 효율 향상

따라서 VACUUM은 디스크 공간을 재사용하기 위한 유지보수 작업인 동시에, MVCC 가시성과 데이터베이스의 장기적인 안정성을 유지하는 작업입니다.

6.4 XID Wraparound 발생 시 오류

오래된 XID를 충분히 freeze하지 못하면 PostgreSQL은 wraparound로 인한 데이터 접근 문제를 막기 위해 새로운 XID 할당을 제한할 수 있습니다.

대표적으로 다음과 같은 오류가 발생할 수 있습니다.

ERROR: database is not accepting commands that assign new transaction IDs
       to avoid wraparound data loss in database "mydb"

이 상태는 단순한 성능 저하가 아니라 트랜잭션 ID 고갈을 막기 위한 보호 동작입니다. 실제 장애 상황에서는 장기 실행 트랜잭션, 오래된 prepared transaction, replication slot, autovacuum 지연 여부를 함께 확인해야 합니다.

구체적인 복구 절차는 PostgreSQL 버전과 장애 상태에 따라 달라질 수 있으므로, 단일 사용자 모드 전환이나 서버 중지를 일률적으로 적용해서는 안 됩니다.

7. 정리

PostgreSQL의 MVCC는 UPDATE 시 기존 튜플을 즉시 덮어쓰지 않고 새로운 튜플 버전을 생성하는 방식으로 동작합니다. PostgreSQL은 튜플의 xmin 과 xmax, Snapshot, 트랜잭션 커밋 상태를 함께 확인해 현재 트랜잭션에서 어떤 튜플 버전이 보여야 하는지 결정합니다.

XID는 이 과정에서 트랜잭션의 선후 관계를 판단하는 핵심 정보입니다. 하지만 일반 XID는 32비트 공간에서 순환하므로 단순한 숫자 비교를 사용할 수 없습니다. PostgreSQL은 원형 XID 비교를 사용하고, 안전한 범위를 벗어나기 전에 VACUUM FREEZE를 통해 오래된 튜플을 영원한 과거의 튜플로 처리합니다.

결국 VACUUM은 단순한 dead tuple 정리 작업이 아닙니다. MVCC 가시성을 유지하고 XID wraparound을 방지하며, PostgreSQL이 장기간 정상적으로 동작하도록 하는 핵심 유지보수 작업입니다.

Reference

0
글에 대한 당신의 생각을 기다립니다. 댓글 의견 주세요!x