본문 바로가기
삵
Amazon Web Services

PostgreSQL 대용량 테이블 RANGE 파티셔닝

아아무로레이·2026년 10월 23일·조회 0

로그성 테이블 하나가 수억 건으로 불어나면 조회도 느려지지만 더 성가신 건 정리다. 지난 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으로 파티션이 실제로 걸러지는지 눈으로 확인하고 운영에 올린다.

자주 묻는 질문

RANGE 파티션 경계값은 양 끝이 포함인가 제외인가?

아래값은 포함, 위값은 제외다. FOR VALUES FROM ('2026-10-01') TO ('2026-11-01')은 10월 1일부터 10월 31일 끝까지 담고 11월 1일 0시는 다음 파티션 몫이다. 그래서 인접 파티션이 같은 경계값을 공유해도 겹치지 않는다.

EXPLAIN을 봤는데 파티션이 하나도 안 걸러지면 뭘 확인하나?

먼저 WHERE 조건에 파티션 키에 함수를 씌우지 않았는지 본다. date_trunc('day', created_at) 같은 조건은 프루닝을 깨므로 created_at >= ... AND created_at < ... 범위 형태로 바꾼다. 그래도 전체가 스캔되면 SHOW enable_partition_pruning; 으로 설정이 on인지 확인한다.

RDS에서 pg_partman 백그라운드 워커(pg_partman_bgw)를 쓸 수 있나?

RDS에서는 pg_partman_bgw 대신 pg_cron으로 유지관리를 돌리는 방식을 AWS가 안내한다. 파라미터 그룹의 shared_preload_libraries에 pg_cron을 추가하고 재부팅한 뒤, CREATE EXTENSION pg_cron으로 설치하고 cron.schedule로 run_maintenance_proc를 주기 실행한다.

파티션 테이블 기본키는 왜 created_at을 포함해야 하나?

선언적 파티셔닝에서 유니크 제약과 기본키에는 모든 파티션 키 컬럼이 포함되어야 한다는 제약이 있다. created_at으로 파티셔닝한다면 PRIMARY KEY (event_id, created_at)처럼 복합키로 잡아야 하고, 빼면 unique constraint must include all partitioning columns 에러가 난다.

오래된 데이터를 지울 때 DELETE와 파티션 DROP 중 뭐가 낫나?

파티션 단위로 끊어 저장했다면 DROP TABLE 또는 DETACH가 훨씬 빠르고 디스크도 바로 회수된다. DELETE는 대량 삭제 후 autovacuum 부담이 크고 공간 반환도 즉시 되지 않는다. pg_partman의 retention 설정으로 이 과정을 자동화할 수 있다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.