설치·접속: PostgreSQL 설치와 접속
부제: 로그를 월별 RANGE 파티션으로 나눴는데, 특정 달만 조회할 때 정말 그 파티션만 읽는지, 오래된 달은 어떻게 무중단으로 내리고 새 달은 어떻게 붙이는지
로그 테이블 log를 logdate 기준 월별 RANGE 파티션으로 운영한다. 파티션을 나눴다고 자동으로 빨라지는 건 아니다. 조회가 실제로 필요한 파티션만 읽는지(프루닝), 만료된 달을 어떻게 즉시 내리고 새 달을 어떻게 대량 적재해 붙이는지가 "운영"의 핵심이다.
CREATE TABLE log (
id bigserial, logdate date NOT NULL, level text, msg text
) PARTITION BY RANGE (logdate);
CREATE TABLE log_2026m01 PARTITION OF log FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE log_2026m02 PARTITION OF log FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE log_2026m03 PARTITION OF log FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
1단계 — 계획 시점 프루닝: EXPLAIN에서 파티션이 사라진다
WHERE에 파티션 키 조건을 상수로 주면, 플래너가 계획 단계에서 조건을 만족 못 하는 파티션을 아예 플랜에서 빼버린다.
EXPLAIN (COSTS OFF)
SELECT count(*) FROM log WHERE logdate >= '2026-03-01';
QUERY PLAN
-------------------------------------------------
Aggregate
-> Seq Scan on log_2026m03 log
Filter: (logdate >= '2026-03-01'::date)
(3 rows)파티션이 셋인데 플랜에는 log_2026m03 하나만 남았다. 나머지 둘은 목록에서 통째로 사라졌다. WHERE에 파티션 키 조건이 있어야 이게 작동한다. 조건이 없으면 세 파티션을 다 훑는다.
2단계 — 실행 시점 프루닝: (never executed) 와 Subplans Removed
문제는 값을 계획 시점에 모를 때다. PreparedStatement 파라미터 바인딩, 서브쿼리 결과, 조인 파라미터 같은 경우다. 이때는 프루닝이 실행 중에 일어나고, 플랜에는 파티션이 남아 있다. 파라미터 쿼리를 generic plan으로 강제해보자.
PREPARE q(date) AS SELECT count(*) FROM log WHERE logdate >= $1;
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
EXECUTE q('2026-03-01');
RESET plan_cache_mode;
QUERY PLAN
-----------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
-> Append (actual rows=15500 loops=1)
Subplans Removed: 2
-> Seq Scan on log_2026m03 log_1 (actual rows=15500 loops=1)
Filter: (logdate >= $1)
(5 rows)Subplans Removed: 2 — 계획 시점엔 $1 값을 몰라 세 파티션을 다 준비했지만, 실행 직전에 $1='2026-03-01'이 정해지자 두 개를 제거했다. 서브쿼리로 값을 넘기면 제거 대신 (never executed) 표식으로 남는다.
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM log
WHERE logdate >= (SELECT max(logdate) - 5 FROM log);
QUERY PLAN
-------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
InitPlan 1 (returns $0)
-> Aggregate (actual rows=1 loops=1)
-> Append (actual rows=45000 loops=1)
-> Seq Scan on log_2026m01 log_5 (actual rows=15500 loops=1)
-> Seq Scan on log_2026m02 log_6 (actual rows=14000 loops=1)
-> Seq Scan on log_2026m03 log_7 (actual rows=15500 loops=1)
-> Append (actual rows=3000 loops=1)
-> Seq Scan on log_2026m01 log_1 (never executed)
Filter: (logdate >= $0)
-> Seq Scan on log_2026m02 log_2 (never executed)
Filter: (logdate >= $0)
-> Seq Scan on log_2026m03 log_3 (actual rows=3000 loops=1)
Filter: (logdate >= $0)
Rows Removed by Filter: 12500
(15 rows)바깥 Append의 m01·m02 파티션이 (never executed)다 — 플랜에는 남아 있지만 실행 중 $0 값이 최근 5일로 정해지자 건너뛰었다. 계획 시 프루닝은 EXPLAIN에서 파티션이 아예 안 보이고, 실행 시 프루닝은 플랜엔 남고 never executed/Subplans Removed로만 확인된다는 게 핵심 함정이다.
3단계 — 만료된 달을 DETACH/DROP로 즉시 내린다
3개월치만 온라인 유지하는 정책이라면, 만료된 1월을 내린다. 데이터를 보존한 채 분리하려면 DETACH(독립 테이블로 남음), 완전 폐기면 DROP이다. 대량 DELETE와 달리 VACUUM 부담이 없고 즉시다.
-- 데이터를 살린 채 파티션에서 분리 (독립 테이블이 됨)
ALTER TABLE log DETACH PARTITION log_2026m01;
SELECT count(*) FROM log_2026m01; -- 15500 (그대로 살아있음)
-- 정말 필요 없으면 즉시 폐기
DROP TABLE log_2026m01;
-- DETACH 후: log_2026m01 은 여전히 15500행을 가진 독립 테이블
-- DROP 후 남은 파티션
partition
----------------
log_2026m02
log_2026m03
(2 rows)4단계 — 새 달은 COPY로 대량 적재한 뒤 ATTACH한다
새 달(4월) 파티션은 파티션 트리 밖에서 먼저 만들어 데이터를 대량 적재하고, 마지막에 ATTACH로 붙인다. 이러면 적재하는 동안 부모 테이블에 락이 걸리지 않는다. ATTACH 직전에 파티션 범위와 똑같은 CHECK 제약을 걸어두면, PostgreSQL이 "이 테이블 전체가 4월 범위에 들어감"을 제약만 보고 증명해서 ATTACH 시 전체 스캔을 건너뛴다.
-- 4월치를 CSV로 스테이징 (외부 export 시나리오)
COPY ( SELECT d::date, ... FROM generate_series('2026-04-01','2026-04-30','1 day') d, ... )
TO '/tmp/apr.csv' WITH (FORMAT csv);
-- 트리 밖 독립 테이블로 만들고 대량 COPY 적재
CREATE TABLE log_2026m04 (LIKE log INCLUDING DEFAULTS INCLUDING CONSTRAINTS);
COPY log_2026m04 (logdate, level, msg) FROM '/tmp/apr.csv' WITH (FORMAT csv);
-- 범위와 일치하는 CHECK → ATTACH 시 검증 스캔 생략
ALTER TABLE log_2026m04 ADD CONSTRAINT log_2026m04_ck
CHECK (logdate >= '2026-04-01' AND logdate < '2026-05-01');
-- 붙인다
ALTER TABLE log ATTACH PARTITION log_2026m04
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
COPY 15000
partition
----------------
log_2026m02
log_2026m03
log_2026m04
(3 rows)결론 — 언제 이 조합을 쓰나
"최근 N개월만 온라인" 같은 롤링 보관 정책을 DELETE 없이 운영할 때다. 조회는 파티션 키를 WHERE에 실어 프루닝을 태우고(파라미터 쿼리는 never executed로 검증), 만료 월은 DETACH/DROP로 즉시 내리고, 새 월은 트리 밖에서 COPY로 적재한 뒤 CHECK를 걸어 ATTACH한다. 파티션은 나누는 게 아니라 이렇게 굴리는 것이다.
이렇게도 쓴다
락을 최소화하며 분리한다 — DETACH CONCURRENTLY.
ALTER TABLE log DETACH PARTITION log_2026m02 CONCURRENTLY;
현재 파티션 목록과 각 경계를 한눈에 본다. (조합: pg_inherits)
SELECT inhrelid::regclass AS partition,
pg_get_expr(relpartbound, inhrelid) AS bounds
FROM pg_inherits JOIN pg_class ON oid = inhrelid
WHERE inhparent = 'log'::regclass ORDER BY 1;
기본(DEFAULT) 파티션으로 범위 밖 데이터를 흘려보낸다.
CREATE TABLE log_default PARTITION OF log DEFAULT;
다음 달 파티션을 미리 만들어 둔다 (적재 실패 방지).
CREATE TABLE log_2026m05 PARTITION OF log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CHECK 없이 ATTACH하면 어떻게 다른가 — PostgreSQL이 범위 적합성을 확인하려고 대상 테이블 전체를 스캔한다(큰 테이블일수록 오래 걸림). 대량 파티션은 반드시 일치하는 CHECK를 먼저 건다.
언제 다른 도구로: 파티션 키가 시계열이 아니거나 조회가 파티션 경계를 항상 가로지르면 프루닝 이득이 없다. 이럴 땐 파티셔닝보다 적절한 인덱스 하나가 낫다. 파티션 수가 수천 개로 늘면 계획 시간이 오히려 늘어난다.