TL;DR
- B-Tree가 90%: 대부분의 쿼리에 적합. 정렬, 범위, 정확 일치 모두 지원
- GIN은 검색에 최강: 배열, JSON, 전문 검색에 사용. PostgreSQL의 비밀 무기
- 복합 인덱스 순서가 결정적:
WHERE a=? AND b=?≠WHERE b=? AND a=?로 사용 가능 여부 - 커버링 인덱스로 테이블 접근 회피:
INCLUDE절 활용 - 인덱스가 안 쓰이는 10가지 이유 모두 알아야 함 — 통계, 함수, 캐스팅, 패턴 등
1. 인덱스가 왜 빠른가?
1.1 인덱스 없이는?
SELECT * FROM users WHERE email = 'alice@example.com';
인덱스 없으면 Sequential Scan — 모든 행을 읽고 비교. 1억 행이면 1억 번 비교.
1억 행 × 100 nanosecond = 10초
1.2 B-Tree로
1억 행 → log₂(10⁸) ≈ 27회 비교
27회 × 100 ns = 2.7 microsecond
약 4백만 배 빠름! 이게 인덱스의 마법입니다.
1.3 인덱스의 비용
공짜는 아닙니다:
- 저장공간: 인덱스도 디스크 공간 차지
- 쓰기 느림: INSERT/UPDATE/DELETE 시 인덱스도 업데이트
- 유지보수: VACUUM, REINDEX
규칙: 읽기 vs 쓰기 비율을 고려하세요. 100:1이면 인덱스 추가, 1:100이면 신중히.
2. B-Tree 내부 구조
2.1 균형 트리
[50]
/ \
[25] [75]
/ \ / \
[10] [40] [60] [90]
/\ /\ /\ /\
... ... ... ...
특성:
- 모든 리프가 같은 깊이 (균형)
- 각 노드는 여러 키 저장 (수백 개)
- 정렬된 순서 유지
2.2 페이지 단위 저장
PostgreSQL B-Tree 페이지 (8KB)
┌──────────────────────────────────┐
│ Header │
├──────────────────────────────────┤
│ Item Pointer 1 → key1 + ctid │
│ Item Pointer 2 → key2 + ctid │
│ ... │
├──────────────────────────────────┤
│ Free Space │
├──────────────────────────────────┤
│ Items (low to high) │
└──────────────────────────────────┘
ctid: PostgreSQL의 행 위치 (페이지 번호, 오프셋). 인덱스가 가리키는 실제 데이터.
2.3 트리 깊이 계산
8KB 페이지에 200 키 가정:
- Depth 1: 200 키
- Depth 2: 200 × 200 = 40,000
- Depth 3: 200 × 200 × 200 = 8,000,000
- Depth 4: 1,600,000,000
1억 행 = 깊이 4 → 디스크 4번 읽기로 검색 완료.
3. 인덱스 종류 (PostgreSQL 기준)
3.1 B-Tree (기본)
CREATE INDEX idx_users_email ON users(email);
적합:
- 정확 일치 (
=) - 범위 (
<,>,BETWEEN) - 정렬 (
ORDER BY) - 패턴 (앞에
%없을 때):LIKE 'abc%' IS NULL
부적합:
LIKE '%abc'(앞 와일드카드)LIKE '%abc%'- 함수 적용:
LOWER(email)(표현식 인덱스 필요)
3.2 Hash
CREATE INDEX idx_users_email_hash ON users USING hash(email);
적합: 정확 일치만 (=)
부적합: 범위, 정렬, 패턴
언제 사용?: 대부분의 경우 B-Tree가 더 낫습니다. PostgreSQL 10+에서 hash가 WAL 지원 후 가끔 유용 (B-Tree보다 약간 작음).
3.3 GIN (Generalized Inverted Index)
-- 배열
CREATE INDEX idx_tags ON posts USING gin(tags);
-- JSONB
CREATE INDEX idx_metadata ON products USING gin(metadata);
-- 전문 검색
CREATE INDEX idx_content_fts ON articles USING gin(to_tsvector('english', content));
원리: "역색인" — 각 키에서 행 목록으로 매핑.
키 "redis" → [row1, row5, row100, ...]
키 "kafka" → [row3, row7, row50, ...]
키 "postgres" → [row1, row2, row100, ...]
적합:
- 배열에서 요소 검색 (
tags @> ARRAY['redis']) - JSONB 키/값 검색
- 전문 검색 (
to_tsvector @@ to_tsquery) - 다중 값 컬럼
예시:
-- 'redis' 태그가 있는 모든 포스트
SELECT * FROM posts WHERE tags @> ARRAY['redis'];
-- 'redis'와 'database' 모두 있는 포스트
SELECT * FROM posts WHERE tags @> ARRAY['redis', 'database'];
-- JSONB
SELECT * FROM products WHERE metadata @> '{"category": "electronics"}';
단점: 빌드 느림. UPDATE 시 fastupdate=on으로 지연 처리.
3.4 GiST (Generalized Search Tree)
-- 지리 정보 (PostGIS)
CREATE INDEX idx_locations ON places USING gist(location);
-- 범위 타입
CREATE INDEX idx_periods ON events USING gist(period);
적합:
- 지리 데이터 (점, 다각형)
- 범위 타입 (
int4range,tstzrange) - "근처" 검색
- 중복 검사
3.5 BRIN (Block Range Index)
CREATE INDEX idx_logs_created ON logs USING brin(created_at);
원리: 페이지 범위마다 min/max만 저장. 매우 작은 인덱스.
적합:
- 자연스러운 순서의 큰 테이블 (시계열, 로그)
- 시간순 INSERT
- 디스크 공간 절약
단점: 데이터가 정렬되지 않으면 효과 없음.
예시: 1억 행의 로그 테이블에서 BRIN 인덱스는 수 MB, B-Tree는 수 GB. 100배+ 작음.
3.6 SP-GiST (Space-Partitioned GiST)
비균형 트리. PostGIS의 일부 인덱스, IP 범위 등.
CREATE INDEX idx_ips ON connections USING spgist(ip_addr);
3.7 인덱스 종류 비교
| 종류 | 정확 일치 | 범위 | 패턴 | 배열/JSON | 지리 | 크기 | 빌드 |
|---|---|---|---|---|---|---|---|
| B-Tree | ✅ | ✅ | 앞만 | ❌ | ❌ | 보통 | 빠름 |
| Hash | ✅ | ❌ | ❌ | ❌ | ❌ | 작음 | 빠름 |
| GIN | ✅ | ❌ | 전문 | ✅ | ❌ | 큼 | 느림 |
| GiST | ✅ | ✅ | ❌ | ❌ | ✅ | 보통 | 보통 |
| BRIN | ❌ | ✅* | ❌ | ❌ | ❌ | 매우 작음 | 빠름 |
| SP-GiST | ✅ | ✅ | ❌ | ❌ | ✅ | 보통 | 보통 |
*BRIN은 데이터가 정렬되어 있을 때만 효과적
4. 복합 인덱스 (Composite Index)
4.1 컬럼 순서가 결정적
CREATE INDEX idx_users_country_age ON users(country, age);
사용 가능한 쿼리:
WHERE country = 'KR' -- ✅
WHERE country = 'KR' AND age = 30 -- ✅
WHERE country = 'KR' AND age > 25 -- ✅
사용 불가능한 쿼리:
WHERE age = 30 -- ❌ (country가 leading)
WHERE age > 25 -- ❌
규칙: leftmost prefix rule — 인덱스의 왼쪽부터 사용해야 함.
4.2 컬럼 순서 결정 가이드
우선순위:
- 자주 사용되는 컬럼을 왼쪽
- 선택도(selectivity) 높은 컬럼을 왼쪽 (많이 거름)
- 등호 조건 > 범위 조건
예시: 사용자 검색
WHERE country = 'KR' AND age > 25 AND created_at > '2024-01-01'
선택도 분석:
country = 'KR': 30% (한국 사용자)age > 25: 70%created_at > '2024-01-01': 50%
최적 순서: country (30%) → created_at (50%) → age (70%) (가장 많이 거르는 것부터)
CREATE INDEX idx_users_search ON users(country, created_at, age);
4.3 등호 + 범위 조합
WHERE country = 'KR' AND age BETWEEN 25 AND 35
최적: (country, age) — 등호 컬럼 먼저.
WHERE country IN ('KR', 'JP') AND age BETWEEN 25 AND 35
이 경우는 더 복잡 — IN은 범위처럼 동작. 옵티마이저가 결정.
5. 특수 인덱스 패턴
5.1 부분 인덱스 (Partial Index)
-- 활성 사용자만 인덱싱
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';
장점:
- 인덱스 크기 감소 (예: 활성 사용자가 10%면 인덱스도 10%)
- 빠른 빌드, 빠른 유지보수
- 더 좋은 캐시 효율
언제 사용:
- 자주 조회하는 부분 집합 (활성 사용자, 최근 데이터)
WHERE status = 'pending'같은 빈번한 필터
5.2 표현식 인덱스 (Expression Index)
-- 대소문자 무시 검색
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- 사용 시 같은 표현식 필요
SELECT * FROM users WHERE LOWER(email) = LOWER('Alice@example.com');
일반적 사용:
LOWER(email),UPPER(name)EXTRACT(year FROM created_at)- JSON 필드:
(metadata->>'category')
5.3 커버링 인덱스 (Covering Index, INCLUDE)
-- PostgreSQL 11+
CREATE INDEX idx_users_country_email ON users(country) INCLUDE (email, name);
원리: 인덱스에 추가 컬럼을 포함시켜 테이블 접근 없이 결과 반환.
-- 인덱스만으로 답할 수 있음
SELECT email, name FROM users WHERE country = 'KR';
EXPLAIN에서 Index Only Scan 표시.
장점: 디스크 I/O 절감 (인덱스만 읽음), 매우 빠름.
단점: 인덱스 크기 증가.
5.4 유니크 인덱스 (Unique Index)
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
-- 또는
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE(email);
부수 효과: 중복 방지 + 빠른 검색 (옵티마이저가 유일성 활용).
5.5 다중 컬럼 유니크
CREATE UNIQUE INDEX idx_user_device ON sessions(user_id, device_id);
복합 키처럼 동작.
6. EXPLAIN ANALYZE 마스터하기
6.1 기본 사용법
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM users WHERE country = 'KR' AND age > 25;
핵심 옵션:
ANALYZE: 실제 실행 (시간 측정)BUFFERS: 캐시 hit/missFORMAT JSON|TEXT|XML|YAML
6.2 출력 해석
Index Scan using idx_users_country_age on users
(cost=0.43..2547.39 rows=10000 width=120)
(actual time=0.025..15.234 rows=9847 loops=1)
Index Cond: ((country)::text = 'KR'::text)
Filter: (age > 25)
Rows Removed by Filter: 153
Buffers: shared hit=1234 read=56
Planning Time: 0.123 ms
Execution Time: 15.567 ms
해석:
Index Scan: 인덱스 사용 ✅cost=0.43..2547.39: 옵티마이저 추정 비용rows=10000: 추정 행 수actual time=0.025..15.234: 실제 시간actual rows=9847: 실제 행 수Buffers: shared hit=1234 read=56: 1234 페이지 캐시 hit, 56 페이지 디스크 read
6.3 추정 vs 실제
rows=10000 (estimated)
actual rows=9847
차이가 작으면 좋은 통계, 크면 통계 부정확 → ANALYZE 실행 필요.
rows=100 (estimated)
actual rows=100000 -- 1000배 차이!
문제: 옵티마이저가 잘못된 계획 선택. default_statistics_target 증가, ANALYZE 재실행.
6.4 주요 노드 타입
| 노드 | 의미 | 좋음? |
|---|---|---|
Seq Scan | 전체 스캔 | 작은 테이블만 OK |
Index Scan | 인덱스로 행 찾고 테이블 접근 | ✅ |
Index Only Scan | 인덱스만으로 답 | ⭐⭐⭐ 최고 |
Bitmap Index Scan | 인덱스로 비트맵 만들고 한 번에 처리 | ✅ |
Nested Loop | 작은 테이블 조인 | 작을 때만 OK |
Hash Join | 큰 테이블 조인 | ✅ |
Merge Join | 정렬된 데이터 조인 | ✅ |
Sort | 정렬 | 메모리 부족하면 디스크 |
6.5 빨간 깃발
- Rows Removed by Filter: 100000+ — 인덱스가 충분히 거르지 못함
- Disk: external merge — 메모리 부족 (work_mem 증가)
- estimated rows ÷ actual rows > 100 — 통계 문제
- Seq Scan on large table — 인덱스 누락
7. 인덱스가 안 쓰이는 10가지 이유
7.1 함수/표현식 적용
-- ❌ 인덱스 안 씀
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
-- ✅ 표현식 인덱스 만들기
CREATE INDEX idx_email_lower ON users(LOWER(email));
7.2 타입 캐스팅
-- ❌ varchar 컬럼에 정수 비교
SELECT * FROM users WHERE phone = 12345678;
-- ✅
SELECT * FROM users WHERE phone = '12345678';
7.3 패턴 검색 앞에 와일드카드
-- ❌ B-Tree 못 씀
SELECT * FROM users WHERE name LIKE '%alice%';
-- ✅ pg_trgm + GIN 인덱스
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_name_trgm ON users USING gin(name gin_trgm_ops);
7.4 NULL 비교
-- ❌ 일부 DB에서 안 씀
SELECT * FROM users WHERE deleted_at IS NULL;
-- ✅ 부분 인덱스
CREATE INDEX idx_active ON users(id) WHERE deleted_at IS NULL;
7.5 OR 조건
-- ❌ 비효율적일 수 있음
SELECT * FROM users WHERE country = 'KR' OR country = 'JP';
-- ✅ IN 사용
SELECT * FROM users WHERE country IN ('KR', 'JP');
-- 또는 UNION
SELECT * FROM users WHERE country = 'KR'
UNION
SELECT * FROM users WHERE country = 'JP';
7.6 통계 부정확
ANALYZE users;
-- 또는 자동
ALTER TABLE users SET (autovacuum_analyze_scale_factor = 0.05);
7.7 너무 많은 행 반환
옵티마이저가 "전체 스캔이 더 빠르다"고 판단:
- 전체의 10%+ 반환 시 자주 발생
- 부분 인덱스나 다른 쿼리 전략 필요
7.8 옵티마이저 힌트 부재
PostgreSQL은 hint 미지원 (의도적). MySQL은 지원:
SELECT * FROM users USE INDEX (idx_email) WHERE email = '...';
PostgreSQL: pg_hint_plan 확장으로 추가 가능.
7.9 leading 컬럼 빠짐
CREATE INDEX idx_users_country_age ON users(country, age);
-- ❌ country 빠짐
SELECT * FROM users WHERE age > 25;
7.10 Implicit Conversion
-- BIGINT 컬럼에 INT 비교 — 일부 DB에서 인덱스 안 씀
SELECT * FROM orders WHERE order_id = 12345;
-- 명시적 캐스팅
SELECT * FROM orders WHERE order_id = 12345::bigint;
8. 실전 튜닝 사례
8.1 사례 1: 느린 검색
문제:
SELECT * FROM products
WHERE category = 'electronics'
AND price BETWEEN 100 AND 500
ORDER BY created_at DESC
LIMIT 20;
EXPLAIN 결과: Seq Scan + Sort (10초)
해결:
CREATE INDEX idx_products_search ON products(category, price, created_at DESC);
결과: Index Scan (5ms) — 2000배 빠름.
8.2 사례 2: JOIN 느림
문제:
SELECT u.*, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.country = 'KR'
GROUP BY u.id;
진단: orders.user_id 인덱스 없음 → Hash Join with full scan.
해결:
CREATE INDEX idx_orders_user_id ON orders(user_id);
8.3 사례 3: JSON 쿼리 느림
문제:
SELECT * FROM events
WHERE metadata @> '{"event_type": "login"}';
해결:
CREATE INDEX idx_events_metadata ON events USING gin(metadata);
8.4 사례 4: 복합 인덱스 vs 단일 인덱스
상황: WHERE a=? AND b=? AND c=?가 자주 사용됨.
옵션 1: 3개 단일 인덱스 → BitmapAnd로 결합 (느림)
CREATE INDEX idx_a ON t(a);
CREATE INDEX idx_b ON t(b);
CREATE INDEX idx_c ON t(c);
옵션 2: 1개 복합 인덱스 (빠름)
CREATE INDEX idx_abc ON t(a, b, c);
옵션 2가 거의 항상 빠름 — 단일 인덱스 스캔.
9. 인덱스 유지보수
9.1 사용되지 않는 인덱스 찾기
SELECT
schemaname || '.' || relname AS table,
indexrelname AS unused_index,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan AS index_scans
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
idx_scan = 0이 오랫동안 유지되면 삭제 후보.
9.2 인덱스 bloat
UPDATE/DELETE가 많으면 인덱스가 부풀어 오릅니다.
-- 락 없이 인덱스 재구성
REINDEX INDEX CONCURRENTLY idx_users_email;
9.3 중복 인덱스
-- 중복 발견
CREATE INDEX idx_a ON t(a);
CREATE INDEX idx_ab ON t(a, b); -- idx_a를 포함 (불필요)
idx_ab가 있으면 idx_a 삭제 가능 (leftmost rule).
9.4 인덱스 모니터링
SELECT
schemaname,
relname,
indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
idx_tup_read vs idx_tup_fetch 비율로 효율성 판단.
10. MySQL vs PostgreSQL 인덱스
| 특성 | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| 기본 인덱스 | B+Tree | B-Tree |
| 클러스터 인덱스 | ✅ (PRIMARY KEY) | ❌ |
| Hash 인덱스 | Memory 엔진만 | ✅ (전체) |
| 전문 검색 | FULLTEXT | tsvector + GIN |
| JSON 인덱싱 | Generated columns | GIN on JSONB |
| 부분 인덱스 | ❌ (8.0+ 일부) | ✅ |
| 표현식 인덱스 | 8.0+ | ✅ |
| 커버링 인덱스 | ✅ | ✅ (INCLUDE) |
| BRIN | ❌ | ✅ |
| GiST | ❌ | ✅ |
핵심 차이: PostgreSQL이 인덱스 종류와 유연성에서 우위. MySQL의 클러스터 인덱스는 PK 기반 lookup에서 약간 빠름.
퀴즈
1. 복합 인덱스 (a, b, c)에서 사용 가능한 쿼리는?
답: leftmost prefix rule — 왼쪽부터 사용해야 합니다.
- ✅
WHERE a=? - ✅
WHERE a=? AND b=? - ✅
WHERE a=? AND b=? AND c=? - ✅
WHERE a=? AND c=?(부분적, b가 없어도 a로 시작) - ❌
WHERE b=?(a가 leading) - ❌
WHERE c=? - ❌
WHERE b=? AND c=?
2. GIN 인덱스가 적합한 경우는?
답: 다중 값 컬럼에 적합합니다. (1) 배열 — tags @> ARRAY['redis'], (2) JSONB — metadata @> '{"key": "value"}', (3) 전문 검색 — tsvector @@ tsquery, (4) trgm — pg_trgm 확장으로 LIKE '%abc%' 가속. 단점은 빌드가 느리고 인덱스가 큽니다.
3. EXPLAIN의 'estimated rows'와 'actual rows'가 1000배 차이 나면?
답: 통계가 부정확한 것입니다. 옵티마이저가 잘못된 계획을 선택할 수 있습니다. 해결: (1) ANALYZE table_name으로 통계 재구성, (2) default_statistics_target 증가 (기본 100 → 1000), (3) 특정 컬럼만: ALTER TABLE t ALTER COLUMN c SET STATISTICS 1000. autovacuum의 analyze_scale_factor를 낮추면 자동으로 더 자주 분석.
4. 부분 인덱스(Partial Index)의 장점은?
답: (1) 인덱스 크기 감소 — 활성 사용자가 10%면 인덱스도 10% 크기, (2) 빠른 빌드/유지보수, (3) 더 좋은 캐시 효율, (4) 자주 조회하는 부분 집합 최적화. 예: CREATE INDEX idx_active ON users(email) WHERE status='active'. 비활성 사용자는 인덱스에 없음. 자주 사용되는 필터 조건이 있으면 거의 항상 부분 인덱스가 더 효율적입니다.
5. Index Only Scan이 일반 Index Scan보다 빠른 이유는?
답: 일반 Index Scan은 인덱스에서 행 위치(ctid)를 찾고 테이블에 가서 전체 행을 읽습니다. Index Only Scan은 인덱스에 필요한 모든 컬럼이 포함되어 있어 테이블 접근 자체가 없습니다. INCLUDE 절(PostgreSQL 11+)로 만들 수 있습니다. EXPLAIN에서 Index Only Scan 표시를 확인하세요. 단, PostgreSQL은 visibility map도 확인해야 하므로 VACUUM이 필요합니다.
참고 자료
- Use The Index, Luke! — 인덱스 가이드의 표준
- PostgreSQL Documentation: Indexes
- PostgreSQL B-Tree Internals
- Mastering PostgreSQL Indexes
- MySQL Index Optimization
- pg_stat_statements — 쿼리 통계
- pg_hint_plan — PostgreSQL 힌트
- PgHero — PostgreSQL 성능 대시보드
- Database Internals — Alex Petrov 책
- Designing Data-Intensive Applications — Martin Kleppmann
- PostgreSQL Wiki: Slow Query Questions
현재 단락 (1/361)
- **B-Tree가 90%**: 대부분의 쿼리에 적합. 정렬, 범위, 정확 일치 모두 지원