DB Study

4장. Storage · Tuple · TOAST

3장이 MVCC의 논리였다면, 이 장은 그 논리가 디스크 위에 실제로 놓이는 모습입니다. 모든 것의 단위는 8KB 페이지 — 그 안에 튜플이 쌓이는 법, 큰 값이 밀려나는 법(TOAST)을 그림으로 봅니다.

중급 ⏱ 약 25분 🎬 애니메이션 3개 선수 지식: 3장 MVCC
🧭
이 장에서 배우는 것

① 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가 반복될 때 무슨 일이 벌어지는지 봅시다.

heap page (8KB, block 0) Header 24B I1 Tuple 1 (0,1) I2 I3 Tuple 2 (0,2) Tuple 3 (0,3) Free Space pd_lower → ← pd_upper ItemId는 앞→뒤 튜플은 뒤→앞 I4 I5 Tuple 4 Tuple 5 pd_lower가 pd_upper에 닿음 → 페이지 풀(full)! 새 튜플은 다음 페이지(block 1)로 — FSM이 빈 페이지를 찾아줌
영역크기설명
PageHeader24BLSN·checksum·pd_lower/pd_upper(빈 공간 포인터)
ItemId4B 각튜플의 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 (기본) 튜플로 꽉 참 (여유 0%) fillfactor = 80 80%만 채움 여유 ⚡ UPDATE users SET score = score + 1 (인덱스 컬럼은 그대로) 자리 없음 → 새 버전은 다른 페이지로 인덱스에도 새 엔트리 추가 (Non-HOT) 💸 같은 페이지의 여유 공간에 새 버전 ✓ 인덱스 갱신 없음 = HOT update 🎉 HOT: 인덱스 WAL 없음 · HOT pruning으로 페이지 내 dead tuple 즉시 회수 가능
-- 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개 튜플은 들어가게"입니다.

articles heap 페이지 INSERT: id=1, title, body = 50KB 텍스트 😱 튜플이 2KB 임계 초과 — 페이지에 못 들어감 🗜 1단계: 압축 (pglz / lz4) 50KB → 18KB… 아직도 큼 🧊 pg_toast.pg_toast_16432 (숨겨진 TOAST 테이블) chunk_id=16501 seq=0 [~2000B] chunk_id=16501 seq=1 [~2000B] chunk_id=16501 seq=2 [~2000B] … seq=8까지 청크 분할 id=1, title, body = 📌 TOAST pointer 원본엔 18바이트 포인터만! SELECT body → 포인터를 따라 청크들을 모아 압축 해제 후 원본 복원 (자동, 투명) 읽을 때 역순으로 조립
STORAGE 전략압축외부 저장비고
PLAIN고정 길이 타입(integer 등). TOAST 안 함
EXTENDED (기본)text·jsonb·bytea의 기본
EXTERNALsubstr() 잦으면 압축 해제 비용 회피
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
⚠️
TOAST 테이블도 bloat됩니다

거대한 jsonb를 자주 UPDATE하면 TOAST 쪽에도 dead tuple이 쌓입니다. 원본 테이블 VACUUM 시 함께 처리되지만, "테이블은 작은데 total size가 크다"면 TOAST bloat를 의심하고 pg_total_relation_size와 비교해 보세요.

4.5 VM과 FSM — 테이블 옆의 두 조수

각 테이블 본체 파일 옆에는 보조 파일(fork)이 두 개 붙습니다: 24576_fsm, 24576_vm.

-- 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로 넘어감

✍️ 이해도 체크

8KB 페이지 안에서 ItemId 배열과 튜플 본체가 각각 자라는 방향은?
✅ ItemId(line pointer)는 헤더 뒤에서 앞→뒤로, 튜플 본체는 페이지 끝에서 뒤→앞으로 자랍니다. pd_lowerpd_upper가 만나면 페이지 풀. ItemId 번호가 곧 ctid의 두 번째 값이기 때문에 배열 순서를 유지해야 하는 구조입니다.
50KB짜리 text 값을 가진 행을 INSERT하면 (기본 EXTENDED 전략) 무슨 일이 일어날까요?
✅ 튜플이 ~2KB 임계를 넘으면 TOAST가 발동합니다. 먼저 압축(pglz/lz4), 그래도 크면 pg_toast.pg_toast_<oid>에 ~2000B 청크로 분할 저장하고 원본 heap에는 18바이트 포인터만 남깁니다. 전 과정이 자동이라 개발자는 평소 존재조차 못 느낍니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch04_storage_tuples_toast.md — PageHeaderData C 구조체, MAXALIGN 세부, TOAST 임계 상수표, 운영 관찰 쿼리 모음이 담겨 있습니다.