PostgreSQL 파티셔닝이란?
PostgreSQL 파티셔닝은 하나의 논리 테이블을 여러 물리 테이블(파티션)로 분할하는 기법입니다. 수억 건 이상의 데이터를 다루는 시계열 로그, 주문 이력, IoT 센서 데이터에서 쿼리 성능을 10~100배 개선할 수 있습니다. PostgreSQL 10부터 선언적 파티셔닝(Declarative Partitioning)을 지원하며, 12 이후 파티션 프루닝이 크게 개선되었습니다.
3가지 파티셔닝 전략: RANGE · LIST · HASH
RANGE 파티셔닝 — 시계열 데이터의 정석
날짜, 숫자 범위로 파티션을 나눕니다. 시계열 데이터에 가장 적합합니다:
-- 주문 테이블: 월별 RANGE 파티셔닝
CREATE TABLE orders (
id BIGSERIAL,
user_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
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');
-- DEFAULT 파티션: 범위 밖 데이터 수용
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
핵심 주의점: 파티션 키(created_at)는 반드시 PRIMARY KEY에 포함되어야 합니다. 그렇지 않으면 ERROR: unique constraint must include all partitioning columns 에러가 발생합니다.
LIST 파티셔닝 — 카테고리·지역별 분할
명확한 값 목록으로 파티션을 나눕니다. 멀티 테넌시, 지역별 데이터에 적합합니다:
-- 지역별 LIST 파티셔닝
CREATE TABLE user_events (
id BIGSERIAL,
user_id BIGINT NOT NULL,
region TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id, region)
) PARTITION BY LIST (region);
CREATE TABLE user_events_asia PARTITION OF user_events
FOR VALUES IN ('kr', 'jp', 'cn', 'sg');
CREATE TABLE user_events_eu PARTITION OF user_events
FOR VALUES IN ('de', 'fr', 'gb', 'nl');
CREATE TABLE user_events_na PARTITION OF user_events
FOR VALUES IN ('us', 'ca', 'mx');
CREATE TABLE user_events_default PARTITION OF user_events DEFAULT;
HASH 파티셔닝 — 균등 분산
해시 값으로 데이터를 균등하게 분산합니다. 특정 컬럼에 핫스팟이 있을 때 유용합니다:
-- user_id 기반 HASH 파티셔닝 (4개 파티션)
CREATE TABLE user_sessions (
id BIGSERIAL,
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 user_sessions_0 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_sessions_1 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_sessions_2 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_sessions_3 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 3);
파티션 프루닝: 성능의 핵심
파티션 프루닝은 쿼리 조건에 맞지 않는 파티션을 아예 스캔하지 않는 최적화입니다. PostgreSQL 12+에서 런타임 프루닝까지 지원합니다:
-- 파티션 프루닝 확인
SET enable_partition_pruning = on; -- 기본값: on
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
-- 실행 계획:
-- Append
-- Subplans Removed: 2 ← 2개 파티션 스킵
-- -> Seq Scan on orders_2026_03
-- Filter: (created_at >= ... AND created_at < ...)
-- ❌ 프루닝이 안 되는 패턴
SELECT * FROM orders
WHERE EXTRACT(MONTH FROM created_at) = 3; -- 함수 적용 → 프루닝 불가
-- ✅ 프루닝이 되는 패턴
SELECT * FROM orders
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
-- 런타임 프루닝: Prepared Statement에서도 동작
PREPARE find_orders(timestamptz, timestamptz) AS
SELECT * FROM orders WHERE created_at >= $1 AND created_at < $2;
EXECUTE find_orders('2026-03-01', '2026-04-01');
-- → orders_2026_03만 스캔
서브 파티셔닝: 다단계 분할
대규모 시스템에서는 1단계 파티셔닝만으로 부족합니다. 날짜 + 지역 등 복합 기준으로 다단계 분할할 수 있습니다:
-- 1단계: 월별 RANGE 파티셔닝
CREATE TABLE logs (
id BIGSERIAL,
region TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, region, created_at)
) PARTITION BY RANGE (created_at);
-- 2단계: 각 월 파티션을 지역별 LIST로 재분할
CREATE TABLE logs_2026_03 PARTITION OF logs
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
PARTITION BY LIST (region);
CREATE TABLE logs_2026_03_asia PARTITION OF logs_2026_03
FOR VALUES IN ('kr', 'jp', 'cn');
CREATE TABLE logs_2026_03_eu PARTITION OF logs_2026_03
FOR VALUES IN ('de', 'fr', 'gb');
CREATE TABLE logs_2026_03_default PARTITION OF logs_2026_03 DEFAULT;
-- 쿼리: 날짜 + 지역 조건 → 2단계 프루닝
SELECT * FROM logs
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'
AND region = 'kr';
-- → logs_2026_03_asia 파티션만 스캔
인덱스 전략: 글로벌 vs 로컬
파티셔닝된 테이블의 인덱스는 각 파티션에 자동으로 로컬 인덱스가 생성됩니다:
-- 부모 테이블에 인덱스 생성 → 모든 파티션에 자동 적용
CREATE INDEX idx_orders_user ON orders (user_id);
-- 실제로는 orders_2026_01_user_id_idx, orders_2026_02_user_id_idx ... 생성됨
-- 특정 파티션에만 추가 인덱스
CREATE INDEX idx_orders_2026_03_status ON orders_2026_03 (status)
WHERE status = 'pending'; -- Partial Index
-- UNIQUE 제약: 파티션 키 포함 필수
CREATE UNIQUE INDEX idx_orders_unique ON orders (id, created_at);
-- ✅ 파티션 키(created_at) 포함
-- ❌ 실패: 파티션 키 미포함
-- CREATE UNIQUE INDEX idx_fail ON orders (user_id);
-- ERROR: unique constraint must include all partitioning columns
-- 인덱스 현황 확인
SELECT
schemaname, tablename, indexname, indexdef
FROM pg_indexes
WHERE tablename LIKE 'orders_%'
ORDER BY tablename, indexname;
파티션 자동 생성: pg_partman
수동 파티션 생성은 운영 부담이 큽니다. pg_partman 확장을 사용하면 자동으로 파티션을 생성하고 오래된 파티션을 정리할 수 있습니다:
-- pg_partman 설치
CREATE EXTENSION pg_partman;
-- 자동 파티션 관리 설정
SELECT partman.create_parent(
p_parent_table => 'public.orders',
p_control => 'created_at',
p_type => 'range',
p_interval => '1 month',
p_premake => 3 -- 3개월 미리 생성
);
-- 자동 유지보수 (cron으로 실행)
-- 새 파티션 생성 + 오래된 파티션 정리
SELECT partman.run_maintenance();
-- 보존 정책: 12개월 이후 파티션 자동 DROP
UPDATE partman.part_config
SET retention = '12 months',
retention_keep_table = false -- true면 DETACH만, false면 DROP
WHERE parent_table = 'public.orders';
-- cron 설정 (매일 실행)
-- 0 3 * * * psql -d mydb -c "SELECT partman.run_maintenance();"
pg_partman 없이 직접 구현하려면 PL/pgSQL 함수로 파티션을 생성합니다:
-- 다음 달 파티션 자동 생성 함수
CREATE OR REPLACE FUNCTION create_monthly_partition()
RETURNS void AS $$
DECLARE
next_month DATE := date_trunc('month', now()) + interval '1 month';
partition_name TEXT;
start_date TEXT;
end_date TEXT;
BEGIN
partition_name := 'orders_' || to_char(next_month, 'YYYY_MM');
start_date := to_char(next_month, 'YYYY-MM-DD');
end_date := to_char(next_month + interval '1 month', 'YYYY-MM-DD');
-- 이미 존재하면 스킵
IF NOT EXISTS (
SELECT 1 FROM pg_class WHERE relname = partition_name
) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF orders FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
RAISE NOTICE 'Created partition: %', partition_name;
END IF;
END;
$$ LANGUAGE plpgsql;
파티션 관리: ATTACH · DETACH · 데이터 마이그레이션
운영 중 파티션 추가/제거 시 CONCURRENTLY 옵션으로 락 영향을 최소화합니다:
-- 기존 테이블을 파티션으로 ATTACH (데이터 마이그레이션)
-- 1. 기존 테이블에 CHECK 제약 추가 (ATTACH 시 검증 스킵 → 속도 향상)
ALTER TABLE legacy_orders_2025
ADD CONSTRAINT chk_date CHECK (
created_at >= '2025-01-01' AND created_at < '2026-01-01'
);
-- 2. 파티션으로 ATTACH
ALTER TABLE orders
ATTACH PARTITION legacy_orders_2025
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
-- 파티션 DETACH (PostgreSQL 14+: CONCURRENTLY 지원)
ALTER TABLE orders
DETACH PARTITION orders_2025_01 CONCURRENTLY;
-- CONCURRENTLY: 읽기 트래픽 차단 없이 분리
-- DETACH 후 독립 테이블로 아카이빙
ALTER TABLE orders_2025_01 RENAME TO orders_archive_2025_01;
-- 또는 바로 DROP으로 빠른 삭제 (DELETE보다 수천 배 빠름)
DROP TABLE orders_2025_01;
-- vs DELETE FROM orders WHERE created_at < '2025-02-01';
-- → DROP은 즉시, DELETE는 수억 건이면 수 시간 소요
Spring Boot + JPA 파티셔닝 통합
JPA 엔티티에서 파티셔닝 테이블을 사용할 때 주의할 점입니다:
@Entity
@Table(name = "orders")
public class Order {
// 복합 PK: id + created_at (파티션 키 포함 필수)
@EmbeddedId
private OrderId orderId;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal amount;
@Column(nullable = false)
private String status;
}
@Embeddable
public class OrderId implements Serializable {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
}
// Repository: 파티션 프루닝을 위해 항상 날짜 조건 포함
public interface OrderRepository extends JpaRepository<Order, OrderId> {
// ✅ 파티션 프루닝 가능
@Query("SELECT o FROM Order o WHERE o.orderId.createdAt BETWEEN :start AND :end AND o.userId = :userId")
List<Order> findByDateRangeAndUser(
@Param("start") Instant start,
@Param("end") Instant end,
@Param("userId") Long userId
);
// ❌ 프루닝 불가: 날짜 조건 없음
// List<Order> findByUserId(Long userId);
// → 모든 파티션 Full Scan
}
NestJS + Prisma/TypeORM 파티셔닝 패턴
// Prisma: Raw SQL로 파티션 프루닝 활용
@Injectable()
export class OrderService {
constructor(private prisma: PrismaService) {}
async findOrders(userId: bigint, startDate: Date, endDate: Date) {
// Prisma 쿼리 → 자동으로 파티션 프루닝 적용
return this.prisma.order.findMany({
where: {
userId,
createdAt: {
gte: startDate,
lt: endDate, // 반드시 날짜 범위 포함!
},
},
orderBy: { createdAt: 'desc' },
});
}
// 대량 데이터 집계: Raw SQL로 파티션 지정
async getMonthlyStats(year: number, month: number) {
return this.prisma.$queryRaw`
SELECT status, COUNT(*), SUM(amount)
FROM orders
WHERE created_at >= ${new Date(year, month - 1, 1)}
AND created_at < ${new Date(year, month, 1)}
GROUP BY status
`;
}
}
// TypeORM: QueryBuilder에서 날짜 조건 강제
@Injectable()
export class OrderRepository {
constructor(
@InjectRepository(Order)
private repo: Repository<Order>,
) {}
findByUser(userId: number, dateRange: DateRange) {
return this.repo
.createQueryBuilder('o')
.where('o.created_at >= :start', { start: dateRange.start })
.andWhere('o.created_at < :end', { end: dateRange.end })
.andWhere('o.user_id = :userId', { userId })
.orderBy('o.created_at', 'DESC')
.getMany();
}
}
성능 모니터링과 운영 쿼리
-- 파티션별 데이터 크기 확인
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) AS data_size,
n_live_tup AS row_count
FROM pg_stat_user_tables
WHERE tablename LIKE 'orders_%'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;
-- 파티션 프루닝 횟수 모니터링 (pg_stat_statements)
SELECT query, calls, mean_exec_time, rows
FROM pg_stat_statements
WHERE query LIKE '%orders%'
ORDER BY mean_exec_time DESC
LIMIT 10;
-- 파티션 구조 트리 확인
SELECT
parent.relname AS parent,
child.relname AS partition,
pg_get_expr(child.relpartbound, child.oid) AS bounds
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname;
-- VACUUM·ANALYZE 파티션별 실행
ANALYZE orders_2026_03; -- 특정 파티션만 통계 갱신
VACUUM (VERBOSE) orders_2026_02; -- 특정 파티션만 VACUUM
파티셔닝 전략 선택 가이드
| 전략 | 적합한 케이스 | 주의점 |
|---|---|---|
| RANGE | 시계열 로그, 주문, IoT 센서 | 오래된 파티션 DROP으로 빠른 삭제 |
| LIST | 멀티 테넌시, 지역, 카테고리 | 값 목록 변경 시 파티션 추가 필요 |
| HASH | 핫스팟 분산, 균등 부하 | 파티션 수 변경 어려움, 범위 검색 불가 |
| 서브 파티셔닝 | 대규모 복합 조건 쿼리 | 파티션 수 폭발 주의 (수천 개 이상 비권장) |
핵심 정리
PostgreSQL 파티셔닝은 대용량 데이터의 쿼리 성능, 유지보수, 데이터 수명 관리를 동시에 해결합니다. 핵심은 파티션 프루닝이 작동하도록 쿼리에 항상 파티션 키 조건을 포함하는 것입니다. pg_partman으로 자동화하고, DETACH CONCURRENTLY로 무중단 파티션 관리를 하면 수십억 건 규모에서도 안정적으로 운영할 수 있습니다. PostgreSQL Partial Index 심화와 PostgreSQL CTE 재귀 쿼리 심화도 함께 참고하세요.