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

Deep EVM #27: PostgreSQL 대규모 성능 — 인덱스, VACUUM, 쿼리 최적화

OS
Open Soft Team

Engineering Team

PostgreSQL 성능의 기초

대규모 PostgreSQL 데이터베이스를 운영할 때 가장 중요한 세 가지 영역은 인덱스 전략, VACUUM 관리, 쿼리 최적화입니다. 이 세 가지를 올바르게 조합하면 수백만 행의 테이블에서도 밀리초 단위의 쿼리 응답을 유지할 수 있습니다.

인덱스 전략

B-Tree: 기본이자 최강

B-Tree 인덱스는 PostgreSQL의 기본 인덱스 유형이며, 대부분의 워크로드에 최적입니다:

-- 기본 B-Tree 인덱스
CREATE INDEX idx_transactions_block ON transactions (block_number);

-- 복합 인덱스: 왼쪽에서 오른쪽으로 선택도 높은 순서
CREATE INDEX idx_transactions_addr_block
    ON transactions (from_addr, block_number DESC);

복합 인덱스 설계의 핵심 규칙:

  • 등호 조건 열을 먼저, 범위 조건 열을 나중에
  • 선택도가 높은 열을 먼저
  • 정렬 순서를 쿼리의 ORDER BY와 일치시키기

부분 인덱스: 필요한 것만 인덱싱

전체 테이블의 작은 부분만 자주 쿼리된다면, 부분 인덱스가 극적인 공간과 성능 개선을 제공합니다:

-- 최근 미확인 트랜잭션만 인덱싱
CREATE INDEX idx_pending_tx
    ON transactions (created_at)
    WHERE status = 'pending';

-- 고가치 전송만 인덱싱
CREATE INDEX idx_high_value_transfers
    ON transactions (block_number, from_addr)
    WHERE value > 1000000000000000000;  -- > 1 ETH

부분 인덱스는 행의 5% 미만이 조건을 충족할 때 특히 효과적입니다. 인덱스 크기가 95% 줄어들고, 삽입/업데이트 시 인덱스 유지 비용도 감소합니다.

BRIN: 시계열 데이터의 비밀 무기

Block Range INdex(BRIN)는 물리적으로 정렬된 데이터에 적합합니다:

CREATE INDEX idx_transactions_block_brin
    ON transactions USING brin (block_number)
    WITH (pages_per_range = 128);

BRIN vs B-Tree 비교:

특성B-TreeBRIN
인덱스 크기 (1억 행)2.1 GB5 MB
INSERT 오버헤드높음매우 낮음
포인트 쿼리O(log N)O(범위 스캔)
범위 쿼리우수우수 (정렬된 데이터)

BRIN은 시계열 데이터에서 B-Tree 대비 400배 작은 인덱스를 제공하면서, 범위 쿼리에서는 비슷한 성능을 유지합니다.

GIN: JSONB와 배열 검색

GIN 인덱스는 JSONB, 배열, 전문 검색에 사용됩니다:

-- JSONB 키 검색
CREATE INDEX idx_events_data ON events USING gin (data);

-- 특정 경로만 인덱싱 (더 효율적)
CREATE INDEX idx_events_type
    ON events USING gin ((data -> 'type'));

VACUUM 관리

PostgreSQL의 MVCC 모델은 업데이트/삭제된 행을 즉시 제거하지 않습니다. 대신 “데드 튜플“로 남겨두고, VACUUM이 정리합니다.

autovacuum 튜닝

기본 autovacuum 설정은 대규모 테이블에 불충분합니다:

-- 테이블별 autovacuum 설정
ALTER TABLE transactions SET (
    autovacuum_vacuum_scale_factor = 0.01,     -- 기본 0.2 -> 0.01
    autovacuum_vacuum_threshold = 10000,        -- 기본 50
    autovacuum_analyze_scale_factor = 0.005,    -- 기본 0.1
    autovacuum_vacuum_cost_delay = 2            -- 기본 2ms -> 더 공격적
);

1억 행 테이블에서:

  • 기본: 2천만 행이 변경되어야 VACUUM 트리거
  • 튜닝 후: 100만 행 변경 시 트리거

VACUUM 모니터링

-- 데드 튜플 모니터링
SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    ROUND(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

데드 튜플 비율이 10%를 초과하면 VACUUM이 뒤처지고 있으며, autovacuum 설정을 더 공격적으로 조정해야 합니다.

EXPLAIN ANALYZE 읽기

EXPLAIN ANALYZE는 쿼리 최적화의 가장 강력한 도구입니다:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM transactions
WHERE from_addr = '\x1234...'
  AND block_number > 19000000
ORDER BY block_number DESC
LIMIT 50;

출력에서 주의할 핵심 지표:

  • actual time: 실제 실행 시간 (첫 행..마지막 행)
  • rows: 예상 행 vs 실제 행 (큰 차이 = 오래된 통계)
  • Buffers: shared hit (캐시) vs shared read (디스크)
  • Sort Method: quicksort(메모리) vs external merge(디스크)

일반적인 안티 패턴

-- 안티 패턴 1: SELECT * (필요 없는 열까지 로드)
-- 나쁨:
SELECT * FROM transactions WHERE block_number = 19000000;
-- 좋음:
SELECT tx_hash, from_addr, value FROM transactions WHERE block_number = 19000000;

-- 안티 패턴 2: 함수 래핑으로 인덱스 무효화
-- 나쁨 (인덱스 사용 불가):
SELECT * FROM events WHERE LOWER(event_type) = 'transfer';
-- 좋음 (표현식 인덱스 사용):
CREATE INDEX idx_events_type_lower ON events (LOWER(event_type));

-- 안티 패턴 3: OFFSET 페이지네이션
-- 나쁨 (OFFSET 1000000은 백만 행을 스캔 후 폐기):
SELECT * FROM transactions ORDER BY id OFFSET 1000000 LIMIT 50;
-- 좋음 (커서 기반 페이지네이션):
SELECT * FROM transactions WHERE id > 1000000 ORDER BY id LIMIT 50;

커넥션 풀링

PostgreSQL은 커넥션당 프로세스를 포크합니다. 커넥션이 100개를 넘으면 메모리와 컨텍스트 스위칭 오버헤드가 심각해집니다:

# PgBouncer 구성
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
server_idle_timeout = 300

PgBouncer를 사용하면 1000개의 애플리케이션 커넥션을 20개의 실제 PostgreSQL 커넥션으로 다중화할 수 있습니다.

쿼리 통계 모니터링

-- pg_stat_statements 활성화
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 가장 느린 쿼리 상위 10개
SELECT
    LEFT(query, 100) AS query_preview,
    calls,
    ROUND(total_exec_time::numeric / 1000, 2) AS total_sec,
    ROUND(mean_exec_time::numeric, 2) AS avg_ms,
    ROUND(stddev_exec_time::numeric, 2) AS stddev_ms,
    rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

결론

PostgreSQL 대규모 성능은 세 개의 기둥 위에 서 있습니다: 올바른 인덱스 전략(B-Tree, 부분 인덱스, BRIN, GIN), 공격적인 VACUUM 관리(autovacuum 튜닝, 데드 튜플 모니터링), 지능적인 쿼리 설계(EXPLAIN ANALYZE, 안티 패턴 회피). 이 세 가지를 체계적으로 접근하면, 수억 행의 테이블에서도 밀리초 단위의 응답 시간을 유지할 수 있습니다.