infra
Platform

모듈 맵

[Database] B-Tree 인덱스의 작동 원리와 인덱스 설계의 핵심 조건

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 09 / 37

[Database] B-Tree 인덱스의 작동 원리와 인덱스 설계의 핵심 조건

인덱스의 내부 동작을 이해하고 언제 만들고 언제 피해야 하는지 판단합니다

🚨INCIDENT ALERT
HIGH

WHERE 조건을 붙였는데도 테이블 전체를 읽는 쿼리는 실무에서 자주 만납니다. 인덱스는 단순히 “빠르게 해주는 옵션”이 아니라 DB가 데이터를 찾는 경로입니다. 기본 원리를 알면 어떤 쿼리에 인덱스가 필요한지 스스로 판단할 수 있습니다.

이번 챕터에서 배울 것

인덱스는 조회를 빠르게 하지만 쓰기 성능을 낮추고 디스크를 사용합니다. 올바른 컬럼에 올바른 순서로 인덱스를 만드는 것이 쿼리 최적화의 출발점입니다.

  • 1인덱스가 없을 때 — Full Table Scan 선형 탐색의 문제
  • 2B-Tree 구조 — 루트, 브랜치, 리프 노드의 역할
  • 3Cardinality — 인덱스 효과를 결정하는 핵심 지표
  • 4CREATE INDEX 문법과 인덱스 확인 방법
  • 5Composite Index — 컬럼 순서가 중요한 이유
  • 6인덱스를 만들지 말아야 할 상황

인덱스 기초 — B-Tree 원리와 쿼리 속도

1억 개의 사용자 레코드에서 특정 이메일을 찾아야 한다면 어떻게 해야 할까요? 인덱스 없이는 1억 개를 전부 읽어야 하지만(Full Table Scan), B-Tree 인덱스가 있으면 약 27번의 비교만으로 찾을 수 있습니다. 인덱스는 데이터베이스 성능의 핵심입니다.


💡개념

B-Tree 인덱스 — 왜 쿼리가 로그 시간으로 빨라지는가

사용자가 100만 명인 테이블에서 WHERE email = 'user@example.com'을 실행했더니 5초가 걸립니다. 인덱스를 걸었더니 즉시 반환됩니다. 왜 이렇게 빠른지 이해하지 못하면 인덱스를 아무 컬럼에나 걸거나, 반대로 필요한 곳에 안 걸어서 슬로우 쿼리가 반복됩니다.

B-Tree 인덱스 — 왜 쿼리가 로그 시간으로 빨라지는가

Full Table Scan vs 인덱스 검색

인덱스가 없는 테이블에서 특정 이메일을 검색하면 DB는 첫 번째 행부터 마지막 행까지 모든 행을 순서대로 비교합니다. 1억 행이 있으면 최악의 경우 1억 번 비교가 필요합니다. 이것이 Full Table Scan이고 시간 복잡도는 O(n)입니다.

B-Tree 인덱스가 있으면 루트 노드에서 시작해 각 단계마다 탐색 범위가 절반씩 줄어듭니다. 1억 행에서도 약 27번(log₂(1억) ≈ 27)의 비교만으로 원하는 행을 찾을 수 있습니다. 시간 복잡도는 O(log n)입니다.

방식시간 복잡도1억 행 검색 시 비교 횟수
Full Table ScanO(n)최대 1억 번
B-Tree 인덱스O(log n)약 27번
Hash 인덱스O(1)1번 (등호 검색 전용)

B-Tree 구조 상세

B-Tree 인덱스는 루트 노드, 브랜치 노드, 리프 노드의 세 계층으로 구성됩니다. users.email 컬럼에 인덱스를 만들면 아래와 같은 트리가 내부적으로 생성됩니다.

              [루트 노드]
              [M | T]
             /   |   \
            /    |    \
    [Branch]  [Branch]  [Branch]
    [D | H]   [N | R]   [V | Z]
    /  |  \    ...        ...
   /   |   \
[Leaf][Leaf][Leaf]
[A,B] [C,D] [E,F,G,H]
  ↓     ↓       ↓
실제 행 포인터 (heap page, offset)

각 노드의 역할은 다음과 같습니다. 루트 노드는 탐색의 시작점으로 전체 값 범위를 크게 나눕니다. 브랜치 노드는 중간 경로로 값 범위에 따라 탐색 방향을 결정합니다. 리프 노드는 실제 인덱스 키값과 해당 행의 물리적 위치(heap pointer)를 저장합니다. 리프 노드들은 서로 링크드 리스트로 연결되어 있어 BETWEEN, 부등호 같은 범위 검색에서 연속된 결과를 효율적으로 읽을 수 있습니다.

Cardinality — 인덱스 효과의 핵심 지표

Cardinality는 컬럼의 고유값 수를 의미합니다. 높을수록 인덱스 효과가 큽니다.

컬럼고유값 수Cardinality인덱스 적합성
users.email~전체 행매우 높음최적
orders.id전체 행최고(PK 자동 생성)
users.country~200개보통상황에 따라
orders.status5개낮음비효율적
users.is_active2개매우 낮음Full Scan이 나음

is_active=true인 행이 전체의 90%라면, 인덱스를 통해 90%를 읽는 것보다 그냥 테이블 전체를 읽는 Full Scan이 오히려 빠릅니다. DB 옵티마이저가 이를 감지하고 인덱스를 자동으로 무시하기도 합니다.

CREATE INDEX 문법

기본 인덱스, UNIQUE 인덱스, 복합 인덱스, 부분 인덱스를 용도에 맞게 선택합니다. 운영 중인 대용량 테이블에 인덱스를 추가할 때는 CONCURRENTLY 옵션으로 테이블 락 없이 생성합니다. 시간이 더 걸리지만 서비스를 중단하지 않아도 됩니다.

1인덱스 생성

email 컬럼에 인덱스를 생성합니다. 복합 인덱스와 부분 인덱스까지 순서대로 실행해봅니다.

SQL
CREATE INDEX idx_users_email ON users (email);

CREATE UNIQUE INDEX idx_users_email_unique ON users (email);

CREATE INDEX idx_orders_user_status ON orders (user_id, status);

CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';

CREATE INDEX idx_posts_created_desc ON posts (created_at DESC);

CREATE INDEX CONCURRENTLY idx_large_table_col ON large_table (column_name);
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
CREATE INDEX idx_users_email ON users (email);
🔍실행 후 확인할 것
  • 스캔 방식EXPLAIN에서 Seq Scan인지 Index Scan인지 확인합니다.
  • 조건 컬럼WHERE와 ORDER BY 컬럼이 인덱스 설계와 맞는지 봅니다.
  • 쓰기 비용인덱스 추가 후 INSERT/UPDATE 비용 증가도 함께 고려합니다.

인덱스 확인 방법

psql에서 \d users로 테이블 구조와 인덱스를 함께 확인합니다. pg_stat_user_indexesidx_scan이 0인 인덱스는 생성 이후 한 번도 사용되지 않은 것으로, 삭제 대상 후보입니다.

2EXPLAIN ANALYZE로 인덱스 사용 확인

인덱스 생성 전후로 EXPLAIN ANALYZE를 실행해 실행 계획이 Seq Scan에서 Index Scan으로 바뀌는지 확인합니다.

SQL
SELECT
    indexname,
    indexdef
FROM pg_indexes
WHERE tablename = 'users';

SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,
    idx_tup_read
FROM pg_stat_user_indexes
WHERE tablename = 'users'
ORDER BY idx_scan DESC;

SHOW INDEX FROM users;

EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
🔍실행 후 확인할 것
  • EXPLAIN 결과에서 'Index Scan using idx_users_email'이 보이면 인덱스를 사용 중입니다.
  • 인덱스 추가 전후 실행 시간(actual time)을 비교합니다.
  • pg_stat_user_indexes의 idx_scan 값이 증가하는지 확인합니다.

EXPLAIN ANALYZE 결과에서 Index Scan using idx_users_email이 보이면 인덱스를 사용 중이고, Seq Scan이 보이면 Full Table Scan 중입니다.

💡개념

인덱스 설계 원칙 — 언제 만들고 언제 안 만드는가

인덱스를 많이 걸수록 좋을 것 같아서 모든 컬럼에 인덱스를 걸었습니다. INSERT/UPDATE가 갑자기 느려졌습니다. 인덱스는 읽기를 빠르게 하지만 쓰기를 느리게 합니다. 어느 컬럼에 걸어야 하고 어느 컬럼에 걸면 안 되는지 — 이 판단 기준을 모르면 인덱스가 오히려 독이 됩니다.

인덱스 설계 원칙 — 언제 만들고 언제 안 만드는가

인덱스를 만들어야 하는 컬럼

WHERE 조건에 자주 사용되는 컬럼, JOIN ON 조건 컬럼, ORDER BY 컬럼, Cardinality가 높은 컬럼이 인덱스 대상입니다. 특히 JOIN에서 외래키 컬럼(orders.user_id)에 인덱스가 없으면 조인 시 Full Scan이 발생합니다.

3실무 패턴별 인덱스 생성

외래키, 이메일, 날짜, 고유 식별자 컬럼에 상황별 인덱스를 만들어봅니다.

SQL
CREATE INDEX idx_orders_user_id ON orders (user_id);

CREATE UNIQUE INDEX idx_users_email ON users (email);

CREATE INDEX idx_posts_created_at ON posts (created_at DESC);

CREATE INDEX idx_products_sku ON products (sku);
CREATE INDEX idx_orders_user_id ON orders (user_id);
🔍실행 후 확인할 것
  • 외래키 컬럼(user_id) 인덱스 생성 후 JOIN 쿼리 EXPLAIN에서 Hash Join → Index Scan 변화를 확인합니다.
  • UNIQUE 인덱스는 중복 삽입 시 'duplicate key' 오류가 발생하는지 확인합니다.
  • 내림차순 인덱스(DESC)는 ORDER BY created_at DESC 정렬 쿼리에서 효과를 봅니다.

Composite Index 컬럼 순서 설계

복합 인덱스의 컬럼 순서는 어떤 WHERE 조건으로 사용할 수 있는지를 결정합니다. 선두 컬럼부터 순서대로만 사용할 수 있습니다. 컬럼 순서 설계 원칙은 등호(=) 조건 컬럼을 앞에, Cardinality가 높은 컬럼을 앞에, 범위 조건(>, <, BETWEEN)은 뒤에, ORDER BY 컬럼은 마지막에 배치합니다.

4복합 인덱스 생성 및 선두 컬럼 규칙 확인

복합 인덱스를 만들고, 선두 컬럼이 있는 쿼리와 없는 쿼리의 EXPLAIN 결과를 비교합니다.

SQL
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
SQL
-- 인덱스 사용됨 (선두 컬럼 user_id 포함)
EXPLAIN SELECT * FROM orders WHERE user_id = 1 AND status = 'pending';

-- 인덱스 미사용 (선두 컬럼 user_id 없음)
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
CREATE INDEX idx_orders_user_status_created ON orders (user_id, status, created_at DESC);
🔍실행 후 확인할 것
  • user_id = 1 조건 쿼리에서 'Index Scan using idx_orders_user_status_created'가 보이는지 확인합니다.
  • status = 'pending' 만으로 조회 시 Seq Scan이 발생하는지 확인합니다.
  • 선두 컬럼이 없는 조건은 인덱스를 사용하지 못한다는 '선두 컬럼 규칙'을 직접 확인합니다.

위 인덱스는 WHERE user_id = 1, WHERE user_id = 1 AND status = 'pending', WHERE user_id = 1 AND status = 'pending' ORDER BY created_at 조건에서 사용됩니다. 반면 WHERE status = 'pending'이나 WHERE created_at > '2024-01-01'처럼 선두 컬럼(user_id)이 없는 조건에서는 사용되지 않습니다.

인덱스를 만들지 말아야 할 상황

인덱스는 조회를 빠르게 하지만 쓰기(INSERT, UPDATE, DELETE)를 느리게 합니다. 데이터 변경마다 인덱스도 함께 갱신해야 하기 때문입니다.

소규모 테이블(1000행 미만)은 Full Scan이 인덱스 조회보다 빠를 수 있습니다. 인덱스를 통해 행 위치를 찾고 다시 힙에 접근하는 오버헤드가 더 크기 때문입니다.

Cardinality가 낮은 컬럼(gender, is_active)은 인덱스 효율이 없습니다. 초당 수만 건 INSERT가 발생하는 로그 테이블이나 사용자 활동마다 갱신되는 last_activity_at 컬럼은 인덱스가 쓰기 성능을 떨어뜨립니다.

인덱스 유지보수

시간이 지나면 인덱스가 단편화됩니다. REINDEX로 인덱스를 재구성할 수 있고, 운영 중에는 CONCURRENTLY 옵션을 사용합니다. 분기별로 idx_scan = 0인 미사용 인덱스를 점검하고 삭제합니다.

위험 명령어삭제 즉시 적용되며 되돌릴 수 없습니다. 해당 인덱스를 사용하는 쿼리 성능이 즉시 저하됩니다

인덱스 삭제

안전한 실행 조건: EXPLAIN으로 해당 인덱스를 사용하는 쿼리가 없음을 확인 후 삭제

실행 전 반드시 확인

  • pg_stat_user_indexes에서 idx_scan 값이 0인지 확인
  • 삭제 대상 인덱스 이름이 정확한지 이중 확인
  • 롤백 계획 수립 (DROP 후 CREATE INDEX CONCURRENTLY)
DROP INDEX idx_old_unused_index

위 항목을 모두 확인한 후 복사할 수 있습니다

SQL
REINDEX INDEX CONCURRENTLY idx_users_email;

REINDEX TABLE users;

DROP INDEX idx_old_unused_index;

SELECT
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    indexrelname,
    idx_scan AS times_used
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelname NOT LIKE 'pk_%'
ORDER BY pg_relation_size(indexrelid) DESC;

"인덱스가 검색을 빠르게 한다"는 사실만 알고 중요해 보이는 컬럼마다 인덱스를 추가하다 보면, 테이블에 인덱스가 10개 이상 쌓이기도 합니다. 문제는 INSERT 한 건이 실행될 때마다 모든 인덱스를 동시에 갱신해야 한다는 점입니다. 인덱스가 10개라면 INSERT 한 번에 인덱스 10번 갱신이 발생합니다. 주문, 로그 같은 쓰기가 빈번한 테이블에서는 이것이 병목이 됩니다.

해결책은 EXPLAIN ANALYZE로 실제로 사용되는 인덱스만 남기는 것입니다. pg_stat_user_indexes에서 idx_scan = 0인 인덱스를 찾아 삭제합니다. 인덱스는 테이블당 5-6개 이하를 권장합니다. 새 인덱스를 추가하기 전에 항상 "이 인덱스가 사용하는 쿼리가 얼마나 자주 실행되는가", "삭제 대신 기존 복합 인덱스를 확장할 수 있는가"를 먼저 확인하세요.

WHERE name LIKE '%apple'처럼 앞에 와일드카드가 붙는 패턴은 B-Tree 인덱스를 사용할 수 없습니다. B-Tree는 값의 시작 부분을 기준으로 정렬되어 있기 때문에, 앞부분을 알 수 없으면 트리를 탐색할 기준이 없습니다. LIKE 'apple%'(앞에 와일드카드 없음)는 인덱스를 사용할 수 있지만 LIKE '%apple%'LIKE '%apple'은 불가능합니다.

전방 와일드카드 검색이 꼭 필요하다면 Full Text Search(tsvector, tsquery)나 Trigram 인덱스(pg_trgm 확장의 GIN 인덱스)를 사용합니다. CREATE INDEX idx_name_trgm ON products USING gin(name gin_trgm_ops);로 만든 인덱스는 LIKE '%apple%' 패턴에도 동작합니다.

💼
실무 맥락슬로우 쿼리 알림이 발생해 원인을 파악하고 인덱스로 성능을 개선하는 루틴
현업 패턴

프로덕션에서 APM 도구나 CloudWatch에서 특정 API의 응답 시간이 2초 이상으로 치솟는 알림이 왔습니다. 먼저 psql로 프로덕션 읽기 전용 계정에 접속해 pg_stat_activity로 현재 실행 중인 쿼리를 확인합니다. 문제 쿼리를 식별했으면 EXPLAIN ANALYZE를 실행해 실행 계획을 확인합니다.

Seq Scan on orders (cost=0.00..45231.00 rows=982340 width=...) 같은 출력이 보이면 Full Table Scan이 원인입니다. WHERE 조건 컬럼에 인덱스가 없거나 복합 인덱스의 컬럼 순서가 맞지 않는 경우입니다. CREATE INDEX CONCURRENTLY idx_orders_user_created ON orders (user_id, created_at DESC);로 인덱스를 추가한 뒤 다시 EXPLAIN ANALYZE를 실행해 Index Scan으로 바뀌었는지 확인합니다. 적용 전후 실행 시간을 비교해 개선량을 측정하고 변경 이력에 기록합니다.

다음 모듈에서는 3-Tier, MSA, CQRS 아키텍처에서 데이터베이스가 담당하는 역할 분담 패턴을 다룹니다.

지식 확인

퀴즈 — 5문제

Q1

users 테이블 1억 건에서 WHERE email = 'user@example.com' 을 조회한다. email 컬럼에 B-Tree 인덱스가 없을 때와 있을 때의 차이를 가장 잘 설명한 것은?

Q2

Cardinality가 낮은 컬럼(예: is_active, gender)에 인덱스를 만들면?

Q3

Composite Index (a, b, c)가 있을 때 WHERE b = 1 AND c = 2 조건만으로 쿼리하면?

Q4

인덱스를 만들면 느려지는 작업은?

Q5

users.email 컬럼에 ALTER TABLE ADD CONSTRAINT uq_email UNIQUE (email)을 추가했다. 이후 WHERE email = ? 조회 성능은 어떻게 되는가?

0 / 5 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Ubuntu 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

40📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

database중급 · 35
[Database] DBeaver, TablePlus, psql, mycli 실무 100% 활용법
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점