3KB 값은 본 테이블에 남고 1.5KB 값은 밖으로 나가는 이유 — PostgreSQL TOAST 4단계 루프

seonwooj0810·어제

1. "2KB 넘으면 TOAST"로는 설명이 안 되는 결과

pg_column_size로 컬럼을 보다가 이상한 걸 발견했다. 3000자짜리 text 값은 수십 바이트로 압축된 채 본 테이블에 그대로 있었고, 1500바이트짜리 값은 TOAST 테이블로 나가 있었다. "2KB가 넘는 값은 TOAST 테이블로 간다"는 흔한 설명과 정반대다.

소스(heaptoast.c)를 따라가 보니 이유는 하나였다. 토스터가 보는 건 값이 아니라 행 전체의 크기이고, 행이 목표 크기 아래로 내려갈 때까지 가장 큰 컬럼을 하나씩 처리하는 탐욕적 루프다.

2. 트리거는 행 길이 t_len

삽입 경로(heapam.c)는 tup->t_len > TOAST_TUPLE_THRESHOLD일 때 토스터를 부르고, UPDATE도 같은 비교를 한다. 비교 대상은 튜플 길이 t_len이지 특정 컬럼이 아니다.

임계값은 heaptoast.h에서 "한 페이지에 튜플 4개가 들어가게"라는 의미로 정의된다.

#define TOAST_TUPLES_PER_PAGE      4
#define TOAST_TUPLE_THRESHOLD      MaximumBytesPerTuple(TOAST_TUPLES_PER_PAGE)  // 8KB 페이지에서 약 2KB
#define TOAST_TUPLE_TARGET         TOAST_TUPLE_THRESHOLD
#define TOAST_TUPLES_PER_PAGE_MAIN 1
#define TOAST_TUPLE_TARGET_MAIN    MaximumBytesPerTuple(TOAST_TUPLES_PER_PAGE_MAIN)  // 거의 페이지 전체

공식 문서가 "보통 2kB"라고 하는 숫자가 여기서 나온다. 행이 작으면 그 안의 값이 아무리 커도 토스터는 아예 불리지 않는다.

3. 4개 패스, 매번 가장 큰 컬럼 하나

토스터는 목표 maxDataLen(= toast_tuple_target − 헤더)을 잡고, 소스 주석에 적힌 순서대로 네 번의 루프를 돈다.

Pass 1  EXTENDED 중 가장 큰 값 → 압축 시도
        (압축해도 그 값 혼자 목표 초과면 바로 외부화)
Pass 2  EXTENDED/EXTERNAL 중 가장 큰 인라인 값 → 외부화
Pass 3  MAIN 중 가장 큰 값 → 압축
  ── 목표를 TOAST_TUPLE_TARGET_MAIN(페이지 1개 크기)으로 완화 ──
Pass 4  MAIN 중 가장 큰 값 → 외부화 (최후의 수단)

각 패스는 while (data_size > maxDataLen)이다. 한 컬럼을 처리할 때마다 행 크기를 다시 재고, 목표 아래로 내려가면 바로 멈춘다. 같은 테이블이라도 행마다 어떤 컬럼이 압축되고 어떤 컬럼이 나갔는지가 다른 이유가 이것이다.

토스터는 "모든 큰 값을 처리"하지 않는다. 행이 들어갈 만큼만, 큰 것부터 처리한다.

후보에는 하한이 있다. toast_tuple_find_biggest_attribute()는 비교 시작값을 TOAST 포인터 크기(문서 기준 18바이트)로 둔다. 포인터보다 작은 값은 내보내면 오히려 커지니 후보에서 빠진다.

압축이 "실패"로 처리되는 기준도 짚어둘 만하다. pglz 기본 전략은 25% 이상 줄지 않거나 처음 1KB 안에서 매치를 못 찾으면 포기한다. 실패한 컬럼은 TOASTCOL_INCOMPRESSIBLE이 붙어 이후 압축 패스에서 건너뛰고, Pass 2에서 외부화 후보가 된다.

4. 직접 재현해 보기

PostgreSQL 14 이상이면 pg_column_compression으로 압축 여부까지 볼 수 있다.

CREATE TABLE t (id int, a text, b text);
-- (1) 압축 잘 되는 3KB 값 → Pass 1 압축만으로 행이 목표 아래로
INSERT INTO t VALUES (1, repeat('x', 3000), 'short');
-- (2) 압축 안 되는 3KB 값(md5 hex 연결) → INCOMPRESSIBLE → Pass 2 외부화
INSERT INTO t VALUES (2, (SELECT string_agg(md5(g::text), '') FROM generate_series(1,94) g), 'short');
-- (3) 약 1.5KB 값 두 개 → 값은 작아도 행 합계가 임계값 초과
INSERT INTO t VALUES (3,
  (SELECT string_agg(md5(g::text), '') FROM generate_series(1,47) g),
  (SELECT string_agg(md5((g+100)::text), '') FROM generate_series(1,47) g));

SELECT id, octet_length(a) AS a_raw,
       pg_column_size(a) AS a_sz, pg_column_compression(a) AS a_cmp,
       pg_column_size(b) AS b_sz
FROM t ORDER BY id;

소스 흐름대로라면 id=1은 a가 압축된 채(수십 바이트) 인라인으로 남는다. id=2는 압축에 실패해 외부화되고, TOAST 테이블(pg_class.reltoastrelid)에 청크 두 개가 생긴다. 가장 흥미로운 건 id=3이다. 두 값 중 하나만 외부화되고 나머지는 인라인으로 남는다. 하나를 내보내는 순간 행이 목표 아래로 내려가 루프가 멈추기 때문이다.

ALTER TABLE t SET (toast_tuple_target = 4080);으로 목표를 키우고 같은 INSERT를 반복하면 외부화되는 행이 줄어든다. 테이블 옵션이 Pass 1~3의 maxDataLen에 반영되기 때문이다.

5. 정리

흔한 오해 세 가지를 소스 기준으로 바로잡으면 이렇다.

오해실제
값이 2KB를 넘으면 TOAST된다기준은 행 길이. 큰 값도 행이 작으면 그대로 남는다
EXTERNAL이면 무조건 밖으로 나간다EXTERNAL은 압축만 금지한다. 외부화 여부는 여전히 행 크기 루프가 정한다
MAIN이면 절대 외부화되지 않는다목표를 페이지 1개 크기로 완화한 Pass 4에서는 외부화된다

UPDATE 시 바뀌지 않은 외부 값은 포인터만 유지돼 TOAST 비용이 들지 않는다고 문서에 나와 있다. UPDATE 경로의 다른 최적화인 HOT(Heap-Only Tuple)과 같이 보면 "PostgreSQL UPDATE가 실제로 무엇을 다시 쓰는가"가 한 그림으로 이어진다. 다음에는 default_toast_compression의 pglz와 lz4 차이, 그리고 SELECT *가 외부 값을 전부 디토스트하는 비용을 실측해 볼 생각이다.

참고 자료

  • PostgreSQL 18 Docs §66.2 TOAST — https://www.postgresql.org/docs/current/storage-toast.html
  • PostgreSQL 소스: src/backend/access/heap/heaptoast.c (heap_toast_insert_or_update), src/backend/access/table/toast_helper.c (toast_tuple_find_biggest_attribute), src/include/access/heaptoast.h, src/common/pg_lzcompress.c

0개의 댓글