Skip to content
Published on

DB 인덱스 마스터 가이드 2025: B-Tree, Hash, GIN, BRIN, 복합 인덱스, 실행 계획 분석

Authors

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 컬럼 순서 결정 가이드

우선순위:

  1. 자주 사용되는 컬럼을 왼쪽
  2. 선택도(selectivity) 높은 컬럼을 왼쪽 (많이 거름)
  3. 등호 조건 > 범위 조건

예시: 사용자 검색

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/miss
  • FORMAT 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+TreeB-Tree
클러스터 인덱스✅ (PRIMARY KEY)
Hash 인덱스Memory 엔진만✅ (전체)
전문 검색FULLTEXTtsvector + GIN
JSON 인덱싱Generated columnsGIN 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) JSONBmetadata @> '{"key": "value"}', (3) 전문 검색tsvector @@ tsquery, (4) trgmpg_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이 필요합니다.


참고 자료