데이터베이스 인덱스 전략: 느린 쿼리를 10배 빠르게 만드는 실전 가이드
EXPLAIN ANALYZE 읽는 법부터 복합 인덱스 설계, 부분 인덱스, 함수 인덱스까지. 실제 프로덕션 쿼리를 8초에서 0.8초로 줄인 인덱스 최적화 실전 사례.

요약
EXPLAIN ANALYZE 읽는 법부터 복합 인덱스 설계, 부분 인덱스, 함수 인덱스까지. 실제 프로덕션 쿼리를 8초에서 0.8초로 줄인 인덱스 최적화 실전 사례.
데이터베이스 인덱스 전략: 느린 쿼리를 10배 빠르게 만드는 실전 가이드
Executive Summary - Topic: PostgreSQL 인덱스 설계와 쿼리 최적화 실전 패턴 - Target: 백엔드 개발자, DBA, 성능 최적화 담당자 - TL;DR 1: 잘못된 인덱스는 없는 것보다 나쁠 수 있다 — 쓰기 성능과 용량을 희생한다 - TL;DR 2: EXPLAIN ANALYZE의 Seq Scan vs Index Scan, cost, rows 3가지만 읽어도 문제의 80%를 파악한다 - TL;DR 3: 복합 인덱스 컬럼 순서는 선택도(cardinality) 높은 것부터, WHERE 절 패턴에 맞게
작년 커머스 클라이언트에서 긴급 호출을 받았습니다. 주문 조회 API가 평균 8초 응답 시간을 기록하며 사용자 이탈이 급증한다는 것이었습니다. EXPLAIN ANALYZE 한 번으로 문제를 찾는 데 5분, 해결하는 데 30분이 걸렸습니다.
그 경험을 바탕으로 인덱스 최적화의 핵심을 정리합니다.
기초: EXPLAIN ANALYZE 읽는 법
모든 최적화는 여기서 시작합니다.
1EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)2SELECT o.id, o.status, u.name, u.email3FROM orders o4JOIN users u ON o.user_id = u.id5WHERE o.status = 'pending'6 AND o.created_at > NOW() - INTERVAL '30 days'7ORDER BY o.created_at DESC8LIMIT 50;출력 읽는 법:
1Limit (cost=0.00..45823.12 rows=50 width=89) (actual time=8234.123..8234.567 rows=50 loops=1)2 -> Sort (cost=0.00..45823.12 rows=1234567 width=89) (actual time=8234.120..8234.123 rows=50 loops=1)3 Sort Key: o.created_at DESC4 Sort Method: top-N heapsort Memory: 32kB5 -> Hash Join (cost=...) (actual time=... rows=... loops=1)6 -> Seq Scan on orders (cost=0.00..43212.34 rows=1234567 width=...) ← 문제!7 Filter: ((status = 'pending') AND (created_at > ...))8 Rows Removed by Filter: 987654핵심 지표:
- Seq Scan → 인덱스 미사용 (테이블 전체 스캔)
- cost=0.00..43212.34 → 오른쪽 숫자가 실제 비용 (높을수록 느림)
- Rows Removed by Filter: 987654 → 인덱스가 있으면 버려지지 않을 행 수
- actual time=8234 → 밀리초 단위 실제 실행 시간
실전 케이스 1: 복합 인덱스 순서 실수
문제 쿼리:
1SELECT * FROM orders2WHERE user_id = 123453 AND status = 'completed'4 AND created_at > '2026-01-01';잘못된 인덱스:
1-- status의 선택도가 낮아서 (pending/completed/cancelled 3개)2-- 첫 번째 컬럼으로 두면 효과가 거의 없음3CREATE INDEX idx_wrong ON orders (status, user_id, created_at);올바른 인덱스:
1-- user_id: 선택도 높음 (수백만 명 중 한 명)2-- status: 선택도 낮음 (3개 값)3-- created_at: 범위 조회이므로 마지막4CREATE INDEX idx_orders_user_status_created5 ON orders (user_id, status, created_at DESC);복합 인덱스 컬럼 순서 원칙:
- = 조건 컬럼 먼저 (등치 비교)
- 선택도 높은 것 먼저 (user_id가 status보다 선택도 높음)
- 범위 조건 (>, <, BETWEEN) 마지막
- ORDER BY 컬럼을 인덱스에 포함하면 Sort 제거 가능
실전 케이스 2: 부분 인덱스로 90% 절약
문제:orders 테이블에 1억 건이 있는데, 실제 조회는 status = 'pending' (전체의 3%)에 집중됩니다.
일반 인덱스 (비효율):
1-- 1억 건 전체에 인덱스 생성 → 큰 용량, 쓰기 오버헤드2CREATE INDEX idx_orders_status ON orders (status);부분 인덱스 (효율적):
1-- 300만 건만 인덱싱 → 작은 용량, 빠른 조회2CREATE INDEX idx_orders_pending3 ON orders (created_at DESC)4 WHERE status = 'pending';5 6-- 실제 크기 비교7-- 일반 인덱스: 2.1GB8-- 부분 인덱스: 180MB (91% 절감)부분 인덱스가 사용되려면 쿼리의 WHERE 절이 인덱스 조건을 포함해야 합니다:
1-- 이 쿼리에서 idx_orders_pending 사용됨2SELECT * FROM orders3WHERE status = 'pending'4 AND created_at > NOW() - INTERVAL '7 days';5 6-- 이 쿼리에서는 사용 안 됨 (status 조건 없음)7SELECT * FROM orders8WHERE created_at > NOW() - INTERVAL '7 days';실전 케이스 3: 함수 인덱스
문제 쿼리:
1-- 이메일 대소문자 구분 없이 검색2SELECT * FROM users3WHERE LOWER(email) = LOWER('[email protected]');일반 인덱스로는 해결 안 됨:
1CREATE INDEX idx_users_email ON users (email);2-- LOWER()가 적용되면 인덱스 사용 안 됨!함수 인덱스로 해결:
1CREATE INDEX idx_users_email_lower2 ON users (LOWER(email));3 4-- 또는 PostgreSQL의 citext 타입 사용5CREATE EXTENSION IF NOT EXISTS citext;6ALTER TABLE users ALTER COLUMN email TYPE citext;7CREATE INDEX idx_users_email ON users (email);자주 쓰는 함수 인덱스 패턴:
1-- JSON 필드 인덱스2CREATE INDEX idx_orders_metadata_type3 ON orders ((metadata->>'type'));4 5-- 날짜 추출 인덱스6CREATE INDEX idx_orders_year_month7 ON orders (EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at));8 9-- 배열 포함 인덱스 (GIN)10CREATE INDEX idx_posts_tags ON posts USING GIN (tags);실전 케이스 4: 커버링 인덱스
문제: 인덱스를 탔는데도 느린 경우 — "Heap Fetch" 때문입니다.
1Index Scan on idx_orders_user (cost=...) (actual time=1234.567)2 Index Cond: (user_id = 12345)3 → Heap Fetches: 45678 ← 이게 문제!인덱스에서 행 위치를 찾은 후, 실제 테이블(Heap)로 가서 나머지 컬럼을 가져오는 작업입니다.
커버링 인덱스로 Heap Fetch 제거:
1-- SELECT에서 필요한 컬럼을 인덱스에 모두 포함2CREATE INDEX idx_orders_covering3 ON orders (user_id, status)4 INCLUDE (id, total_amount, created_at); -- INCLUDE 절5 6-- 이제 이 쿼리는 Heap을 전혀 안 건드림 (Index Only Scan)7SELECT id, status, total_amount, created_at8FROM orders9WHERE user_id = 12345 AND status = 'completed';실전 케이스 5: 8초 → 0.8초 실제 사례
문제 쿼리 (8.2초):
1SELECT2 o.id,3 o.order_number,4 o.total_amount,5 o.status,6 o.created_at,7 u.name AS customer_name,8 u.email AS customer_email,9 COUNT(oi.id) AS item_count10FROM orders o11JOIN users u ON o.user_id = u.id12JOIN order_items oi ON oi.order_id = o.id13WHERE o.status IN ('pending', 'processing')14 AND o.created_at BETWEEN '2026-01-01' AND '2026-03-01'15 AND u.country = 'KR'16GROUP BY o.id, o.order_number, o.total_amount, o.status, o.created_at,17 u.name, u.email18ORDER BY o.created_at DESC19LIMIT 20;EXPLAIN 분석 결과:
- orders: Seq Scan (전체 230만 건 스캔)
- users: Seq Scan (전체 150만 건 스캔)
- order_items: Seq Scan (전체 890만 건 스캔)
- Sort: 메모리 초과로 디스크 정렬
적용한 인덱스:
1-- orders: status + created_at 범위 조회 최적화2CREATE INDEX idx_orders_status_created3 ON orders (status, created_at DESC)4 WHERE status IN ('pending', 'processing');5 6-- users: country 필터 + join 조건7CREATE INDEX idx_users_country_id8 ON users (country, id)9 WHERE country = 'KR';10 11-- order_items: join 조건 커버링12CREATE INDEX idx_order_items_order_id13 ON order_items (order_id)14 INCLUDE (id);결과 (0.82초, 90% 개선):
1이전: 8.234s (Seq Scan × 3, Disk Sort)2이후: 0.823s (Index Scan × 3, Memory Sort)인덱스 유지 관리
사용 안 되는 인덱스 찾기:
1SELECT2 schemaname,3 tablename,4 indexname,5 idx_scan,6 pg_size_pretty(pg_relation_size(indexrelid)) AS index_size7FROM pg_stat_user_indexes8WHERE idx_scan = 09 AND schemaname = 'public'10ORDER BY pg_relation_size(indexrelid) DESC;중복 인덱스 찾기:
1SELECT2 indrelid::regclass AS table_name,3 array_agg(indexrelid::regclass) AS duplicate_indexes4FROM pg_index5GROUP BY indrelid, indkey6HAVING COUNT(*) > 1;인덱스 bloat 확인 (VACUUM 필요성):
1SELECT2 indexrelid::regclass AS index_name,3 pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,4 round(100 * pg_stat_get_dead_tuples(indexrelid) /5 NULLIF(pg_stat_get_live_tuples(indexrelid), 0), 1) AS bloat_ratio6FROM pg_stat_user_indexes7WHERE schemaname = 'public'8ORDER BY pg_relation_size(indexrelid) DESC9LIMIT 20;인덱스 체크리스트
| 상황 | 권장 인덱스 유형 |
|---|---|
| 등치 + 범위 조건 | 복합 인덱스 (등치 먼저) |
| 일부 행만 조회 (e.g., status='active') | 부분 인덱스 |
| LOWER(), EXTRACT() 등 함수 사용 | 함수 인덱스 |
| SELECT 컬럼이 적고 고정적 | 커버링 인덱스 (INCLUDE) |
| 배열, JSONB, 전문 검색 | GIN 인덱스 |
| 지리 데이터 | GiST 인덱스 |
| 동시 쓰기 중 인덱스 생성 필요 | CREATE INDEX CONCURRENTLY |
FAQ
Q. 인덱스를 많이 만들면 읽기 성능이 무조건 좋아지나요? A. 아닙니다. 인덱스는 INSERT, UPDATE, DELETE 성능을 저하시킵니다. 또한 쿼리 플래너가 잘못된 인덱스를 선택할 수도 있습니다. 실제로 사용되는 쿼리 패턴에 맞게 설계하고, 정기적으로 pg_stat_user_indexes로 사용률을 모니터링하세요.
Q. EXPLAIN과 EXPLAIN ANALYZE의 차이는 무엇인가요? A. EXPLAIN은 실행 계획만 보여줍니다 (추정치). EXPLAIN ANALYZE는 실제로 쿼리를 실행하고 실제 통계를 보여줍니다 (정확하지만 실제 실행됨). 프로덕션에서 분석할 때는 BEGIN/ROLLBACK으로 감싸거나, 읽기 전용 쿼리에만 사용하세요.
Q. 인덱스를 추가했는데 쿼리 플래너가 사용하지 않아요. A. 통계가 최신이 아닐 수 있습니다. ANALYZE table_name을 실행해 통계를 업데이트하세요. 또는 인덱스 선택도가 낮아 플래너가 Seq Scan을 더 효율적으로 판단할 수 있습니다. enable_seqscan = off로 임시 강제해 인덱스 성능을 비교해보세요.