Skip to content
Published on

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

Authors
  • Name
    Twitter

파티셔닝이 필요한 시점

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

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

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

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';

📝 확인 퀴즈 (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 파티셔닝은 어떤 경우에 적합한가요?

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