수억 건이 쌓인 시계열 테이블은 어느 순간부터 조용히 서비스를 갉아먹습니다. 인덱스가 메모리에 다 올라가지 못해 캐시 히트율이 떨어지고, VACUUM은 몇 시간씩 돌며, 오래된 데이터를 지우는 DELETE는 테이블 전체에 락과 dead tuple을 뿌립니다. 흔한 대응이 “인덱스를 더 붙이자”이지만, 진짜 해법은 대개 테이블 파티셔닝입니다.
PostgreSQL은 10 버전부터 선언적 파티셔닝(declarative partitioning)을 지원하며, 이후 프루닝(pruning), 파티션 단위 조인, ATTACH/DETACH 락 완화가 꾸준히 개선됐습니다. 이 글에서는 시계열 데이터를 range 파티셔닝으로 나누는 실전 설계, 파티션 자동 생성·정리, 프루닝 검증, 그리고 기존 대형 테이블을 무중단에 가깝게 전환하는 방법까지 다룹니다.
왜 시계열 데이터에 파티셔닝인가
시계열 데이터는 대부분의 쿼리가 최근 구간을 조회하고, 오래된 데이터는 언젠가 통째로 삭제되거나 아카이빙됩니다. 단일 거대 테이블에서는 두 패턴 모두 비효율적입니다. 최근 데이터만 읽어도 인덱스는 전체 크기이고, 30일 지난 로그를 지우려면 수천만 건을 DELETE해야 합니다. 파티셔닝은 논리 테이블을 시간 구간별 물리 테이블로 쪼개 이를 구조적으로 해결합니다.
- 파티션 프루닝: 쿼리에 시간 범위가 있으면 해당 파티션만 스캔하고 나머지는 건너뜁니다.
- 정리 비용 제로: 오래된 데이터는
DROP·DETACH한 번으로 즉시 회수됩니다. dead tuple도 VACUUM 부하도 없습니다. - 작은 인덱스: 각 파티션 인덱스가 작아 캐시 효율이 높고 재구성도 파티션 단위로 가능합니다.
- 병렬 유지보수:
VACUUM·ANALYZE·REINDEX를 파티션별로 나눠 돌릴 수 있습니다.
다만 파티셔닝은 공짜가 아닙니다. 파티션이 수천 개로 늘어나면 플래너 오버헤드가 커지고, 파티션 키를 포함하지 않는 쿼리는 오히려 모든 파티션을 뒤집니다. 설계 단계에서 파티션 키와 구간 크기를 신중히 정해야 합니다.
range 파티셔닝 기본 설계
시계열에는 거의 항상 range 파티셔닝이 정답입니다. 타임스탬프 컬럼을 파티션 키로 삼아 월별 또는 일별로 나눕니다. 이벤트 로그 테이블을 예로 들면 다음과 같습니다.
-- 부모 테이블: 데이터를 직접 담지 않고 파티션 라우팅만 담당
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
device_id bigint NOT NULL,
event_type text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL
) PARTITION BY RANGE (created_at);
-- 중요: 파티션 키(created_at)는 PK/UNIQUE에 반드시 포함돼야 한다
ALTER TABLE events ADD CONSTRAINT events_pk PRIMARY KEY (id, created_at);
-- 월별 파티션 (경계는 [하한, 상한) 반열린 구간)
CREATE TABLE events_2026_07 PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE events_2026_08 PARTITION OF events
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
놓치기 쉬운 제약이 있습니다. 파티션 테이블의 UNIQUE·PRIMARY KEY 제약은 파티션 키 컬럼을 반드시 포함해야 합니다. 그래서 자연스러운 PRIMARY KEY (id) 대신 (id, created_at) 복합 키를 씁니다. 전역 유일성이 필요한 id는 IDENTITY로 보장하되 물리 제약은 복합 키로 우회하는 것이 관행입니다.
구간 크기는 유입량으로 결정합니다. 파티션 하나가 수천만 건 또는 수십 GB를 넘지 않도록 하되, 너무 잘게 쪼개면 파티션 수가 폭증해 플래너가 느려집니다(기준은 하단 FAQ 참고).
인덱스는 부모 테이블에 한 번 정의하면 모든 파티션에 자동 전파되고 이후 생성되는 파티션에도 따라붙으므로 CREATE INDEX idx_events_device ON events (device_id, created_at)처럼 부모에만 만들면 됩니다. 한편 기본 파티션(DEFAULT)은 시계열 설계에서 두지 않는 편이 낫습니다. 있으면 새 파티션 ATTACH 시 “겹치는 행이 없는지” 전체 스캔이 발생해 락 시간이 길어지기 때문입니다. 대신 파티션을 미리 넉넉히 만들어 라우팅 실패를 방지하세요.
파티션 프루닝 검증
파티셔닝을 해 놓고도 EXPLAIN으로 프루닝이 실제 동작하는지 확인하지 않으면 반쪽짜리입니다. 쿼리에 파티션 키 조건이 없거나 함수로 감싸면 플래너가 범위를 추론하지 못해 모든 파티션을 스캔합니다.
-- 좋은 쿼리: created_at 범위가 직접 드러나 프루닝됨
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE created_at >= '2026-08-01' AND created_at = '2026-08-03' AND created_at < '2026-08-04'
PostgreSQL은 플래닝 단계 프루닝(상수 조건)과 실행 단계 프루닝(enable_partition_pruning, 프리페어드 파라미터·조인 조건)을 모두 수행합니다. 실행 계획에 Subplans Removed: N이 보이면 실행 단계 프루닝이 작동한 것입니다. 파티션 구조가 같은 테이블을 조인·집계한다면 기본 꺼짐인 enable_partitionwise_join·enable_partitionwise_aggregate를 켜서 이득을 볼 수 있습니다.
파티션 자동 생성·자동 정리
파티션을 수작업으로 만들다 보면 어느 날 새 파티션을 깜빡해 INSERT가 “no partition of relation … found for row” 에러로 실패합니다. 운영에서는 반드시 자동화해야 합니다. 순수 SQL 프로시저로 다음 달 파티션을 미리 만들고 보관 기간이 지난 파티션을 떼어내는 함수를 둘 수 있습니다.
-- 다음 N개월 파티션을 미리 생성 (멱등)
CREATE OR REPLACE FUNCTION ensure_events_partitions(months_ahead int DEFAULT 2)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
part_name text;
lo date;
hi date;
BEGIN
FOR i IN 0..months_ahead LOOP
lo := date_trunc('month', now())::date + (i || ' month')::interval;
hi := lo + interval '1 month';
part_name := format('events_%s', to_char(lo, 'YYYY_MM'));
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = part_name) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF events FOR VALUES FROM (%L) TO (%L)',
part_name, lo, hi);
END IF;
END LOOP;
END;
$$;
-- 보관 기간(개월)이 지난 파티션을 DETACH 후 DROP
CREATE OR REPLACE FUNCTION drop_old_events_partitions(retain_months int DEFAULT 6)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
cutoff date := date_trunc('month', now())::date - (retain_months || ' month')::interval;
r record;
BEGIN
FOR r IN
SELECT c.relname FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'events'
AND c.relname ~ '^events_[0-9]{4}_[0-9]{2}$'
AND to_date(right(c.relname, 7), 'YYYY_MM') < cutoff
LOOP
EXECUTE format('ALTER TABLE events DETACH PARTITION %I CONCURRENTLY', r.relname);
EXECUTE format('DROP TABLE %I', r.relname);
END LOOP;
END;
$$;
DETACH PARTITION ... CONCURRENTLY는 부모 테이블 강한 락을 피하며 파티션을 떼어냅니다(트랜잭션 블록 밖에서만 가능). 곧바로 DROP하지 않고 잠시 DETACH 상태로 두었다가 아카이빙 후 삭제하는 운영도 흔합니다. 이 함수들을 pg_cron이나 외부 크론으로 매일 실행하면 파티션 라이프사이클이 자동으로 굴러갑니다.
기존 대형 테이블 무중단 전환
가장 어려운 시나리오는 이미 수억 건이 쌓인 단일 테이블을 파티션 테이블로 바꾸는 것입니다. in-place 전환은 없으므로, 새 파티션 부모 events_new에 신규 쓰기를(또는 트리거 이중 쓰기로) 흘려보낸 뒤 과거 데이터를 월 단위 배치로 복사하고, 마지막에 짧은 트랜잭션에서 이름만 스왑합니다. 핵심은 긴 락과 한 방 대량 복사를 피하는 것입니다.
-- 1) 과거 데이터를 구간별로 배치 복사 (한 트랜잭션에 한 달치만)
INSERT INTO events_new (id, device_id, event_type, payload, created_at)
SELECT id, device_id, event_type, payload, created_at FROM events_old
WHERE created_at >= '2026-06-01' AND created_at < '2026-07-01';
-- 각 배치 후 COMMIT, 부하를 보며 다음 구간 진행
-- 2) 최종 스왑: 아주 짧은 트랜잭션에서 이름만 교체
BEGIN;
ALTER TABLE events_old RENAME TO events_legacy;
ALTER TABLE events_new RENAME TO events;
COMMIT;
데이터를 옮기지 않고 기존 테이블을 단일 파티션으로 편입시키는 방법도 있습니다. 새 부모에 기존 테이블을 ATTACH PARTITION으로 붙이는 것입니다. ATTACH는 모든 행이 경계 안에 있는지 검증하려 전체 스캔을 하는데, 미리 동등한 CHECK 제약을 걸어 두면 이 스캔을 건너뛰어 락을 거의 순간적으로 만들 수 있습니다.
-- 경계와 동일한 CHECK 를 미리 (NOT VALID 후 VALIDATE) 걸어 둔다
ALTER TABLE events_2026_q2 ADD CONSTRAINT ck_range
CHECK (created_at >= '2026-04-01' AND created_at < '2026-07-01') NOT VALID;
ALTER TABLE events_2026_q2 VALIDATE CONSTRAINT ck_range; -- 이때 스캔(공유락)
-- 이제 ATTACH 는 CHECK 를 신뢰해 재스캔 없이 짧은 락으로 완료
ALTER TABLE events ATTACH PARTITION events_2026_q2
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
운영 시 흔한 함정
파티셔닝을 도입한 팀이 반복적으로 밟는 지뢰들입니다. 미리 알면 대부분 피할 수 있습니다.
- 파티션 키 없는 쿼리:
WHERE device_id = ?처럼 시간 조건이 빠지면 모든 파티션을 스캔합니다. 애플리케이션 레벨에서 시간 범위를 항상 함께 넘기도록 강제하세요. - 과도한 파티션 수: 파티션이 수천 개면 플래닝 시간이 눈에 띄게 늘어납니다. 오래된 일별 파티션은 월별로 병합하는 롤업을 고려합니다.
- 자동 통계 누락: 새 파티션은 통계가 비어 초기 실행 계획이 나쁠 수 있습니다. 대량 적재 직후
ANALYZE를 명시적으로 돌리세요. - DEFAULT 파티션 오염: 라우팅 실패 행이 기본 파티션에 쌓이면 ATTACH가 느려집니다. 두더라도 정기적으로 비우세요.
# 새 파티션 적재 직후 통계 갱신 — 나쁜 초기 실행계획 예방
psql -d appdb -c "ANALYZE events_2026_08;"
# 파티션별 크기 점검 (비대해진 파티션 조기 발견)
psql -d appdb -c "SELECT c.relname, pg_size_pretty(pg_total_relation_size(c.oid))
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'events' ORDER BY 1;"
마무리
시계열 파티셔닝의 핵심은 세 가지입니다. 파티션 키는 거의 항상 타임스탬프이고 range 파티셔닝이 자연스럽다는 것, 생성·정리를 반드시 자동화해 라우팅 실패와 수동 관리 부담을 없앤다는 것, 그리고 EXPLAIN으로 프루닝을 항상 검증하고 쿼리가 파티션 키를 포함하도록 강제한다는 것입니다.
파티셔닝이 만능은 아닙니다. 데이터가 수천만 건 수준이고 정리 요구가 없다면 좋은 인덱스 하나로 충분할 수 있습니다. 하지만 데이터가 시간에 따라 무한히 쌓이고 오래된 것을 주기적으로 버려야 하는 워크로드라면, 파티셔닝은 유지보수 비용을 근본적으로 낮추는 몇 안 되는 구조적 해법입니다. 작게 시작해 자동화를 갖추고 프루닝을 계측하며 점진적으로 키워 나가세요.
자주 묻는 질문
Q. 파티션을 월별로 할지 일별로 할지 어떻게 정하나요?
A. 파티션 하나의 크기를 기준으로 삼으세요. 파티션당 수천만 건 또는 수십 GB 이하가 목표입니다. 하루 유입이 수억 건이면 일별, 수백만 건이면 월별이 무난합니다. 보관 기간까지 고려해 전체 파티션 수가 수백 개를 크게 넘지 않도록 조정하세요. 파티션이 지나치게 많으면 플래닝 오버헤드가 이점을 상쇄합니다.
Q. 기존 운영 중인 대형 테이블을 다운타임 없이 파티션 테이블로 바꿀 수 있나요?
A. 완전 무중단은 어렵지만 체감 다운타임은 초 단위로 줄일 수 있습니다. 이중 쓰기로 신규 데이터를 새 테이블에 흘려보내고, 과거 데이터는 월 단위 배치로 복사한 뒤, 짧은 트랜잭션에서 이름만 스왑하는 방식이 표준입니다. 기존 테이블을 통째로 하나의 파티션으로 ATTACH하는 경우에는 동등한 CHECK 제약을 미리 VALIDATE해 두어 ATTACH 시점의 전체 스캔을 회피하세요.
Q. 파티셔닝을 했는데도 쿼리가 느립니다. 무엇을 먼저 확인해야 하나요?
A. EXPLAIN (ANALYZE, BUFFERS)로 실행 계획에 몇 개의 파티션이 등장하는지 보세요. 모든 파티션이 스캔된다면 프루닝이 실패한 것입니다. 원인은 대개 쿼리에 파티션 키 조건이 없거나, date_trunc() 같은 함수로 키를 감싸 플래너가 범위를 추론하지 못하는 경우입니다. 키를 함수 없이 범위 조건으로 직접 표현하고, 새로 적재한 파티션에 ANALYZE로 통계를 채웠는지도 확인하세요.