Skip to content
Published on

PostgreSQL 쿼리 최적화와 성능 튜닝 실전 가이드: EXPLAIN부터 파티셔닝까지

Authors
  • Name
    Twitter
PostgreSQL 쿼리 최적화와 성능 튜닝

들어가며

PostgreSQL은 엔터프라이즈급 오픈소스 관계형 데이터베이스로, 복잡한 쿼리 처리 능력과 확장성에서 높은 평가를 받고 있다. 하지만 아무리 뛰어난 DBMS라도 쿼리 최적화 없이는 프로덕션 환경에서 기대한 성능을 얻기 어렵다. 수백만 건의 데이터가 쌓이고, 동시 접속자가 늘어나면 느린 쿼리 하나가 전체 서비스를 마비시킬 수 있다.

실제로 필자가 운영하던 서비스에서 단일 쿼리의 실행 시간이 30초를 넘기면서 커넥션 풀이 고갈되고, 연쇄적으로 API 타임아웃이 발생한 사례가 있었다. EXPLAIN ANALYZE로 실행 계획을 분석하고, 적절한 인덱스를 추가하고, 쿼리를 리라이팅한 결과 동일 쿼리가 50ms 이내로 개선되었다.

이 글에서는 PostgreSQL의 EXPLAIN 분석부터 인덱스 전략, VACUUM 튜닝, 테이블 파티셔닝, 커넥션 풀링까지 프로덕션 환경에서 바로 적용할 수 있는 성능 최적화 기법을 체계적으로 다룬다.

EXPLAIN ANALYZE 완벽 이해

기본 사용법

PostgreSQL의 쿼리 최적화는 항상 EXPLAIN ANALYZE에서 시작한다. 이 명령은 쿼리를 실제로 실행하면서 각 노드의 예상 비용과 실제 실행 시간, 반환 행 수를 보여준다.

-- 기본 EXPLAIN ANALYZE 사용
EXPLAIN ANALYZE
SELECT u.id, u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= '2026-01-01'
  AND o.status = 'completed'
ORDER BY o.total_amount DESC
LIMIT 100;

실행 결과는 다음과 같은 형태로 출력된다.

Limit  (cost=15234.56..15234.81 rows=100 width=52) (actual time=45.123..45.156 rows=100 loops=1)
  ->  Sort  (cost=15234.56..15347.89 rows=45332 width=52) (actual time=45.121..45.142 rows=100 loops=1)
        Sort Key: o.total_amount DESC
        Sort Method: top-N heapsort  Memory: 35kB
        ->  Hash Join  (cost=3456.78..13890.12 rows=45332 width=52) (actual time=12.345..38.901 rows=45332 loops=1)
              Hash Cond: (o.user_id = u.id)
              ->  Seq Scan on orders o  (cost=0.00..9876.54 rows=45332 width=20) (actual time=0.015..18.234 rows=45332 loops=1)
                    Filter: ((created_at >= '2026-01-01') AND (status = 'completed'))
                    Rows Removed by Filter: 154668
              ->  Hash  (cost=2345.00..2345.00 rows=100000 width=36) (actual time=12.123..12.123 rows=100000 loops=1)
                    Buckets: 131072  Batches: 1  Memory Usage: 6234kB
                    ->  Seq Scan on users u  (cost=0.00..2345.00 rows=100000 width=36) (actual time=0.008..5.678 rows=100000 loops=1)
Planning Time: 0.456 ms
Execution Time: 45.234 ms

핵심 읽는 법

실행 계획에서 주목해야 할 핵심 지표는 다음과 같다.

  • actual time: 실제 실행 시간(밀리초). 첫 번째 값은 첫 행 반환까지의 시간, 두 번째 값은 전체 완료 시간이다.
  • rows: 예상 행 수(cost 옆)와 실제 행 수(actual 옆)의 차이가 크면 통계 정보가 오래되었다는 신호다.
  • Seq Scan: 테이블 전체를 순차 스캔한다. 대용량 테이블에서 이것이 보이면 인덱스 추가를 고려한다.
  • Rows Removed by Filter: 필터링으로 제거된 행이 많을수록 불필요한 I/O가 발생하고 있다는 뜻이다.

BUFFERS 옵션 활용

더 깊은 분석을 위해 BUFFERS 옵션을 함께 사용하면 디스크 I/O와 캐시 히트 정보를 확인할 수 있다.

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT *
FROM orders
WHERE user_id = 12345
  AND created_at BETWEEN '2026-01-01' AND '2026-03-14';
Index Scan using idx_orders_user_created on orders  (cost=0.43..8.45 rows=1 width=64) (actual time=0.023..0.025 rows=3 loops=1)
  Index Cond: ((user_id = 12345) AND (created_at >= '2026-01-01') AND (created_at <= '2026-03-14'))
  Buffers: shared hit=4
Planning Time: 0.123 ms
Execution Time: 0.045 ms

여기서 shared hit=4는 4개의 페이지를 shared buffer(캐시)에서 읽었다는 뜻이다. 만약 shared read=N이 보이면 디스크에서 읽은 것이므로, shared_buffers 크기를 늘리거나 쿼리를 최적화해야 할 수 있다.

시각화 도구

텍스트 형태의 실행 계획이 복잡하면 다음 도구를 활용하자.

  • explain.depesz.com - 실행 계획을 붙여넣으면 색상으로 병목 구간을 표시해준다
  • pgexplain.dev - 노드별 시각화와 통계 분석을 제공한다

인덱스 전략: B-tree, GIN, GiST, BRIN

B-tree 인덱스 (기본)

B-tree는 PostgreSQL의 기본 인덱스 타입으로, 등호 비교와 범위 검색에 가장 적합하다. CREATE INDEX 명령 시 별도 타입을 지정하지 않으면 B-tree가 생성된다.

-- 단일 컬럼 B-tree 인덱스
CREATE INDEX idx_orders_user_id ON orders (user_id);

-- 복합 인덱스: 선행 컬럼 순서가 중요하다
-- WHERE user_id = ? AND created_at >= ? 형태의 쿼리에 최적
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);

-- 커버링 인덱스: Index-Only Scan을 유도
CREATE INDEX idx_orders_covering ON orders (user_id, created_at)
  INCLUDE (status, total_amount);

-- 부분 인덱스: 특정 조건의 행만 인덱싱하여 크기를 줄인다
CREATE INDEX idx_orders_active ON orders (user_id, created_at)
  WHERE status = 'active';

복합 인덱스 설계 시 가장 중요한 원칙은 선행 컬럼의 선택도이다. WHERE 절에서 등호 조건으로 사용되는 컬럼을 먼저, 범위 조건 컬럼을 뒤에 배치한다. 위 예시에서 user_id = ?는 등호, created_at >= ?는 범위 조건이므로 user_id를 선행 컬럼으로 둔 것이다.

GIN (Generalized Inverted Index)

GIN 인덱스는 하나의 행에 여러 값이 포함된 경우에 최적이다. JSONB, 배열, 전문 검색(tsvector)에 주로 사용한다.

-- JSONB 필드에 대한 GIN 인덱스
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);

-- jsonb_path_ops: 특정 연산자에 더 효율적이고 인덱스 크기가 작다
CREATE INDEX idx_products_metadata_path ON products USING GIN (metadata jsonb_path_ops);

-- 전문 검색용 GIN 인덱스
CREATE INDEX idx_articles_search ON articles USING GIN (
  to_tsvector('korean', title || ' ' || content)
);

-- 배열 컬럼에 대한 GIN
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);

GIN의 핵심 주의사항은 쓰기 성능 저하이다. GIN 인덱스는 갱신 비용이 높으므로, 쓰기가 빈번한 테이블에서는 fastupdate = on (기본값) 설정으로 pending list에 일괄 반영하는 방식을 사용하되, pending list가 너무 커지면 읽기 성능이 떨어질 수 있으므로 gin_pending_list_limit을 조정해야 한다.

GiST (Generalized Search Tree)

GiST 인덱스는 지리 데이터(PostGIS), 범위 타입, 근접 검색에 주로 사용한다.

-- PostGIS 공간 인덱스
CREATE INDEX idx_stores_location ON stores USING GiST (location);

-- 범위 타입 인덱스
CREATE INDEX idx_reservations_period ON reservations USING GiST (
  tstzrange(start_time, end_time)
);

-- 범위 겹침 검색
SELECT * FROM reservations
WHERE tstzrange(start_time, end_time) && tstzrange('2026-03-14 09:00', '2026-03-14 18:00');

BRIN (Block Range INdex)

BRIN 인덱스는 테이블의 물리적 순서와 데이터 값의 상관관계가 높을 때 극도로 효율적이다. 시계열 데이터(로그, 이벤트)에서 인덱스 크기를 B-tree의 1/100 수준으로 줄일 수 있다.

-- 시계열 로그 테이블에 BRIN 인덱스
CREATE INDEX idx_logs_created_brin ON access_logs USING BRIN (created_at)
  WITH (pages_per_range = 32);

-- BRIN이 효과적인지 확인: 상관계수가 1에 가까워야 한다
SELECT correlation
FROM pg_stats
WHERE tablename = 'access_logs' AND attname = 'created_at';

correlation 값이 0.9 이상이면 BRIN이 매우 효과적이고, 0.5 이하이면 B-tree를 사용하는 것이 낫다.

쿼리 리라이팅과 실행 계획 최적화

N+1 문제 해결

ORM을 사용할 때 흔히 발생하는 N+1 문제는 데이터베이스 성능의 가장 큰 적이다.

-- Bad: 애플리케이션에서 N+1 패턴으로 실행되는 쿼리
-- 1) SELECT * FROM users WHERE department = 'engineering';  -- 50명 반환
-- 2) SELECT * FROM orders WHERE user_id = 1;  -- 50번 반복 실행
-- 3) SELECT * FROM orders WHERE user_id = 2;
-- ...

-- Good: JOIN으로 한 번에 가져오기
SELECT u.id, u.name, o.id AS order_id, o.total_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.department = 'engineering';

-- Good: LATERAL JOIN으로 사용자당 최근 주문 3건만 가져오기
SELECT u.id, u.name, recent_orders.*
FROM users u
CROSS JOIN LATERAL (
  SELECT o.id, o.total_amount, o.created_at
  FROM orders o
  WHERE o.user_id = u.id
  ORDER BY o.created_at DESC
  LIMIT 3
) recent_orders
WHERE u.department = 'engineering';

서브쿼리 vs JOIN 최적화

-- Bad: 상관 서브쿼리 - 외부 쿼리의 각 행마다 서브쿼리가 실행될 수 있다
SELECT u.id, u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u
WHERE u.status = 'active';

-- Good: GROUP BY와 JOIN 활용
SELECT u.id, u.name, COALESCE(oc.cnt, 0) AS order_count
FROM users u
LEFT JOIN (
  SELECT user_id, COUNT(*) AS cnt
  FROM orders
  GROUP BY user_id
) oc ON u.id = oc.user_id
WHERE u.status = 'active';

EXISTS vs IN 전략

-- 대용량 데이터에서는 EXISTS가 유리한 경우가 많다
-- EXISTS는 첫 번째 매칭을 찾으면 즉시 중단한다
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id
    AND o.created_at >= '2026-01-01'
);

-- IN은 서브쿼리 결과가 작을 때 유리하다
SELECT u.id, u.name
FROM users u
WHERE u.department_id IN (
  SELECT id FROM departments WHERE region = 'APAC'
);

페이지네이션 최적화

OFFSET 기반 페이지네이션은 대용량 테이블에서 심각한 성능 문제를 일으킨다. 100만 번째부터 10건을 가져오려면 100만 건을 먼저 스캔하고 버린다.

-- Bad: OFFSET이 커질수록 느려진다
SELECT * FROM orders
ORDER BY id
OFFSET 1000000 LIMIT 10;

-- Good: 키셋(Keyset) 페이지네이션 - 항상 일정한 성능
SELECT * FROM orders
WHERE id > 1000000  -- 이전 페이지의 마지막 id
ORDER BY id
LIMIT 10;

-- 복합 정렬 기준일 때의 키셋 페이지네이션
SELECT * FROM orders
WHERE (created_at, id) > ('2026-03-13 15:30:00', 98765)
ORDER BY created_at, id
LIMIT 10;

VACUUM과 Autovacuum 튜닝

VACUUM이 필요한 이유

PostgreSQL은 MVCC 아키텍처 덕분에 UPDATE나 DELETE 시 기존 행을 즉시 삭제하지 않고 Dead Tuple로 남긴다. VACUUM은 이 Dead Tuple을 정리하여 공간을 재사용 가능하게 만드는 핵심 유지보수 작업이다.

VACUUM을 소홀히 하면 다음과 같은 문제가 발생한다.

  • 테이블 Bloat: 실제 데이터 대비 테이블 크기가 비정상적으로 커진다
  • 인덱스 Bloat: 인덱스 크기 증가로 쿼리 성능 저하
  • XID Wraparound: 트랜잭션 ID 소진 시 데이터베이스가 자동으로 읽기 전용 모드로 전환되는 치명적 장애

Autovacuum 핵심 파라미터 튜닝

# postgresql.conf - Autovacuum 튜닝 권장 설정

# Autovacuum 활성화 (절대 끄지 마라)
autovacuum = on

# 동시에 실행 가능한 autovacuum worker 수
# CPU 코어 수와 테이블 수를 고려하여 설정
autovacuum_max_workers = 5

# VACUUM 트리거 임계값: dead tuple이 테이블의 10% + 50건 초과 시 실행
autovacuum_vacuum_threshold = 50
autovacuum_vacuum_scale_factor = 0.1

# 대용량 테이블에서는 scale_factor를 낮춰야 한다
# 1억 건 테이블에서 0.2(기본값)이면 2천만 건이 쌓여야 VACUUM이 실행된다

# ANALYZE 트리거: 변경이 테이블의 5% + 50건 초과 시 통계 갱신
autovacuum_analyze_threshold = 50
autovacuum_analyze_scale_factor = 0.05

# 비용 기반 지연: 높을수록 VACUUM이 빠르지만 I/O 부하 증가
autovacuum_vacuum_cost_delay = 2ms
autovacuum_vacuum_cost_limit = 1000

# XID Wraparound 방지를 위한 설정
autovacuum_freeze_max_age = 300000000

테이블별 개별 튜닝

대용량 트랜잭션 테이블은 글로벌 설정으로는 부족하다. 테이블별로 autovacuum 파라미터를 개별 설정할 수 있다.

-- 대용량 주문 테이블: 더 빈번하게 VACUUM 실행
ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor = 0.01,      -- 1%만 변경되어도 VACUUM
  autovacuum_vacuum_threshold = 1000,
  autovacuum_analyze_scale_factor = 0.005,
  autovacuum_vacuum_cost_delay = 0             -- 최대 속도로 VACUUM
);

-- VACUUM 상태 모니터링
SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  ROUND(n_dead_tup::numeric / GREATEST(n_live_tup, 1) * 100, 2) AS dead_ratio_pct,
  last_vacuum,
  last_autovacuum,
  last_analyze,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

테이블 파티셔닝 전략

언제 파티셔닝을 적용하는가

파티셔닝은 논리적으로 하나인 대형 테이블을 물리적으로 여러 파티션으로 분할하는 기법이다. 다음 조건에 해당하면 파티셔닝을 고려해야 한다.

  • 테이블 크기가 수십 GB 이상이고 계속 증가하는 경우
  • 쿼리가 항상 특정 범위의 데이터만 조회하는 경우 (예: 최근 3개월)
  • 오래된 데이터를 주기적으로 삭제해야 하는 경우 (DELETE 대신 DROP PARTITION)
  • VACUUM이 대형 테이블을 처리하는 데 오래 걸리는 경우

Range 파티셔닝 (날짜 기반)

-- 월별 Range 파티셔닝 테이블 생성
CREATE TABLE events (
  id          BIGSERIAL,
  event_type  VARCHAR(50) NOT NULL,
  payload     JSONB,
  created_at  TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- 월별 파티션 생성
CREATE TABLE events_2026_01 PARTITION OF events
  FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE events_2026_03 PARTITION OF events
  FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- 각 파티션에 인덱스 생성 (부모 테이블에 생성하면 자동 상속)
CREATE INDEX idx_events_type_created ON events (event_type, created_at);

-- 파티션 프루닝 확인: 2026년 3월 데이터만 조회
EXPLAIN ANALYZE
SELECT * FROM events
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'
  AND event_type = 'purchase';

실행 계획에서 Append 노드 하위에 2026_03 파티션만 스캔하는 것을 확인할 수 있다. 이것이 **파티션 프루닝(Partition Pruning)**이다.

자동 파티션 관리

pg_partman 확장을 사용하면 파티션 생성과 삭제를 자동화할 수 있다.

-- pg_partman 설치 후 자동 관리 설정
CREATE EXTENSION pg_partman;

SELECT partman.create_parent(
  p_parent_table => 'public.events',
  p_control => 'created_at',
  p_type => 'range',
  p_interval => '1 month',
  p_premake => 3  -- 3개월 분 미리 생성
);

-- 오래된 파티션 자동 삭제 (12개월 보관)
UPDATE partman.part_config
SET retention = '12 months',
    retention_keep_table = false
WHERE parent_table = 'public.events';

오래된 데이터 정리: DELETE vs DROP PARTITION

-- Bad: 대량 DELETE는 Dead Tuple을 생성하고 VACUUM 부하를 유발한다
DELETE FROM events WHERE created_at < '2025-01-01';

-- Good: 파티션을 통째로 분리하고 삭제 - 즉시 완료, VACUUM 불필요
ALTER TABLE events DETACH PARTITION events_2024_12;
DROP TABLE events_2024_12;

커넥션 풀링 (PgBouncer, Pgpool-II)

커넥션 풀링이 필요한 이유

PostgreSQL은 연결당 하나의 프로세스를 fork하는 구조이다. 각 연결은 약 5-10MB의 메모리를 소비하며, 동시 연결이 수백 개를 넘으면 컨텍스트 스위칭 오버헤드로 인해 성능이 급격히 저하된다. max_connections를 무작정 높이는 것은 해결책이 아니다.

PgBouncer 설정

PgBouncer는 경량 커넥션 풀러로, 단일 프로세스에서 수천 개의 클라이언트 연결을 처리할 수 있다.

;; pgbouncer.ini

[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp_production

[pgbouncer]
;; 풀링 모드: transaction이 가장 효율적
pool_mode = transaction

;; 클라이언트 최대 연결 수
max_client_conn = 1000

;; 데이터베이스당 서버 연결 수 (실제 PostgreSQL 연결)
default_pool_size = 30
min_pool_size = 10
reserve_pool_size = 5

;; 유휴 연결 타임아웃
server_idle_timeout = 300

;; 클라이언트 연결 대기 큐 타임아웃
client_login_timeout = 60

;; 로깅
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1

;; 모니터링용 포트
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

풀링 모드별 차이점은 다음과 같다.

  • session: 클라이언트 연결이 끊길 때까지 서버 연결을 점유한다. 가장 호환성이 높지만 효율이 낮다.
  • transaction: 트랜잭션이 끝나면 서버 연결을 반납한다. 대부분의 경우 최적의 선택이다.
  • statement: 각 SQL 문 실행 후 연결을 반납한다. 멀티 스테이트먼트 트랜잭션에서 문제가 발생하므로 주의가 필요하다.

Pgpool-II vs PgBouncer 선택 기준

Pgpool-II는 커넥션 풀링 외에도 로드 밸런싱, 페일오버, 쿼리 캐싱 기능을 제공하는 미들웨어이다. 단순히 커넥션 풀링만 필요하다면 PgBouncer가 더 가볍고 빠르지만, PostgreSQL 클러스터를 직접 관리하면서 통합 솔루션이 필요하다면 Pgpool-II를 고려할 수 있다.

# PgBouncer 상태 확인
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"

# 활성 연결 및 대기 상태 확인
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW CLIENTS;"

# PostgreSQL 쪽 실제 연결 수 확인
psql -c "SELECT count(*) FROM pg_stat_activity WHERE state = 'active';"

인덱스 유형 비교표

인덱스 타입적합한 데이터적합한 연산자인덱스 크기쓰기 비용대표 사용 사례
B-tree스칼라 값 (정수, 문자열, 날짜)=, <, >, BETWEEN, IN, IS NULL중간낮음PK, FK, 범위 검색, 정렬
GIN복합 값 (JSONB, 배열, tsvector)@>, ?|, ?&, @@ (전문검색)높음JSONB 검색, 전문 검색, 태그
GiST기하/공간 데이터, 범위 타입&&, @>, <@, <-> (거리)중간중간PostGIS, IP 범위, 시간 범위
BRIN물리적 순서와 상관관계 높은 데이터=, <, >, BETWEEN매우 작음매우 낮음시계열 로그, append-only 테이블
Hash등호 비교만 필요한 경우=중간낮음정확한 값 매칭 (PG 10 이상에서 WAL 지원)
SP-GiST비균형 트리 구조 데이터전화번호, IP, 기하중간중간접두사 검색, 쿼드트리

VACUUM 전략 비교표

전략대상언제 사용하는가잠금 영향소요 시간
VACUUMDead tuple 정리일상적 유지보수테이블 읽기/쓰기 가능테이블 크기에 비례
VACUUM FULL테이블 재작성으로 디스크 반환Bloat이 심각할 때ACCESS EXCLUSIVE 잠금 (서비스 중단)매우 길다
VACUUM FREEZEXID Wraparound 방지오래된 트랜잭션 ID 동결읽기/쓰기 가능전체 테이블 스캔
Autovacuum자동 Dead tuple 정리항상 활성화읽기/쓰기 가능설정에 따라 조절
pg_repack무중단 테이블 재구성VACUUM FULL 대신 사용짧은 잠금만 필요테이블 크기에 비례
ANALYZE통계 정보 갱신대량 INSERT/UPDATE 후읽기/쓰기 가능샘플링 기반으로 빠름

운영 시 주의사항과 트러블슈팅

느린 쿼리 모니터링 설정

# postgresql.conf - 슬로우 쿼리 로깅
log_min_duration_statement = 500       # 500ms 이상 쿼리 로깅
log_statement = 'none'                  # DDL만 로깅하려면 'ddl'
log_lock_waits = on                     # 잠금 대기 로깅
deadlock_timeout = 1s                   # 데드락 감지 주기

# pg_stat_statements 확장 활성화 (필수)
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = 'all'

pg_stat_statements로 가장 느린 쿼리 TOP 10을 조회하는 방법이다.

-- pg_stat_statements 확장 설치
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 평균 실행 시간이 가장 긴 쿼리 TOP 10
SELECT
  queryid,
  calls,
  ROUND(total_exec_time::numeric, 2) AS total_ms,
  ROUND(mean_exec_time::numeric, 2) AS avg_ms,
  ROUND((100 * total_exec_time / SUM(total_exec_time) OVER ())::numeric, 2) AS pct,
  rows,
  query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

인덱스 사용률 점검

사용되지 않는 인덱스는 쓰기 성능을 저하시키고 디스크 공간을 낭비한다.

-- 사용되지 않는 인덱스 찾기
SELECT
  schemaname || '.' || indexrelname AS index_name,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  idx_scan AS scan_count,
  idx_tup_read,
  idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelname NOT LIKE 'pg_%'
ORDER BY pg_relation_size(indexrelid) DESC;

-- 중복 인덱스 찾기
SELECT
  pg_size_pretty(SUM(pg_relation_size(idx))::bigint) AS total_size,
  (array_agg(idx))[1] AS idx1,
  (array_agg(idx))[2] AS idx2,
  (array_agg(indkey))[1] AS columns1,
  (array_agg(indkey))[2] AS columns2
FROM (
  SELECT indexrelid::regclass AS idx,
         indrelid, indkey,
         COALESCE(indexprs::text, '') AS exprs,
         COALESCE(indpred::text, '') AS preds
  FROM pg_index
) sub
GROUP BY indrelid, exprs, preds
HAVING COUNT(*) > 1;

잠금(Lock) 모니터링

-- 현재 잠금 대기 상황 확인
SELECT
  blocked_locks.pid AS blocked_pid,
  blocked_activity.usename AS blocked_user,
  blocking_locks.pid AS blocking_pid,
  blocking_activity.usename AS blocking_user,
  blocked_activity.query AS blocked_query,
  blocking_activity.query AS blocking_query,
  blocked_activity.wait_event_type
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity
  ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
  ON blocking_locks.locktype = blocked_locks.locktype
  AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
  AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
  AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
  AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
  AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
  AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity
  ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

shared_buffers 및 work_mem 튜닝

# postgresql.conf - 메모리 관련 핵심 파라미터

# shared_buffers: 전체 RAM의 25% 권장 (최대 40%)
# 64GB RAM 서버 기준
shared_buffers = 16GB

# work_mem: 정렬, 해시 조인 등에 사용하는 세션당 메모리
# 주의: 쿼리당 여러 노드에서 각각 할당되므로 너무 크게 잡지 않는다
# (동시 접속 수 * 노드 수)를 고려하여 설정
work_mem = 64MB

# maintenance_work_mem: VACUUM, CREATE INDEX 등에 사용
maintenance_work_mem = 2GB

# effective_cache_size: OS 캐시를 포함한 총 캐시 크기 (플래너 힌트용)
# 전체 RAM의 50-75%
effective_cache_size = 48GB

# WAL 관련 설정
wal_buffers = 64MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
min_wal_size = 1GB

실패 사례와 복구 절차

사례 1: 인덱스 없는 JOIN으로 인한 서비스 장애

상황: 개발자가 새로운 리포트 쿼리를 프로덕션에 배포했는데, 1000만 건 테이블 두 개를 인덱스 없이 JOIN하여 Nested Loop으로 실행되었다. CPU 사용률 100%, 커넥션 풀 고갈, 전체 서비스 다운.

즉시 대응:

-- 1. 문제 쿼리를 실행 중인 세션 확인
SELECT pid, now() - pg_stat_activity.query_start AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle'
  AND query NOT LIKE '%pg_stat_activity%'
ORDER BY duration DESC;

-- 2. 문제 쿼리 강제 종료 (graceful)
SELECT pg_cancel_backend(12345);

-- 3. 종료되지 않으면 강제 킬
SELECT pg_terminate_backend(12345);

근본 해결: 해당 쿼리에 필요한 인덱스를 CONCURRENTLY 옵션으로 추가한다.

-- CONCURRENTLY: 테이블 잠금 없이 인덱스 생성 (프로덕션 필수)
CREATE INDEX CONCURRENTLY idx_reports_user_date
  ON reports (user_id, report_date);

-- 인덱스 생성이 실패하면 INVALID 상태로 남는다. 반드시 확인!
SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE schemaname = 'public' AND relname = 'reports';

사례 2: VACUUM FULL 실행 중 서비스 장애

상황: DBA가 테이블 Bloat 해소를 위해 프로덕션 업무 시간에 VACUUM FULL을 실행했다. ACCESS EXCLUSIVE 잠금으로 해당 테이블에 대한 모든 읽기/쓰기가 차단되어 서비스 장애 발생.

교훈: VACUUM FULL은 절대 프로덕션 업무 시간에 실행하지 않는다. 대신 pg_repack을 사용한다.

# pg_repack: 무중단으로 테이블 재구성 (짧은 잠금만 필요)
pg_repack -d myapp_production -t orders --no-superuser-check

# 특정 인덱스만 재구성
pg_repack -d myapp_production --index idx_orders_user_created

사례 3: XID Wraparound 임박 경고

상황: autovacuum이 장시간 실행 중인 트랜잭션에 의해 차단되어 XID가 소진 임박. PostgreSQL이 WARNING 로그를 출력하기 시작했다.

-- XID 소진 상태 확인
SELECT
  datname,
  age(datfrozenxid) AS xid_age,
  ROUND(100 * age(datfrozenxid)::numeric / 2147483647, 2) AS pct_toward_wraparound
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

-- 장시간 실행 중인 트랜잭션 확인 (autovacuum 차단 원인)
SELECT
  pid,
  now() - xact_start AS xact_duration,
  now() - query_start AS query_duration,
  state,
  query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND state != 'idle'
ORDER BY xact_start ASC;

-- 문제 트랜잭션 종료 후 수동 VACUUM FREEZE 실행
VACUUM FREEZE orders;

사례 4: 커넥션 풀 고갈

상황: 애플리케이션에서 트랜잭션을 열고 외부 API 호출을 한 후 닫는 패턴으로 인해, 외부 API 응답 지연 시 커넥션을 장시간 점유하여 풀이 고갈되었다.

근본 해결: 트랜잭션 범위를 최소화하고, 외부 API 호출은 트랜잭션 밖에서 수행한다. PgBouncer의 query_wait_timeoutserver_idle_timeout을 적절히 설정하여 유휴 연결을 회수한다.

;; pgbouncer.ini - 커넥션 고갈 방지
query_wait_timeout = 30        ;; 서버 연결 대기 최대 30초
server_idle_timeout = 60       ;; 60초 유휴 시 서버 연결 반납
server_lifetime = 3600         ;; 최대 1시간 후 서버 연결 재생성
client_idle_timeout = 300      ;; 5분 유휴 클라이언트 연결 해제

마치며

PostgreSQL 성능 최적화는 단일 기법이 아닌, 여러 계층의 최적화를 체계적으로 적용해야 달성할 수 있다.

  1. 쿼리 레벨: EXPLAIN ANALYZE로 실행 계획을 분석하고, 쿼리를 리라이팅한다
  2. 인덱스 레벨: 데이터 특성에 맞는 인덱스 타입을 선택하고, 복합 인덱스의 컬럼 순서를 최적화한다
  3. 테이블 레벨: 파티셔닝으로 대용량 테이블을 분할하고, VACUUM 설정을 테이블별로 튜닝한다
  4. 서버 레벨: shared_buffers, work_mem 등 메모리 파라미터를 워크로드에 맞게 조정한다
  5. 인프라 레벨: PgBouncer로 커넥션을 관리하고, 모니터링 체계를 구축한다

성능 최적화에서 가장 중요한 것은 측정이다. pg_stat_statements, EXPLAIN ANALYZE, pg_stat_user_tables 같은 도구로 항상 현재 상태를 파악하고, 변경 전후를 비교해야 한다. 감으로 하는 최적화는 오히려 상황을 악화시킬 수 있다.

프로덕션 환경에서는 항상 다음을 기억하자.

  • 인덱스 생성은 CONCURRENTLY 옵션을 사용한다
  • VACUUM FULL 대신 pg_repack을 사용한다
  • Autovacuum을 절대 끄지 않는다
  • 모니터링 없는 최적화는 의미가 없다

참고자료