PostgreSQL 파티셔닝 전략 심화

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 재귀 쿼리 심화도 함께 참고하세요.

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