본문으로 건너뛰기
DevOpsMar 28, 2026

Deep EVM #25: PostgreSQL 테이블 파티셔닝 — 테이블이 1천만 행을 넘을 때

OS
Open Soft Team

Engineering Team

파티셔닝이 필요한 시점

작게 시작한 transactions 테이블이 이제 3,400만 행에 도달했습니다. 50ms 걸리던 쿼리가 이제 5초 걸립니다. VACUUM은 몇 시간 동안 실행되며 다른 테이블의 autovacuum을 차단합니다. 인덱스 재구축은 테이블을 몇 분간 오프라인으로 만듭니다. 데이터베이스가 느린 것이 아닙니다 — 테이블이 단일 힙 파일로 너무 커진 것입니다.

테이블 파티셔닝은 논리적 테이블을 여러 물리적 테이블(파티션)로 분할합니다. PostgreSQL의 쿼리 플래너가 올바른 파티션으로 쿼리를 자동 라우팅하여 필요한 데이터만 스캔합니다.

파티셔닝의 경험 법칙

  • 파티셔닝할 때: 테이블이 1천만 행 초과, 쿼리가 일관되게 테이블의 20% 이상 스캔, VACUUM이 데드 튜플을 따라잡지 못할 때
  • 파티셔닝하지 말 때: 테이블이 100만 행 미만(오버헤드가 이익 초과), 쿼리가 항상 인덱스를 타는 경우(파티션 프루닝 불필요), 쓰기 패턴이 랜덤(자연스러운 파티션 키 없음)

파티션 전략

PostgreSQL은 세 가지 네이티브 파티셔닝 전략을 지원합니다:

범위 파티셔닝

값 범위로 분할합니다. 시계열 데이터에 이상적입니다:

CREATE TABLE transactions (
    id          BIGINT GENERATED ALWAYS AS IDENTITY,
    block_number BIGINT NOT NULL,
    tx_hash     BYTEA NOT NULL,
    from_addr   BYTEA NOT NULL,
    to_addr     BYTEA NOT NULL,
    value       NUMERIC NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (block_number);

-- 월별 파티션
CREATE TABLE transactions_2024_01 PARTITION OF transactions
    FOR VALUES FROM (18900000) TO (19100000);
CREATE TABLE transactions_2024_02 PARTITION OF transactions
    FOR VALUES FROM (19100000) TO (19300000);
CREATE TABLE transactions_2024_03 PARTITION OF transactions
    FOR VALUES FROM (19300000) TO (19500000);

범위 파티셔닝은 블록 번호나 타임스탬프 기반 쿼리에서 파티션 프루닝의 이점을 극대화합니다.

리스트 파티셔닝

특정 값 목록으로 분할합니다. 카테고리별 분리에 유용합니다:

CREATE TABLE events (
    id         BIGINT GENERATED ALWAYS AS IDENTITY,
    chain_id   INT NOT NULL,
    event_type TEXT NOT NULL,
    data       JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY LIST (chain_id);

CREATE TABLE events_ethereum PARTITION OF events
    FOR VALUES IN (1);
CREATE TABLE events_polygon PARTITION OF events
    FOR VALUES IN (137);
CREATE TABLE events_arbitrum PARTITION OF events
    FOR VALUES IN (42161);

해시 파티셔닝

해시 값으로 균등 분배합니다. 핫스팟 없이 쓰기를 분산시킵니다:

CREATE TABLE addresses (
    address BYTEA NOT NULL,
    balance NUMERIC NOT NULL,
    nonce   BIGINT NOT NULL
) PARTITION BY HASH (address);

CREATE TABLE addresses_p0 PARTITION OF addresses
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE addresses_p1 PARTITION OF addresses
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE addresses_p2 PARTITION OF addresses
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE addresses_p3 PARTITION OF addresses
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

기존 테이블 마이그레이션

3,400만 행을 가진 기존 테이블을 파티션 테이블로 마이그레이션하는 것은 신중하게 수행해야 합니다:

-- 1. 새 파티션 테이블 생성
CREATE TABLE transactions_new (
    LIKE transactions INCLUDING ALL
) PARTITION BY RANGE (block_number);

-- 2. 파티션 생성
CREATE TABLE transactions_new_p1 PARTITION OF transactions_new
    FOR VALUES FROM (0) TO (19000000);
CREATE TABLE transactions_new_p2 PARTITION OF transactions_new
    FOR VALUES FROM (19000000) TO (19500000);
CREATE TABLE transactions_new_p3 PARTITION OF transactions_new
    FOR VALUES FROM (19500000) TO (20000000);

-- 3. 배치로 데이터 복사
INSERT INTO transactions_new
SELECT * FROM transactions
WHERE block_number < 19000000;

INSERT INTO transactions_new
SELECT * FROM transactions
WHERE block_number >= 19000000 AND block_number < 19500000;

-- 4. 원자적 교체
BEGIN;
ALTER TABLE transactions RENAME TO transactions_old;
ALTER TABLE transactions_new RENAME TO transactions;
COMMIT;

EXPLAIN ANALYZE로 파티션 프루닝 확인

파티셔닝이 올바르게 작동하는지 EXPLAIN ANALYZE로 확인합니다:

EXPLAIN ANALYZE
SELECT * FROM transactions
WHERE block_number BETWEEN 19200000 AND 19300000;

-- 출력에서 확인:
-- Append
--   -> Seq Scan on transactions_2024_02  (실제 스캔)
--   Subplans Removed: 19              (프루닝된 파티션 수)

“Subplans Removed“가 표시되면 파티션 프루닝이 작동하는 것입니다.

자동 파티션 관리

새 파티션을 자동으로 생성하는 cron 작업을 설정합니다:

CREATE OR REPLACE FUNCTION create_next_partition()
RETURNS void AS $$
DECLARE
    max_block BIGINT;
    partition_name TEXT;
    range_start BIGINT;
    range_end BIGINT;
BEGIN
    SELECT MAX(block_number) INTO max_block FROM transactions;
    range_start := (max_block / 200000 + 1) * 200000;
    range_end := range_start + 200000;
    partition_name := 'transactions_' || range_start::TEXT;

    EXECUTE format(
        'CREATE TABLE IF NOT EXISTS %I PARTITION OF transactions FOR VALUES FROM (%s) TO (%s)',
        partition_name, range_start, range_end
    );
END;
$$ LANGUAGE plpgsql;

결론

PostgreSQL 테이블 파티셔닝은 대규모 테이블을 관리 가능한 단위로 분할하여 쿼리 성능, VACUUM 효율성, 인덱스 유지 보수를 크게 개선합니다. 핵심은 올바른 파티션 키 선택입니다: 시계열 데이터에는 범위 파티셔닝, 카테고리별 분리에는 리스트 파티셔닝, 균등 분배에는 해시 파티셔닝을 사용합니다. 파티셔닝은 테이블이 커지기 전에 계획하는 것이 이상적이지만, 이 글에서 보여준 것처럼 기존 테이블을 마이그레이션하는 것도 충분히 가능합니다.