- Published on
PostgreSQL 성능 튜닝 실전 가이드 — 쿼리 최적화, 인덱스 전략, EXPLAIN 분석
- Authors
- Name
- 들어가며
- 1. EXPLAIN — 쿼리 실행 계획 읽기
- 2. 인덱스 전략 — 올바른 인덱스 설계
- 3. 쿼리 최적화 패턴
- 4. 설정 튜닝 — postgresql.conf
- 5. VACUUM과 통계 관리
- 6. 실전 트러블슈팅 — 느린 쿼리 찾기
- 7. 성능 체크리스트
- 퀴즈

들어가며
"쿼리가 느려요" — 백엔드 개발자라면 한 번쯤 듣게 되는 말입니다. PostgreSQL은 강력한 RDBMS이지만, 제대로 튜닝하지 않으면 데이터가 늘어날수록 성능이 급격히 떨어집니다. 이 글에서는 실무에서 바로 적용할 수 있는 PostgreSQL 성능 튜닝 기법을 EXPLAIN 분석부터 인덱스 전략, 쿼리 리팩토링까지 단계별로 다룹니다.
1. EXPLAIN — 쿼리 실행 계획 읽기
EXPLAIN vs EXPLAIN ANALYZE
-- 예상 실행 계획 (실제 실행 X)
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- 실제 실행 + 실측 시간 포함 (⚠️ 실제로 쿼리가 실행됨)
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- 가장 상세한 분석 (버퍼 사용량 포함)
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM users WHERE email = 'test@example.com';
EXPLAIN 출력 읽는 법
EXPLAIN ANALYZE
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at > '2026-01-01';
Hash Join (cost=1250.00..5680.50 rows=15000 width=48)
(actual time=12.5..89.3 rows=14800 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..3500.00 rows=15000 width=16)
(actual time=0.02..45.1 rows=14800 loops=1)
Filter: (created_at > '2026-01-01')
Rows Removed by Filter: 85200
-> Hash (cost=1000.00..1000.00 rows=50000 width=36)
(actual time=12.3..12.3 rows=50000 loops=1)
-> Seq Scan on users u (cost=0.00..1000.00 rows=50000 width=36)
(actual time=0.01..6.8 rows=50000 loops=1)
Planning Time: 0.25 ms
Execution Time: 95.8 ms
핵심 지표 해석
| 지표 | 의미 | 주의 |
|---|---|---|
| cost | 예상 비용 (startup..total) | 상대적 단위, 절대값 아님 |
| rows | 예상 행 수 | actual과 크게 다르면 통계 갱신 필요 |
| actual time | 실제 소요 시간 (ms) | startup..total |
| loops | 반복 횟수 | actual time × loops = 실제 총 시간 |
| Rows Removed by Filter | 필터로 제거된 행 수 | 이 숫자가 크면 인덱스 필요 |
| Buffers: shared hit/read | 캐시 히트/디스크 읽기 | read가 크면 메모리 부족 |
위험 신호 🚨
# Seq Scan on 대용량 테이블 → 인덱스 필요
Seq Scan on orders (rows=1000000)
# Nested Loop with 대량 rows → Join 방식 변경 필요
Nested Loop (actual loops=50000)
# Sort with 외부 디스크 사용 → work_mem 부족
Sort Method: external merge Disk: 128000kB
# 예상 rows와 실제 rows 차이 → ANALYZE 필요
(rows=100) ... (actual rows=50000)
2. 인덱스 전략 — 올바른 인덱스 설계
B-Tree 인덱스 (기본)
-- 단일 컬럼 인덱스
CREATE INDEX idx_users_email ON users(email);
-- 복합 인덱스 (순서가 중요!)
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
-- 부분 인덱스 (조건부 인덱스)
CREATE INDEX idx_orders_active
ON orders(user_id)
WHERE status = 'active';
-- 유니크 인덱스
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
복합 인덱스의 컬럼 순서
복합 인덱스에서 컬럼 순서는 성능에 결정적 영향을 미칩니다:
-- 인덱스: (user_id, created_at, status)
-- ✅ 인덱스 활용 가능 (왼쪽부터 매칭)
WHERE user_id = 1
WHERE user_id = 1 AND created_at > '2026-01-01'
WHERE user_id = 1 AND created_at > '2026-01-01' AND status = 'active'
-- ❌ 인덱스 활용 불가 (중간 컬럼 건너뜀)
WHERE user_id = 1 AND status = 'active' -- created_at 건너뜀
WHERE created_at > '2026-01-01' -- user_id 없음
WHERE status = 'active' -- 첫 컬럼 없음
💡 원칙: 등호(=) 조건 컬럼을 앞에, 범위(>, <, BETWEEN) 조건을 뒤에 배치합니다.
GIN 인덱스 (전문 검색, JSONB, 배열)
-- JSONB 필드 인덱스
CREATE INDEX idx_products_metadata
ON products USING GIN(metadata);
-- 이제 JSONB 검색이 빨라짐
SELECT * FROM products
WHERE metadata @> '{"category": "electronics"}';
-- 배열 인덱스
CREATE INDEX idx_posts_tags
ON posts USING GIN(tags);
SELECT * FROM posts WHERE tags @> ARRAY['postgresql'];
인덱스가 사용되지 않는 경우
-- ❌ 함수 적용 시 인덱스 무시
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
-- ✅ 함수형 인덱스 생성
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- ❌ 타입 불일치
SELECT * FROM users WHERE id = '123'; -- id가 integer인데 문자열 비교
-- ✅ 올바른 타입
SELECT * FROM users WHERE id = 123;
-- ❌ LIKE의 앞부분 와일드카드
SELECT * FROM users WHERE name LIKE '%kim%'; -- Full Scan
-- ✅ 앞부분 고정
SELECT * FROM users WHERE name LIKE 'kim%'; -- Index 사용 가능
-- ❌ OR 조건
SELECT * FROM users WHERE email = 'a@b.com' OR name = 'Kim';
-- ✅ UNION으로 분리
SELECT * FROM users WHERE email = 'a@b.com'
UNION ALL
SELECT * FROM users WHERE name = 'Kim';
인덱스 사용 현황 모니터링
-- 사용되지 않는 인덱스 찾기
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname NOT LIKE '%pkey%'
ORDER BY pg_relation_size(indexrelid) DESC;
-- 테이블별 인덱스 크기
SELECT tablename,
pg_size_pretty(pg_total_relation_size(tablename::regclass)) as total_size,
pg_size_pretty(pg_indexes_size(tablename::regclass)) as index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(tablename::regclass) DESC;
3. 쿼리 최적화 패턴
N+1 쿼리 제거
-- ❌ N+1 패턴 (ORM에서 자주 발생)
-- 1번: SELECT * FROM users LIMIT 100;
-- 100번: SELECT * FROM orders WHERE user_id = ?;
-- ✅ JOIN으로 한 번에
SELECT u.name, o.total, o.created_at
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2026-01-01'
LIMIT 100;
-- ✅ 또는 서브쿼리 + IN
SELECT * FROM orders
WHERE user_id IN (
SELECT id FROM users WHERE created_at > '2026-01-01'
);
페이지네이션 최적화
-- ❌ OFFSET 방식 (페이지가 깊어질수록 느림)
SELECT * FROM orders ORDER BY id DESC OFFSET 100000 LIMIT 20;
-- ✅ Keyset Pagination (커서 기반)
SELECT * FROM orders
WHERE id < 900000 -- 이전 페이지 마지막 id
ORDER BY id DESC
LIMIT 20;
-- ✅ 커버링 인덱스 + 서브쿼리
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders ORDER BY id DESC OFFSET 100000 LIMIT 20
) sub ON o.id = sub.id;
EXISTS vs IN
-- 대용량에서 EXISTS가 더 효율적 (상관 서브쿼리)
-- ✅ EXISTS
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.total > 10000
);
-- ❌ IN (서브쿼리 결과가 클 때)
SELECT * FROM users
WHERE id IN (
SELECT user_id FROM orders WHERE total > 10000
);
COUNT 최적화
-- ❌ 정확한 COUNT (Full Scan 발생)
SELECT COUNT(*) FROM orders;
-- ✅ 근사값으로 충분한 경우
SELECT reltuples::bigint AS estimate
FROM pg_class WHERE relname = 'orders';
-- ✅ 조건부 COUNT 최적화
-- 인덱스가 있다면 Index Only Scan 가능
SELECT COUNT(*) FROM orders WHERE status = 'completed';
-- 필요: CREATE INDEX idx_orders_status ON orders(status);
CTE vs 서브쿼리
-- PostgreSQL 12+ 에서 CTE는 기본적으로 인라인됨
-- MATERIALIZED 힌트로 강제 물리화 가능
-- 자동 인라인 (옵티마이저가 최적화)
WITH active_users AS (
SELECT id, name FROM users WHERE status = 'active'
)
SELECT * FROM active_users WHERE name LIKE 'K%';
-- 강제 물리화 (중복 실행 방지)
WITH active_users AS MATERIALIZED (
SELECT id, name FROM users WHERE status = 'active'
)
SELECT * FROM active_users au
JOIN orders o ON au.id = o.user_id;
4. 설정 튜닝 — postgresql.conf
메모리 관련
# shared_buffers: 전체 RAM의 25% (가장 중요!)
# 16GB RAM 기준
shared_buffers = 4GB
# effective_cache_size: OS 캐시 포함 예상 메모리 (RAM의 75%)
effective_cache_size = 12GB
# work_mem: 정렬/해시 작업 메모리 (세션 × 쿼리당)
# 주의: 커넥션 수 × work_mem 만큼 메모리 사용 가능
work_mem = 256MB
# maintenance_work_mem: VACUUM, CREATE INDEX 시 사용
maintenance_work_mem = 1GB
WAL 및 체크포인트
# WAL 크기 (쓰기 많은 워크로드)
wal_buffers = 64MB
max_wal_size = 4GB
min_wal_size = 1GB
# 체크포인트 간격
checkpoint_completion_target = 0.9
쿼리 플래너
# 병렬 쿼리 활성화
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
# 랜덤 I/O 비용 (SSD는 낮게)
random_page_cost = 1.1 # HDD: 4.0, SSD: 1.1
# 통계 수집 정밀도
default_statistics_target = 200 # 기본 100
5. VACUUM과 통계 관리
VACUUM이 필요한 이유
PostgreSQL은 MVCC(Multi-Version Concurrency Control)를 사용합니다. UPDATE/DELETE 시 기존 행을 즉시 삭제하지 않고 "dead tuple"로 남겨둡니다. VACUUM이 이를 정리합니다.
-- 테이블별 dead tuple 확인
SELECT schemaname, relname,
n_live_tup, n_dead_tup,
ROUND(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) as dead_ratio
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
-- 수동 VACUUM (일반적으로 autovacuum이 처리)
VACUUM ANALYZE orders;
-- VACUUM FULL (테이블 잠금 주의! — 디스크 공간 회수)
VACUUM FULL orders; -- ⚠️ 운영 중 사용 금지
Autovacuum 튜닝
# autovacuum 기본 설정
autovacuum = on
autovacuum_max_workers = 5
# 대용량 테이블용 테이블별 설정
ALTER TABLE orders SET (
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_scale_factor = 0.01, -- 1%가 dead tuple이면 실행
autovacuum_analyze_threshold = 500,
autovacuum_analyze_scale_factor = 0.005
);
통계 갱신
-- EXPLAIN의 예상 rows가 실제와 크게 다를 때
ANALYZE orders;
-- 전체 DB 통계 갱신
ANALYZE;
-- 특정 컬럼의 통계 정밀도 높이기
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;
6. 실전 트러블슈팅 — 느린 쿼리 찾기
pg_stat_statements로 슬로우 쿼리 발견
-- 확장 활성화
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 가장 느린 쿼리 TOP 10
SELECT query,
calls,
ROUND(total_exec_time::numeric, 2) as total_time_ms,
ROUND(mean_exec_time::numeric, 2) as avg_time_ms,
rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- 가장 자주 호출되는 쿼리 (총 시간 기준)
SELECT query, calls,
ROUND(total_exec_time::numeric, 2) as total_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
현재 실행 중인 쿼리 확인
-- 오래 실행 중인 쿼리
SELECT pid, now() - pg_stat_activity.query_start AS duration,
query, state
FROM pg_stat_activity
WHERE state != 'idle'
AND query NOT ILIKE '%pg_stat_activity%'
ORDER BY duration DESC;
-- 잠금 대기 중인 쿼리
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype
AND kl.relation = bl.relation
AND kl.pid != bl.pid
JOIN pg_stat_activity blocking ON blocking.pid = kl.pid
WHERE NOT bl.granted;
7. 성능 체크리스트
운영 전 반드시 확인할 항목들:
- EXPLAIN ANALYZE로 주요 쿼리의 실행 계획 확인
- Seq Scan이 대용량 테이블에 발생하지 않는지 확인
- 복합 인덱스 컬럼 순서가 쿼리 패턴에 맞는지 확인
- 사용되지 않는 인덱스 정리 (쓰기 성능 저하 방지)
-
pg_stat_statements로 슬로우 쿼리 모니터링 설정 - Autovacuum이 정상 작동하는지 확인
-
shared_buffers,work_mem등 메모리 설정 검토 - 커넥션 풀링 (PgBouncer 등) 적용 여부 확인
- 정기적인
ANALYZE실행으로 통계 최신화
퀴즈
Q1: EXPLAIN과 EXPLAIN ANALYZE의 차이는?
EXPLAIN은 예상 실행 계획만 보여주고, EXPLAIN ANALYZE는 실제로 쿼리를 실행하여 실측 시간과 행 수를 함께 보여줍니다. ANALYZE는 실제 실행되므로 INSERT/UPDATE/DELETE에 주의가 필요합니다.
Q2: 복합 인덱스 (a, b, c)에서 WHERE a = 1 AND c = 3 쿼리가 인덱스를 완전히 활용하지 못하는 이유는?
B-Tree 복합 인덱스는 왼쪽부터 순서대로 매칭합니다. 중간 컬럼(b)을 건너뛰면 c 컬럼의 인덱스를 활용할 수 없습니다. a 컬럼만 인덱스를 사용합니다.
Q3: OFFSET 기반 페이지네이션이 깊은 페이지에서 느린 이유는?
OFFSET N은 N개의 행을 읽고 버리는 방식입니다. OFFSET 100000이면 100,000행을 읽은 후 버리고 그 다음 행부터 반환하므로, 페이지가 깊어질수록 읽어야 할 행이 늘어납니다.
Q4: random_page_cost를 SSD에서 1.1로 낮추는 이유는?
SSD는 랜덤 I/O와 순차 I/O의 성능 차이가 거의 없습니다. 기본값 4.0은 HDD 기준이므로, SSD에서는 1.1로 낮춰서 플래너가 인덱스 스캔을 더 적극적으로 선택하도록 유도합니다.
Q5: PostgreSQL에서 dead tuple이 생기는 이유는?
MVCC 구조 때문입니다. UPDATE/DELETE 시 기존 행을 즉시 삭제하지 않고, 다른 트랜잭션이 참조할 수 있도록 "dead tuple"로 남겨둡니다. VACUUM이 이를 정리합니다.
Q6: WHERE LOWER(email) = 'test@example.com'이 인덱스를 사용하지 못할 때 해결법은?
함수형 인덱스를 생성합니다: CREATE INDEX idx_users_email_lower ON users(LOWER(email));
Q7: shared_buffers의 권장 설정값은?
전체 시스템 RAM의 약 25%입니다. 예: 16GB RAM → shared_buffers = 4GB.
Q8: EXPLAIN 출력에서 "Rows Removed by Filter" 수치가 매우 클 때 의미하는 것은?
해당 테이블에서 많은 행을 읽은 후 필터로 대부분 버리고 있다는 뜻입니다. 적절한 인덱스를 추가하면 불필요한 행 읽기를 줄일 수 있습니다.
Q9: VACUUM FULL을 운영 중에 사용하면 안 되는 이유는?
VACUUM FULL은 테이블에 대해 ACCESS EXCLUSIVE 잠금을 걸어, 작업이 완료될 때까지 해당 테이블의 모든 읽기/쓰기가 차단됩니다.
Q10: EXISTS와 IN 중 대용량 서브쿼리에서 더 효율적인 것은?
일반적으로 EXISTS가 더 효율적입니다. EXISTS는 첫 번째 매칭 행을 찾으면 즉시 중단하지만, IN은 서브쿼리의 전체 결과를 먼저 생성해야 할 수 있습니다.