Skip to content
Published on

PostgreSQL 파티셔닝 완벽 가이드: Range, List, Hash 전략과 성능 최적화

공유하기
Authors

파티셔닝이 필요한 시점

테이블이 커지면 성능 문제가 발생합니다:

  • 인덱스 크기 증가로 INSERT 성능 저하
  • 풀 테이블 스캔 비용 증가
  • VACUUM 작업 시간 증가
  • 데이터 보관/삭제 비용 증가

일반적으로 테이블 크기가 수십 GB 이상이거나, 시계열 데이터로 일정 기간 후 삭제가 필요한 경우 파티셔닝을 고려합니다.

이 글의 범위와 기준 버전

이 글은 어떤 파티셔닝 전략을 고를지, 파티션 키를 무엇으로 잡을지 를 다룹니다. 파티션을 붙이고 떼는 DDL이 실제로 어떤 락을 잡는지, 무중단으로 인덱스를 만들려면 어떤 순서로 해야 하는지 같은 운영 기계학은 PostgreSQL Partitioning 실전 가이드에서 따로 다룹니다. 여기서 나오는 문법 예제는 "이 전략이 어떤 모양인지" 보여주는 용도이고, 지면은 판단의 근거 쪽에 씁니다.

기준 엔진은 PostgreSQL 18 입니다. 이 글에서 이름과 기본값을 밝히는 설정은 모두 PostgreSQL 18 문서에서 확인한 값이며, 문서 주소는 마지막 참고 자료 절에 모아 두었습니다. 파티셔닝은 메이저 버전마다 플래너 동작이 달라진 영역이라, 다른 버전을 쓴다면 기본값을 그 버전 문서에서 다시 확인하세요.

전략 선택에 이만큼 지면을 쓰는 이유는 단순합니다. 파티셔닝에서 되돌리기 가장 어려운 결정이 파티션 키이기 때문입니다. 인덱스는 잘못 만들면 지우면 됩니다. 파티션 하나는 떼어내면 됩니다. 그런데 파티션 키를 잘못 고르면 테이블 전체를 다시 만들어야 합니다. 게다가 잘못 골랐다는 사실은 대개 데이터가 충분히 쌓인 뒤, 즉 다시 만들기가 가장 비싼 시점에 드러납니다.

파티션 키를 고르는 순서

전략(Range·List·Hash)을 먼저 고르고 키를 나중에 고르는 순서는 거의 항상 틀립니다. 순서는 반대입니다.

1단계: 쿼리가 무엇으로 필터하는지 센다

파티셔닝의 이득은 거의 전부 프루닝에서 나옵니다. 그리고 프루닝은 WHERE 절에 파티션 키가 있을 때만 걸립니다. 그러므로 파티션 키 후보는 "우리 쿼리의 WHERE 절에 가장 자주, 가장 선택적으로 등장하는 컬럼"입니다. 감으로 정하지 말고 세어 보세요.

-- pg_stat_statements가 켜져 있다면 실제 쿼리 텍스트에서 세는 것이 가장 정확하다.
-- 컬럼 이름은 버전에 따라 다르므로 사용 중인 버전의 문서를 확인하세요.
SELECT calls, total_exec_time, left(query, 120) AS query
FROM pg_stat_statements
WHERE query ILIKE '%from events%'
ORDER BY calls DESC
LIMIT 20;

여기서 확인할 것은 두 가지입니다. 어떤 컬럼이 WHERE에 자주 나오는가, 그리고 그 조건이 실제로 데이터를 얼마나 줄이는가. 자주 나오지만 절반을 남기는 조건은 파티션 키로 쓸모가 없습니다.

2단계: 보관 정책이 있는지 본다

"90일이 지나면 지운다"가 있으면 시간 기반 Range가 사실상 정답입니다. 파티셔닝의 두 번째 이득인 DROP TABLE 은 시간으로 잘려 있을 때만 쓸 수 있기 때문입니다. 보관 정책이 시간이 아닌 다른 축(예: 해지한 고객 데이터 삭제)이라면, 그 축이 파티션 키 후보가 됩니다.

3단계: 후보 컬럼의 분포를 확인한다

SELECT region,
       count(*) AS rows,
       round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct
FROM orders
GROUP BY region
ORDER BY rows DESC;
예시 출력

 region |   rows    |  pct
--------+-----------+-------
 KR     | 412000000 |  82.4
 US     |  51000000 |  10.2
 JP     |  28000000 |   5.6
 DE     |   9000000 |   1.8

이 한 번의 조회가 List 파티셔닝의 운명을 결정합니다. 82%가 한 값에 몰려 있으면 orders_kr 파티션은 원래 테이블과 거의 같은 크기이고, 파티셔닝으로 얻은 것이 사실상 없습니다. 이때 흔히 나오는 오답이 "그럼 Hash로 바꾸자"입니다. Hash는 크기를 균등하게 만들어 주지만, 그 대가로 범위 조회 프루닝을 완전히 포기하게 만듭니다. 크기가 고르다는 것과 쿼리가 빨라지는 것은 다른 문제입니다.

4단계: 이제 전략을 고른다

앞의 세 가지가 정해지면 전략은 거의 자동으로 결정됩니다.

상황전략이유
시간으로 필터하고 오래된 데이터를 지운다Range (시간)프루닝과 DROP TABLE을 둘 다 얻는다
값이 유한하고 분포가 고르며 그 값으로 필터한다List값 하나가 파티션 하나로 깔끔하게 대응된다
값 분포가 심하게 편중되어 있다List + 큰 값만 하위 분할편중은 Hash로 해결되지 않는다
특정 키로 등호 조회만 하고 범위 조회가 없다Hash균등 분배가 필요하고 범위 프루닝은 안 쓰므로
WHERE에 안정적으로 들어오는 컬럼이 없다파티셔닝하지 않는다프루닝이 안 걸리면 얻는 것이 없다

마지막 행이 실제로 가장 자주 나오는 답입니다.

5단계: 유일 제약과 충돌하지 않는지 확인한다

이 단계를 마지막에 두면 늦습니다. 문서 표현대로, 파티션 테이블에 유니크 제약이나 기본 키를 만들려면 파티션 키에 표현식이나 함수 호출이 포함되면 안 되고, 제약의 컬럼이 파티션 키 컬럼을 모두 포함해야 합니다. 이유도 문서가 설명합니다. 제약을 구성하는 개별 인덱스는 자기 파티션 안에서만 유일성을 강제할 수 있으므로, 서로 다른 파티션에 중복이 없다는 사실은 파티션 구조 자체가 보장해야 하기 때문입니다.

이건 문법 제약이 아니라 도메인 제약입니다. email 이 전역에서 유일해야 하는 사용자 테이블은 created_at 으로 파티셔닝할 수 없습니다. 굳이 하려면 유일성 보장을 애플리케이션이나 별도 테이블로 옮겨야 하는데, 그건 파티셔닝으로 얻는 이득보다 큰 비용인 경우가 많습니다. 그러니 이 확인은 4단계 뒤가 아니라 1단계 옆에서 같이 하세요.

Range 파티셔닝: 시계열 데이터

가장 많이 사용되는 전략으로, 날짜나 ID 범위로 데이터를 분할합니다.

월별 파티셔닝 생성

-- 부모 테이블 생성
CREATE TABLE events (
    id          BIGSERIAL,
    event_type  VARCHAR(50) NOT NULL,
    payload     JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- 월별 파티션 생성
CREATE TABLE events_2026_01 PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE events_2026_02 PARTITION OF events
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE TABLE events_2026_03 PARTITION OF events
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- 기본 파티션 (범위에 맞는 파티션이 없을 때)
CREATE TABLE events_default PARTITION OF events DEFAULT;

파티션별 인덱스

-- 각 파티션에 자동으로 생성되는 글로벌 인덱스
CREATE INDEX idx_events_type ON events (event_type);
CREATE INDEX idx_events_payload ON events USING GIN (payload);

-- 파티션별 로컬 인덱스
CREATE INDEX idx_events_2026_03_type
    ON events_2026_03 (event_type, created_at DESC);

파티션 프루닝 확인

-- 파티션 프루닝이 작동하는지 EXPLAIN으로 확인
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2026-03-01'
  AND created_at < '2026-03-15'
  AND event_type = 'purchase';

-- 결과: events_2026_03 파티션만 스캔
-- Append
--   -> Index Scan using events_2026_03_type on events_2026_03
--        Index Cond: (event_type = 'purchase')
--        Filter: (created_at >= '2026-03-01' AND created_at < '2026-03-15')

List 파티셔닝: 카테고리별 분할

특정 값 목록으로 데이터를 분할합니다:

-- 지역별 파티셔닝
CREATE TABLE orders (
    id          BIGSERIAL,
    customer_id BIGINT NOT NULL,
    amount      DECIMAL(12,2) NOT NULL,
    region      VARCHAR(10) NOT NULL,
    status      VARCHAR(20) NOT NULL,
    ordered_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, region)
) PARTITION BY LIST (region);

CREATE TABLE orders_kr PARTITION OF orders
    FOR VALUES IN ('KR');

CREATE TABLE orders_jp PARTITION OF orders
    FOR VALUES IN ('JP');

CREATE TABLE orders_us PARTITION OF orders
    FOR VALUES IN ('US');

CREATE TABLE orders_eu PARTITION OF orders
    FOR VALUES IN ('DE', 'FR', 'GB', 'IT', 'ES');

CREATE TABLE orders_other PARTITION OF orders DEFAULT;

Hash 파티셔닝: 균등 분배

특정 컬럼의 해시값으로 데이터를 균등하게 분배합니다:

-- 사용자 ID 기반 해시 파티셔닝 (4개 파티션)
CREATE TABLE user_activities (
    id          BIGSERIAL,
    user_id     BIGINT NOT NULL,
    activity    VARCHAR(100) NOT NULL,
    metadata    JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

CREATE TABLE user_activities_0 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);

CREATE TABLE user_activities_1 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);

CREATE TABLE user_activities_2 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);

CREATE TABLE user_activities_3 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

다중 레벨 파티셔닝

Range와 List를 조합한 다중 레벨 파티셔닝:

-- 1차: 날짜(Range), 2차: 지역(List)
CREATE TABLE sales (
    id          BIGSERIAL,
    product_id  BIGINT NOT NULL,
    region      VARCHAR(10) NOT NULL,
    amount      DECIMAL(12,2) NOT NULL,
    sold_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, sold_at, region)
) PARTITION BY RANGE (sold_at);

-- 월별 서브 파티션
CREATE TABLE sales_2026_03 PARTITION OF sales
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
    PARTITION BY LIST (region);

CREATE TABLE sales_2026_03_kr PARTITION OF sales_2026_03
    FOR VALUES IN ('KR');

CREATE TABLE sales_2026_03_jp PARTITION OF sales_2026_03
    FOR VALUES IN ('JP');

CREATE TABLE sales_2026_03_other PARTITION OF sales_2026_03 DEFAULT;

자동 파티션 관리

pg_partman 확장 사용

-- pg_partman 설치
CREATE EXTENSION pg_partman;

-- 자동 파티션 관리 설정
SELECT partman.create_parent(
    p_parent_table := 'public.events',
    p_control := 'created_at',
    p_type := 'native',
    p_interval := '1 month',
    p_premake := 3,           -- 3개월 미리 생성
    p_start_partition := '2026-01-01'
);

-- 자동 유지보수 (cron으로 실행)
-- 새 파티션 생성 + 오래된 파티션 관리
SELECT partman.run_maintenance();

쉘 스크립트로 자동 생성

#!/bin/bash
# create_monthly_partitions.sh

PGHOST="localhost"
PGDB="mydb"
PGUSER="admin"

# 향후 3개월 파티션 생성
for i in 0 1 2 3; do
    MONTH=$(date -d "+${i} months" +%Y-%m-01)
    NEXT_MONTH=$(date -d "+$((i+1)) months" +%Y-%m-01)
    TABLE_NAME="events_$(date -d "+${i} months" +%Y_%m)"

    psql -h $PGHOST -d $PGDB -U $PGUSER -c "
        CREATE TABLE IF NOT EXISTS ${TABLE_NAME}
        PARTITION OF events
        FOR VALUES FROM ('${MONTH}') TO ('${NEXT_MONTH}');
    " 2>/dev/null

    echo "Created partition: ${TABLE_NAME}"
done

오래된 파티션 삭제/보관

-- 파티션 분리 (데이터 보존, 쿼리에서 제외)
ALTER TABLE events DETACH PARTITION events_2025_01;

-- 분리된 파티션을 압축 테이블스페이스로 이동
ALTER TABLE events_2025_01 SET TABLESPACE archive_tablespace;

-- 또는 완전 삭제 (DROP이 DELETE보다 훨씬 빠름!)
DROP TABLE events_2025_01;
-- vs.
-- DELETE FROM events WHERE created_at < '2025-02-01';
-- ↑ 이 방식은 수백만 행 삭제 시 수십 분 소요

성능 비교: 파티셔닝 전 vs 후

테스트 환경

-- 1억 행 테이블 생성 (파티셔닝 없음)
CREATE TABLE events_no_part (
    id BIGSERIAL PRIMARY KEY,
    event_type VARCHAR(50),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- 동일 데이터로 파티셔닝 테이블 생성 (월별)
-- ... (위의 events 테이블 사용)

쿼리 성능 비교

-- 1개월 데이터 조회
-- 파티셔닝 없음: 15.2초 (Full Table Scan)
-- 파티셔닝 있음: 0.8초 (Partition Pruning → 단일 파티션 스캔)

-- 인덱스 크기
-- 파티셔닝 없음: 2.1 GB (단일 인덱스)
-- 파티셔닝 있음: 175 MB/파티션 × 12 = 2.1 GB (총합은 같지만 개별 인덱스 효율적)

-- 데이터 삭제 (1개월분)
-- 파티셔닝 없음: DELETE → 45분 + VACUUM 30분
-- 파티셔닝 있음: DROP TABLE → 0.01초

주의사항과 제약

PRIMARY KEY 제약

파티션 키는 반드시 PRIMARY KEY에 포함되어야 합니다:

-- 오류! 파티션 키(created_at)가 PK에 없음
CREATE TABLE events (
    id BIGSERIAL PRIMARY KEY,  -- ERROR
    created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);

-- 올바른 방법: 복합 PK
CREATE TABLE events (
    id BIGSERIAL,
    created_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

UNIQUE 제약

-- UNIQUE 제약도 파티션 키를 포함해야 함
CREATE UNIQUE INDEX idx_events_unique
    ON events (event_type, created_at);  -- OK

-- 파티션 키 없는 UNIQUE는 불가
-- CREATE UNIQUE INDEX ON events (event_type);  -- ERROR

크로스 파티션 조인 성능

-- 파티션 키로 필터링하지 않으면 모든 파티션을 스캔
-- 반드시 WHERE 절에 파티션 키 포함!
SELECT * FROM events
WHERE created_at >= '2026-03-01'  -- 파티션 프루닝 작동
  AND event_type = 'purchase';

-- enable_partition_pruning 설정 확인
SHOW enable_partition_pruning;  -- 'on' 이어야 함

모니터링

-- 파티션별 크기 확인
SELECT
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) as total_size,
    pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) as table_size
FROM pg_tables
WHERE tablename LIKE 'events_%'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;

-- 파티션별 행 수 확인
SELECT
    relname as partition_name,
    n_live_tup as row_count
FROM pg_stat_user_tables
WHERE relname LIKE 'events_%'
ORDER BY relname;

-- 파티션 프루닝 효과 확인
EXPLAIN (ANALYZE, COSTS, BUFFERS, FORMAT TEXT)
SELECT count(*) FROM events
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';

프루닝이 실제로 걸리는 조건

전략을 아무리 잘 골라도 프루닝이 안 걸리면 파티셔닝은 순손해입니다. 그러니 "걸렸는지 어떻게 아는가"와 "안 걸리면 무엇을 봐야 하는가"가 전략 선택의 마지막 검증입니다.

프루닝은 두 시점에 일어납니다. 문서는 둘을 구분해서 설명합니다.

계획 시점 프루닝 은 플래너가 파티션 정의를 보고 "이 파티션은 볼 필요가 없다"를 증명하는 것입니다. 잘라낸 파티션은 EXPLAIN 출력에 아예 나타나지 않습니다.

실행 시점 프루닝 은 실제 실행 중에만 값을 알 수 있는 파라미터에 대해 일어납니다. 서브쿼리에서 나온 값이나, 파라미터화된 중첩 루프 조인에서 넘어오는 실행 시점 파라미터가 그 예입니다. 문서는 이 단계에서 파티션이 잘렸는지 확인하려면 EXPLAIN ANALYZE 출력의 loops 값을 주의 깊게 봐야 한다고 적고 있습니다.

두 시점을 구분해야 하는 이유는 진단이 달라지기 때문입니다. 계획 시점에 안 걸리는 것은 쿼리를 고쳐야 하고, 실행 시점 프루닝에 의존하는 계획은 원래 EXPLAIN 만 봐서는 판단할 수 없습니다.

예시 출력 — 프루닝이 걸린 경우

 Aggregate  (actual time=88.412..88.413 rows=1 loops=1)
   ->  Seq Scan on events_2026_03 events  (actual time=0.019..61.203 rows=1204411 loops=1)
         Filter: ((created_at >= '2026-03-01') AND (created_at < '2026-04-01'))
 Planning Time: 0.412 ms
 Execution Time: 88.501 ms
예시 출력 — 프루닝이 안 걸린 경우

 Aggregate  (actual time=2140.882..2140.883 rows=1 loops=1)
   ->  Append  (actual time=0.021..2004.114 rows=1204411 loops=1)
         ->  Seq Scan on events_2026_01 events_1  (actual rows=0 loops=1)
         ->  Seq Scan on events_2026_02 events_2  (actual rows=0 loops=1)
         ->  Seq Scan on events_2026_03 events_3  (actual rows=1204411 loops=1)
         ->  Seq Scan on events_default events_4  (actual rows=0 loops=1)
 Planning Time: 1.882 ms
 Execution Time: 2140.994 ms

읽는 법은 단순합니다. Append 밑에 파티션이 줄줄이 달려 있고 대부분이 actual rows=0 이면 프루닝이 안 걸린 것입니다. 스캔은 했는데 아무것도 안 나온 파티션들이 그 증거입니다. 반대로 잘려 나간 파티션은 이 목록에 아예 등장하지 않습니다. 그래서 "안 보이니까 잘렸겠지"가 아니라 "남아 있는 것 중에 rows=0이 있는가"로 판단해야 합니다. 준비된 구문에서는 몇 개가 제거됐는지 Subplans Removed 로 표시됩니다.

프루닝이 안 걸리는 원인은 사실상 네 가지뿐입니다. 이 순서로 확인하세요.

  1. WHERE 절에 파티션 키가 아예 없다. 가장 흔하고 가장 허무한 원인입니다.
  2. 파티션 키에 함수나 캐스트를 씌웠다. WHERE date_trunc('month', created_at) = '2026-03-01' 은 프루닝을 못 씁니다. created_at >= '2026-03-01' AND created_at < '2026-04-01' 로 다시 쓰면 걸립니다. 타임존 캐스트도 같은 함정입니다.
  3. enable_partition_pruning 이 꺼져 있다. 문서 기준 기본값은 on 이므로, 꺼져 있다면 누군가 명시적으로 껐다는 뜻입니다.
  4. 준비된 구문이 일반 계획(generic plan)을 쓰고 있다. 계획 시점에 파라미터 값을 모르면 계획 시점 프루닝이 걸리지 않습니다. 문서 기준 plan_cache_mode 의 기본값은 auto 이고, 이를 force_custom_plan 으로 바꿨을 때 계획이 달라진다면 원인이 이것입니다.

파티션 간 조인과 집계에 대해서도 한 가지를 알아 둘 필요가 있습니다. 문서 기준으로 enable_partitionwise_joinenable_partitionwise_aggregate 의 기본값은 둘 다 off 입니다. 켜면 파티션끼리 짝지어 조인하거나 집계할 수 있어 큰 이득이 나기도 하지만, 문서는 대가도 함께 적어 둡니다. 최종 계획에서 work_mem 으로 메모리 사용이 제한되는 노드 수가 스캔하는 파티션 수에 비례해 늘어날 수 있고, 그 결과 쿼리 실행 중 전체 메모리 소비가 크게 증가할 수 있으며, 계획 수립 자체도 메모리와 CPU 면에서 상당히 비싸진다는 것입니다. 파티션이 수백 개인 테이블에서 무심코 켜면 OOM으로 돌아옵니다. 켜기 전에 파티션 수를 먼저 세세요.

전략별 실패 모드와 진단 순서

증상: Range로 잘랐는데 특정 달만 압도적으로 느리다

확인 순서는 셋입니다.

SELECT relname, n_live_tup, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname LIKE 'events_%'
ORDER BY relname;
  1. 파티션별 크기를 비교합니다. 프로모션이나 장애로 특정 달만 트래픽이 몇 배였다면, 파티션 크기도 그만큼 다릅니다. 시간 기반 Range의 전제인 "기간이 같으면 크기도 비슷하다"가 깨진 경우입니다.
  2. 그 파티션의 last_analyzelast_autoanalyze 를 봅니다. 갓 만든 파티션에 대량 적재를 하면 통계가 따라오기 전에 쿼리가 먼저 도착합니다.
  3. 부모 인덱스가 그 파티션에도 제대로 붙었는지 확인합니다. 파티션마다 따로 인덱스를 만들어 붙이는 방식으로 운영하고 있다면 한 파티션만 누락되는 일이 실제로 생깁니다.

증상: List로 잘랐는데 한 파티션이 전체의 80%다

3단계의 분포 확인을 건너뛴 결과입니다. Hash로 바꾸면 크기는 균등해지지만 범위 프루닝을 잃습니다. 실무적으로 나은 답은 큰 값만 다시 쪼개는 것입니다. 이 글의 다중 레벨 파티셔닝 절에 나온 방식대로, 큰 값 전용 파티션을 다시 하위 파티셔닝하면(예: KR 파티션을 다시 월별 Range로) 균등 분배가 아니라 실제 쿼리 패턴에 맞는 분할이 됩니다. 작은 값들은 굳이 쪼갤 필요가 없으니 그대로 둡니다.

증상: Hash로 잘랐더니 날짜 조회가 전부 전체 스캔이다

설계대로 동작한 것입니다. Hash 파티셔닝은 키의 해시값으로 나누므로, 범위 조건으로는 어떤 파티션도 배제할 수 없습니다. 해시는 순서를 보존하지 않기 때문입니다. Hash는 "이 키로 등호 조회만 한다"가 참일 때만 맞는 전략이고, 이 확인은 4단계에서 끝나야 합니다. 이미 만들어 놓고 발견하면 테이블을 다시 만드는 것 외에 방법이 없습니다.

또 하나, Hash 파티션은 나중에 개수를 바꾸기가 어렵습니다. MODULUS 를 바꾸면 키가 어느 파티션에 속하는지가 전부 달라지므로 사실상 전체 재배치입니다. 처음 개수를 정할 때 앞으로 몇 배까지 커질지를 함께 생각해야 합니다.

증상: 파티션을 늘렸더니 계획 시간이 실행 시간보다 길어졌다

파티션이 너무 많습니다. 문서는 "플래너는 대체로 수천 개 정도의 파티션 계층까지는 잘 다루지만, 그건 일반적인 쿼리가 소수만 남기고 나머지를 프루닝할 수 있을 때"라는 조건을 붙입니다. 즉 문제는 파티션 개수 자체가 아니라 프루닝 후 남는 개수입니다.

진단은 EXPLAIN 출력의 Planning TimeExecution Time 비교로 충분합니다. 계획 시간이 실행 시간에 육박하거나 넘어서면 파티션 단위가 너무 잘다는 신호입니다. 일별을 월별로 바꾸는 것만으로 파티션 수가 30분의 1이 됩니다.

문서는 워크로드에 따라 답이 다르다는 점도 명시합니다. 데이터 웨어하우스 성격의 워크로드에서는 OLTP보다 많은 파티션이 합리적일 수 있다는 것입니다. 그리고 "파티션이 많은 쪽이 항상 낫다거나 항상 나쁘다고 가정하지 말라"고 덧붙입니다.

증상: 새 파티션을 붙일 때마다 몇십 분씩 걸린다

이건 전략이 아니라 운영 DDL 문제입니다. DEFAULT 파티션 스캔이나 CHECK 제약 누락이 원인이고, 락 수준까지 포함한 진단과 해결은 실전 가이드 쪽에 정리해 두었습니다.

언제 파티셔닝하지 않나

여기까지의 판단 절차를 다 밟고 나면, 꽤 많은 경우에 답이 "하지 않는다"로 나옵니다. 그게 정상입니다.

쿼리가 파티션 키로 필터하지 않으면 하지 마세요. 프루닝이 안 걸리는 파티셔닝은 하나의 큰 테이블 스캔을 N개의 작은 테이블 스캔으로 바꾸고, 거기에 계획 비용을 얹습니다. 이 글의 성능 비교 절에 나온 극적인 숫자들은 전부 프루닝이 걸린다는 전제 위에 있습니다.

테이블이 아직 작으면 하지 마세요. 파티셔닝은 되돌리기 어려운 스키마 변경입니다. "언젠가 커질 테니 미리"는 대개 손해입니다. 그때 가서 하는 마이그레이션 비용보다, 그때까지 지불할 운영 비용과 제약이 더 큰 경우가 많습니다.

전역 유일성이 필요한 컬럼이 파티션 키와 무관하면 하지 마세요. 5단계의 제약은 우회할 수 있지만, 우회 비용이 파티셔닝 이득보다 큰 경우가 대부분입니다.

보관 정책이 없으면 이득의 절반을 못 씁니다. 데이터를 절대 지우지 않는 테이블에서는 DROP TABLE 을 영영 쓰지 않고, 남는 것은 프루닝뿐입니다. 그리고 프루닝만 필요하다면 대개 인덱스로도 충분합니다. 파티셔닝을 검토하기 전에 인덱스와 쿼리를 먼저 보세요.

정직하게 말하면, 이 글의 성능 비교 표는 여러분의 데이터에서 재현되지 않을 수 있습니다. 저 숫자들은 특정한 스키마, 특정한 분포, 특정한 쿼리에서 나온 것입니다. 파티셔닝을 결정하기 전에 여러분의 실제 쿼리를 EXPLAIN ANALYZE 로 측정하세요. 그것이 이 글 전체에서 가장 중요한 한 문장입니다.

참고 자료

  • PostgreSQL 18 — Table Partitioning — 계획 시점과 실행 시점 프루닝의 구분, Subplans Removed, 유니크 제약이 파티션 키를 포함해야 하는 이유, 파티션 개수 지침과 워크로드별 차이. 2026-08-16 확인
  • PostgreSQL 18 — Planner Method Configuration — enable_partition_pruning의 기본값 on, enable_partitionwise_join과 enable_partitionwise_aggregate의 기본값 off와 그 메모리 비용, plan_cache_mode의 기본값 auto. 2026-08-16 확인
  • PostgreSQL 18 — CREATE TABLE — RANGE 경계의 포함·미포함, MINVALUE와 MAXVALUE, DEFAULT 파티션의 정의와 제약. 2026-08-16 확인
  • PostgreSQL 18 — ALTER TABLE — ATTACH·DETACH가 잡는 락과 DEFAULT 파티션 관련 주의. 2026-08-16 확인
  • pg_partman 문서 — create_parent 시그니처와 5.0 이후 변경. 2026-08-16 확인

이 글 자동 관리 절의 pg_partman 예제는 p_type := 'native' 를 쓰고 있는데, pg_partman 5.0 문서 기준으로 p_type 이 받는 값은 rangelist 이며 기본값은 range 입니다. 5.x부터는 트리거 기반 파티셔닝이 제거되고 모든 파티셔닝이 선언적 파티셔닝으로 처리됩니다. p_premake 의 문서상 기본값은 4입니다. 설치된 확장의 버전을 먼저 확인하고 그 버전의 문서에 맞춰 인자를 쓰세요.


📝 확인 퀴즈 (6문제)

Q1. PostgreSQL에서 지원하는 세 가지 파티셔닝 전략은?

Range, List, Hash 파티셔닝

Q2. 파티션 프루닝(Partition Pruning)이란?

쿼리의 WHERE 조건에 따라 불필요한 파티션을 스캔하지 않고 건너뛰는 최적화 기법입니다.

Q3. 파티션 키가 PRIMARY KEY에 포함되어야 하는 이유는?

PostgreSQL의 선언적 파티셔닝에서 각 파티션은 독립적인 테이블이므로, 전체 테이블에 걸친 유니크 보장을 위해 파티션 키가 PK에 포함되어야 합니다.

Q4. 오래된 데이터 삭제 시 DELETE 대신 DROP TABLE을 사용하는 이점은?

DELETE는 행 단위로 삭제하고 VACUUM이 필요하지만, DROP TABLE은 파티션 전체를 즉시 제거하므로 수십 분 → 0.01초로 단축됩니다.

Q5. DETACH PARTITION의 용도는?

파티션을 부모 테이블에서 분리하여 쿼리 대상에서 제외하되 데이터는 보존합니다. 아카이브나 백업에 유용합니다.

Q6. Hash 파티셔닝은 어떤 경우에 적합한가요?

특정 범위나 카테고리 없이 데이터를 균등하게 분배해야 할 때 적합합니다. 특히 핫스팟을 방지하고 병렬 처리 성능을 높이는 데 유용합니다.

퀴즈

Q1: 파티션 키를 고를 때 가장 먼저 확인해야 할 것은? 실제 쿼리가 무엇으로 필터하는지입니다. 파티셔닝의 이득은 거의 전부 프루닝에서 나오고, 프루닝은 WHERE 절에 파티션 키가 있을 때만 걸립니다. 감으로 정하지 말고 pg_stat_statements 같은 도구로 실제 쿼리를 세어 보세요.

Q2: 한 값에 데이터의 80%가 몰린 컬럼을 List 파티셔닝했다면? 가장 큰 파티션이 원래 테이블과 거의 같은 크기이므로 얻은 것이 거의 없습니다. Hash로 바꾸면 크기는 균등해지지만 범위 프루닝을 잃습니다. 실무적으로 나은 답은 큰 값 전용 파티션만 다시 하위 분할하는 것입니다.

Q3: Hash 파티셔닝에서 날짜 범위 조회가 전체 스캔이 되는 이유는? 해시는 순서를 보존하지 않으므로 범위 조건으로는 어떤 파티션도 배제할 수 없습니다. Hash는 해당 키로 등호 조회만 하는 경우에만 맞는 전략이고, 이 판단은 파티션을 만들기 전에 끝나야 합니다.

Q4: EXPLAIN 출력에서 프루닝이 걸리지 않았다는 신호는? Append 아래에 파티션이 여러 개 달려 있고 대부분이 actual rows=0 인 경우입니다. 잘려 나간 파티션은 출력에 아예 나타나지 않으므로, 남아 있는 것 중 rows=0 이 있는지로 판단합니다. 준비된 구문에서는 Subplans Removed 로 제거 개수가 표시됩니다.

Q5: 파티션 키가 PRIMARY KEY에 포함되어야 한다는 제약을 언제 확인해야 하나? 키 후보를 고르는 초반에 함께 확인해야 합니다. 전역 유일성이 필요한 컬럼이 파티션 키와 무관하면 그 테이블은 사실상 그 키로 파티셔닝할 수 없습니다. 이 사실을 설계를 마친 뒤에 발견하면 처음부터 다시 해야 합니다.