Deep EVM #25: PostgreSQL 테이블 파티셔닝 — 테이블이 1천만 행을 넘을 때
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 효율성, 인덱스 유지 보수를 크게 개선합니다. 핵심은 올바른 파티션 키 선택입니다: 시계열 데이터에는 범위 파티셔닝, 카테고리별 분리에는 리스트 파티셔닝, 균등 분배에는 해시 파티셔닝을 사용합니다. 파티셔닝은 테이블이 커지기 전에 계획하는 것이 이상적이지만, 이 글에서 보여준 것처럼 기존 테이블을 마이그레이션하는 것도 충분히 가능합니다.