테이블 파티셔닝이란?
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으로 파티션 라이프사이클을 자동화할 수 있습니다.