데이터베이스/Postgres

[PostgreSQL] 스토리지 레이어: 페이지 레이아웃 & TOAST

러러 2026. 5. 2. 21:51

왜 페이지가 8KB인가

PostgreSQL의 모든 테이블 데이터는 8KB 고정 크기 블록(페이지) 단위로 관리된다. 행이 1바이트짜리 값 하나를 담든, 수백 컬럼을 담든 관계없이 디스크 I/O의 최소 단위는 항상 8KB다. 이 결정은 PostgreSQL 초창기에 내려졌고, 오늘날까지 기본값으로 이어진다.

 

왜 8KB인가? 답은 세 가지 트레이드오프에 있다.

  • 행 크기와 페이지 크기의 균형이다.
    4KB 페이지를 쓰면 텍스트 컬럼 몇 개만 있어도 행 하나가 페이지를 꽉 채워 버리고, 페이지당 행이 하나뿐인 극단적인 상황이 생긴다. 반면 32KB처럼 너무 크면 단 한 행을 읽으려 해도 불필요하게 많은 데이터를 메모리에 올려야 한다.
    8KB는 "웬만한 행은 한 페이지 안에 담기고, 메모리 낭비도 크지 않다"는 실용적 타협점이다. 대부분의 파일시스템 블록(4KB)의 배수이기도 해서 디스크 I/O 정렬도 깔끔하다. 컴파일 시 --with-blocksize 옵션으로 1KB~32KB로 조정할 수 있지만, 운영 환경에서 기본값을 바꾸는 경우는 극히 드물다.
  • Shared Buffer 효율이다.
    백엔드는 디스크 대신 Shared Buffer에서 페이지를 가져온다. 버퍼 하나가 곧 페이지 하나다. 페이지 크기가 크면 한 번의 디스크 I/O로 더 많은 행을 함께 읽어올 수 있기 때문에 같은 페이지에 있는 다른 데이터를 재사용할 가능성이 높아져 Shared Buffer hit 효율이 좋아진다. 하지만 반대로 단 한 행만 필요해도 더 큰 페이지 전체를 메모리에 적재해야 하므로불필요한 데이터까지 함께 읽게 되어 메모리 낭비와 I/O 비용이 증가할 수 있다.즉, 페이지가 클수록 공간 지역성(Locality)의 이점은 커지지만, 동시에 읽기 증폭(Read Amplification)도 커진다. 8KB는 그 이득과 메모리 사용량 사이의 균형점이다.
  • 대형 값 처리 기준과의 관계다.
    한 행 안에 수만 바이트짜리 텍스트나 이미지가 들어있으면 8KB 페이지에 담을 수 없다. PostgreSQL은 이런 값을 별도 저장소로 자동 분리하는데(이를 TOAST라 한다), 그 분리 기준이 "페이지의 약 1/4인 2KB"다. 이 임계값 자체가 8KB 페이지 크기에서 도출된 것이다.

이 8KB 페이지 내부가 실제로 어떻게 구성되어 있는지를 이해하는 것이 중요한 이유는 간단하다. VACUUM이 어떻게 죽은 행을 회수하는지, 인덱스가 어떻게 행을 가리키는지, MVCC가 어떻게 가시성을 판단하는지 등등 이러한 메커니즘들은 페이지 내부 구조 위에서 작동한다.


핵심 개념과 용어

  • 페이지(Page) / 블록(Block): 디스크와 메모리 모두에서 8KB 고정 크기 단위를 가리킨다. PostgreSQL 소스에서는 Page(메모리 포인터 시각: 구조 자체)와 BlockNumber(디스크 위치 시각: 디스크 상 위치 번호 관점)로 구분하지만, 물리적 단위 자체는 같다. Shared Buffer에서는 이 page가 buffer slot 하나에 캐싱된다.
  • 튜플(Tuple): PostgreSQL에서 행 하나의 물리적 표현. 논리적 "행(row)"이 여러 버전의 튜플로 저장될 수 있다는 점에서 구분된다. MVCC 갱신은 기존 튜플을 수정하는 게 아니라 새 튜플을 추가한다.
  • ItemId (line pointer: 줄여서 lp라고 표현): 페이지 내에서 각 튜플의 위치와 길이를 가리키는 4바이트 포인터(슬롯). 인덱스는 행을 (BlockNumber, OffsetNumber) 쌍인 ctid로 참조하며, OffsetNumber가 ItemId 배열의 인덱스다.
  • ctid: 튜플의 물리적 위치를 나타내는 (BlockNumber, OffsetNumber) 쌍. (0, 3)이면 "블록 0의 ItemId[3]번 슬롯에 있는 튜플"이다. 인덱스는 행을 ctid로 가리키고, HeapTupleHeader 안의 t_ctid 필드도 이 형식을 쓴다. t_ctid가 자기 자신을 가리키면 현재 최신 버전이고, 다른 위치를 가리키면 더 새로운 버전이 존재한다는 신호다.
  • HeapTupleHeader: 튜플 앞에 붙는 23바이트 고정 구조체. 트랜잭션 ID(xmin/xmax), 명령 ID(cmin/cmax), 가시성 힌트 비트가 담기며, NULL 컬럼이 있을 때는 구조체 바로 뒤에 null 비트맵(컬럼당 1비트)이 추가된다. MVCC 가시성 판단의 핵심 데이터다.
  • TOAST (The Oversized-Attribute Storage Technique): 단일 컬럼 값이 일정 크기를 초과하면 자동으로 압축하거나 외부 테이블로 분리하는 메커니즘. 애플리케이션에게는 완전히 투명하다.

페이지의 물리 구조

전체 레이아웃

8KB 페이지는 다섯 영역으로 나뉜다.

 

PageHeader(소스: storage/page/bufpage.h)의 핵심 필드:

필드 크기 역할
pd_lsn 8B 이 페이지를 마지막으로 수정한 WAL 레코드의 LSN (WAL 스트림 내 위치를 가리키는 64비트 포인터)
pd_checksum 2B 데이터 무결성 체크섬 (data_checksums = on 시 사용)
pd_flags 2B 페이지 상태 비트 (PD_HAS_FREE_LINES, PD_ALL_VISIBLE 등)
pd_lower 2B 빈 공간 시작 오프셋 (ItemId 배열 끝)
pd_upper 2B 빈 공간 끝 오프셋 (튜플 영역 시작)
pd_special 2B Special Space 시작 오프셋 (힙은 8192)

 

새 행이 삽입될 때마다 pd_lower는 올라가고(ItemId 추가), pd_upper는 내려간다(튜플 데이터 추가). 둘이 만나는 순간 페이지가 꽉 찬 것이다.

ItemId: 튜플을 가리키는 슬롯

ItemId는 4바이트 안에 세 가지 정보를 비트 필드로 압축한다.

lp_flags는 네 가지 상태를 가진다:

  • LP_UNUSED (0): 미사용 슬롯
  • LP_NORMAL (1): 정상 튜플
  • LP_REDIRECT (2): HOT 업데이트로 이동한 슬롯 (다음 ItemId로 리다이렉트)
  • LP_DEAD (3): VACUUM이 회수 가능하다고 표시한 슬롯

인덱스의 ctid (0, 3)은 "블록 0, ItemId[3]번 슬롯의 튜플"을 가리킨다. 행이 HOT 업데이트되면 인덱스의 ctid는 그대로고, ItemId[3]이 LP_REDIRECT로 전환되어 새 튜플 슬롯을 가리키는 방식으로 인덱스 갱신 없이 최신 버전으로 도달한다.

HeapTupleHeader: MVCC의 물리적 기반

t_infomask의 힌트 비트는 성능 최적화의 핵심이다. 가시성 판단은 원칙적으로 pg_xact(내부적으로 CLOG라 부르는 커밋 로그)를 조회해야 하지만, 한 번 커밋 상태가 확인되면 그 결과를 XMIN_COMMITTED 비트에 기록한다. 이후 같은 튜플을 읽을 때는 CLOG 조회 없이 헤더 비트만 보면 된다. 이 힌트 비트는 WAL에 기록되지 않으므로 크래시 복구 후 다시 채워진다.

 

t_ctid가 자기 자신을 가리키면 현재 최신 버전이다. UPDATE 시 새 튜플이 삽입되고, 이전 튜플의 t_ctid가 새 튜플 위치로 업데이트되며 t_xmax에 갱신 트랜잭션 ID가 기록된다.

MAXALIGN: 튜플 정렬 패딩

PostgreSQL은 모든 튜플 데이터를 MAXALIGN(64비트 플랫폼 기준 8바이트) 경계에 맞춰 저장한다. CPU가 정렬되지 않은 주소에서 멀티바이트 값을 읽으면 성능 패널티가 발생하기 때문이다.

 

heap_page_itemslp_len에는 이 패딩이 이미 포함되어 있다. 실제 튜플 데이터가 85바이트라면 lp_len은 88(다음 8의 배수)이 된다. 패딩 영역은 쓰레기 값이 남아 있으며 읽을 때 무시된다.

 

튜플이 pd_upper에서 위로 채워질 때도 마찬가지다. 새 튜플 삽입 시 pd_upper -= MAXALIGN(tuple_size)가 적용되어 항상 8바이트 정렬된 위치에서 다음 튜플이 시작된다. 이 규칙 덕분에 모든 튜플이 하드웨어 정렬 요구사항을 자동으로 만족한다.

-- 행별 실제 데이터 크기와 MAXALIGN 적용 후 크기 비교
SELECT
    id,
    customer,
    pg_column_size(orders.*) AS data_bytes,
    ((pg_column_size(orders.*) + 7) / 8 * 8) AS aligned_bytes
FROM orders;
 id | customer | data_bytes | aligned_bytes
----+----------+------------+---------------
  1 | Alice    |         55 |            56
  2 | Bob      |        141 |           144
  3 | Carol    |         41 |            48

Bob 행의 data_bytes=141aligned_bytes=144로 올림된다. lp_len에는 HeapTupleHeader(24B) 오버헤드가 추가로 포함되므로 aligned_bytes와 직접 일치하지는 않으나, MAXALIGN 정렬 원칙은 lp_len 계산에도 동일하게 적용된다. 


행 삽입과 갱신의 내부 흐름

INSERT와 UPDATE가 페이지 레벨에서 어떻게 처리되는지를 추적해보자.

INSERT

1. 페이지를 메모리로 가져오기
PostgreSQL은 디스크를 직접 건드리지 않고 모든 작업은 Shared Buffer(공유 메모리 풀)에서 일어난다.
디스크 파일 -> Shared Buffer(메모리) -> 수정 -> 나중에 디스크로 flush
"pin + exclusive lock"은 쉽게 말해 "내가 지금 이 페이지 쓸게, 아무도 건드리지 마" 라고 예약하는 것이다.

 

2. 페이지 안에 공간 확보
pd_lower / pd_upper 개념이 여기서 실제로 쓰인다.

  1. pd_lower 위치에 ItemId 슬롯 하나 추가    (4바이트, 위에서 아래로)
  2. pd_upper를 tuple 크기만큼 줄이고          (아래에서 위로)
       그 위치에 실제 튜플 데이터를 씀
  3. 방금 만든 ItemId가 ②의 위치를 가리키게 연결

3. 튜플 헤더 채우기
튜플이 기록될 때 HeapTupleHeader 필드들이 아래 표와 같이 초기화된다.

필드 초기값 이유
t_xmin 현재 트랜잭션 XID "나를 만든 트랜잭션"
t_xmax 0 아직 아무도 삭제/갱신 안 함
t_ctid 자기 자신 (page, lp) 최신 버전임을 표시

 

4. WAL 먼저, 페이지 나중

PostgreSQL은 서버가 갑자기 꺼졌을 때를 대비해 페이지를 디스크에 쓰기 전에 반드시 WAL(Write-Ahead Log)에 먼저 기록한다. 이것이 "WAL-before-data 원칙"이다.

상황: INSERT 도중 서버 크래시

WAL 없이 페이지만 썼다면 → 반쯤 쓴 데이터가 디스크에 남음 → 복구 불가
WAL을 먼저 썼다면        → 재시작 시 WAL을 읽고 재적용   → 완벽 복구

WAL 기록 후 페이지의 pd_lsn 필드에 "나는 이 WAL 위치까지 반영했다"는 도장을 찍는다. 나중에 bgwriter가 페이지를 디스크에 flush할 때 이 LSN을 기준으로 안전 여부를 판단합니다.

 

5. dirty 마킹 후 unpin

수정된 페이지는 dirty 상태로 표시되고 Shared Buffer에 남는다. 디스크에 즉시 쓰지 않는 이유는 성능 때문이다. bgwriter나 checkpointer가 나중에 모아서 flush한다.

 

UPDATE는 INSERT + 논리적 DELETE의 결합

UPDATE는 기존 행을 수정하는 게 아니라 새 행을 만들고 기존 행을 죽은 것으로 표시한다. 이는 MVCC(다중 버전 동시성 제어) 때문에 다른 트랜잭션이 동시에 기존 버전을 읽고 있을 수 있으니 그냥 덮어쓸 수 없다.

UPDATE orders SET customer = 'Alice2' WHERE id = 1;

이때 페이지 내부에서 일어나는 일을 순서대로 보면:

[기존 튜플 - Alice]                [새 튜플 - Alice2]
t_xmin = 1016                      t_xmin = 1017  ← 새 트랜잭션
t_xmax = 0          →변경→         t_xmax = 0
t_ctid = (0,1)      →변경→         t_ctid = (0,4)  ← 자기 자신
                                   
기존 튜플에 추가:
t_xmax = 1017  ← "나는 1017번 트랜잭션에 의해 죽었다"
t_ctid = (0,4) ← "최신 버전은 저쪽에 있다"

이 체인 덕분에 PostgreSQL은 이전 버전을 추적할 수 있다.

 

HOT UPDATE — 같은 페이지 안에서의 최적화

UPDATE 시 새 튜플이 같은 페이지에 들어갈 수 있고, 인덱스 컬럼이 바뀌지 않았다면 HOT(Heap Only Tuple) 최적화가 적용된다. 일반 UPDATE는 인덱스도 새 항목을 추가해야 하는데, HOT은 그걸 생략한다.

일반 UPDATE:  인덱스 → 기존 튜플 → (t_ctid) → 새 튜플
              인덱스에도 새 항목 추가 필요 ← 비용 큼

HOT UPDATE:   인덱스 → 기존 튜플 → (t_ctid) → 새 튜플
              인덱스는 그대로, 체인만 따라가면 됨 ← 저렴

이를 위해 두 플래그가 사용된다.

  • 기존 튜플의 t_infomask2에 HEAP_HOT_UPDATED → "나는 HOT 방식으로 갱신됐다"
  • 새 튜플의 t_infomask2에 HEAP_ONLY_TUPLE → "나는 인덱스에 등록되지 않은 튜플이다"

DELETE — t_xmax만 건드린다

DELETE FROM orders WHERE id = 1;

실제로 하는 일은 딱 하나다.

t_xmax = 현재 트랜잭션 XID

데이터는 페이지에 그대로 남아 있다. 다른 트랜잭션이 이 행을 읽으려 할 때 t_xmax를 보고 "아, 이미 삭제된 행이구나"라고 판단할 뿐이다. 실제로 디스크에서 제거되는 건 VACUUM이 실행될 때다. VACUUM이 t_xmax가 설정된 튜플 중 더 이상 어떤 트랜잭션도 볼 필요가 없는 것들을 골라 공간을 회수한다.


TOAST: 8KB를 넘어서는 값의 처리

TOAST의 작동 조건

단일 행의 물리적 크기가 페이지의 약 1/4인 2KB(정확히는 TOAST_TUPLE_THRESHOLD = 2,040바이트)를 초과하면 TOAST를 적용한다. 이 임계값은 한 페이지에 최소 4개의 행이 들어갈 수 있어야 인덱스와 VACUUM이 효율적으로 작동한다는 설계 판단에서 나온다.

TOAST 저장 전략

컬럼마다 TOAST 처리 방식을 전략으로 지정할 수 있다. 기본값은 타입마다 다르며 아래와 같은 쿼리로 변경할 수 있다.

ALTER TABLE ... ALTER COLUMN ... SET STORAGE

 

PLAIN

TOAST를 아예 사용하지 않는 전략이다. 압축도 외부 저장도 하지 않고 항상 인라인으로 저장한다. integer, boolean 같은 고정 길이 타입에 적용되며, 가변 길이 타입에는 지정할 수 없다. 값이 페이지 크기를 초과하면 오류가 발생한다.

 

EXTENDED

text, jsonb, bytea 등 대부분의 가변 길이 타입의 기본 전략이다. 두 단계로 동작한다.

  • 1단계 — 압축 시도: 행이 임계값을 초과하면 먼저 인라인 압축을 시도한다. 압축 후 크기가 임계값 아래로 내려오면 외부 저장 없이 페이지에 그대로 둔다.
  • 2단계 — 외부 저장: 압축해도 여전히 크다면 TOAST 테이블로 옮긴다. 이때 저장되는 데이터는 압축된 상태다.

값을 읽을 때 압축 해제가 항상 수반되므로, 값 전체를 자주 읽는 컬럼이라면 CPU 비용을 고려해야 한다.

 

EXTERNAL

압축 없이 외부 저장만 한다. 행이 임계값을 초과하면 압축 시도 없이 바로 TOAST 테이블로 옮기며, 데이터는 원본 그대로 청크 단위로 저장된다.

 

압축을 건너뛰는 대신 부분 읽기가 가능하다는 장점이 있다. substring(col, 1, 100)처럼 일부만 필요한 쿼리는 (chunk_id, chunk_seq) 인덱스를 이용해 필요한 청크만 골라 읽는다. 압축된 데이터는 전체를 가져와야 원하는 위치를 찾을 수 있지만, EXTERNAL은 청크 번호로 바로 접근할 수 있다.

 

저장 공간을 더 쓰더라도 읽기 지연을 줄여야 하는 컬럼, 예를 들어 대용량 로그 텍스트나 일부만 조회하는 바이너리 데이터에 적합하다.

 

MAIN

인라인 저장을 최대한 유지하려는 전략이다. 행이 임계값을 초과하면 먼저 압축을 시도하고, 압축 후에도 크다면 외부 저장 대신 다른 컬럼을 먼저 TOAST 테이블로 밀어낸다. 이 컬럼 자신은 최후의 수단으로만 외부 저장된다.

인덱스나 자주 조인되는 컬럼처럼 값 전체가 메인 테이블에 있을 때 성능이 좋은 경우에 유용하다.

전략 설명 압축 외부 저장 부분 읽기
기본 적용 타입 적용 순서
PLAIN TOAST 금지
페이지 내 인라인 저장만 허용
없음 없음 불가 integer, boolean 등 고정 길이
EXTENDED 압축 후 외부 저장 (기본값) 있음 있음 불가 text, jsonb, bytea 등 순위 압축 → 그래도 크면 외부
EXTERNAL 압축 없이 외부 저장 없음 있음 가능
MAIN 가능한 한 인라인,
최후 수단으로 외부 저장
있음 최후 수단 불가 1순위 압축 → 압축으로 부족하면 외부

 

기본 전략은 EXTENDED다. text, bytea, jsonb 같은 가변 길이 타입이 대상이며, integer, timestamp 같은 고정 길이 타입은 항상 PLAIN이다.

 

EXTENDED 전략에서 TOAST 적용 여부를 결정하는 흐름은 다음과 같다.

TOAST 테이블 구조

TOAST 포인터

단일 행의 전체 크기가 2,040바이트(TOAST_TUPLE_THRESHOLD)를 초과하면, 해당 행에서 가장 큰 컬럼부터 순서대로 TOAST 테이블로 옮기고 원래 컬럼 자리에는 18바이트 포인터를 남긴다. 행 전체 크기가 임계값 아래로 내려올 때까지 이 과정을 반복한다.

┌──────┬──────┬────────────────────┐
│ va_header  │ va_type    │         varatt_external (16B)          │
│ (1B) = 0x01│ (1B)       │ rawsize │ extinfo │ valueid │ relid │
│            │            │   (4B)  │   (4B)  │   (4B)  │ (4B)  │
└──────┴──────┴────────────────────┘

TOAST 포인터(varlena pointer)는 18바이트로 구성된다:

  • va_rawsize: 압축 해제 후 원본 크기
  • va_extinfo: TOAST 테이블에 저장된 압축 크기 (PG 14 이전에는 va_extsize)
  • va_valueid: TOAST 테이블의 chunk_id (OID)
  • va_toastrelid: TOAST 테이블의 OID

va_header = 0x01은 bit 0이 1이므로 1바이트 헤더처럼 보이지만, 바로 뒤의 va_type이 외부 참조 포인터임을 나타낸다. TOAST 포인터도 varlena의 특수한 형태이다.

 

포인터를 이용해 실제 값을 읽을 때는 다음 순서로 진행된다.

① va_toastrelid로 TOAST 테이블 식별
        ↓
② va_valueid(chunk_id)로 해당 값의 청크 전체 조회
        ↓
③ chunk_seq 순서대로 청크 데이터를 이어 붙임
        ↓
④ va_rawsize와 비교하며 압축 해제 (압축된 경우)
        ↓
⑤ 원본 값 반환

TOAST 테이블에는 (chunk_id, chunk_seq) 인덱스가 있어 ②~③ 단계를 효율적으로 수행할 수 있다. 각 청크는 최대 1,996바이트이므로(2040에서 헤더등 기본값 제외), 예를 들어 원본이 6,000바이트라면 최소 3개의 청크를 읽고 합친다.

varlena 헤더와 TOAST 포인터의 구조

PostgreSQL의 모든 가변 길이 타입(text, bytea, jsonb 등)은 실제 데이터 앞에 varlena 헤더를 붙인다.이 헤더가 "길이가 얼마인가", "압축됐는가", "외부에 저장됐는가"를 인코딩한다.

디스크 저장 시 헤더 형식

헤더의 최하위 비트(bit 0)로 형태를 구분한다.

 

1바이트 헤더 — bit 0이 1일 때

나머지 7비트(bits 7..1)에 전체 길이(헤더 포함)를 저장한다. 데이터가 126바이트 이하일 때 적용되며 오버헤드가 1바이트뿐이다.

0x0d = 0b 0 0 0 0 1 1 0 1
 비트번호: 7 6 5 4 3 2 1 0
                         ^
                         └─ bit0=1: 1바이트 헤더

 bits 7..1: 0 0 0 0 1 1 0
          = 0b0000110 = 6  →  전체 6바이트 (헤더 1 + 데이터 5 = "Alice")

실제로 디스크에서 "Alice"는 0d 41 6c 69 63 65로 저장됩니다. 첫 바이트 0x0d가 헤더이고 나머지 5바이트가 데이터이다.

 

4바이트 헤더 — bit 0이 0일 때

데이터가 127바이트 이상이거나 압축이 적용된 경우 사용된다. bit 1로 압축 여부를 추가로 구분한다.

bit 0 = 0, bit 1 = 0  →  일반 인라인 데이터
bit 0 = 0, bit 1 = 1  →  pglz / lz4로 압축된 데이터

압축 알고리즘: pglz와 lz4

EXTENDED·MAIN 전략에서 압축에 사용되는 알고리즘은 두 가지다.

항목 pglz lz4
내장 여부 PostgreSQL 자체 구현 외부 라이브러리 (--with-lz4 빌드 필요)
압축률 높음 중간
압축·해제 속도 보통 매우 빠름 (약 10배 이상)
도입 버전 전통적 기본값 PostgreSQL 14+

 

default_toast_compression 파라미터로 인스턴스 기본값을 설정하고, 컬럼별로 오버라이드할 수 있다.

-- 현재 기본 압축 방식 확인
SHOW default_toast_compression;

-- 컬럼 단위 압축 방식을 lz4로 지정 (PG14+)
ALTER TABLE orders ALTER COLUMN memo SET COMPRESSION lz4;

-- 컬럼별 압축 설정 확인
SELECT
    attname,
    CASE attcompression
        WHEN 'p' THEN 'pglz'
        WHEN 'l' THEN 'lz4'
        ELSE '기본값 따름'
    END AS compression
FROM pg_attribute
WHERE attrelid = 'orders'::regclass
  AND attnum > 0
  AND NOT attisdropped;
  attname  | compression
-----------+-------------
 id        | 기본값 따름
 customer  | 기본값 따름
 amount    | 기본값 따름
 memo      | lz4

attcompression = 'l'은 lz4, 'p'는 pglz, 빈 문자열은 default_toast_compression 설정을 따른다. 대용량 텍스트를 빈번하게 읽는 컬럼(로그 본문, JSON 이벤트 데이터 등)에는 lz4가 조회 지연을 줄이는 데 유리하다. 저장 공간을 극한으로 줄여야 하는 아카이브성 컬럼에는 압축률이 높은 pglz가 낫다.


SQL로 직접 관찰하기

pageinspect 확장을 사용해 페이지 내부를 직접 들여다본다.

    CREATE EXTENSION IF NOT EXISTS pageinspect;
    CREATE EXTENSION IF NOT EXISTS pg_freespacemap;

    -- 관찰용 테이블 준비
    CREATE TABLE orders (
        id        serial PRIMARY KEY,
        customer  text,
        amount    numeric(10,2),
        memo      text
    );

INSERT INTO orders (customer, amount, memo) VALUES
    ('Alice',  1200.50, '일반 주문'),
    ('Bob',    980.00,  '긴급 배송 요청: 내일 오전까지 반드시 도착해야 합니다. 고객이 직접 요청함.'),
    ('Carol',  450.75,  NULL);

 PageHeader 관찰

SELECT
    lower,
    upper,
    special,
    pagesize,
    version,
    prune_xid
FROM page_header(get_raw_page('orders', 0));
 lower | upper | special | pagesize | version | prune_xid
-------+-------+---------+----------+---------+-----------
    36 |  7944 |    8192 |     8192 |       4 |         0

 

lower=36은 PageHeader(24B) + ItemId 3개(4B × 3 = 12B)의 합이다. pd_lower는 ItemId가 추가될 때마다 4씩 증가하므로, 행이 N개이면 lower = 24 + 4N이 된다. upper=7944는 7944~8192 = 288바이트가 실제 튜플 데이터로 사용되었음을 뜻한다. special=8192는 힙 테이블이라 Special Space가 없음을 확인한다.

ItemId 배열과 튜플 위치 관찰

SELECT
    lp,
    lp_off,
    lp_flags,
    lp_len
FROM heap_page_items(get_raw_page('orders', 0));
 lp | lp_off | lp_flags | lp_len
----+--------+----------+--------
  1 |   8136 |        1 |     55
  2 |   7992 |        1 |    141
  3 |   7944 |        1 |     41

튜플이 페이지 끝(8136)에서 위쪽으로 채워지는 구조가 보인다. lp=1의 오프셋은 8136, lp_len은 55(튜플 길이 55바이트), lp=2는 오프셋 7992(lp_len 141), lp=3은 오프셋 7944다. lp_flags=1은 LP_NORMAL 즉 살아있는 튜플이다.

HeapTupleHeader 직접 관찰

SELECT
    lp,
    t_xmin,
    t_xmax,
    t_ctid,
    t_infomask::bit(16)   AS infomask_bits,
    t_attrs[1]::text      AS col_id,
    t_attrs[2]::text      AS col_customer
FROM heap_page_item_attrs(
    get_raw_page('orders', 0),
    'orders'::regclass
);
 lp | t_xmin | t_xmax | t_ctid | infomask_bits    | col_id     | col_customer
----+--------+--------+--------+------------------+------------+--------------
  1 |   1016 |      0 | (0,1)  | 0000100000000010 | \x01000000 | \x0d416c696365
  2 |   1016 |      0 | (0,2)  | 0000100000000010 | \x02000000 | \x09426f62
  3 |   1016 |      0 | (0,3)  | 0000100000000011 | \x03000000 | \x0d4361726f6c

 

t_xmin / t_xmax — 트랜잭션 가시성

세 튜플 모두 t_xmin = 1016으로 동일한 트랜잭션에서 삽입되었으며, t_xmax = 0은 아직 어떤 트랜잭션도 이 튜플을 삭제하거나 갱신하지 않았음을 의미한다. MVCC 모델에서 t_xmax는 단순 삭제 표시가 아니라 "이 버전을 무효화한 트랜잭션 ID"이다. 0(또는 InvalidTransactionId)이면 무효화 사실 자체가 없으므로 해당 튜플은 현재 시점에서 살아있는(visible) 버전을 의미한다.

 

t_ctid — 버전 체인의 끝점

t_ctid = (0, N)이 자기 자신의 (page, lp)를 가리킨다. PostgreSQL은 UPDATE 시 기존 튜플의 t_ctid를 신규 버전의 위치로 교체하여 버전 체인을 구성한다. 세 튜플 모두 자기 자신을 가리키고 있으므로 체인이 더 이어지지 않는다는 뜻이며, 각 행의 최신(current) 버전임을 확인할 수 있다.

 

infomask — 비트 단위 해석

lp infomask (binary) 주요 플래그
1 (Alice) 0000 1000 0000 0010 HEAP_XMIN_COMMITTED
2 (Bob) 0000 1000 0000 0010 HEAP_XMIN_COMMITTED
3 (Carol) 0000 1000 0000 0011 HEAP_XMIN_COMMITTED + HEAP_HASNULL

infomask의 주요 비트 의미는 다음과 같다.

  • bit 0 (0x0001) — HEAP_HASNULL: 튜플 내에 NULL 값을 가진 컬럼이 하나 이상 존재함. 이 플래그가 켜지면 튜플 헤더 바로 뒤에 null bitmap이 추가로 기록되어 각 컬럼의 NULL 여부를 1비트씩 표현한다. Carol 행에만 이 비트가 set되어 있으므로 Carol 행에만 세 번째 컬럼(memo 등)이 NULL임을 알 수 있다.
  • bit 1 (0x0002) — HEAP_HASVARWIDTH: 가변 길이 컬럼(text, varchar, bytea 등)이 존재함. 세 튜플 모두 set되어 있는데, col_customer가 text 타입이기 때문이다.
  • bit 11 (0x0800) — HEAP_XMIN_COMMITTED: t_xmin 트랜잭션이 성공적으로 커밋되었음을 힌트 비트(hint bit)로 캐싱한 것이다. PostgreSQL은 매번 pg_xact를 조회하는 비용을 줄이기 위해 커밋 여부를 최초 확인 시점에 이 비트에 기록해 둔다. 세 튜플 모두 set되어 있으므로 xid 1016은 이미 커밋된 상태이다.

col_attrs의 바이너리 인코딩

col_customer 값도 raw bytes로 , varlena 헤더를 포함한 값이다.

lp hex 해석
1 0d 416c696365 0x0d = 길이 헤더(13 → 실제 길이 13 >> 1 = 6바이트 → "Alice" 5자 + varlena 1바이트), 416c696365 = ASCII "Alice"
2 09 426f62 0x09 → "Bob"
3 0d 4361726f6c 0x0d → "Carol"

varlena의 1바이트 헤더에서 최하위 1비트가 0이면 1-byte header(길이 ≤ 126바이트), 값은 length << 1로 인코딩된다. 예를 들어 0x0d = 0b00001101이므로 실제 데이터 길이는 0x0d >> 1 = 6바이트(헤더 1 + 데이터 5 = "Alice")입니다.

UPDATE 후 MVCC 구조 관찰

UPDATE orders SET amount = 1300.00 WHERE id = 1;

SELECT
    lp,
    lp_flags,
    t_xmin,
    t_xmax,
    t_ctid
FROM heap_page_items(get_raw_page('orders', 0));
 lp | lp_flags | t_xmin | t_xmax | t_ctid
----+----------+--------+--------+--------
  1 |        1 |   1016 |   1018 | (0,4)
  2 |        1 |   1016 |      0 | (0,2)
  3 |        1 |   1016 |      0 | (0,3)
  4 |        1 |   1018 |      0 | (0,4)

lp=1의 t_xmax에 갱신 트랜잭션 ID(1018)가 기록되었고, t_ctid가 (0,4)로 새 튜플을 가리킨다. lp=4가 새로 삽입된 튜플이며 t_xmin=1018, t_ctid=(0,4)로 현재 최신이다. xmax(1018)가 커밋된 것으로 보이는 스냅샷은 lp=1을 "삭제된 버전"으로 간주하고 lp=4를 현재 유효한 행으로 읽는다. 1018이 아직 커밋되지 않은 스냅샷은 lp=1(amount=1200)을 유효한 현재 행으로 본다.

TOAST 작동 확인

-- TOAST가 적용될 만큼 큰 값 삽입
CREATE EXTENSION IF NOT EXISTS pgcrypto;

UPDATE orders
SET memo = encode(gen_random_bytes(1000), 'base64') ||
           encode(gen_random_bytes(1000), 'base64') ||
           encode(gen_random_bytes(1000), 'base64')
WHERE id = 2;

-- TOAST 테이블 이름 확인
SELECT
    relname,
    reltoastrelid::regclass AS toast_table
FROM pg_class
WHERE relname = 'orders';

영문/한글 반복 텍스트는 EXTENDED 전략이 기본이므로 실제로는 압축이 적용되어 원본보다 작을 수 있다. 따라서 base64로 인코딩해 저장해 압축이 거의 되지 않도록 저장했다.

 relname |       toast_table
---------+-------------------------
 orders  | pg_toast.pg_toast_17394

 

-- TOAST 테이블에 실제 청크 저장 확인
SELECT
    chunk_id,
    chunk_seq,
    octet_length(chunk_data) AS chunk_bytes
FROM pg_toast.pg_toast_17394
ORDER BY chunk_id, chunk_seq;
 chunk_id | chunk_seq | chunk_bytes
----------+-----------+-------------
    17441 |         0 |        1996
    17441 |         1 |        1996
    17441 |         2 |          67

 

약 4,000바이트 값이 3개의 청크로 분할되어 저장되었다. 

 

4059 바이트를 1996씩 나누면 3개의 청크가 생기고, 메인 테이블의 memo 컬럼 자리에는 18바이트 TOAST 포인터만 남게된다.

 

컬럼별 TOAST 전략 조회

SELECT
    attname,
    atttypid::regtype    AS type,
    attstorage           AS storage_code,
    CASE attstorage
        WHEN 'p' THEN 'PLAIN'
        WHEN 'e' THEN 'EXTERNAL'
        WHEN 'm' THEN 'MAIN'
        WHEN 'x' THEN 'EXTENDED'
    END                  AS storage_strategy
FROM pg_attribute
WHERE attrelid = 'orders'::regclass
  AND attnum > 0
  AND NOT attisdropped;
  attname  |    type    | storage_code | storage_strategy
-----------+------------+--------------+------------------
 id        | integer    | p            | PLAIN
 customer  | text       | x            | EXTENDED
 amount    | numeric    | m            | MAIN
 memo      | text       | x            | EXTENDED

integer는 항상 PLAIN이다. numeric은 가변 길이지만 보통은 작으므로 MAIN(인라인 저장을 우선하되 필요하면 압축 후 외부 저장)이 된다. text는 EXTENDED가 기본이다. 전략 변경은 ALTER TABLE orders ALTER COLUMN memo SET STORAGE EXTERNAL;로 가능하다.

Free Space Map으로 빈 공간 추적

SELECT
    blkno,
    avail
FROM pg_freespace('orders')
ORDER BY blkno;
 blkno | avail
-------+-------
     0 |   608

페이지 0에 608바이트의 빈 공간이 있다. PostgreSQL은 이 FSM(Free Space Map) 정보를 이용해 새 행을 삽입할 페이지를 빠르게 찾는다. FSM은 테이블 파일과 나란히 {relfilenode}_fsm 이름의 파일로 저장된다($PGDATA/base/{db_oid}/{relfilenode}_fsm). VACUUM이 이 파일을 갱신한다.

Dead Tuple 누적과 VACUUM 전 페이지 상태

DELETE와 UPDATE를 반복하면 페이지에 dead tuple이 쌓인다. VACUUM이 실행되기 전까지 이 공간은 재사용되지 않으며, 테이블 팽창(Bloat)의 직접적 원인이 된다.

-- UPDATE를 두 번 더 실행해 dead tuple 생성
UPDATE orders SET amount = 1400.00 WHERE id = 1;
UPDATE orders SET amount = 1500.00 WHERE id = 1;

-- 페이지 내 모든 튜플 상태 관찰
SELECT
    lp,
    lp_flags,
    t_xmin,
    t_xmax,
    t_ctid,
    CASE lp_flags
        WHEN 0 THEN 'LP_UNUSED'
        WHEN 1 THEN 'LP_NORMAL'
        WHEN 2 THEN 'LP_REDIRECT'
        WHEN 3 THEN 'LP_DEAD'
    END AS flag_name
FROM heap_page_items(get_raw_page('orders', 0))
ORDER BY lp;
 lp | lp_flags | t_xmin | t_xmax | t_ctid | flag_name
----+----------+--------+--------+--------+-----------
  1 |        1 |   1016 |   1018 | (0,4)  | LP_NORMAL
  2 |        1 |   1016 |   1019 | (0,5)  | LP_NORMAL
  3 |        1 |   1016 |      0 | (0,3)  | LP_NORMAL
  4 |        1 |   1018 |   1023 | (0,7)  | LP_NORMAL
  5 |        1 |   1019 |   1021 | (0,6)  | LP_NORMAL
  6 |        1 |   1021 |      0 | (0,6)  | LP_NORMAL
  7 |        1 |   1023 |   1024 | (0,8)  | LP_NORMAL
  8 |        1 |   1024 |      0 | (0,8)  | LP_NORMAL

lp=1, lp=4, lp=5는 t_xmax가 설정된 dead tuple이다. lp_flags가 아직 LP_NORMAL인 것은 VACUUM이 스캔하기 전이기 때문이다. VACUUM이 이 튜플들을 확인하고 나서야 ItemId가 LP_DEAD(3)로 전환된다. 현재 실제로 살아있는 행은 lp=2(Bob), lp=3(Carol), lp=6(Alice 최신 버전) 세 개뿐이다.

-- dead tuple 누적 통계 확인
SELECT
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'orders';

 

 n_live_tup | n_dead_tup | last_vacuum | last_autovacuum
------------+------------+-------------+-----------------
          3 |          5 |             |

n_dead_tup=5이고 last_vacuum, last_autovacuum이 모두 NULL이다. autovacuum의 autovacuum_vacuum_threshold(기본 50행) + autovacuum_vacuum_scale_factor(기본 0.2) 조건을 충족하면 autovacuum이 트리거된다. 지금처럼 소규모 테이블에서는 임계값을 넘지 않아 자동으로 실행되지 않을 수 있다. 

VACUUM 실행과 정리 결과

VACUUM 실행

VACUUM VERBOSE orders;

VERBOSE 옵션을 붙이면 VACUUM이 무엇을 했는지 상세하게 출력된다.

INFO:  vacuuming "pgmq.public.orders"
INFO:  finished vacuuming "pgmq.public.orders": index scans: 0
pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 eagerly scanned
tuples: 5 removed, 3 remain, 0 are dead but not yet removable ->  dead tuple 5개 제거, 살아있는 튜플 3개 유지
removable cutoff: 1025, which was 0 XIDs old when operation ended
new relfrozenxid: 1025, which is 10 XIDs ahead of previous value
frozen: 1 pages from table (100.00% of total) had 3 tuples frozen → 트랜잭션 ID 동결(freeze) 처리
visibility map: 1 pages set all-visible, 1 pages set all-frozen (0 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
avg read rate: 0.000 MB/s, avg write rate: 62.919 MB/s
buffer usage: 20 hits, 0 reads, 6 dirtied
WAL usage: 7 records, 6 full page images, 40875 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
INFO:  vacuuming "pgmq.pg_toast.pg_toast_17394" -> 도 함께 VACUUM됐음을 확인
INFO:  finished vacuuming "pgmq.pg_toast.pg_toast_17394": index scans: 0
pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 eagerly scanned
tuples: 0 removed, 3 remain, 0 are dead but not yet removable
removable cutoff: 1025, which was 0 XIDs old when operation ended
new relfrozenxid: 1021, which is 6 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 1 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
avg read rate: 0.000 MB/s, avg write rate: 93.006 MB/s
buffer usage: 15 hits, 0 reads, 5 dirtied
WAL usage: 5 records, 5 full page images, 37446 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

 

dead tuple 정리 확인

SELECT
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'orders';
 n_live_tup | n_dead_tup |          last_vacuum          | last_autovacuum
------------+------------+-------------------------------+-----------------
          3 |          0 | 2026-05-02 12:40:08.863791+00 |

n_dead_tup이 0으로 줄고 last_vacuum에 방금 실행한 시각이 찍히면 정리가 완료되었다.

 

페이지 내부 상태 확인

SELECT
    lp,
    lp_flags,
    t_xmin,
    t_xmax,
    t_ctid,
    CASE lp_flags
        WHEN 0 THEN 'LP_UNUSED'
        WHEN 1 THEN 'LP_NORMAL'
        WHEN 2 THEN 'LP_REDIRECT'
        WHEN 3 THEN 'LP_DEAD'
    END AS flag_name
FROM heap_page_items(get_raw_page('orders', 0))
ORDER BY lp;

 

 lp | lp_flags | t_xmin | t_xmax | t_ctid |  flag_name
----+----------+--------+--------+--------+-------------
  1 |        2 |        |        |        | LP_REDIRECT
  2 |        2 |        |        |        | LP_REDIRECT
  3 |        1 |   1016 |      0 | (0,3)  | LP_NORMAL
  4 |        0 |        |        |        | LP_UNUSED
  5 |        0 |        |        |        | LP_UNUSED
  6 |        1 |   1021 |      0 | (0,6)  | LP_NORMAL
  7 |        0 |        |        |        | LP_UNUSED
  8 |        1 |   1024 |      0 | (0,8)  | LP_NORMAL

 

VACUUM 후 dead tuple 슬롯은 상황에 따라 다르게 처리된다. HOT 체인의 시작점이었던 슬롯은 LP_REDIRECT(2)로 전환되어 인덱스 참조를 유지하고, 체인 중간의 불필요한 슬롯은 LP_UNUSED(0)으로 전환되어 재사용 가능한 상태가 된다.