본문 바로가기
Database2026년 9월 17일12분 읽기

포스트그레SQL 파티션 재구성 — 병합·분할을 안전하게 수행하는 절차

YS
김영삼
조회 126
포스트그레SQL 파티션 재구성 — 병합·분할을 안전하게 수행하는 절차

시계열 데이터를 월 단위로 파티션했는데 시간이 지나 파티션이 너무 많아졌다면 분기나 연 단위로 합치고 싶어진다. 반대로 특정 월의 데이터가 예상보다 커지면 더 잘게 쪼개고 싶어진다. 포스트그레SQL에는 아직 이를 한 번에 처리하는 표준 DDL이 널리 쓰이지 않아, DETACH/ATTACH 조합으로 수행하는 것이 실무 표준이다.

핵심은 잠금 시간을 최소화하는 것이다. 순진하게 수행하면 수 분에서 수십 분간 테이블 전체가 잠긴다. 운영 중인 서비스에서는 그대로 장애다.

나는 이 작업을 처음 할 때 유지보수 창 없이 시도했다가 크게 데인 적이 있다. ATTACH PARTITION이 제약 조건을 검증하느라 전체 스캔을 했고, 그동안 테이블에 ACCESS EXCLUSIVE 락이 걸렸다. 5분이면 될 줄 알았던 작업이 40분이 됐고, 그 40분 동안 서비스가 멈췄다.

먼저 이해할 것 — 잠금의 종류

작업필요한 잠금영향
DETACH PARTITIONACCESS EXCLUSIVE (부모)모든 읽기·쓰기 차단
DETACH PARTITION CONCURRENTLY약한 잠금읽기·쓰기 계속 가능(제약 있음)
ATTACH PARTITION부모에 SHARE UPDATE EXCLUSIVE + 대상 검증검증 중 대상 테이블 잠금
CREATE TABLE ... LIKE새 테이블만영향 없음
데이터 INSERT SELECT대상 테이블 쓰기 잠금원본은 읽기만
ATTACH의 숨은 비용 ATTACH PARTITION은 붙이려는 테이블의 모든 행이 파티션 경계 조건을 만족하는지 검증한다. 이 검증은 전체 스캔이다. 미리 동등한 CHECK 제약을 걸어 두면 이 스캔을 건너뛴다. 대용량에서는 이 한 가지가 수십 분을 좌우한다.

병합 절차 — 월 3개를 분기 1개로

1
새 파티션 테이블 생성
아직 붙이지 않은 독립 테이블로 만든다. 인덱스와 제약을 포함한다.
2
데이터 이동
배치로 나눠 INSERT ... SELECT. 한 번에 다 옮기면 WAL이 폭증하고 잠금 시간이 길어진다.
3
CHECK 제약 추가
파티션 경계와 동일한 조건으로. 이것이 ATTACH 시 검증 생략의 열쇠다.
4
기존 파티션 DETACH
가능하면 CONCURRENTLY 사용. 짧은 잠금으로 끝난다.
5
새 파티션 ATTACH
CHECK 제약이 있으면 검증이 생략돼 빠르게 붙는다.
6
정리
분리된 옛 테이블은 검증 후 삭제한다. 바로 지우지 말고 하루 정도 남겨 두는 편이 안전하다.
-- 0) 사전 준비: 작업 범위 확인
SELECT relname, pg_size_pretty(pg_total_relation_size(oid))
FROM pg_class WHERE relname LIKE 'events_2026%' ORDER BY relname;

-- 1) 새 파티션이 될 테이블 생성 (아직 붙이지 않는다)
CREATE TABLE events_2026q1 (LIKE events INCLUDING DEFAULTS INCLUDING CONSTRAINTS);

-- 2) 데이터 이동 — 배치로 나눈다
INSERT INTO events_2026q1
SELECT * FROM events_202601;          -- 큰 테이블이면 이것도 청크로 분할
INSERT INTO events_2026q1
SELECT * FROM events_202602;
INSERT INTO events_2026q1
SELECT * FROM events_202603;

-- 3) 인덱스 생성 (데이터 이동 후가 빠르다)
CREATE INDEX CONCURRENTLY idx_events_2026q1_created
  ON events_2026q1 (created_at);

-- 4) CHECK 제약 — ATTACH 시 전체 스캔을 건너뛰기 위해 필수
ALTER TABLE events_2026q1 ADD CONSTRAINT events_2026q1_range
  CHECK (created_at >= '2026-01-01' AND created_at < '2026-04-01');

-- 5) 기존 파티션 분리 (가능하면 CONCURRENTLY)
ALTER TABLE events DETACH PARTITION events_202601 CONCURRENTLY;
ALTER TABLE events DETACH PARTITION events_202602 CONCURRENTLY;
ALTER TABLE events DETACH PARTITION events_202603 CONCURRENTLY;

-- 6) 새 파티션 연결
ALTER TABLE events ATTACH PARTITION events_2026q1
  FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');

-- 7) 검증 후 정리
SELECT count(*) FROM events WHERE created_at >= '2026-01-01'
                              AND created_at <  '2026-04-01';
-- DROP TABLE events_202601, events_202602, events_202603;  -- 하루 뒤에

분할 절차 — 큰 파티션을 쪼개기

방향만 반대일 뿐 구조는 같다. 다만 데이터를 여러 대상으로 나눠 넣어야 하므로 이동 단계가 더 오래 걸린다.

-- 분할: 2026년 1분기 → 월별 3개
CREATE TABLE events_202601 (LIKE events INCLUDING DEFAULTS INCLUDING CONSTRAINTS);
CREATE TABLE events_202602 (LIKE events INCLUDING DEFAULTS INCLUDING CONSTRAINTS);
CREATE TABLE events_202603 (LIKE events INCLUDING DEFAULTS INCLUDING CONSTRAINTS);

-- 조건별 이동 (배치 크기를 제어하며)
INSERT INTO events_202601 SELECT * FROM events_2026q1
  WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
-- ... 나머지 월도 동일

-- CHECK 제약 + DETACH + ATTACH 는 병합과 같은 순서

대용량에서의 배치 이동

수억 행을 한 번의 INSERT ... SELECT로 옮기면 트랜잭션이 거대해지고, WAL이 폭증하며, 실패 시 롤백에도 오랜 시간이 걸린다. 청크로 나누는 것이 정석이다.

-- 키 범위로 나눠 반복 실행 (애플리케이션이나 스크립트에서 루프)
DO $$
DECLARE
  lo timestamptz := '2026-01-01';
  hi timestamptz;
BEGIN
  WHILE lo < '2026-02-01' LOOP
    hi := lo + interval '1 day';
    INSERT INTO events_202601
    SELECT * FROM events_2026q1
    WHERE created_at >= lo AND created_at < hi;
    COMMIT;                       -- 하루치마다 커밋해 WAL 과 잠금을 끊는다
    lo := hi;
  END LOOP;
END $$;
대용량 작업 전 확인
디스크 여유 — 이동 중에는 원본과 사본이 동시에 존재한다 (최소 2배 필요)
WAL 아카이브·복제 지연 — 대량 쓰기가 복제본을 뒤처지게 만들 수 있다
오토배큠 영향 — 작업 후 통계 갱신과 배큠이 필요하다
외래 키 제약 — 파티션 테이블을 참조하는 FK가 있으면 절차가 복잡해진다
롤백 계획 — 중간에 실패하면 어디까지 되돌릴 것인가
유지보수 창 확보 — CONCURRENTLY를 써도 완전 무중단은 아니다

CONCURRENTLY의 제약

DETACH PARTITION CONCURRENTLY는 잠금을 크게 줄여 주지만 조건이 있다. 트랜잭션 블록 안에서 실행할 수 없고, 한 번에 하나의 파티션만 처리할 수 있으며, 중단되면 파티션이 중간 상태로 남을 수 있다. 중간 상태가 되면 FINALIZE 구문으로 마무리해야 한다.

-- 중단된 DETACH 마무리
ALTER TABLE events DETACH PARTITION events_202601 FINALIZE;

-- 현재 파티션 상태 확인
SELECT c.relname, p.partstrat, i.inhparent::regclass
FROM pg_class c
LEFT JOIN pg_inherits i ON i.inhrelid = c.oid
LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
WHERE c.relname LIKE 'events%'
ORDER BY c.relname;

애초에 덜 아프게 설계하기

파티션 설계 원칙
  • 파티션 수를 적정하게 — 수천 개면 계획 수립 자체가 느려진다
  • 보관 기간이 명확한 데이터는 파티션 단위 삭제(DROP)로 정리
  • 기본 파티션(DEFAULT)을 두되 거기에 쌓이지 않게 감시
  • 새 파티션 생성을 자동화(스케줄러)해 수작업을 없앤다
피할 것
  • 파티션 키를 자주 바꾸는 설계
  • 월 파티션 수백 개를 방치
  • 파티션 경계에 걸치는 쿼리 패턴
  • 파티션마다 인덱스 구성이 다른 상태

경험상 재구성 작업의 절반은 초기 설계에서 파티션 단위를 잘못 잡아서 생긴다. 데이터 증가 속도를 보수적으로 예측해 단위를 정하고, 자동 생성 스크립트를 처음부터 넣어 두면 이 고생을 상당 부분 피할 수 있다.

자주 묻는 질문

파티션을 합치거나 쪼갤 때 서비스를 멈춰야 하나요?

완전 무중단은 어렵지만 중단 시간을 크게 줄일 수 있습니다. 데이터 이동을 미리 끝내 두고, CHECK 제약으로 ATTACH 검증을 생략하며, DETACH에 CONCURRENTLY를 사용하면 실제 잠금 구간은 매우 짧아집니다.

ATTACH PARTITION이 오래 걸리는 이유는 무엇인가요?

붙이려는 테이블의 모든 행이 파티션 경계 조건을 만족하는지 전체 스캔으로 검증하기 때문입니다. 파티션 경계와 동일한 CHECK 제약을 미리 추가해 두면 이 검증이 생략돼 즉시 완료됩니다.

데이터 이동은 한 번에 해도 되나요?

대용량에서는 권장하지 않습니다. 트랜잭션이 커지면 WAL이 폭증하고 복제 지연이 생기며, 실패 시 롤백에도 오랜 시간이 걸립니다. 날짜나 키 범위로 청크를 나눠 각 청크마다 커밋하는 방식이 안전합니다.

DETACH CONCURRENTLY 중에 작업이 중단되면 어떻게 하나요?

파티션이 중간 상태로 남을 수 있습니다. 이 경우 DETACH PARTITION ... FINALIZE로 마무리해야 합니다. 상태는 pg_inherits와 pg_class를 조회해 확인할 수 있습니다.

작업 중 디스크가 부족해질 수 있나요?

이동 중에는 원본과 사본이 동시에 존재하므로 최소 대상 데이터 크기의 2배 이상 여유가 필요합니다. 인덱스 재생성 공간과 WAL 증가분까지 고려해 여유를 넉넉히 확보한 뒤 시작하세요.

이런 작업을 줄이려면 처음에 어떻게 설계해야 하나요?

데이터 증가 속도를 보수적으로 예측해 파티션 단위를 정하고, 파티션 자동 생성과 오래된 파티션 삭제를 스케줄러로 자동화하세요. 파티션 수가 수천 개로 늘어나면 실행 계획 수립 자체가 느려지므로 적정 개수를 유지하는 것이 중요합니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록