PostgreSQL 파티셔닝 실전 심화

테이블 파티셔닝이란?

PostgreSQL 파티셔닝은 하나의 큰 테이블을 여러 물리적 하위 테이블(파티션)로 분할하는 기능입니다. 수억~수십억 행의 테이블에서 쿼리 성능을 극적으로 향상시키고, 데이터 보관·삭제를 효율적으로 관리할 수 있습니다. PostgreSQL 10에서 선언적 파티셔닝이 도입된 이후 버전마다 기능이 강화되어 현재는 실무에서 안정적으로 사용할 수 있습니다.

이 글에서는 3가지 파티셔닝 전략, 파티션 프루닝 동작 원리, 인덱스 설계, 파티션 관리 운영, 그리고 실전 시계열 데이터 파티셔닝까지 심층적으로 다룹니다. PostgreSQL pg_stat_statements 분석 가이드와 함께 읽으면 대용량 PostgreSQL 운영의 전체 그림을 파악할 수 있습니다.

3가지 파티셔닝 전략

전략 분할 기준 적합한 경우 예시
Range 값 범위 시계열, 날짜 기반 월별 주문, 일별 로그
List 특정 값 목록 카테고리, 지역, 상태 국가별 사용자, 타입별 이벤트
Hash 해시 모듈러 균등 분산, 특정 키 없음 사용자 ID 기반 분산

Range 파티셔닝 (시계열)

-- 주문 테이블: 월별 파티셔닝
CREATE TABLE orders (
    id BIGSERIAL,
    user_id BIGINT NOT NULL,
    amount DECIMAL(12,2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, created_at)  -- 파티션 키는 PK에 포함 필수!
) PARTITION BY RANGE (created_at);

-- 월별 파티션 생성
CREATE TABLE orders_2026_01 PARTITION OF orders
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE orders_2026_02 PARTITION OF orders
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE orders_2026_03 PARTITION OF orders
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- 기본 파티션 (어디에도 속하지 않는 데이터 수용)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

-- 쿼리 시 파티션 프루닝 동작
EXPLAIN (ANALYZE) SELECT * FROM orders
WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';
-- → orders_2026_02만 스캔 (나머지 파티션 제외!)
-- "Append" 플랜에 orders_2026_02만 포함됨

List 파티셔닝

-- 이벤트 테이블: 타입별 파티셔닝
CREATE TABLE events (
    id BIGSERIAL,
    event_type VARCHAR(30) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, event_type)
) PARTITION BY LIST (event_type);

CREATE TABLE events_user PARTITION OF events
    FOR VALUES IN ('user_created', 'user_updated', 'user_deleted');
CREATE TABLE events_order PARTITION OF events
    FOR VALUES IN ('order_placed', 'order_shipped', 'order_cancelled');
CREATE TABLE events_payment PARTITION OF events
    FOR VALUES IN ('payment_success', 'payment_failed', 'payment_refunded');
CREATE TABLE events_default PARTITION OF events DEFAULT;

-- 쿼리
SELECT * FROM events WHERE event_type = 'order_placed';
-- → events_order 파티션만 스캔

Hash 파티셔닝

-- 세션 테이블: user_id 해시 기반 4등분
CREATE TABLE sessions (
    id UUID DEFAULT gen_random_uuid(),
    user_id BIGINT NOT NULL,
    token TEXT NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

CREATE TABLE sessions_0 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_1 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_2 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_3 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

-- Hash는 균등 분산이 장점이지만 범위 쿼리에 프루닝 안됨
-- 등가 조건만 프루닝 동작
SELECT * FROM sessions WHERE user_id = 12345;
-- → hash(12345) % 4 = 1 → sessions_1만 스캔

다중 레벨 파티셔닝

-- 1단계: 연도별 Range → 2단계: 지역별 List
CREATE TABLE sales (
    id BIGSERIAL,
    region VARCHAR(10) NOT NULL,
    amount DECIMAL(12,2),
    sold_at DATE NOT NULL,
    PRIMARY KEY (id, sold_at, region)
) PARTITION BY RANGE (sold_at);

-- 2026년 파티션 (하위를 List로 분할)
CREATE TABLE sales_2026 PARTITION OF sales
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')
    PARTITION BY LIST (region);

CREATE TABLE sales_2026_kr PARTITION OF sales_2026
    FOR VALUES IN ('KR');
CREATE TABLE sales_2026_us PARTITION OF sales_2026
    FOR VALUES IN ('US');
CREATE TABLE sales_2026_eu PARTITION OF sales_2026
    FOR VALUES IN ('EU');

-- 쿼리: 두 레벨 모두 프루닝
SELECT * FROM sales
WHERE sold_at >= '2026-03-01' AND sold_at < '2026-04-01'
  AND region = 'KR';
-- → sales_2026_kr만 스캔!

파티션 프루닝 동작 원리

-- 파티션 프루닝: 쿼리 조건에 맞지 않는 파티션을 아예 스캔하지 않음
-- PostgreSQL 11+: 실행 시점(runtime) 프루닝도 지원

-- 정적 프루닝 (계획 시점)
EXPLAIN SELECT * FROM orders WHERE created_at = '2026-02-15';
-- Seq Scan on orders_2026_02 (← 이 파티션만 계획에 포함)

-- 동적 프루닝 (실행 시점, Prepared Statement/파라미터)
PREPARE q AS SELECT * FROM orders WHERE created_at = $1;
EXECUTE q('2026-02-15');
-- 실행 시점에 $1 값을 보고 프루닝

-- 프루닝 활성화 확인
SHOW enable_partition_pruning;  -- 기본 on

-- ⚠️ 프루닝이 안 되는 경우
-- 1. 파티션 키에 함수 적용
SELECT * FROM orders WHERE DATE_TRUNC('month', created_at) = '2026-02-01';
-- → 프루닝 안됨! 함수가 인덱스/파티션 키를 가림

-- 2. OR 조건이 복잡한 경우
-- 3. 타입 불일치 (implicit cast)

인덱스 전략

-- 파티션 테이블의 인덱스는 각 파티션에 개별 생성됨

-- 부모 테이블에 인덱스 생성 → 모든 파티션에 자동 전파
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- → orders_2026_01, orders_2026_02, ... 각각에 인덱스 생성됨

-- 새 파티션 추가 시에도 자동으로 인덱스 생성
CREATE TABLE orders_2026_04 PARTITION OF orders
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
-- → idx_orders_user_id가 자동으로 orders_2026_04에도 생성

-- UNIQUE 제약: 파티션 키를 반드시 포함해야 함
CREATE UNIQUE INDEX idx_orders_email ON orders (user_id, created_at);
-- ✅ 파티션 키(created_at) 포함

-- CREATE UNIQUE INDEX idx_orders_email ON orders (user_id);
-- ❌ 에러! 파티션 키 미포함 (파티션 간 유니크 보장 불가)

-- 파티션별 개별 인덱스도 가능
CREATE INDEX idx_orders_2026_01_status 
    ON orders_2026_01 (status)
    WHERE status = 'pending';  -- Partial Index

파티션 관리 운영

-- 1. 파티션 자동 생성 (pg_partman 확장)
CREATE EXTENSION pg_partman;

SELECT partman.create_parent(
    p_parent_table := 'public.orders',
    p_control := 'created_at',
    p_type := 'native',
    p_interval := '1 month',
    p_premake := 3  -- 3개월 미리 생성
);

-- 매일 실행 (cron 또는 pg_cron)
SELECT partman.run_maintenance();
-- → 새 파티션 자동 생성 + 오래된 파티션 관리

-- 2. 파티션 분리 (DETACH) - 무중단으로 데이터 아카이브
-- CONCURRENTLY: 테이블 잠금 없이 분리 (PostgreSQL 14+)
ALTER TABLE orders DETACH PARTITION orders_2024_01 CONCURRENTLY;
-- → orders_2024_01은 독립 테이블이 됨 (조회는 가능)

-- 아카이브 후 삭제
-- pg_dump으로 백업 후 DROP
pg_dump -t orders_2024_01 mydb > orders_2024_01.sql
DROP TABLE orders_2024_01;
-- → DELETE 대비 수천 배 빠름! (개별 행 삭제 없이 파일 제거)

-- 3. 파티션 연결 (ATTACH) - 기존 테이블을 파티션으로 추가
-- 먼저 제약 조건 검증 (ATTACH 시 전체 스캔 방지)
ALTER TABLE orders_new_2026_04
    ADD CONSTRAINT check_date
    CHECK (created_at >= '2026-04-01' AND created_at < '2026-05-01');

ALTER TABLE orders ATTACH PARTITION orders_new_2026_04
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
-- 제약 조건이 있으면 검증 스캔 건너뜀!

실전: 시계열 로그 파티셔닝

-- 일별 파티셔닝 + 30일 보관 정책
CREATE TABLE access_logs (
    id BIGSERIAL,
    path VARCHAR(500) NOT NULL,
    method VARCHAR(10) NOT NULL,
    status_code INT NOT NULL,
    response_time_ms INT,
    user_agent TEXT,
    ip INET,
    logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, logged_at)
) PARTITION BY RANGE (logged_at);

-- 인덱스
CREATE INDEX idx_logs_path ON access_logs (path, logged_at);
CREATE INDEX idx_logs_status ON access_logs (status_code) 
    WHERE status_code >= 500;  -- 에러만 인덱싱

-- 파티션 생성 스크립트 (cron으로 매일 실행)
DO $$
DECLARE
    partition_date DATE;
    partition_name TEXT;
    start_date TEXT;
    end_date TEXT;
BEGIN
    -- 7일 후까지 미리 생성
    FOR i IN 0..7 LOOP
        partition_date := CURRENT_DATE + i;
        partition_name := 'access_logs_' || TO_CHAR(partition_date, 'YYYYMMDD');
        start_date := TO_CHAR(partition_date, 'YYYY-MM-DD');
        end_date := TO_CHAR(partition_date + 1, 'YYYY-MM-DD');
        
        IF NOT EXISTS (
            SELECT 1 FROM pg_class WHERE relname = partition_name
        ) THEN
            EXECUTE format(
                'CREATE TABLE %I PARTITION OF access_logs 
                 FOR VALUES FROM (%L) TO (%L)',
                partition_name, start_date, end_date
            );
        END IF;
    END LOOP;
    
    -- 30일 이전 파티션 삭제
    FOR partition_name IN
        SELECT tablename FROM pg_tables
        WHERE tablename LIKE 'access_logs_%'
          AND tablename < 'access_logs_' || TO_CHAR(CURRENT_DATE - 30, 'YYYYMMDD')
    LOOP
        EXECUTE format('DROP TABLE IF EXISTS %I', partition_name);
    END LOOP;
END $$;

파티셔닝 도입 판단 기준

도입 추천 도입 불필요
테이블 1억 행 이상 수백만 행 이하
시계열 데이터 (로그, 이벤트, 메트릭) 참조 테이블 (코드, 설정)
주기적 대량 삭제가 필요한 경우 삭제가 거의 없는 경우
쿼리에 항상 파티션 키 포함 파티션 키 없는 쿼리가 많은 경우
VACUUM 부하 분산 필요 인덱스만으로 충분한 성능

PgBouncer 커넥션 풀링 운영 심화와 함께 적용하면 대용량 PostgreSQL 운영을 더욱 효과적으로 관리할 수 있습니다.

정리

PostgreSQL 파티셔닝은 대용량 테이블의 성능과 운영 효율을 극적으로 향상시킵니다. Range(시계열), List(카테고리), Hash(균등 분산) 중 데이터 특성에 맞는 전략을 선택하고, 파티션 키가 쿼리 조건에 항상 포함되도록 설계해야 프루닝 효과를 극대화할 수 있습니다. 특히 DETACH PARTITION으로 오래된 데이터를 DROP하는 것은 DELETE 대비 수천 배 빠르며, pg_partman으로 파티션 라이프사이클을 자동화할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux