12장. 파티셔닝 — 거대한 테이블을 서랍장으로
10억 행짜리 테이블은 "느린 것"이 문제가 아니라 "관리 불가능한 것"이 문제입니다. 테이블을 월별 서랍으로 나누면 쿼리는 필요한 서랍만 열고, 오래된 서랍은 통째로 버릴 수 있습니다.
① 왜 파티셔닝인가 — DELETE vs DROP의 압도적 비용 차이 ② RANGE/LIST/HASH 세 가지 분할 방식 ③ Partition Pruning — 필요한 파티션만 스캔하는 원리 ④ 월별 로테이션 운영 패턴과 함정들
12.1 왜 파티셔닝인가 — 성능보다 먼저 "운영 가능성"
단일 테이블이 수억 행을 넘어가면 세 가지 문제가 동시에 옵니다: VACUUM이 한 바퀴 도는 데 몇 시간(autovacuum이 못 따라감), 인덱스가 거대해져 REINDEX가 부담, 그리고 "3개월 지난 로그 삭제" 같은 대량 DELETE가 Bloat 폭탄이 됩니다.
파티셔닝(Partitioning)은 하나의 논리 테이블을 물리적으로 여러 자식 테이블(파티션)로 나누는 표준 기법입니다. 앱은 여전히 logs 하나에 쿼리하지만, 실제 데이터는 logs_2024_01, logs_2024_02… 로 나뉘어 저장됩니다.
같은 과제입니다: "1월 데이터를 지워라". 왼쪽은 10억 행 단일 테이블, 오른쪽은 월별 파티션 구조입니다.
단일 테이블의 DELETE는 수백만 행 각각에 xmax를 마킹합니다. dead tuple 폭증 → Bloat, WAL 대량 생성 → 복제 지연, 그리고 VACUUM이 몇 시간 — 삭제 한 번이 클러스터 전체를 흔듭니다.
파티션 구조에서는 DROP TABLE logs_2024_01 한 줄이면 끝. 1월 파티션 파일 자체를 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 불가 |
글로벌 인덱스가 없기 때문에 order_id bigserial UNIQUE만으로는 에러가 납니다. UNIQUE (order_id, created_at)처럼 복합으로 만들거나, 전역 유일성이 꼭 필요하면 애플리케이션에서 UUID/ULID로 보장해야 합니다.
12.3 Partition Pruning — 필요한 서랍만 연다
파티셔닝의 쿼리 성능 이득은 대부분 Pruning(가지치기)에서 나옵니다. 플래너가 WHERE 조건을 각 파티션의 범위와 비교해서, 관련 없는 파티션을 스캔 대상에서 아예 제외하는 것입니다.
"6월 주문만 집계해줘"라는 쿼리가 들어옵니다. 테이블은 분기별 4개 파티션으로 나뉘어 있습니다.
플래너가 WHERE 조건의 범위(6월)를 각 파티션의 경계(FOR VALUES FROM ... TO ...)와 비교합니다.
6월은 q2(4~6월)에만 존재하므로 q1, q3, q4는 계획에서 아예 제거됩니다. 실행 시점에 결정되는 파라미터 조건도 v11+부터는 execution-time pruning으로 처리됩니다.
결과: 스캔량이 1/4 이하로. 단, to_char(created_at,'YYYY-MM') = '2024-06'처럼 파티션 키를 함수로 감싸면 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으로 자동화합니다.
12월이 활성 쓰기 파티션인 시점, 다음 달 파티션 2025_01을 미리 생성(premake)합니다. 미리 안 만들어두면 1월 1일 0시에 INSERT가 실패합니다!
해가 바뀌면 새 행들이 파티션 키 값에 따라 자동으로 2025_01에 라우팅됩니다. 앱 코드는 아무것도 바꾸지 않습니다.
보관 기간이 지난 가장 오래된 파티션은 DETACH PARTITION ... CONCURRENTLY(v14+)로 부모와의 연결만 끊습니다. 데이터는 독립 테이블로 그대로 남습니다.
분리된 테이블을 S3 등에 아카이브한 뒤 DROP TABLE — ms 단위로 공간이 반환됩니다. DELETE 대비 Bloat도 WAL 폭증도 없습니다.
이 사이클(premake → 활성 → 읽기 위주 → detach → drop)을 pg_partman + pg_cron으로 자동화하는 것이 운영 표준입니다. 13장에서 이 extension들을 다룹니다.
-- 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 시 긴 락 | 이상값 감지용으로만, 쌓이면 즉시 이관 |
| 파티션 키 UPDATE | v11+는 DELETE+INSERT로 재라우팅되지만 비용 큼 | 변경되지 않는 컬럼을 키로 선택 |
| HASH + 범위 쿼리 | BETWEEN에서 pruning 불가 | 등가 조건 워크로드에만 HASH |
독립 테이블을 파티션으로 편입(ATTACH PARTITION)할 때, 미리 같은 범위의 CHECK 제약을 만들어 VALIDATE까지 해두면 ATTACH 시 전체 스캔을 생략합니다. 대량 데이터 이관의 표준 패턴입니다.
✍️ 이해도 체크
to_char() 같은 함수로 감싸면 플래너가 파티션 경계와 비교할 수 없어 모든 파티션을 스캔합니다. pruning을 살리려면 키 원본 컬럼에 대한 범위/등가 조건으로 작성해야 합니다. EXPLAIN에서 파티션이 전부 나열된다면 이 패턴부터 의심하세요.이 장의 원문 문서: chapters/ch12_partitioning.md — Sub-partitioning, DEFAULT 파티션 이관 패턴, TimescaleDB 비교, 버전별 기능 매트릭스, 진단 쿼리 모음이 담겨 있습니다.