기존 데이터베이스에서 여러 파티션으로 안전하게 데이터를 옮기는 mLog 유령 캐릭터

PostgreSQL 파티셔닝 완전 가이드: 기존 대용량 테이블을 안전하게 마이그레이션하는 방법

데이터베이스 PostgreSQL 2026년 9월 1일

파티셔닝은 큰 테이블을 단순히 여러 개로 쪼개는 기능이 아니다. 조회 조건, 보존 주기, 기본키, 외래키, 잠금, 운영 자동화까지 함께 설계해야 효과를 얻을 수 있다.

이 글의 핵심 요약

  • PostgreSQL 파티셔닝은 논리적으로 하나인 테이블을 여러 물리 파티션에 저장하고, 파티션 키에 따라 행을 자동으로 라우팅하는 기능이다.
  • 성능 향상의 핵심은 모든 쿼리가 빨라지는 것이 아니라, 조건에 맞지 않는 파티션을 제외하는 파티션 프루닝과 파티션 단위 데이터 관리에 있다.
  • 기존 일반 테이블은 ALTER TABLE 한 줄로 파티션 부모가 되지 않는다. 새 부모를 만들고 복사하거나, 기존 테이블을 파티션으로 연결하는 전환 절차가 필요하다.
  • 운영 환경에서는 시퀀스, 기본키·고유 제약, 외래키, 뷰, 트리거, 권한, RLS, 복제 설정까지 함께 점검해야 한다.
  • DEFAULT 파티션은 누락된 범위를 받아 주는 안전망이지, 기존 데이터를 월별 파티션으로 자동 재분배하는 마법 같은 기능이 아니다.

파티셔닝이란 무엇인가

애플리케이션에서는 하나의 orders 테이블로 보이지만, 실제 데이터는 월별 또는 연도별 하위 테이블에 나누어 저장할 수 있다.

예를 들어 2026년 8월 주문은 orders_2026_08, 9월 주문은 orders_2026_09에 저장한다. 애플리케이션은 어느 파티션에 넣을지 직접 판단할 필요가 없다. 파티션 부모인 ordersINSERT하면 PostgreSQL이 파티션 키를 확인해 알맞은 파티션으로 라우팅한다.

PostgreSQL 공식 문서에서는 파티션 부모를 실제 저장 공간이 없는 “가상 테이블”로 설명한다. 실제 행과 인덱스는 하위 파티션에 존재한다. 자세한 구조는 PostgreSQL 18 Table Partitioning에서 확인할 수 있다.

  • 애플리케이션은 논리적 부모인 orders만 사용한다.
  • PostgreSQL은 2026년 8월 행을 orders_2026_08에 저장한다.
  • 2026년 9월 행은 orders_2026_09, 10월 행은 orders_2026_10으로 라우팅한다.

파티션 키가 바뀌어 기존 경계를 벗어나는 UPDATE가 실행되면 PostgreSQL은 조건을 만족하는 다른 파티션으로 행을 이동시킬 수 있다. 반대로 맞는 파티션이 하나도 없고 DEFAULT 파티션도 없다면 쓰기는 오류로 끝난다. 따라서 미래 파티션을 미리 만드는 운영 절차가 필요하다.

PostgreSQL이 지원하는 세 가지 방식

RANGE 파티셔닝

날짜나 숫자 범위로 나눈다. 로그, 주문, 결제, 접속 이력처럼 시간의 흐름에 따라 계속 쌓이고 보존 기간이 정해진 데이터에 가장 자연스럽다.

CREATE TABLE public.orders_partitioned (
    order_id    bigint GENERATED BY DEFAULT AS IDENTITY,
    customer_id bigint NOT NULL,
    order_date date NOT NULL,
    amount      numeric(14, 2) NOT NULL CHECK (amount >= 0),
    created_at  timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT orders_partitioned_pkey
        PRIMARY KEY (order_id, order_date)
)
PARTITION BY RANGE (order_date);

CREATE TABLE public.orders_2026_08
    PARTITION OF public.orders_partitioned
    FOR VALUES FROM (DATE '2026-08-01')
                 TO (DATE '2026-09-01');

CREATE TABLE public.orders_2026_09
    PARTITION OF public.orders_partitioned
    FOR VALUES FROM (DATE '2026-09-01')
                 TO (DATE '2026-10-01');

CREATE TABLE public.orders_2026_10
    PARTITION OF public.orders_partitioned
    FOR VALUES FROM (DATE '2026-10-01')
                 TO (DATE '2026-11-01');

RANGE 경계는 하한을 포함하고 상한을 제외한다. 즉 8월 파티션의 범위는 2026-08-01 이상, 2026-09-01 미만이다. 9월 1일은 8월 파티션이 아니라 9월 파티션에 들어간다.

이 규칙 때문에 다음처럼 양쪽 끝을 모두 포함하는 조건을 사람이 직접 만들면 경계값이 겹치기 쉽다.

잘못 이해하기 쉬운 범위: 8월 1일 이상 9월 1일 이하
권장 범위:             8월 1일 이상 9월 1일 미만

LIST 파티셔닝

정해진 값 목록으로 나눈다. 지역 코드, 서비스 구분, 상태 그룹처럼 값의 종류가 제한적이고 각 그룹의 운영 정책이 다를 때 적합하다.

CREATE TABLE public.support_ticket (
    ticket_id   bigint NOT NULL,
    region_code text NOT NULL,
    subject     text NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (ticket_id, region_code)
)
PARTITION BY LIST (region_code);

CREATE TABLE public.support_ticket_kr
    PARTITION OF public.support_ticket
    FOR VALUES IN ('KR');

CREATE TABLE public.support_ticket_us_eu
    PARTITION OF public.support_ticket
    FOR VALUES IN ('US', 'DE', 'FR');

CREATE TABLE public.support_ticket_other
    PARTITION OF public.support_ticket DEFAULT;

LIST는 값 분포가 한쪽에 몰릴 수 있다는 점을 살펴봐야 한다. 국가 코드가 100개라고 해서 국가마다 파티션을 하나씩 만드는 것이 항상 좋은 설계는 아니다. 실제 데이터 양과 조회·보관 정책이 비슷한 값은 하나의 파티션으로 묶는 편이 나을 수 있다.

HASH 파티셔닝

특정 값의 의미보다 여러 파티션에 데이터를 비교적 고르게 분산하는 것이 목적일 때 사용한다. 테넌트 ID나 장비 ID처럼 시간 기준 보존 정책은 없지만 단일 테이블이 지나치게 커지는 경우에 고려할 수 있다.

CREATE TABLE public.event_store (
    tenant_id bigint NOT NULL,
    event_id  bigint NOT NULL,
    payload   jsonb NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, event_id)
)
PARTITION BY HASH (tenant_id);

CREATE TABLE public.event_store_p0
    PARTITION OF public.event_store
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);

CREATE TABLE public.event_store_p1
    PARTITION OF public.event_store
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);

CREATE TABLE public.event_store_p2
    PARTITION OF public.event_store
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);

CREATE TABLE public.event_store_p3
    PARTITION OF public.event_store
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

HASH 파티셔닝에는 DEFAULT 파티션이 없다. 모든 나머지 값을 빠짐없이 정의해야 한다. 또한 데이터가 균등해 보이더라도 특정 기간 전체를 제거하는 작업에는 RANGE보다 불편하다.

어떤 방식을 선택해야 할까

  • RANGE: 날짜·연속 숫자에 적합하다. 기간 조회와 기간별 보존·삭제가 쉽고, RANGE와 LIST는 DEFAULT 파티션을 둘 수 있다.
  • LIST: 국가·유형·상태처럼 값의 의미에 따라 운영 그룹을 분리할 때 적합하다. 데이터 쏠림을 함께 살펴봐야 한다.
  • HASH: 테넌트·식별자 기준으로 비교적 고르게 분산할 때 적합하다. 기간별 데이터 제거에는 불편하고 DEFAULT 파티션을 지원하지 않는다.

선택 기준은 “데이터가 무엇이냐”보다 “어떤 조건으로 조회하고 어떤 단위로 지울 것이냐”에 가깝다. 매일 날짜 조건으로 조회하고 2년이 지난 데이터를 월 단위로 제거한다면 RANGE가 자연스럽다. 반대로 대부분의 조회가 tenant_id 하나로 시작하고 기간별 삭제 요구가 없다면 HASH가 더 나을 수 있다.

파티셔닝의 실제 장점

1. 파티션 프루닝

다음 쿼리는 2026년 8월 범위만 요청한다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, customer_id, amount
FROM public.orders_partitioned
WHERE order_date >= DATE '2026-08-01'
  AND order_date <  DATE '2026-09-01';

플래너가 쿼리 조건과 파티션 경계를 비교해 9월·10월 파티션에는 결과가 존재할 수 없다고 증명하면 해당 파티션을 실행 계획에서 제외한다. 이것이 파티션 프루닝이다.

중요한 점은 프루닝이 인덱스가 아니라 파티션 경계를 이용한다는 사실이다. 파티션 키 인덱스가 없어도 프루닝은 일어날 수 있다. 다만 선택된 파티션 안에서 소수의 행만 찾는다면 그 파티션의 인덱스가 별도로 도움이 된다.

enable_partition_pruning의 기본값은 켜져 있지만, 실제 적용 여부는 반드시 실행 계획으로 확인해야 한다.

SHOW enable_partition_pruning;

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT count(*)
FROM public.orders_partitioned
WHERE order_date >= DATE '2026-08-01'
  AND order_date <  DATE '2026-09-01';

파티션 키에 불필요한 함수나 형 변환을 적용하거나, 쿼리 조건이 파티션 경계와 직접 대응하지 않으면 기대한 만큼 제외되지 않을 수 있다. “파티션을 만들었으니 빠르다”가 아니라 대표 쿼리별 EXPLAIN 결과를 비교해야 한다.

자세한 동작은 PostgreSQL Partition Pruning에 설명돼 있다.

2. 오래된 데이터의 빠른 제거

수억 건 가운데 오래된 한 달 데이터를 DELETE하면 각 행을 처리하고 WAL과 Vacuum 부담을 관리해야 한다. 월별 파티션이라면 해당 파티션을 분리하거나 삭제하는 방식으로 데이터 생명주기를 관리할 수 있다.

ALTER TABLE public.orders_partitioned
    DETACH PARTITION public.orders_2025_08 CONCURRENTLY;

-- 백업과 검증을 마친 뒤 별도 작업으로 수행
DROP TABLE public.orders_2025_08 RESTRICT;

DETACH PARTITION ... CONCURRENTLY는 부모 테이블에 대한 접근 방해를 줄일 수 있지만 아무 잠금도 없는 명령은 아니다. 트랜잭션 블록 안에서 실행할 수 없고, 부모에 DEFAULT 파티션이 있으면 사용할 수 없는 제한도 있다. 운영 버전의 ALTER TABLE 문서를 먼저 확인해야 한다.

분리된 테이블은 즉시 삭제하지 않고 보관·압축·백업·집계 작업에 사용할 수 있다. 삭제할 때 CASCADE를 습관적으로 붙이면 뷰나 외래키 같은 의존 객체까지 영향을 받을 수 있으므로 RESTRICT 상태에서 의존성을 확인하는 편이 안전하다.

3. 파티션별 인덱스와 저장 정책

부모에 일반 인덱스를 만들면 기존 파티션에 대응하는 인덱스가 생성되고, 이후 만들어지거나 연결되는 파티션에도 구조가 이어진다.

CREATE INDEX idx_orders_customer_date
    ON public.orders_partitioned (customer_id, order_date DESC);

하지만 부모 파티션 테이블에는 CREATE INDEX CONCURRENTLY를 직접 사용할 수 없다. 이미 운영 중인 대형 파티션 세트라면 부모에 ONLY 인덱스를 만든 뒤 각 파티션 인덱스를 동시에 생성하고 연결하는 절차를 고려한다.

CREATE INDEX idx_orders_customer_date_v2
    ON ONLY public.orders_partitioned (customer_id, order_date DESC);

CREATE INDEX CONCURRENTLY idx_orders_2026_08_customer_date
    ON public.orders_2026_08 (customer_id, order_date DESC);

ALTER INDEX public.idx_orders_customer_date_v2
    ATTACH PARTITION public.idx_orders_2026_08_customer_date;

모든 파티션의 동등한 인덱스를 연결해야 부모 인덱스가 유효 상태가 된다. 실제 운영에서는 파티션 수만큼 순차적으로 작업하고, 각 단계의 잠금 대기와 실패 복구를 준비해야 한다. 공식 절차는 Partition Maintenance에 있다.

4. 자주 쓰는 데이터와 오래된 데이터의 분리

최근 데이터는 빠른 스토리지에, 거의 조회하지 않는 과거 파티션은 저렴한 테이블스페이스에 둘 수 있다. 파티션마다 저장 매개변수와 인덱스 구성을 다르게 운영할 수도 있다.

다만 파티션마다 설정이 달라질수록 장애 복구와 운영 자동화가 복잡해진다. 저장 비용 절감 효과와 관리 복잡도를 함께 계산해야 한다.

파티셔닝이 항상 성능을 높이는 것은 아니다

파티셔닝은 “큰 테이블이면 무조건 적용”하는 옵션이 아니다. PostgreSQL 공식 문서도 일반적으로 테이블이 매우 클 때 이점이 나타나며, 정확한 기준은 워크로드에 달려 있다고 설명한다.

다음 상황에서는 오히려 단일 테이블과 적절한 인덱스가 단순하고 빠를 수 있다.

  • 테이블이 메모리와 스토리지 성능에 비해 충분히 작다.
  • 대부분의 쿼리가 파티션 키 조건을 사용하지 않는다.
  • 여러 파티션의 데이터를 항상 합쳐 집계한다.
  • 수천 개의 지나치게 작은 파티션 때문에 계획 시간이 늘어난다.
  • 파티션 생성·보존·모니터링을 자동화할 운영 체계가 없다.
  • 파티션 키를 포함할 수 없는 기존 기본키·외래키 모델을 유지해야 한다.

먼저 느린 쿼리의 실행 계획, 테이블과 인덱스 크기, 데이터 증가량, 삭제 정책을 측정해야 한다. 단일 테이블의 복합 인덱스, 부분 인덱스, BRIN 인덱스, 쿼리 개선만으로 충분한지도 비교해야 한다.

가장 먼저 막히는 지점: PRIMARY KEY와 UNIQUE

날짜로 파티셔닝한 부모에 다음 제약을 만들고 싶을 수 있다.

PRIMARY KEY (order_id)

하지만 PARTITION BY RANGE (order_date)인 부모의 PRIMARY KEYUNIQUE에는 모든 파티션 키 열이 포함되어야 한다.

PRIMARY KEY (order_id, order_date)

이 제한은 각 파티션의 인덱스가 자기 파티션 안에서만 유일성을 직접 검사하기 때문에 존재한다. PostgreSQL에는 모든 파티션을 하나로 묶어 order_id만 검사하는 일반적인 전역 고유 인덱스가 없다.

여기서 더 중요한 문제가 생긴다. PRIMARY KEY (order_id, order_date)는 두 열의 조합만 고유하게 만든다. 날짜가 다르면 같은 order_id가 존재할 수 있으므로, order_id 단독의 DB 수준 전역 유일성을 보장하지 않는다.

또한 기존 참조 테이블이 다음 외래키를 가지고 있다면 그대로 재생성할 수 없을 수 있다.

FOREIGN KEY (order_id)
REFERENCES public.orders (order_id)

이 경우 마이그레이션 전에 다음 선택지 중 하나를 설계해야 한다.

  1. 참조 테이블에도 order_date를 저장하고 복합 외래키로 바꾼다.
  2. 날짜가 아니라 order_id를 기준으로 HASH 파티셔닝해 단독 기본키를 유지한다.
  3. 비파티션 키 레지스트리 테이블을 별도로 두고 외래키 대상을 분리한다.
  4. 현재 관계 모델에서는 날짜 파티셔닝을 적용하지 않는다.

파티션 키가 함수나 표현식이면 부모의 UNIQUE·PRIMARY KEY에 추가 제한이 생긴다. 따라서 date_trunc() 같은 표현식을 파티션 키로 정하기 전에 공식 Declarative Partitioning Limitations를 확인해야 한다.

기존 테이블을 바꾸기 전에 조사할 것

기존 orders가 단순히 네 개 열만 가진 독립 테이블이라고 가정하면 안 된다. 운영 테이블은 보통 많은 객체와 연결되어 있다.

데이터 범위와 품질

SELECT
    count(*) AS total_rows,
    min(order_date) AS min_order_date,
    max(order_date) AS max_order_date,
    count(*) FILTER (WHERE order_date IS NULL) AS null_order_date,
    min(order_id) AS min_order_id,
    max(order_id) AS max_order_id
FROM public.orders;

SELECT pg_size_pretty(pg_total_relation_size('public.orders'))
    AS total_relation_size;

파티션 키에 NULL이 있으면 명시적 RANGE 파티션으로 라우팅할 수 없다. 값을 보정할지, 별도 정책을 둘지, 전환을 중단할지 먼저 결정한다.

시퀀스와 기본값

serial은 실제 데이터 타입이 아니라 시퀀스, nextval() 기본값, 소유 관계를 만들어 주는 축약 문법이다.

SELECT pg_get_serial_sequence('public.orders', 'order_id');

기존 테이블 이름을 바꿔도 연관 시퀀스의 이름이 같은 규칙으로 자동 변경된다고 가정하면 안 된다. 새 테이블에서 serial을 다시 선언하면 다른 시퀀스가 만들어질 수 있고, 복사한 최대 ID보다 낮은 값에서 시작해 충돌할 수 있다.

이 글의 복사 방식 예제는 새 부모에 GENERATED BY DEFAULT AS IDENTITY를 사용하고, 명시적 ID를 복사한 뒤 쓰기를 재개하기 전에 새 시퀀스를 최대 ID보다 뒤로 맞춘다. 기존 시퀀스를 재사용하려면 실제 이름, 기본값, OWNED BY 대상을 확인하고 소유권 이전 시점을 별도로 설계해야 한다.

의존 객체

다음 제약부터 확인한다.

SELECT
    conname,
    contype,
    conrelid::regclass AS source_table,
    confrelid::regclass AS referenced_table,
    pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE conrelid = 'public.orders'::regclass
   OR confrelid = 'public.orders'::regclass
ORDER BY contype, conname;

추가로 다음 항목을 목록화한다.

  • 기본키, UNIQUE, CHECK, 외래키
  • 인덱스와 확장 통계
  • 트리거와 규칙
  • 뷰와 Materialized View
  • 테이블·열 권한과 소유자
  • RLS 정책과 FORCE ROW LEVEL SECURITY
  • REPLICA IDENTITY
  • 논리 복제 publication 등록
  • 테이블스페이스, 저장 매개변수, COMMENT
  • ORM 매핑, 준비된 쿼리, 배치 및 외부 ETL

ALTER TABLE orders RENAME TO orders_legacy는 이름만 바꾼다. 기존 뷰와 외래키는 객체 식별자를 통해 기존 테이블을 계속 참조하므로, 같은 이름의 새 orders를 만들었다고 자동으로 새 테이블을 바라보지 않는다.

따라서 전환은 “테이블 이름 교체”가 아니라 “의존 객체를 새 부모로 다시 연결하는 작업”으로 이해해야 한다.

마이그레이션 전략을 먼저 선택하자

상황 권장 전략 핵심 특징
쓰기를 충분히 멈출 수 있고 데이터가 비교적 작음 유지보수 창 전체 복사 가장 단순하지만 복사 시간만큼 중단
대용량이고 중단 시간을 짧게 해야 함 사전 적재 + 변경 동기화 구현은 복잡하지만 최종 잠금 시간을 단축
기존 데이터가 하나의 경계에 들어감 기존 테이블 ATTACH 물리 복사 없이 연결 가능
과거 전체를 당장 세분화할 필요가 없음 DEFAULT 브리지 빠르게 전환 후 점진 분할 가능

어떤 전략을 선택하더라도 먼저 운영 버전과 동일한 복제 환경에서 리허설해야 한다. 소요 시간뿐 아니라 WAL 증가량, Replica 지연, 잠금 대기, 디스크 여유, 백업 복구, 애플리케이션 회귀 테스트를 함께 측정한다.

전략 A: 유지보수 시간에 전체 데이터를 복사한다

가장 이해하기 쉬운 방식이다. 새 파티션 부모와 파티션을 준비하고, 쓰기를 완전히 멈춘 상태에서 기존 데이터를 복사한 뒤 새 테이블로 전환한다.

다음 조건에 잘 맞는다.

  • 복사와 인덱스 작업을 포함할 만큼 유지보수 시간이 충분하다.
  • 원본과 새 테이블, 인덱스, WAL을 함께 저장할 디스크 여유가 있다.
  • 저중단 동기화 로직보다 단순하고 검증하기 쉬운 절차가 중요하다.
  • 기존 데이터까지 원하는 월·연도 단위로 완전히 재배치해야 한다.

1단계: 새 부모는 다른 이름으로 미리 만든다

운영 테이블을 먼저 이름 변경하면 애플리케이션이 비어 있는 새 테이블을 보게 될 수 있다. 먼저 orders_v2를 준비하고, 필요한 모든 과거·현재·미래 파티션을 생성한다.

CREATE TABLE public.orders_v2 (
    order_id     bigint GENERATED BY DEFAULT AS IDENTITY,
    customer_id bigint NOT NULL,
    order_date  date NOT NULL,
    amount       numeric(14, 2) NOT NULL CHECK (amount >= 0),
    created_at   timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT orders_v2_pkey
        PRIMARY KEY (order_id, order_date)
)
PARTITION BY RANGE (order_date);

CREATE TABLE public.orders_v2_before_2026
    PARTITION OF public.orders_v2
    FOR VALUES FROM (MINVALUE)
                 TO (DATE '2026-01-01');

CREATE TABLE public.orders_v2_2026_01
    PARTITION OF public.orders_v2
    FOR VALUES FROM (DATE '2026-01-01')
                 TO (DATE '2026-02-01');

-- 실제 min/max와 보존 정책에 맞춰 나머지 파티션도 생성한다.

운영 스키마를 기계적으로 복제하려고 다음 명령을 바로 사용하고 싶을 수 있다.

CREATE TABLE public.orders_v2
    (LIKE public.orders INCLUDING ALL)
    PARTITION BY RANGE (order_date);

하지만 INCLUDING ALL은 모든 데이터베이스 계약을 복제한다는 뜻이 아니다. LIKE가 지원하는 속성 전체를 의미할 뿐, 외래키·트리거·권한·RLS 정책까지 전부 옮기지 않는다.

특히 INCLUDING DEFAULTS는 기존 nextval() 기본값을 복사해 새 테이블이 기존 시퀀스를 함께 사용하게 만들 수 있고, INCLUDING IDENTITY는 새 시퀀스를 만든다. INCLUDING INDEXES는 날짜 파티션 부모에서 허용되지 않는 기존 PRIMARY KEY (order_id)까지 복사하려다 실패할 수 있다.

따라서 운영 마이그레이션에서는 대상 스키마를 명시적으로 정의하고, 원본 스키마 덤프와 카탈로그 목록을 기준으로 차이를 검토하는 편이 안전하다. LIKE 옵션의 정확한 범위는 CREATE TABLE 문서를 참고한다.

2단계: 데이터 적재 전에 쓰기를 확실히 차단한다

“점검 공지를 올렸으니 쓰기가 없을 것”이라고 가정해서는 안 된다. 웹 서버뿐 아니라 배치, 관리자 도구, 메시지 소비자, ETL, 예약 작업의 writer까지 멈춰야 한다.

DB에서도 전환 경계를 명확하게 만들려면 잠금을 사용한다.

BEGIN;

SET LOCAL lock_timeout = '5s';

LOCK TABLE public.orders
    IN ACCESS EXCLUSIVE MODE;

-- 이 트랜잭션 안에서 최종 복사·검증·전환을 수행한다.

이 잠금은 다른 세션의 읽기와 쓰기를 막으므로 대용량 복사를 한 트랜잭션에 넣으면 중단 시간이 길어진다. 데이터가 크다면 애플리케이션 writer를 차단한 유지보수 상태에서 범위별 적재를 진행하고, 이름 교환 구간만 짧은 트랜잭션으로 처리하는 방법도 있다. 어느 쪽이든 실제 writer가 존재하지 않는다는 사실을 절차와 잠금으로 보장해야 한다.

3단계: SELECT 별표 대신 열을 명시한다

원문의 다음 형태는 간단한 실습에서는 동작할 수 있지만 운영 스크립트로는 피하는 편이 좋다.

INSERT INTO public.orders_v2
SELECT * FROM public.orders;

열 순서가 달라지거나 generated 열이 생기면 잘못된 매핑 또는 실행 실패가 발생할 수 있다. 양쪽 열을 명시한다.

INSERT INTO public.orders_v2 (
    order_id,
    customer_id,
    order_date,
    amount,
    created_at
)
SELECT
    order_id,
    customer_id,
    order_date,
    amount,
    created_at
FROM public.orders;

GENERATED BY DEFAULT AS IDENTITY는 기존 ID를 명시해 넣을 수 있다. GENERATED ALWAYS를 사용했다면 복사 시 OVERRIDING SYSTEM VALUE가 필요하다. 이 차이도 리허설에서 확인한다.

대형 테이블은 기간 단위로 적재할 수 있다.

INSERT INTO public.orders_v2 (
    order_id,
    customer_id,
    order_date,
    amount,
    created_at
)
SELECT
    order_id,
    customer_id,
    order_date,
    amount,
    created_at
FROM public.orders
WHERE order_date >= DATE '2026-01-01'
  AND order_date <  DATE '2026-02-01';

배치마다 커밋하면 재시작은 쉬워지지만 전체가 하나의 원자적 작업은 아니게 된다. 범위별 완료 상태, 재실행 시 중복 처리, 최종 변경분 반영 방법을 별도로 관리해야 한다.

4단계: ID 시퀀스를 앞으로 이동한다

명시적인 ID를 복사해도 새 identity 시퀀스가 자동으로 최대값까지 이동하지는 않는다. 새 테이블 쓰기를 허용하기 전에 실제 시퀀스 이름을 확인하고, 기존 최대 ID보다 뒤에서 시작하게 해야 한다.

SELECT pg_get_serial_sequence(
    'public.orders_v2',
    'order_id'
);

새 identity 시퀀스가 마이그레이션 외에는 전혀 사용되지 않았다는 전제에서 다음처럼 조정할 수 있다.

SELECT setval(
    pg_get_serial_sequence('public.orders_v2', 'order_id'),
    COALESCE(max(order_id), 1),
    max(order_id) IS NOT NULL
)
FROM public.orders_v2;

온라인 쓰기나 사전 테스트로 시퀀스가 이미 사용됐다면 이 명령을 그대로 실행하면 안 된다. 현재 시퀀스 값과 최대 ID 가운데 더 큰 값을 기준으로 앞으로만 이동해야 한다. PostgreSQL 시퀀스 변경은 일반적인 데이터 변경과 트랜잭션 특성이 다르므로 롤백으로 원래 번호가 돌아올 것이라고 기대해서도 안 된다.

5단계: 데이터와 객체를 검증한다

전체 건수만 같다고 데이터가 같은 것은 아니다. 기간별 건수, ID 범위, 금액 합계, NULL, 경계값을 함께 확인한다.

WITH source AS (
    SELECT
        date_trunc('month', order_date)::date AS month,
        count(*) AS rows,
        min(order_id) AS min_id,
        max(order_id) AS max_id,
        sum(amount) AS amount_sum
    FROM public.orders
    GROUP BY 1
),
target AS (
    SELECT
        date_trunc('month', order_date)::date AS month,
        count(*) AS rows,
        min(order_id) AS min_id,
        max(order_id) AS max_id,
        sum(amount) AS amount_sum
    FROM public.orders_v2
    GROUP BY 1
)
SELECT
    COALESCE(source.month, target.month) AS month,
    source.rows AS source_rows,
    target.rows AS target_rows,
    source.amount_sum AS source_amount,
    target.amount_sum AS target_amount
FROM source
FULL JOIN target USING (month)
WHERE ROW(source.rows, source.min_id, source.max_id, source.amount_sum)
      IS DISTINCT FROM
      ROW(target.rows, target.min_id, target.max_id, target.amount_sum)
ORDER BY 1;

정확한 양방향 차이 비교가 필요하면 EXCEPT ALL을 사용할 수 있지만, 전체 대용량 테이블에서는 정렬과 I/O 비용이 매우 클 수 있다. 기간별·파티션별로 나누어 오프피크에 수행한다.

실제 라우팅도 확인한다.

SELECT
    tableoid::regclass AS physical_partition,
    count(*) AS rows
FROM public.orders_v2
GROUP BY tableoid
ORDER BY physical_partition;

6단계: 이름 교환과 의존 객체 재연결

검증을 마친 뒤 짧은 잠금 구간에서 이름을 교환한다.

ALTER TABLE public.orders
    RENAME TO orders_legacy;

ALTER TABLE public.orders_v2
    RENAME TO orders;

하지만 이 두 문장만으로 전환이 끝나지 않는다. 기존 orders를 참조하던 뷰와 외래키는 orders_legacy를 계속 바라볼 수 있다. 새 부모의 소유자, 권한, RLS, 트리거, publication, replica identity, COMMENT와 저장 설정도 별도로 적용해야 한다.

모든 변경문은 사전에 생성하고 리허설한 순서대로 실행한다. 커밋 후에는 커넥션 풀을 재연결해 오래된 prepared statement와 메타데이터 캐시가 남지 않게 한다.

COMMIT;

ANALYZE public.orders;

orders_legacy는 즉시 삭제하지 않는다. 쓰기 권한을 제거하고 안정화 기간 동안 읽기 전용으로 보존한다. 삭제는 백업 복원과 롤백 기한이 끝난 뒤 DROP TABLE ... RESTRICT로 의존 객체를 다시 확인하며 진행한다.

전략 B: 저중단 마이그레이션

수백 GB 또는 TB 규모에서는 전체 복사 시간만큼 서비스를 멈추기 어렵다. 이때는 평상시 서비스 중 새 파티션 테이블을 채우고 변경분을 계속 동기화한 뒤, 마지막 전환 순간만 짧게 쓰기를 차단한다.

전체 흐름은 다음과 같다.

  1. 새 파티션 부모와 모든 파티션을 만든다.
  2. 원본의 INSERT·UPDATE·DELETE를 새 테이블에 반영할 동기화 경계를 만든다.
  3. 과거 데이터를 작은 범위로 나눠 backfill한다.
  4. 동기화 지연과 불일치를 줄인다.
  5. writer를 잠시 차단하고 마지막 변경분을 반영한다.
  6. 데이터와 객체를 확인한 뒤 애플리케이션 경로를 새 테이블로 전환한다.
  7. 롤백 기한 동안 원본과 변경 로그를 보존한다.

트리거 dual-write

같은 데이터베이스 안에서 ordersorders_v2를 동기화할 때 사용할 수 있다. 행 트리거는 INSERT뿐 아니라 UPDATE, DELETE, 파티션 키 변경까지 처리해야 한다.

CREATE OR REPLACE FUNCTION public.mirror_orders_to_v2()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
    IF TG_OP = 'DELETE' THEN
        DELETE FROM public.orders_v2
        WHERE order_id = OLD.order_id
          AND order_date = OLD.order_date;
        RETURN OLD;
    END IF;

    IF TG_OP = 'UPDATE'
       AND ROW(OLD.order_id, OLD.order_date)
           IS DISTINCT FROM
           ROW(NEW.order_id, NEW.order_date)
    THEN
        DELETE FROM public.orders_v2
        WHERE order_id = OLD.order_id
          AND order_date = OLD.order_date;
    END IF;

    INSERT INTO public.orders_v2 (
        order_id,
        customer_id,
        order_date,
        amount,
        created_at
    )
    VALUES (
        NEW.order_id,
        NEW.customer_id,
        NEW.order_date,
        NEW.amount,
        NEW.created_at
    )
    ON CONFLICT (order_id, order_date)
    DO UPDATE SET
        customer_id = EXCLUDED.customer_id,
        amount = EXCLUDED.amount,
        created_at = EXCLUDED.created_at;

    RETURN NEW;
END;
$function$;

이 코드는 개념을 보여 주는 골격이다. 그대로 운영에 적용하기 전에 다음 경쟁 조건을 해결해야 한다.

  • backfill이 오래된 행을 읽은 뒤 트리거가 최신 값을 반영하고, backfill이 다시 오래된 값으로 덮을 수 있다.
  • DELETE가 먼저 반영된 뒤 backfill이 삭제된 행을 되살릴 수 있다.
  • 대상 쓰기 오류가 원본 트랜잭션까지 실패시킬 수 있다.
  • 대상의 트리거가 다시 실행돼 알림·감사·외부 이벤트가 중복될 수 있다.
  • TRUNCATE는 행 단위 트리거만으로 잡히지 않는다.

작은 배치에서 원본 행을 일정한 순서로 잠근 뒤 복사하거나, 단조 증가 버전과 tombstone을 사용하거나, WAL 기반 CDC를 이용해 변경 순서를 보존해야 한다. ON CONFLICT DO NOTHING 하나만으로 DELETE 경쟁까지 해결되지는 않는다.

트리거 방식은 원본 트랜잭션에 동기식 비용을 추가한다. 실제 쓰기 부하에서 p95·p99 지연과 교착, 장애 전파를 측정해야 한다.

논리 복제 또는 CDC

쓰기량이 많거나 별도 클러스터로 전환한다면 WAL 기반 논리 복제나 외부 CDC를 고려할 수 있다. snapshot과 WAL 위치의 원자적 경계, 재시작 가능한 checkpoint, INSERT·UPDATE·DELETE·키 변경 처리, 중복 적용 안전성, 지연·오류 모니터링이 필요하다.

PostgreSQL 내장 논리 복제는 DDL과 시퀀스 상태를 자동 복제하지 않으며, publisher와 subscriber의 테이블 이름 대응에도 제약이 있다. 같은 데이터베이스의 orders에서 orders_v2로 이름을 바꿔 복제하는 용도로 단순 적용할 수 있다고 가정하면 안 된다.

전환 판단도 순간적으로 “lag가 0”으로 보이는지에만 의존하지 않는다. 확정된 marker 또는 LSN까지 반영됐는지, replication slot과 WAL 보존이 정상인지, apply 오류가 없는지를 함께 확인한다. 자세한 제약은 PostgreSQL Logical Replication Restrictions에서 확인할 수 있다.

저중단은 무잠금이 아니다

평상시 backfill과 변경 동기화는 온라인으로 수행할 수 있지만, 마지막 전환에는 짧은 write fence와 강한 잠금이 필요하다. 기존 writer 트랜잭션이 모두 끝났는지 확인하고, 최종 변경분과 시퀀스를 맞춘 뒤 이름과 의존 객체를 전환해야 한다.

또한 새 테이블에 쓰기가 시작된 뒤 단순히 이름만 되돌리면 새 변경분을 잃는다. 롤백을 보장하려면 다음 중 하나를 미리 준비한다.

  • 새 테이블에서 원본으로 변경분을 되돌리는 검증된 reverse CDC
  • durable change log 또는 outbox와 재생 도구
  • 쓰기를 다시 멈추고 새 변경분을 원본에 병합하는 runbook

즉흥적인 양방향 트리거는 재귀 호출, 순서 역전, unique conflict를 만들 수 있으므로 롤백 수단으로 바로 켜서는 안 된다.

전략 C: 기존 테이블을 파티션으로 ATTACH한다

PostgreSQL은 일반 테이블 자체를 파티션 부모로 제자리 변환할 수는 없지만, 기존 일반 테이블을 새 부모의 파티션으로 연결할 수 있다.

ALTER TABLE public.orders_partitioned
    ATTACH PARTITION public.orders_history
    FOR VALUES FROM (MINVALUE)
               TO (DATE '2026-09-01');

이 방식은 기존 데이터가 모두 하나의 연속 범위에 들어가고, 과거 데이터를 당장 월별로 다시 나눌 필요가 없을 때 특히 유용하다. 물리적으로 행을 복사하지 않고 “거대한 과거 파티션 하나”로 연결한 뒤, 기준일 이후 데이터만 월별 파티션에 저장할 수 있다.

ATTACH 전에 경계 CHECK를 검증한다

일반 테이블을 붙일 때 PostgreSQL은 모든 행이 파티션 경계를 만족하는지 기본적으로 전체 스캔한다. 대형 테이블에는 그동안 ACCESS EXCLUSIVE 잠금이 유지될 수 있다.

먼저 경계를 증명하는 CHECK를 추가하고 검증하면 ATTACH의 검증 스캔을 피할 수 있다.

ALTER TABLE public.orders_history
    ADD CONSTRAINT orders_history_before_cutoff_ck
    CHECK (
        order_date IS NOT NULL
        AND order_date < DATE '2026-09-01'
    )
    NOT VALID;

ALTER TABLE public.orders_history
    VALIDATE CONSTRAINT orders_history_before_cutoff_ck;

NOT VALID 상태만으로는 충분하지 않다. VALIDATE CONSTRAINT까지 완료해 유효한 제약이어야 ATTACH가 이를 검증 근거로 사용할 수 있다.

대상 테이블은 부모와 열 이름·개수·타입이 정확히 맞아야 하고, 부모의 NOT NULL과 CHECK 조건도 만족해야 한다. 부모 인덱스와 동등한 유효 인덱스가 대상에 없다면 ATTACH 중 새 인덱스 생성이 발생할 수 있다. 큰 테이블은 동등한 leaf 인덱스를 사전에 CONCURRENTLY로 준비한다.

DEFAULT 파티션 연결은 무엇을 하는가

문법 자체는 유효하다.

ALTER TABLE public.orders_partitioned
    ATTACH PARTITION public.orders_history DEFAULT;

하지만 기존 데이터가 월별 파티션으로 자동 이동하는 것은 아니다. orders_history 전체가 명시적 파티션에 들어가지 않는 모든 값을 받는 하나의 catch-all 파티션이 된다.

이미 월별 파티션이 있다면 orders_history 안에 그 범위와 겹치는 행이 없어야 한다. 겹치는 행이 하나라도 있으면 ATTACH가 실패한다.

또한 DEFAULT가 있는 상태에서 새 명시적 파티션을 추가하면 PostgreSQL은 DEFAULT에 새 범위의 행이 없는지 검사해야 한다. 적절한 제외 CHECK가 없으면 DEFAULT 전체를 스캔하며 ACCESS EXCLUSIVE 잠금을 잡을 수 있다.

예를 들어 2026년 9월 파티션을 새로 붙이려면 DEFAULT에는 다음 범위를 제외하는 유효한 조건이 필요하다.

CHECK (
    order_date < DATE '2026-09-01'
    OR order_date >= DATE '2026-10-01'
)

따라서 DEFAULT는 누락 방지와 단계적 전환에 유용하지만, 행이 계속 쌓이는지 반드시 감시해야 한다. 장기간 방치하면 프루닝 효과와 유지관리 이점을 얻지 못한 채 또 하나의 거대한 테이블이 된다.

공식 ATTACH 조건과 잠금은 ALTER TABLE ATTACH PARTITION에 자세히 설명돼 있다.

원문의 간단한 절차를 그대로 쓰면 위험한 이유

원문은 이해를 돕기 위해 다음 흐름을 제시한다.

기존 orders 이름 변경
→ 새 파티션 orders 생성
→ COMMIT
→ INSERT INTO orders SELECT * FROM old_orders
→ 검증 후 old_orders 삭제

개념 설명으로는 이해하기 쉽지만 운영 환경에서는 다음 문제가 생긴다.

COMMIT 직후 새 테이블이 비어 있다

애플리케이션은 새 orders에서 기존 주문을 찾지 못한다. 그 사이 새 주문은 새 테이블에 쌓이고, 과거 복사 작업은 별도로 진행돼 두 데이터 흐름이 갈라진다. 복사가 실패하면 DDL만 이미 확정된 상태가 된다.

serial을 다시 선언하면 별도 시퀀스가 생길 수 있다

기존 테이블의 이름을 바꿔도 관련 시퀀스의 이름과 소유 관계가 새 테이블에 맞게 자동 이전된다고 가정할 수 없다. 새 serial은 사용 가능한 다른 이름의 시퀀스를 만들 수 있고, 낮은 값에서 시작해 복사된 ID와 충돌할 수 있다.

이름이 같아도 의존 객체는 새 테이블로 이동하지 않는다

뷰와 외래키는 기존 객체를 계속 참조할 수 있다. 새 테이블에는 기존 트리거, 권한, RLS, 복제 설정이 없을 수 있다. 단순한 이름 교환은 객체 계약의 이전이 아니다.

PRIMARY KEY가 만들어지지 않을 수 있다

날짜 파티셔닝 부모에서 PRIMARY KEY (order_id)는 허용되지 않는다. 복합키로 바꾸면 기존 외래키와 UPSERT 조건까지 영향을 받는다.

SELECT 별표는 구조 차이를 숨긴다

열 순서, generated 열, identity, 기본값이 달라지는 순간 스크립트가 실패하거나 잘못 매핑될 수 있다.

기존 테이블을 즉시 삭제하면 롤백이 어려워진다

특히 DROP TABLE ... CASCADE는 의존 객체까지 제거할 수 있다. 백업이 존재한다는 사실과 빠른 롤백이 가능하다는 사실은 같지 않다.

운영 전환 체크리스트

설계

  • [ ] 실제 PostgreSQL 메이저 버전에서 DDL과 잠금 동작을 확인했다.
  • [ ] 대표 조회 조건과 데이터 보존 단위를 기준으로 파티션 키를 정했다.
  • [ ] RANGE 경계의 시간대와 하한 포함·상한 제외 규칙을 확정했다.
  • [ ] 파티션 키의 NULL, 과거 날짜 입력, 먼 미래 날짜 입력 정책을 정했다.
  • [ ] 파티션 개수와 생성 주기를 정하고 계획 시간도 측정했다.
  • [ ] PRIMARY KEY·UNIQUE가 파티션 키를 포함할 수 있는지 확인했다.
  • [ ] 기존 inbound·outbound FK와 UPSERT 조건의 변경을 승인받았다.

객체와 데이터

  • [ ] 시퀀스, identity, 기본값과 소유 관계를 확인했다.
  • [ ] 인덱스, CHECK, FK, 트리거, 뷰, 함수 의존성을 목록화했다.
  • [ ] owner, table/column grant, RLS, policy를 비교했다.
  • [ ] publication, replica identity, CDC 설정을 확인했다.
  • [ ] 전체·기간별 건수, min/max, 합계와 경계값을 검증했다.
  • [ ] tableoid::regclass로 실제 파티션 라우팅을 확인했다.
  • [ ] 대표 쿼리에서 EXPLAIN (ANALYZE, BUFFERS)로 프루닝을 확인했다.

운영과 복구

  • [ ] 원본·대상·인덱스·WAL·임시 공간을 포함한 디스크 여유가 있다.
  • [ ] 마이그레이션 중 Replica 지연과 WAL 증가를 감시한다.
  • [ ] 짧은 lock_timeout과 재시도 기준이 있다.
  • [ ] 장기 트랜잭션과 idle in transaction 세션을 사전에 정리했다.
  • [ ] writer 차단과 최종 경계를 애플리케이션·DB 양쪽에서 보장한다.
  • [ ] 백업이 아니라 실제 복원 리허설까지 완료했다.
  • [ ] 새 테이블 쓰기 이후의 역동기화 또는 롤백 절차가 있다.
  • [ ] 원본 삭제 시점과 보존 기간을 승인받았다.

전환 후 반드시 자동화할 것

미래 파티션 사전 생성

파티션이 없으면 해당 범위의 INSERT가 실패한다. 월별 파티션이라면 최소 한두 달 앞까지 미리 만들고, 생성 작업이 실패했을 때 알림을 받아야 한다.

단순한 스케줄러로 DDL을 관리할 수도 있고, 시간·숫자 기반 파티션 관리 확장인 pg_partman을 검토할 수도 있다. 확장 도입 여부와 관계없이 변경 DDL의 잠금, 권한, 장애 복구를 운영 절차에 포함해야 한다.

DEFAULT 행 감시

DEFAULT 파티션을 둔다면 행이 들어오는 순간 “예상하지 않은 범위가 발생했다”는 신호로 취급한다.

SELECT count(*) AS unexpected_rows
FROM public.orders_default;

항상 0이어야 하는 구조라면 1건만 생겨도 알림을 보내는 편이 낫다. DEFAULT를 정상 보관소로 사용하는 경우에도 기간별 분포와 크기를 별도로 모니터링한다.

파티션별 통계와 Vacuum

부모만 보지 말고 실제 데이터를 가진 leaf 파티션의 크기, dead tuple, analyze 시점, 인덱스 사용률을 확인한다. 대량 적재 후에는 ANALYZE를 수행해 전환 직후 잘못된 실행 계획이 선택되지 않게 한다.

보존 정책 테스트

분리·백업·삭제 작업은 “한 달에 한 번 실행되는 정상 업무”다. 첫 삭제일에 즉흥적으로 만들지 말고, 대상 파티션 계산, 잠금 실패, 백업 실패, 재실행, 잘못된 파티션 선택을 포함해 미리 테스트한다.

상황별 최종 권장안

데이터가 아직 크지 않고 충분한 점검 시간이 있다

파티션을 먼저 적용하기보다 현재 쿼리와 인덱스를 개선하고 증가 추세를 측정한다. 파티셔닝이 필요하다는 근거가 생기면 운영과 같은 복제 환경에서 전체 복사 방식을 리허설한다.

유지보수 시간을 확보할 수 있다

orders_v2를 명시적 스키마로 만들고, writer를 확실히 중단한 뒤 범위별 복사·검증·의존 객체 전환을 수행하는 방식이 가장 이해하기 쉽다. 다운타임 계산에는 테이블 복사뿐 아니라 인덱스, FK, ANALYZE, 애플리케이션 검증 시간까지 포함한다.

대용량이지만 중단 시간을 줄여야 한다

사전 backfill과 트리거 또는 CDC를 결합한다. 마지막 순간만 write fence를 적용한다. 이 방식은 구현보다 정합성 증명이 어렵다. UPDATE·DELETE 경쟁, 재시작, 중복 적용, tombstone, 역동기화를 장애 시나리오로 테스트한다.

기존 데이터가 모두 기준일 이전이다

기존 테이블에 유효한 경계 CHECK를 만든 뒤 새 부모의 FROM (MINVALUE) TO (cutoff) 파티션으로 ATTACH하는 방식이 유리할 수 있다. 기존 데이터는 거대한 과거 파티션 하나로 유지하고, cutoff 이후만 월별로 나눈다.

DEFAULT를 이용해 먼저 전환하고 싶다

가능하지만 임시 브리지로 취급한다. 기존 명시 파티션과 겹치는 행이 없어야 하고, 이후 새 파티션을 추가할 때 DEFAULT 스캔과 잠금을 줄일 제외 CHECK가 필요하다. DEFAULT 행 증가와 크기를 상시 감시한다.

자주 묻는 질문

Q1. 기존 일반 테이블에 PARTITION BY만 추가할 수 있나?

없다. PostgreSQL은 일반 테이블을 파티션 부모로 제자리 변경하는 단일 명령을 제공하지 않는다. 새 파티션 부모를 만든 뒤 데이터를 복사하거나 기존 테이블을 그 부모의 파티션으로 ATTACH해야 한다.

Q2. 파티셔닝하면 모든 SELECT가 빨라지나?

아니다. 파티션 키 조건을 통해 많은 파티션을 제외할 수 있는 쿼리에서 효과가 크다. 모든 파티션을 읽는 집계나 파티션 키와 무관한 조회는 개선이 제한되며, 파티션이 지나치게 많으면 계획 비용이 늘 수 있다. 대표 SQL을 EXPLAIN (ANALYZE, BUFFERS)로 비교해야 한다.

Q3. 파티션 키에도 인덱스가 반드시 필요한가?

프루닝 자체는 인덱스가 아니라 파티션 경계를 사용하므로 필수는 아니다. 다만 선택된 파티션 내부에서 소수의 행을 찾거나 정렬·조인을 수행한다면 적절한 인덱스가 필요할 수 있다.

Q4. 월별 파티션과 일별 파티션 중 무엇이 좋은가?

하루 데이터량, 주요 조회 범위, 삭제 단위, 파티션 개수를 함께 본다. 하루 수천만 건이고 일 단위 보존·삭제가 필요하면 일별이 자연스러울 수 있다. 데이터량이 적고 조회가 월 단위라면 월별이 단순하다. “더 잘게 나누면 더 빠르다”는 규칙은 없다.

Q5. 날짜 파티셔닝에서 order_id 단독 기본키를 유지할 수 있나?

파티션 부모의 PRIMARY KEY·UNIQUE에는 모든 파티션 키가 포함되어야 하므로 일반적으로 PRIMARY KEY (order_id)를 그대로 만들 수 없다. 복합키, 다른 파티션 키, 별도 키 레지스트리, 파티셔닝 보류 중 하나를 데이터 모델 관점에서 선택해야 한다.

Q6. DEFAULT 파티션을 항상 만들어 두는 것이 안전한가?

쓰기 실패를 줄이는 장점은 있지만 누락된 파티션 운영을 숨길 수 있다. 새 명시 파티션 추가 시 DEFAULT 검증 스캔과 강한 잠금이 발생할 수도 있다. DEFAULT를 사용한다면 행 수와 날짜 분포에 반드시 경보를 건다.

Q7. ATTACH PARTITION은 무중단 명령인가?

아니다. 부모에는 SHARE UPDATE EXCLUSIVE, 붙이는 테이블과 DEFAULT에는 ACCESS EXCLUSIVE 잠금이 필요할 수 있다. 유효한 CHECK로 검증 스캔을 피하더라도 잠금 자체가 모두 사라지는 것은 아니다. 장기 트랜잭션을 제거하고 짧은 lock_timeout과 재시도 절차를 준비한다.

Q8. 오래된 파티션은 DROP과 DETACH 중 무엇이 좋은가?

즉시 폐기해도 되는 데이터라면 DROP이 단순하지만 부모에 강한 잠금이 필요하다. 백업·집계·검증이 필요하다면 먼저 DETACH해 독립 테이블로 보존하는 편이 안전하다. DETACH ... CONCURRENTLY에는 트랜잭션 블록 및 DEFAULT 관련 제한이 있으므로 버전 문서를 확인한다.

Q9. 부모 테이블에 CREATE INDEX CONCURRENTLY를 사용할 수 있나?

파티션 부모 자체에는 직접 사용할 수 없다. 부모에 CREATE INDEX ... ON ONLY로 가상 인덱스를 만든 뒤 각 leaf에 CREATE INDEX CONCURRENTLY를 실행하고 ALTER INDEX ... ATTACH PARTITION으로 연결하는 공식 절차를 사용할 수 있다.

Q10. 데이터 복사 후 count가 같으면 전환해도 되나?

부족하다. 기간별 건수, min/max, 금액 같은 비즈니스 합계, NULL, 경계값, 양방향 차이, 실제 파티션 라우팅을 함께 확인해야 한다. FK·뷰·권한·RLS·시퀀스·replica identity 같은 객체 계약도 별도로 검증해야 한다.

Q11. 파티션은 PostgreSQL이 자동으로 만들어 주나?

기본 선언적 파티셔닝은 미래 파티션을 자동 생성하지 않는다. 스케줄러로 DDL을 실행하거나 pg_partman 같은 확장을 검토하고, 파티션 생성 실패를 감지하는 경보를 둬야 한다.

Q12. 무중단 마이그레이션이 가능한가?

평상시 복사와 변경 동기화는 온라인으로 수행할 수 있지만 마지막 쓰기 경계와 객체 전환까지 잠금이 전혀 없는 방식은 일반화하기 어렵다. 정확한 표현은 “저중단 마이그레이션”에 가깝다. 허용 가능한 중단 시간과 실패 시 복구 목표를 먼저 정해야 한다.

마무리

PostgreSQL 파티셔닝의 문법은 어렵지 않다. 어려운 부분은 파티션을 만드는 SQL이 아니라, 현재 데이터 모델과 운영 절차가 파티션 구조를 감당할 수 있는지 증명하는 일이다.

새 테이블이라면 다음 순서가 좋다.

  1. 조회 조건과 보존 정책에서 파티션 키를 선택한다.
  2. PRIMARY KEY·UNIQUE·FK 모델을 먼저 검증한다.
  3. 파티션 크기와 미래 생성 자동화를 설계한다.
  4. 대표 쿼리의 프루닝을 실행 계획으로 확인한다.
  5. DEFAULT와 오래된 파티션의 운영 정책을 만든다.

기존 대용량 테이블이라면 여기에 더해 다음 원칙을 지켜야 한다.

  1. 단순 rename이 의존 객체를 새 테이블로 옮겨 주지 않는다는 사실을 기억한다.
  2. serial·identity·시퀀스를 명시적으로 점검한다.
  3. 복사 중 신규 쓰기의 정합성 경계를 설계한다.
  4. ATTACH 전에 경계 CHECK와 인덱스를 준비한다.
  5. 전체 건수뿐 아니라 데이터와 객체 계약을 함께 검증한다.
  6. 기존 테이블은 롤백 기한이 끝날 때까지 보존한다.

파티셔닝을 잘 설계하면 최근 데이터 조회 범위를 줄이고, 오래된 데이터를 파티션 단위로 빠르게 분리하며, 데이터 생명주기를 예측 가능한 운영 작업으로 바꿀 수 있다. 반대로 기본키와 잠금, 미래 파티션 자동화를 놓치면 단일 테이블보다 더 복잡한 장애 지점을 만들 수 있다.

가장 안전한 출발점은 운영 DB에서 바로 명령을 실행하는 것이 아니다. 실제 스키마와 비슷한 복제 환경에서 파티션 경계, 데이터 복사, 잠금 시간, 실패 후 재실행, 롤백까지 한 번의 완전한 리허설로 확인하는 것이다.

참고 자료

이 글은 2026년 9월 1일 기준 PostgreSQL 18.6 공식 문서로 검증했다. 실제 작업에서는 현재 운영 중인 메이저·마이너 버전의 문서와 변경 사항을 다시 확인해야 한다. 예제 SQL은 구조 설명을 위한 골격이며, 운영 스키마의 제약·의존 객체·부하를 반영한 리허설 없이 실행해서는 안 된다.

태그

mLog

8년 이상 풀스택 개발자로 일하고 있습니다. Spring Boot, PostgreSQL, Redis, Vue·TypeScript와 Docker·Linux 서버를 다루며, 직접 운영하고 해결한 내용을 공식 문서와 실행 결과를 바탕으로 정리합니다.