Partitioning 운영: 범위/리스트 파티션, detach/drop, pruning
파티셔닝은 스토리지 구조 결정이다
PostgreSQL의 선언적 파티셔닝은 하나의 논리적 테이블을 물리적으로 여러 child 테이블로 나누는 구조다. 쿼리는 부모 테이블을 대상으로 하지만, 실제 행은 파티션 별 child 테이블에 저장된다. 파티셔닝을 도입할 이유는 크게 세 가지다.
- 쿼리 프루닝: 날짜 범위나 특정 값 조건으로 조회할 때 관련 파티션만 스캔한다. 전체 테이블 스캔을 피할 수 있다.
- 데이터 수명 관리: 오래된 파티션을
DETACH로 분리하고DROP하는 방식으로 대량 삭제 비용을 피한다. 수억 행을DELETE하는 대신 파티션 하나를 제거한다. - 운영 병렬성:
VACUUM,REINDEX,ANALYZE를 파티션 단위로 실행해 영향 범위를 좁힌다.
반대로 파티셔닝이 도움이 안 되는 상황도 있다. 파티션 키를 포함하지 않는 쿼리는 모든 파티션을 스캔해야 한다. 파티션 수가 수백 개를 넘어가면 플래너 비용이 증가한다. 작은 테이블에 파티셔닝을 걸면 이득 없이 복잡도만 늘어난다.
파티션 유형 세 가지
PostgreSQL은 RANGE, LIST, HASH 세 가지 파티션 방식을 제공한다.
RANGE 는 연속 구간으로 나눈다. 날짜, 숫자 범위가 전형적인 사용처다.
CREATE TABLE events (
event_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
payload JSONB
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2025_q1 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE events_2025_q2 PARTITION OF events
FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');경계는 하한 포함, 상한 미포함([lower, upper))이다.
LIST 는 이산 값 집합으로 나눈다. 리전, 테넌트, 상태 코드처럼 고정된 범주가 있을 때 쓴다.
CREATE TABLE user_events (
event_id BIGINT,
region VARCHAR(10) NOT NULL,
payload JSONB
) PARTITION BY LIST (region);
CREATE TABLE user_events_kr PARTITION OF user_events FOR VALUES IN ('KR');
CREATE TABLE user_events_us PARTITION OF user_events FOR VALUES IN ('US', 'CA');
CREATE TABLE user_events_eu PARTITION OF user_events FOR VALUES IN ('DE', 'FR', 'GB');HASH 는 키 값의 해시를 나머지 연산으로 나눈다. 자연스러운 범위나 목록 기준이 없지만 쓰기를 균등하게 분산하고 싶을 때 쓴다.
CREATE TABLE sessions (
session_id UUID NOT NULL,
data JSONB
) PARTITION BY HASH (session_id);
CREATE TABLE sessions_0 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_1 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_2 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_3 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 3);HASH 파티션은 프루닝 효과가 작다. 파티션 키 등가 조건에서만 단일 파티션을 특정할 수 있기 때문이다.
파티션 아키텍처 전체 그림
파티션 생성: ATTACH PARTITION과 초기화 흐름
파티션을 처음부터 child로 만드는 방법과, 기존 테이블을 붙이는 방법 두 가지가 있다.
직접 생성
CREATE TABLE events_2025_q3 PARTITION OF events
FOR VALUES FROM ('2025-07-01') TO ('2025-10-01');부모 테이블에 정의된 인덱스 템플릿이 있다면 새 파티션은 자동으로 해당 인덱스를 상속한다.
ATTACH PARTITION — 기존 테이블 편입
대용량 데이터를 별도 테이블로 로드한 뒤 파티션으로 붙이는 방식이다. 붙이는 시점에 PostgreSQL이 모든 행이 파티션 조건에 맞는지 검증한다.
-- 별도 테이블에 데이터 준비
CREATE TABLE events_2025_q4_stage (LIKE events INCLUDING ALL);
\COPY events_2025_q4_stage FROM 'events_q4.csv' CSV;
-- 붙이기 전에 CHECK 제약 추가 → 검증 스캔을 빠르게 통과할 수 있다
ALTER TABLE events_2025_q4_stage
ADD CONSTRAINT chk_q4_range CHECK (
created_at >= '2025-10-01' AND created_at < '2026-01-01'
) NOT VALID;
ALTER TABLE events_2025_q4_stage VALIDATE CONSTRAINT chk_q4_range;
-- 이제 ATTACH는 스캔 없이 메타데이터만 갱신한다
ALTER TABLE events ATTACH PARTITION events_2025_q4_stage
FOR VALUES FROM ('2025-10-01') TO ('2026-01-01');검증 스캔이 이미 끝난 상태이므로 ATTACH 자체의 잠금 시간이 극히 짧아진다. 수백만 행짜리 파티션을 서비스 중에 붙일 때 유용한 패턴이다.
DETACH와 DROP: 오래된 파티션 제거
날짜 기반 파티션을 정리할 때 DELETE 대신 파티션 제거를 쓰는 이유는 비용 차이가 크기 때문이다. 1억 행 DELETE는 WAL 쓰기, vacuum 부하, 락 경쟁을 만든다. 파티션 한 장을 제거하면 카탈로그 변경 + 파일 삭제로 끝난다.
PostgreSQL 14 이전에는 DETACH PARTITION이 부모 테이블에 AccessExclusiveLock을 잡았다. 서비스 중에 실행하면 모든 읽기·쓰기가 차단되었다.
DETACH PARTITION CONCURRENTLY (PostgreSQL 14+)
-- 비차단 방식으로 분리
ALTER TABLE events DETACH PARTITION events_2024_q1 CONCURRENTLY;내부적으로 두 단계를 거친다. 첫 번째 단계에서 메타데이터를 갱신하고 ShareUpdateExclusiveLock만 잡는다. 두 번째 단계에서 해당 파티션을 참조하는 기존 트랜잭션이 모두 끝나기를 기다린 뒤 분리를 완료한다. 진행 중인 쿼리는 계속 실행된다.
분리 후 파티션은 독립 테이블이 된다. 아카이빙이 필요하면 pg_dump나 외부 저장소로 내보낸 뒤 삭제한다.
-- 분리 완료 후 아카이브 여부 결정
\COPY (SELECT * FROM events_2024_q1) TO '/archive/events_2024_q1.csv' CSV;
-- 검증 완료 후 제거
DROP TABLE events_2024_q1;DETACH 없이 바로 DROP TABLE도 가능하지만, 이 경우 부모 테이블에 AccessExclusiveLock이 걸린다. 서비스 중에는 항상 DETACH CONCURRENTLY → 확인 → DROP 순서를 권장한다.
Partition Pruning: 플래너가 파티션을 제외하는 방법
Pruning은 쿼리 조건과 파티션 경계를 비교해서 스캔이 필요 없는 파티션을 실행 대상에서 제외하는 최적화다. 두 메커니즘이 함께 작동한다.
constraint_exclusion — 플래닝 시점 제거
파티션 CHECK 제약을 보고 플래너가 쿼리 플랜을 만들 때 파티션을 제외한다. 정적인 리터럴 조건에서만 효과적이다.
SHOW constraint_exclusion;
-- 선언적 파티션에서는 'partition'으로 두는 것이 권장값이다
SET constraint_exclusion = partition;enable_partition_pruning — 실행 시점 제거 (PostgreSQL 11+)
파라미터 쿼리나 서브쿼리 결과처럼 실행 시점에야 값이 확정되는 경우에도 파티션을 제외한다. 기본값이 on이며 끄는 경우는 거의 없다.
SHOW enable_partition_pruning; -- on (기본값)두 설정이 협력하는 방식을 실행계획으로 확인할 수 있다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2025-04-01' AND created_at < '2025-07-01';
-- 출력 예시
-- Append (cost=...)
-- -> Seq Scan on events_2025_q2 ... ← 이 파티션만 스캔
-- Subplans Removed: 3 ← 나머지 3개 파티션 제외됨Subplans Removed가 보이면 프루닝이 작동하고 있는 것이다. 이 숫자가 예상보다 작다면 쿼리 조건과 파티션 경계가 제대로 겹치지 않거나 캐스트 문제가 있을 수 있다.
프루닝이 작동하지 않는 경우
- 조건이 파티션 키를 직접 사용하지 않는다 (
WHERE date_trunc('month', created_at) = ...형태면 식이 달라져 제외되지 않는다) - HASH 파티션에서 등가 조건 외 범위 조건
- 파티션 키에 타입 변환이 개입하는 경우
Default 파티션: 편의와 위험 사이
DEFAULT 파티션은 어느 파티션 조건에도 맞지 않는 행을 받아낸다.
CREATE TABLE events_default PARTITION OF events FOR VALUES DEFAULT;운영에서는 두 가지 위험이 있다.
첫째, 잘못된 데이터가 조용히 들어온다. 날짜 범위를 잘못 지정한 행이 default 파티션에 쌓이고 쿼리 결과에 영향을 준다.
둘째, 새 파티션을 ATTACH할 때 default 파티션에 해당 범위에 속하는 행이 있으면 실패한다. PostgreSQL이 default 파티션 전체를 스캔해 충돌 여부를 확인하기 때문에, default 파티션이 크면 ATTACH가 느려지고 잠금 시간도 길어진다.
Default 파티션이 꼭 필요한 경우라면 크기와 내용을 주기적으로 확인하는 모니터링을 함께 두어야 한다.
SELECT
pg_size_pretty(pg_relation_size('events_default')) AS size,
min(created_at) AS earliest,
max(created_at) AS latest,
count(*) AS row_count
FROM events_default;Default 파티션이 의도치 않게 커지고 있다면 소스에서 잘못된 값이 들어오고 있다는 신호다.
운영 함정: 인덱스 상속 주의
부모 테이블에 인덱스를 만들어도 이미 존재하는 child 파티션에 자동 적용되지 않는다. 인덱스는 새로 생성되는 파티션부터 상속된다.
-- 부모에만 인덱스 정의 (새 파티션은 상속받음)
CREATE INDEX idx_events_type ON ONLY events (event_type);
-- 기존 파티션에는 각각 직접 생성해야 한다
CREATE INDEX idx_events_2025_q1_type ON events_2025_q1 (event_type);
CREATE INDEX idx_events_2025_q2_type ON events_2025_q2 (event_type);ON ONLY는 부모 테이블 자체에만 인덱스를 만들고, 이후 생성되는 파티션은 자동으로 해당 인덱스를 갖는다. 기존 파티션에는 수동으로 채워야 한다.
파티션을 ATTACH할 때는 먼저 child 테이블에 인덱스를 만들고 나서 붙이는 순서가 좋다. ATTACH 이후에 인덱스를 만들면 해당 시점까지의 행을 스캔해야 하므로 시간이 더 걸린다.
자동화: pg_partman
반복적인 파티션 생성·보존 정책을 직접 스크립트로 관리하면 실수가 생기기 쉽다. pg_partman 확장이 이 역할을 대신한다.
CREATE EXTENSION pg_partman;
-- 관리 등록 (월 단위 파티션)
SELECT partman.create_parent(
'public.events',
'created_at',
'monthly'
);
-- 보존 정책 설정 (12개월 보관, 오래된 파티션은 detach)
UPDATE partman.part_config SET
retention = '12 months',
retention_keep_table = true,
automatic_maintenance = 'on'
WHERE parent_table = 'public.events';
-- 유지보수 실행 (cron으로 주기 호출)
SELECT partman.run_maintenance('public.events');retention_keep_table = true로 설정하면 오래된 파티션을 drop하지 않고 detach만 한다. 별도 아카이빙 절차를 붙이기 전에 데이터를 보존할 수 있다.
파티션 크기와 상태 모니터링
-- 파티션별 크기 현황
SELECT
c.relname AS partition,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
pg_size_pretty(pg_indexes_size(c.oid)) AS index_size,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
s.n_live_tup AS live_rows,
s.n_dead_tup AS dead_rows,
s.last_autovacuum
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE i.inhparent = 'events'::regclass
ORDER BY pg_total_relation_size(c.oid) DESC;dead_rows 비율이 높은 파티션은 autovacuum 설정을 더 공격적으로 조정하거나 수동 VACUUM을 실행한다. 아카이빙 직전의 파티션은 autovacuum을 비활성화하고 VACUUM FULL ANALYZE로 정리한 뒤 detach하면 깔끔하다.
-- 아카이빙 직전 파티션 정리
ALTER TABLE events_2023_q4 SET (autovacuum_enabled = false);
VACUUM FULL ANALYZE events_2023_q4;
ALTER TABLE events DETACH PARTITION events_2023_q4 CONCURRENTLY;운영 체크리스트
파티셔닝을 서비스에 투입하기 전에 다음 항목을 점검한다.
- 파티션 키가 쿼리의
WHERE조건에 항상 포함되는가? 그렇지 않으면 전 파티션 스캔이 발생한다. - 새 파티션 생성 절차가 자동화(pg_partman 또는 스케줄 스크립트)되어 있는가?
- 오래된 파티션 보존 정책과 아카이빙 절차가 문서화되어 있는가?
DETACH PARTITION CONCURRENTLY를 서비스 시간대에 안전하게 실행할 수 있는지 확인했는가?- 기존 파티션에 인덱스가 누락된 것은 없는가?
- Default 파티션 크기와 내용에 대한 알림이 있는가?
enable_partition_pruning = on,constraint_exclusion = partition설정이 맞는가?- 외래 키 구조가 파티션 제약과 충돌하지 않는가? (PostgreSQL 15 이하에서 참조 대상 테이블 파티셔닝 시 FK 제약 적용 불가)
References
- PostgreSQL Documentation, "Chapter 5. Table Partitioning" — https://www.postgresql.org/docs/current/ddl-partitioning.html
- PostgreSQL Documentation, "ALTER TABLE ... ATTACH/DETACH PARTITION" — https://www.postgresql.org/docs/current/sql-altertable.html
- PostgreSQL Documentation, "enable_partition_pruning" — https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-ENABLE-PARTITION-PRUNING
- PostgreSQL Documentation, "constraint_exclusion" — https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-CONSTRAINT-EXCLUSION
- pg_partman GitHub — https://github.com/pgpartman/pg_partman
- PostgreSQL Wiki, "Table Partitioning" — https://wiki.postgresql.org/wiki/Table_partitioning
- Crunchy Data Blog, "Partition Pruning in PostgreSQL" — https://www.crunchydata.com/blog/postgres-partition-pruning
- Cybertec, "Partitioning in PostgreSQL: An Introduction" — https://www.cybertec-postgresql.com/en/partitioning-in-postgresql-an-introduction/