DB Study

12장. 파티셔닝 — 거대한 테이블을 서랍장으로

10억 행짜리 테이블은 "느린 것"이 문제가 아니라 "관리 불가능한 것"이 문제입니다. 테이블을 월별 서랍으로 나누면 쿼리는 필요한 서랍만 열고, 오래된 서랍은 통째로 버릴 수 있습니다.

고급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 5장 인덱스 · 8장 VACUUM
🧭
이 장에서 배우는 것

① 왜 파티셔닝인가 — DELETE vs DROP의 압도적 비용 차이 ② RANGE/LIST/HASH 세 가지 분할 방식 ③ Partition Pruning — 필요한 파티션만 스캔하는 원리 ④ 월별 로테이션 운영 패턴과 함정들

12.1 왜 파티셔닝인가 — 성능보다 먼저 "운영 가능성"

단일 테이블이 수억 행을 넘어가면 세 가지 문제가 동시에 옵니다: VACUUM이 한 바퀴 도는 데 몇 시간(autovacuum이 못 따라감), 인덱스가 거대해져 REINDEX가 부담, 그리고 "3개월 지난 로그 삭제" 같은 대량 DELETE가 Bloat 폭탄이 됩니다.

파티셔닝(Partitioning)은 하나의 논리 테이블을 물리적으로 여러 자식 테이블(파티션)로 나누는 표준 기법입니다. 앱은 여전히 logs 하나에 쿼리하지만, 실제 데이터는 logs_2024_01, logs_2024_02… 로 나뉘어 저장됩니다.

단일 테이블 + DELETE logs (10억 행, 500GB) 1월 데이터 (지울 대상) 2월 데이터 3월 데이터 수백만 dead tuple ☠ DELETE = 행마다 xmax 마킹 → Bloat 폭증 + WAL 대량 생성 → 복제 지연 + VACUUM 몇 시간 파티션 + DROP logs (파티션 부모 — 껍데기) 2024_01 1월 2024_02 2월 2024_03 3월 DROP TABLE logs_2024_01; 파일 unlink 수준 — ms 단위 완료 ⚡ dead tuple 0개 · WAL 거의 없음 인덱스도 같이 사라짐

12.2 Declarative Partitioning — RANGE / LIST / HASH

v10부터 도입된 Declarative Partitioning이 현재 표준입니다. 파티션 키를 테이블 정의에 선언하면 INSERT 라우팅을 엔진이 알아서 합니다. (v9 시절의 상속+트리거 방식은 유지비용이 극심해 신규 도입 이유가 없습니다.)

-- RANGE: 시간/ID 범위 — 가장 일반적
CREATE TABLE orders (
    order_id   bigserial,
    created_at timestamptz NOT NULL,
    amount     numeric(12,2),
    PRIMARY KEY (order_id, created_at)   -- ⚠️ PK에 파티션 키 포함 필수!
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2024_q1 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');  -- [from, to) 반개구간

-- LIST: 이산 값 (국가, 테넌트)
CREATE TABLE events_kr PARTITION OF events FOR VALUES IN ('KR');
CREATE TABLE events_etc PARTITION OF events DEFAULT;      -- 캐치올 (v11+)

-- HASH: 균등 분산 (v11+) — 등가 조건(=)에서만 pruning!
CREATE TABLE users_p0 PARTITION OF users
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
방식적합한 키대표 용도주의
RANGE시간, 연속 ID로그·주문 월별 관리새 파티션 미리 생성 필요
LIST국가, 테넌트, 상태지역·테넌트 분리DEFAULT 파티션 관리
HASH자연 범위가 없는 값균등 분산, 샤딩 흉내범위 쿼리에서 pruning 불가
⚠️
UNIQUE/PK에는 파티션 키가 반드시 포함되어야 한다

글로벌 인덱스가 없기 때문에 order_id bigserial UNIQUE만으로는 에러가 납니다. UNIQUE (order_id, created_at)처럼 복합으로 만들거나, 전역 유일성이 꼭 필요하면 애플리케이션에서 UUID/ULID로 보장해야 합니다.

12.3 Partition Pruning — 필요한 서랍만 연다

파티셔닝의 쿼리 성능 이득은 대부분 Pruning(가지치기)에서 나옵니다. 플래너가 WHERE 조건을 각 파티션의 범위와 비교해서, 관련 없는 파티션을 스캔 대상에서 아예 제외하는 것입니다.

WHERE created_at >= '2024-06-01' AND < '2024-07-01' Planner: 범위 비교 "6월은 어느 파티션에?" orders_2024_q1 1~3월 orders_2024_q2 4~6월 orders_2024_q3 7~9월 orders_2024_q4 10~12월 ✂️ 제외 ✂️ 제외 ✂️ 제외 orders_2024_q2 ✅ 이것만 스캔! 스캔량 1/4 이하 · EXPLAIN에 q2만 등장 — 함수로 감싼 조건은 pruning 실패 주의!
-- Pruning 확인: Append 아래 파티션이 하나만 나오면 성공
EXPLAIN SELECT count(*) FROM orders
WHERE created_at >= '2024-06-01' AND created_at < '2024-07-01';
--  Aggregate
--    ->  Seq Scan on orders_2024_q2    ← 1개만 스캔!

-- ❌ Pruning 실패: 함수 래핑
SELECT * FROM orders WHERE to_char(created_at, 'YYYY-MM') = '2024-06';

12.4 월별 로테이션 — 파티션의 일생

시계열 파티션 운영의 정석은 "미리 만들고, 오래되면 분리해서 버린다"는 컨베이어 벨트입니다. 순정 PostgreSQL은 파티션을 자동으로 만들어주지 않으므로, 이 로테이션을 cron/pg_cron 또는 pg_partman으로 자동화합니다.

logs (파티션 부모) 2024_10 오래됨 (읽기만) 2024_11 읽기 위주 2024_12 🔥 활성 쓰기 2025_01 ✨ premake (미리 생성) 해가 바뀌면 INSERT가 자동 라우팅 2025_01 🔥 활성 쓰기 2024_10 DETACH CONCURRENTLY 부모와의 연결만 끊김 (락 거의 없음, v14+) 📦 S3 아카이브 (pg_dump) → DROP TABLE logs_2024_10; ⚡ 🤖 자동화 pg_partman + pg_cron
-- pg_partman으로 로테이션 자동화 (13장 참조)
SELECT partman.create_parent(
    p_parent_table => 'public.logs',
    p_control      => 'ts',
    p_interval     => 'daily',
    p_premake      => 7            -- 7일치 미리 생성
);
-- pg_cron으로 주기 실행
CALL partman.run_maintenance_proc();

12.5 함정 총정리 — 도입 전 체크

함정내용대응
글로벌 인덱스 없음부모에 CREATE INDEX 하면 파티션마다 로컬 인덱스가 각각 생성설계 시 인지, UNIQUE는 파티션 키 포함
파티션 수 과다플래너 계획 시간이 파티션 수에 비례해 증가경험칙 1,000개 이하 유지
DEFAULT 파티션 방치데이터가 쌓이면 새 파티션 ATTACH 시 긴 락이상값 감지용으로만, 쌓이면 즉시 이관
파티션 키 UPDATEv11+는 DELETE+INSERT로 재라우팅되지만 비용 큼변경되지 않는 컬럼을 키로 선택
HASH + 범위 쿼리BETWEEN에서 pruning 불가등가 조건 워크로드에만 HASH
💡
ATTACH를 빠르게 하는 요령

독립 테이블을 파티션으로 편입(ATTACH PARTITION)할 때, 미리 같은 범위의 CHECK 제약을 만들어 VALIDATE까지 해두면 ATTACH 시 전체 스캔을 생략합니다. 대량 데이터 이관의 표준 패턴입니다.

✍️ 이해도 체크

3개월 지난 로그를 정기 삭제해야 합니다. 파티셔닝이 대량 DELETE보다 압도적으로 유리한 이유는?
✅ DELETE는 행마다 xmax를 마킹해 수백만 dead tuple + 대량 WAL을 만들고 VACUUM 부담과 복제 지연으로 이어집니다. DROP PARTITION은 파일을 통째로 지우므로 ms 단위에 끝나고, 로컬 인덱스도 함께 사라집니다. 파티션에도 인덱스는 정상적으로 존재하며(로컬 인덱스), DELETE도 실행 가능합니다 — 비효율적일 뿐입니다.
월별 RANGE 파티션 테이블에서 다음 중 Partition Pruning이 동작하지 않는 쿼리는?
✅ 파티션 키를 to_char() 같은 함수로 감싸면 플래너가 파티션 경계와 비교할 수 없어 모든 파티션을 스캔합니다. pruning을 살리려면 키 원본 컬럼에 대한 범위/등가 조건으로 작성해야 합니다. EXPLAIN에서 파티션이 전부 나열된다면 이 패턴부터 의심하세요.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch12_partitioning.md — Sub-partitioning, DEFAULT 파티션 이관 패턴, TimescaleDB 비교, 버전별 기능 매트릭스, 진단 쿼리 모음이 담겨 있습니다.