4장. Storage · Tuple · TOAST
3장이 MVCC의 논리였다면, 이 장은 그 논리가 디스크 위에 실제로 놓이는 모습입니다. 모든 것의 단위는 8KB 페이지 — 그 안에 튜플이 쌓이는 법, 큰 값이 밀려나는 법(TOAST)을 그림으로 봅니다.
① 8KB 페이지의 다섯 영역과 튜플이 쌓이는 방식 ② 튜플 헤더와 컬럼 정렬(alignment)의 비용 ③ fillfactor와 HOT update의 관계 ④ 2KB를 넘는 값이 TOAST로 밀려나는 과정과 VM·FSM·CTID
4.1 8KB 페이지의 해부 — 양 끝에서 자라는 구조
PostgreSQL의 모든 heap·인덱스 파일은 8KB 고정 크기 페이지(block)로 구성됩니다. 테이블 파일은 1GB 단위 세그먼트로 쪼개지고요. 페이지 내부는 다섯 영역입니다: PageHeader(24B) → ItemId 배열 → Free Space → Tuples → Special. 재미있는 점은 ItemId는 앞에서, 튜플은 뒤에서 자라 가운데 빈 공간을 좁혀 간다는 것. INSERT가 반복될 때 무슨 일이 벌어지는지 봅시다.
첫 INSERT. 페이지 앞쪽에 4바이트 ItemId(line pointer)가, 페이지 맨 뒤에 튜플 본체가 기록됩니다. ItemId가 튜플의 위치(offset)와 길이를 가리킵니다.
INSERT가 이어지면 ItemId는 앞에서 뒤로, 튜플은 뒤에서 앞으로 쌓입니다. 튜플의 주소 ctid=(0,3)의 3이 바로 ItemId 번호입니다.
왜 양 끝에서? ItemId 배열은 번호 순서를 유지해야 하고, 가변 길이인 튜플 본체는 뒤쪽에서 붙이는 게 쉽기 때문입니다. 가운데 Free Space가 점점 좁아집니다.
pd_lower(ItemId 끝)와 pd_upper(튜플 시작)가 만나면 페이지 풀 — 이 페이지엔 더 못 넣습니다.
새 튜플은 다음 페이지로 갑니다. 어느 페이지에 자리가 있는지는 FSM(Free Space Map)이 알려줍니다(4.5절).
| 영역 | 크기 | 설명 |
|---|---|---|
| PageHeader | 24B | LSN·checksum·pd_lower/pd_upper(빈 공간 포인터) |
| ItemId | 4B 각 | 튜플의 offset+length. 앞에서 자람 |
| Free Space | 가변 | 튜플이 찰수록 줄어듦 |
| Tuples | 가변 | 페이지 끝에서 앞으로 쌓임 |
| Special | 가변 | heap은 0, B-tree 등 인덱스가 메타 저장 |
4.2 튜플의 실제 크기 — 헤더 23B + 정렬(padding)의 비용
모든 튜플 앞에는 3장에서 본 23바이트 헤더(xmin·xmax·ctid·infomask…)가 붙고, 그 뒤에 사용자 데이터가 옵니다. 여기서 초보자가 자주 놓치는 비용이 alignment(정렬)입니다. bigint는 8바이트 경계, integer는 4바이트 경계에 놓여야 해서, 컬럼 순서가 나쁘면 빈틈(padding)이 생깁니다.
-- 나쁜 순서: smallint → bigint → smallint
CREATE TABLE bad (a smallint, b bigint, c smallint);
-- 2B + (6B padding!) + 8B + 2B + (6B padding) = 24B
-- 좋은 순서: 큰 정렬 타입 먼저
CREATE TABLE good (b bigint, a smallint, c smallint);
-- 8B + 2B + 2B + (4B padding) = 16B ← 행마다 8B 절약
8바이트 정렬 타입(bigint, timestamptz) → 4바이트(int) → 2바이트(smallint) → 가변 타입(text, jsonb) 순으로 선언하세요. 수억 행 테이블에서는 컬럼 재배치만으로 수 GB를 절약한 사례가 흔합니다.
4.3 fillfactor와 HOT Update — 일부러 비워두는 지혜
기본 fillfactor는 100 — 페이지를 꽉 채웁니다. 그런데 UPDATE가 잦은 테이블이라면? 새 버전 튜플이 같은 페이지에 들어갈 자리가 없어 다른 페이지로 가고, 그러면 인덱스에도 새 엔트리를 추가해야 합니다(Non-HOT). fillfactor를 80~90으로 낮춰 여유를 남기면, 새 버전이 같은 페이지에 들어가는 HOT(Heap-Only Tuple) update가 가능해집니다.
같은 테이블, 다른 fillfactor. 왼쪽은 기본값 100으로 페이지가 꽉 찼고, 오른쪽은 80이라 20%의 여유가 있습니다.
인덱스에 걸리지 않은 컬럼만 바꾸는 UPDATE가 발생합니다. MVCC이므로 새 버전 튜플을 어딘가에 넣어야 하죠.
fillfactor 100: 같은 페이지에 자리가 없어 다른 페이지로 갑니다. 그러면 인덱스도 새 위치를 가리키는 엔트리를 추가해야 합니다 — 쓰기 비용과 인덱스 bloat 증가.
fillfactor 80: 같은 페이지 여유 공간에 새 버전이 들어가는 HOT update 성공! 인덱스는 손대지 않고, 페이지를 열 때마다 HOT pruning으로 옛 버전을 즉시 청소할 수도 있습니다. pg_stat_user_tables.n_tup_hot_upd로 효과를 측정하세요.
-- UPDATE가 잦은 테이블: 여유 공간을 미리 확보
ALTER TABLE orders SET (fillfactor = 80);
-- 대신 디스크 점유는 그만큼 늘어난다 (트레이드오프)
4.4 TOAST — 8KB에 1MB를 넣는 마법
규칙: 한 튜플은 한 페이지에 들어가야 한다. 그런데 50KB짜리 JSONB는요? 답이 TOAST(The Oversized-Attribute Storage Technique)입니다. 튜플이 약 2KB(TOAST_TUPLE_THRESHOLD)를 넘으면 큰 컬럼 값을 압축하고, 그래도 크면 별도의 숨겨진 테이블로 청크 단위 분할 저장합니다. 2KB의 근거는 "8KB 페이지에 최소 4개 튜플은 들어가게"입니다.
50KB 텍스트를 가진 행을 INSERT합니다. 튜플이 약 2KB 임계(TOAST_TUPLE_THRESHOLD)를 넘어 그대로는 페이지에 못 들어갑니다.
1단계는 압축(기본 pglz, PG14+는 lz4 선택 가능). 그래도 목표 크기(~2KB)를 넘으면 다음 단계로.
값을 약 2000바이트 청크로 잘라 숨겨진 테이블 pg_toast.pg_toast_<oid>에 저장합니다. 청크 크기의 근거도 "TOAST 페이지에 4개씩 들어가게"입니다.
원본 heap 튜플에는 18바이트 TOAST 포인터만 남습니다. 이제 튜플은 가볍게 페이지에 들어갑니다.
SELECT 시엔 포인터를 따라 청크를 모아 압축을 풀어 복원합니다. 전 과정이 자동 — 개발자가 호출할 API 자체가 없습니다.
| STORAGE 전략 | 압축 | 외부 저장 | 비고 |
|---|---|---|---|
| PLAIN | ✗ | ✗ | 고정 길이 타입(integer 등). TOAST 안 함 |
| EXTENDED (기본) | ✓ | ✓ | text·jsonb·bytea의 기본 |
| EXTERNAL | ✗ | ✓ | substr() 잦으면 압축 해제 비용 회피 |
| MAIN | ✓ | 최후 수단 | 가능한 한 인라인 유지 |
-- 전략 변경 (이후 UPDATE부터 적용)
ALTER TABLE articles ALTER COLUMN body SET STORAGE EXTERNAL;
-- PG14+: 더 빠른 lz4 압축으로
ALTER TABLE articles ALTER COLUMN body SET COMPRESSION lz4;
-- 내 테이블의 TOAST 테이블 찾기
SELECT relname, reltoastrelid::regclass
FROM pg_class WHERE relname = 'articles';
-- articles | pg_toast.pg_toast_16432
거대한 jsonb를 자주 UPDATE하면 TOAST 쪽에도 dead tuple이 쌓입니다. 원본 테이블 VACUUM 시 함께 처리되지만, "테이블은 작은데 total size가 크다"면 TOAST bloat를 의심하고 pg_total_relation_size와 비교해 보세요.
4.5 VM과 FSM — 테이블 옆의 두 조수
각 테이블 본체 파일 옆에는 보조 파일(fork)이 두 개 붙습니다: 24576_fsm, 24576_vm.
- Visibility Map(VM) — heap 페이지마다 2비트.
all-visible(dead tuple 없음, 모두에게 보임)이면 Index-Only Scan이 heap을 건너뛸 수 있고,all-frozen이면 VACUUM이 그 페이지를 스킵합니다. - Free Space Map(FSM) — 페이지별 남은 공간을 트리로 요약. INSERT·Non-HOT UPDATE가 "자리 있는 페이지"를 빨리 찾게 해줍니다.
-- Index-Only Scan이 진짜로 heap을 안 읽었는지 확인
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id FROM orders WHERE user_id = 42;
-- Index Only Scan using idx_orders_user ...
-- Heap Fetches: 0 ← 완벽 (VM 비트가 1)
-- Heap Fetches: 12 ← VACUUM 지연 신호. VACUUM 후 다시 측정
4.6 CTID — 물리 주소는 영원하지 않다
모든 튜플의 물리 주소는 (블록 번호, ItemId 번호) 쌍, 즉 CTID입니다. 인덱스 엔트리가 가리키는 것이 바로 이 값이죠. 단, CTID는 UPDATE·VACUUM FULL·CLUSTER로 바뀌는 불안정한 주소입니다. 애플리케이션이 저장해 두면 안 되고, 진단·복구 용도로만 씁니다. 영구 식별자는 bigserial·uuid 같은 논리 키로.
SELECT ctid, id, name FROM users LIMIT 3;
-- ctid | id | name
-- -------+----+-------
-- (0,1) | 1 | Alice ← 블록 0, ItemId 1
-- (0,2) | 2 | Bob
-- (1,1) | 4 | Dave ← 블록 1로 넘어감
✍️ 이해도 체크
pd_lower와 pd_upper가 만나면 페이지 풀. ItemId 번호가 곧 ctid의 두 번째 값이기 때문에 배열 순서를 유지해야 하는 구조입니다.pg_toast.pg_toast_<oid>에 ~2000B 청크로 분할 저장하고 원본 heap에는 18바이트 포인터만 남깁니다. 전 과정이 자동이라 개발자는 평소 존재조차 못 느낍니다.이 장의 원문 문서: chapters/ch04_storage_tuples_toast.md — PageHeaderData C 구조체, MAXALIGN 세부, TOAST 임계 상수표, 운영 관찰 쿼리 모음이 담겨 있습니다.