로그성 테이블 하나가 수억 건으로 불어나면 조회도 느려지지만 더 성가신 건 정리다. 지난 90일만 남기고 싶은데 한 테이블에 다 들어 있으니 DELETE ... WHERE created_at < ...를 돌리면 오래 걸리고 autovacuum이 뒤따라오며, 디스크는 바로 안 준다. 예전에 이걸 트리거로 수동 파티셔닝하던 글을 쓴 적이 있는데, PostgreSQL 10부터는 선언적 파티셔닝이 들어와서 이제 그렇게 할 이유가 없다.
결론부터 말하자면, 시계열 테이블은 PARTITION BY RANGE로 날짜 기준으로 쪼개고, 파티션 생성과 오래된 파티션 삭제는 pg_partman에 맡기는 구성이 가장 손이 덜 간다. 조회 성능은 파티션 프루닝(pruning)이 WHERE 조건에 안 맞는 파티션을 아예 스캔 대상에서 빼주면서 올라가고, 오래된 데이터는 파티션을 통째로 DROP하거나 떼어내 버리면 된다. RDS for PostgreSQL에서는 pg_partman에 백그라운드 워커 대신 pg_cron을 붙여 유지관리를 돌린다.
1. 파티션 프루닝이 뭐고 왜 필요한가
선언적 파티셔닝은 큰 테이블(부모)을 파티션 키 범위에 따라 여러 자식 테이블로 나누는 기능이다. 겉으로는 테이블 하나처럼 보이지만 실제 데이터는 각 파티션에 나뉘어 저장된다. INSERT가 들어오면 PostgreSQL이 키 값을 보고 알맞은 파티션으로 자동 배분한다.
파티션 프루닝은 쿼리의 WHERE 절을 보고 결과가 들어 있을 수 없는 파티션을 실행 계획에서 제외하는 최적화다. 예를 들어 월 단위로 12개 파티션으로 쪼갠 테이블에서 WHERE created_at >= '2026-10-01'을 조회하면, 10월 파티션 하나만 스캔하고 나머지 11개는 건드리지 않는다. 테이블을 쪼개는 이유의 절반은 여기에 있다.
프루닝은 두 시점에 일어날 수 있다. 플랜 타임에는 상수 조건으로 파티션을 걸러내고, 실행 타임에는 파라미터나 서브쿼리로 값이 정해질 때 걸러낸다. 설정값 enable_partition_pruning은 기본이 on이다.
2. RANGE 파티션 테이블 설계
시계열 데이터는 날짜 컬럼을 파티션 키로 두는 RANGE 파티셔닝이 맞다. 아래는 이벤트 로그 테이블을 월 단위로 쪼개는 예다.
CREATE TABLE events (
event_id bigint GENERATED ALWAYS AS IDENTITY,
org_id bigint NOT NULL,
event_type text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL,
PRIMARY KEY (event_id, created_at)
) PARTITION BY RANGE (created_at);
여기서 한 번 걸린다. 파티션 테이블의 기본키나 유니크 제약에는 파티션 키 컬럼이 반드시 포함되어야 한다. created_at을 빼고 PRIMARY KEY (event_id)라고만 쓰면 다음 에러가 난다.
ERROR: unique constraint on partitioned table must include all partitioning columns DETAIL: PRIMARY KEY constraint on table "events" lacks column "created_at" which is part of the partition key.
그래서 위 예시는 (event_id, created_at) 복합키로 잡았다. 이 제약 때문에 설계 단계에서 파티션 키를 먼저 정하고 키 구성을 맞추는 순서로 가야 한다.
이제 실제 파티션을 붙인다. 범위 경계는 아래값은 포함, 위값은 제외다. 즉 FROM ('2026-10-01') TO ('2026-11-01')은 10월 1일부터 10월 31일 끝까지 담고 11월 1일은 다음 파티션 몫이다.
CREATE TABLE events_2026_09 PARTITION OF events
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE events_2026_10 PARTITION OF events
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE events_2026_11 PARTITION OF events
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
어느 파티션에도 안 맞는 값이 들어오면 no partition of relation "events" found for row 에러로 INSERT가 실패한다. 이를 받아둘 안전망으로 DEFAULT 파티션을 둘 수 있다.
CREATE TABLE events_default PARTITION OF events DEFAULT;
DEFAULT 파티션에 데이터가 쌓여 있으면 그 범위를 덮는 새 파티션을 나중에 ATTACH할 때 기존 행과 충돌을 검사하느라 느려질 수 있다. DEFAULT는 어디까지나 유실 방지용으로 비워 두는 편이 낫다.
인덱스
부모 테이블에 인덱스를 만들면 기존 파티션과 앞으로 생길 파티션에 자동으로 적용된다.
CREATE INDEX ON events (org_id); CREATE INDEX ON events (created_at);
다만 파티션 테이블에는 CREATE INDEX CONCURRENTLY를 직접 걸 수 없다. 운영 중인 큰 파티션에 락 없이 인덱스를 추가하려면 파티션마다 CONCURRENTLY로 만든 뒤 부모 인덱스에 ATTACH PARTITION으로 붙이는 방식을 쓴다.
CREATE INDEX events_type_idx ON ONLY events (event_type);
CREATE INDEX CONCURRENTLY events_2026_10_type_idx
ON events_2026_10 (event_type);
ALTER INDEX events_type_idx
ATTACH PARTITION events_2026_10_type_idx;
3. EXPLAIN으로 프루닝 확인
쪼개 놓았다고 끝이 아니다. 실제로 파티션이 걸러지는지 EXPLAIN으로 봐야 한다. 특정 월만 조회하는 쿼리를 보자.
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM events WHERE created_at >= '2026-10-01' AND created_at < '2026-11-01';
프루닝이 먹으면 계획에 10월 파티션만 등장하고 나머지는 보이지 않는다.
Aggregate (cost=... rows=1 width=8)
-> Seq Scan on events_2026_10 events
Filter: ((created_at >= '2026-10-01 ...') AND (created_at < '2026-11-01 ...'))
실행 타임 프루닝은 표현이 다르다. 파라미터로 범위가 정해지는 프리페어드 쿼리라면 계획에 Subplans Removed: N이라는 줄이 나온다. 이게 보이면 실행 시점에 N개 파티션이 제외됐다는 뜻이다.
여기서 자주 막히는 지점이 하나 있다. 파티션 키에 함수를 씌우면 프루닝이 깨진다. WHERE date_trunc('day', created_at) = '2026-10-23'처럼 쓰면 PostgreSQL이 어느 파티션인지 못 좁혀서 전체 파티션을 훑는다. 조건은 created_at >= '2026-10-23' AND created_at < '2026-10-24'처럼 컬럼 원본에 범위로 거는 식으로 바꿔야 한다.
만약 모든 파티션이 다 스캔된다면 SHOW enable_partition_pruning;으로 설정부터 확인한다. 기본이 on이라 꺼져 있는 경우는 드물지만, 세션에서 누가 꺼놨을 수 있다.
4. pg_partman으로 파티션 자동 생성과 삭제
파티션을 매달 손으로 CREATE TABLE 하는 건 금방 잊어버린다. 월초에 파티션이 없으면 그날 INSERT가 전부 실패하거나 DEFAULT로 쏠린다. pg_partman은 파티션을 미리 만들어 두고 보존 기간이 지난 파티션을 떼어내거나 지우는 일을 자동화하는 확장이다. 5.x 버전은 PostgreSQL 14 이상을 요구하며 선언적 파티셔닝만 다룬다(트리거 방식은 제거됐다).
RDS for PostgreSQL에서는 12.5 이상에서 pg_partman을 쓸 수 있다. 먼저 전용 스키마를 만들고 확장을 설치한다. rds_superuser 권한이 필요하다.
CREATE SCHEMA partman; CREATE EXTENSION pg_partman WITH SCHEMA partman;
권한이 없으면 아래 에러가 난다. GRANT rds_superuser TO your_user;로 권한을 주거나 슈퍼유저 계정으로 설치한다.
ERROR: permission denied to create extension "pg_partman" HINT: Must be superuser to create this extension.
그다음 create_parent로 기존 파티션 테이블을 pg_partman에 등록한다. 부모 테이블은 이미 PARTITION BY RANGE로 만들어져 있어야 한다.
SELECT partman.create_parent(
p_parent_table => 'public.events',
p_control => 'created_at',
p_type => 'range',
p_interval => '1 month',
p_premake => 4
);
파라미터는 이렇게 읽는다.
p_parent_table: 스키마까지 붙인 부모 테이블 이름.p_control: 파티션 기준 컬럼. 정수형이거나 시간형이어야 한다.p_type: 시계열이면'range'.p_interval: 파티션 한 칸의 간격.'1 day','1 week','1 month'같은 텍스트.p_premake: 미리 만들어 둘 파티션 개수. 기본 4다. 트래픽이 몰리는 테이블이면 넉넉히 둔다.
이 호출 한 번으로 현재 시점 파티션과 앞으로 쓸 파티션 몇 개가 한 번에 생긴다.
5. RDS에서 pg_cron으로 유지관리 돌리기
pg_partman은 run_maintenance_proc라는 프로시저로 새 파티션을 만들고 보존 기간이 지난 파티션을 정리한다. 이걸 주기적으로 불러줄 스케줄러가 필요하다. 자체 설치 PostgreSQL이라면 pg_partman_bgw 백그라운드 워커를 shared_preload_libraries에 올려 쓰지만, RDS에서는 그 워커 대신 pg_cron을 붙이는 방식을 AWS가 안내한다.
먼저 DB 파라미터 그룹의 shared_preload_libraries에 pg_cron을 추가한다. 이 변경은 인스턴스 재부팅이 있어야 적용된다. 재부팅 후 확장을 만든다.
CREATE EXTENSION pg_cron;
보존 정책은 pg_partman의 설정 테이블 part_config에 적어 둔다.
UPDATE partman.part_config
SET infinite_time_partitions = true,
retention = '3 months',
retention_keep_table = true
WHERE parent_table = 'public.events';
각 설정의 뜻은 이렇다.
infinite_time_partitions = true: 제한 없이 미래 파티션을 계속 만들게 한다.retention = '3 months': 3개월보다 오래된 파티션을 유지관리 대상에서 정리한다.retention_keep_table = true: 보존 기간이 지난 파티션을 부모에서 떼어내기만 하고 테이블 자체는 남긴다.false면DROP으로 완전히 지운다.
처음에는 retention_keep_table = true로 두고 떼어낸 파티션을 S3 등으로 내보낸 뒤 손으로 지우는 편을 권한다. 바로 DROP하게 해두면 설정 실수로 멀쩡한 데이터가 날아갈 수 있다.
마지막으로 pg_cron에 유지관리를 매시간 돌도록 등록한다.
SELECT cron.schedule('@hourly', $$CALL partman.run_maintenance_proc()$$);
등록된 작업과 실행 이력은 아래로 확인한다.
SELECT jobid, schedule, command FROM cron.job; SELECT jobid, status, return_message, start_time FROM cron.job_run_details ORDER BY start_time DESC LIMIT 5;
스케줄에만 맡기지 말고 처음 한 번은 CALL partman.run_maintenance_proc();를 직접 불러 파티션이 제대로 생기는지 눈으로 보고 넘어가는 게 좋다.
6. 기존 큰 테이블을 파티션으로 옮기기
이미 수억 건이 쌓인 단일 테이블을 파티션으로 바꾸는 건 설계보다 이전이 더 일이다. 선언적 파티셔닝으로는 기존 테이블에 ALTER TABLE ... PARTITION BY를 바로 걸 수 없다. 새 파티션 부모 테이블을 만들고 데이터를 옮기는 방식으로 간다.
간단한 방법은 새 파티션 테이블 events를 만들어 두고 구 테이블을 배치로 INSERT ... SELECT 해서 넣는 것이다. 다만 한 번에 다 넣으면 WAL과 락이 커지므로 기간을 잘라 나눠 넣는다.
INSERT INTO events SELECT * FROM events_old WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01';
이미 적재된 월별 테이블이 따로 있고 그걸 그대로 파티션으로 편입하고 싶다면 ATTACH PARTITION을 쓴다. 이때 붙이는 테이블에 파티션 범위와 똑같은 CHECK 제약을 미리 걸어 두면, PostgreSQL이 전체 스캔으로 범위를 재검증하는 단계를 건너뛸 수 있다.
ALTER TABLE events_2026_08 ADD CONSTRAINT ck_2026_08
CHECK (created_at >= '2026-08-01' AND created_at < '2026-09-01');
ALTER TABLE events ATTACH PARTITION events_2026_08
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
반대로 오래된 데이터를 치울 때는 파티션을 떼어낸다. 운영 중 락을 피하려면 CONCURRENTLY를 붙인다(단일 트랜잭션 밖에서 실행해야 한다).
ALTER TABLE events DETACH PARTITION events_2026_08 CONCURRENTLY;
7. 정리
시계열 대용량 테이블은 날짜 컬럼으로 RANGE 파티셔닝하고, 기본키에 파티션 키를 포함시키고, 조회 조건은 컬럼 원본에 범위로 걸어 프루닝이 먹게 한다. 파티션 생성과 삭제는 pg_partman에 맡기되 RDS에서는 pg_cron으로 run_maintenance_proc를 돌린다. 보존 삭제는 처음엔 테이블을 남기는 쪽으로 두고 확인한 뒤 완전 삭제로 넘어가면 사고를 줄인다. 설계를 마쳤으면 반드시 EXPLAIN으로 파티션이 실제로 걸러지는지 눈으로 확인하고 운영에 올린다.