인덱스의 개념과 필요성
느린 조회 쿼리의 상당수는 인덱스 설계로 개선할 수 있습니다. 하지만 인덱스를 아무 데나 걸면 오히려 느려집니다 — 원리를 알아야 올바른 결정을 할 수 있습니다.
인덱스는 데이터베이스 성능의 핵심 요소입니다. 잘 설계된 인덱스는 수십 초 걸리던 쿼리를 밀리초 단위로 단축시키고, 잘못 설계된 인덱스는 쓰기 성능을 저하시키고 디스크 공간을 낭비합니다.
인덱스란
인덱스(Index)는 데이터베이스 테이블의 검색 속도를 높여주는 자료구조입니다. 책의 색인(찾아보기)과 같은 원리입니다.
일반적인 B+Tree 인덱스는 검색 대상 컬럼의 값과 해당 행을 찾기 위한 위치 정보(row locator)를 함께 저장합니다. 이 위치 정보는 DBMS와 저장 엔진에 따라 ROWID, 기본 키 값, 페이지 포인터처럼 다르게 표현됩니다. 실무 문서에서는 이를 넓게 B-Tree 인덱스라고 부르는 경우도 많지만, 핵심은 키가 정렬된 상태로 유지되어 트리 탐색, 범위 탐색, 정렬 회피 같은 최적화가 가능하다는 점입니다.
Full Table Scan vs Index Scan
Full Table Scan
테이블의 모든 행을 처음부터 끝까지 순차적으로 읽는 방식입니다. 인덱스가 없거나 인덱스를 사용할 수 없을 때 이 방식으로 검색합니다. 다만 Full Table Scan은 항상 나쁜 계획이 아닙니다. 결과가 테이블의 큰 비율을 차지하거나 순차 I/O와 병렬 처리가 유리한 상황에서는 오히려 더 자연스러운 선택일 수 있습니다.
1,000만 행 테이블에서 1건을 찾으려면 최악의 경우 많은 페이지를 순차적으로 읽어야 합니다. 실제 시간은 캐시 적중률, 행 크기, 디스크 I/O, 병렬 처리 여부에 따라 달라집니다.
Index Scan
인덱스를 사용하여 필요한 행의 위치를 빠르게 찾는 방식입니다. B+Tree 인덱스의 경우 루트에서 리프까지 몇 단계만 내려가 후보 행을 찾고, 필요하면 테이블 행을 추가로 읽습니다.
B+Tree는 한 노드에 많은 키를 담기 때문에 수백만~수천만 건 규모에서도 보통 트리 높이가 몇 단계로 유지됩니다. 다만 조건이 넓어 많은 행을 읽어야 한다면 인덱스 탐색 후 테이블 랜덤 접근 비용이 커질 수 있습니다.
검색 방식 성능 비교
데이터가 많고 조건이 선택적일수록 인덱스의 효과는 커집니다. 반대로 테이블 대부분을 읽는 쿼리는 Full Table Scan이 더 빠를 수 있습니다.
인덱스의 종류
주요 인덱스 분류
Clustered vs Non-Clustered Index
Clustered Index (클러스터형)
* 테이블당 1개만 가능
* 데이터 행이 클러스터 키 순서의 구조 안에 저장됨
* InnoDB는 기본 키를 클러스터 키로 사용
* SQL Server는 PK를 clustered/nonclustered 중 선택 가능
* 범위 검색에 매우 유리 (연속된 페이지 읽기)
Non-Clustered Index (비클러스터형)
* 테이블당 여러 개 가능
* 인덱스와 데이터가 별도로 저장됨
* 인덱스에서 ROWID, 기본 키, 포인터 등으로 데이터 행을 참조
* 랜덤 I/O 발생 (다수 행 접근 시 느릴 수 있음)Clustered = 사전 (단어가 알파벳 순으로 정렬되어 있음)
Non-Clustered = 책의 색인 (색인은 정렬, 본문은 별도)인덱스 생성과 관리
-- 기본 인덱스 생성
CREATE INDEX idx_users_email ON users (email);
-- 유니크 인덱스
CREATE UNIQUE INDEX idx_users_email_unique ON users (email);
-- 복합 인덱스 (컬럼 순서 중요!)
CREATE INDEX idx_orders_user_date ON orders (user_id, order_date DESC);
-- 부분 인덱스 (PostgreSQL)
CREATE INDEX idx_active_users ON users (email) WHERE status = 'active';
-- SQL Server는 filtered index 문법으로 같은 의도를 표현
CREATE INDEX idx_active_users ON users (email) WHERE status = 'active';
-- 표현식/함수 기반 인덱스 (PostgreSQL, Oracle 등)
CREATE INDEX idx_users_lower_email ON users (LOWER(email));-- 인덱스 삭제 (PostgreSQL)
DROP INDEX idx_users_email;
-- 인덱스 삭제 (MySQL)
DROP INDEX idx_users_email ON users;
-- 인덱스 삭제 (SQL Server)
DROP INDEX idx_users_email ON users;
-- 인덱스 목록 조회 (PostgreSQL)
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'users';
-- 인덱스 목록 조회 (MySQL)
SHOW INDEX FROM users;
-- 인덱스 재구성 (PostgreSQL)
REINDEX INDEX idx_users_email;
-- 인덱스 재구성 (SQL Server)
ALTER INDEX idx_users_email ON users REBUILD;부분 인덱스, 표현식 인덱스, 재구성 문법은 DBMS별 차이가 큽니다. 예를 들어 MySQL은 PostgreSQL식 WHERE 부분 인덱스를 그대로 지원하지 않으므로, 생성 컬럼(generated column), 복합 인덱스, 쿼리 조건 재설계 같은 대안을 검토해야 합니다.
인덱스의 비용
인덱스는 공짜가 아닙니다. INSERT, UPDATE, DELETE가 발생할 때마다 인덱스도 갱신해야 합니다.
| 비용 항목 | 설명 | 영향 |
|---|---|---|
| 쓰기 오버헤드 | 데이터 변경 시 인덱스 항목도 함께 갱신 | INSERT/UPDATE/DELETE 느려짐 |
| 저장 공간 | 인덱스 자체가 디스크와 메모리를 사용 | 테이블보다 커질 수도 있음 |
| 유지 비용 | 통계, 단편화, 페이지 분할 관리 필요 | 운영 관리 필요 |
| 옵티마이저 부담 | 후보 인덱스가 많으면 계획 탐색 증가 | 계획 수립 비용 증가 |
인덱스를 걸어야 하는 경우 vs 걸면 안 되는 경우
| 인덱스 추천 | 인덱스 비추천 |
|---|---|
| WHERE 조건에 자주 사용되는 컬럼 | 데이터가 적은 테이블 (수백 건) |
| JOIN 조건 컬럼 (FK) | 단독 조건의 선택도가 낮은 컬럼 |
| ORDER BY, GROUP BY 대상 | INSERT가 매우 빈번한 테이블 |
| UNIQUE 제약이 필요한 컬럼 | 거의 조회하지 않는 컬럼 |
| 선택 비율이 낮은 조건 | 테이블 대부분을 읽어야 하는 경우 |
카디널리티와 선택도
카디널리티(Cardinality)란 컬럼 값의 고유한 수입니다. 주민번호는 카디널리티가 높고(대부분 다름), 성별은 낮습니다(M/F). 다만 카디널리티만으로 결정하지 말고 실제 조건이 얼마나 적은 행을 고르는지 함께 봐야 합니다.
선택도(Selectivity)는 전체 행 중 조건으로 선택되는 행의 비율입니다. 선택도가 낮을수록(적은 행을 선택할수록) 인덱스가 효과적입니다.
선택도 5~10% 이하라는 말은 자주 쓰이는 경험칙입니다. 실제 기준은 DBMS, 테이블 폭, 클러스터링 정도, 캐시 상태, 필요한 컬럼 수에 따라 달라지므로 실행 계획과 실제 실행 시간을 확인해야 합니다.
카디널리티가 낮은 컬럼도 무조건 배제하지는 않습니다. 값 분포가 한쪽으로 크게 치우쳤거나, 부분 인덱스/복합 인덱스의 앞뒤 컬럼과 결합해 실제 선택도를 낮출 수 있다면 충분히 효과가 날 수 있습니다.
복합 인덱스
두 개 이상의 컬럼으로 만든 인덱스입니다. 컬럼 순서가 매우 중요합니다.
CREATE INDEX idx_orders ON orders (user_id, order_date);선행 컬럼 규칙
복합 인덱스는 보통 왼쪽부터 이어지는 컬럼이 조건에 포함될 때 가장 잘 사용됩니다. DBMS에 따라 skip scan, index merge 같은 예외가 있지만, 기본 설계 원칙은 선행 컬럼을 맞추는 것입니다.
-- 인덱스: (user_id, order_date, status)
-- ✅ 사용 가능
WHERE user_id = 1 -- a만
WHERE user_id = 1 AND order_date = '2024-01-01' -- a, b
WHERE user_id = 1 AND order_date = '2024-01-01' AND status = 'completed' -- a, b, c
-- ⚠️ 부분 사용 (user_id만 인덱스 사용)
WHERE user_id = 1 AND status = 'completed' -- a, c (b 건너뜀!)
-- ❌ 보통 사용 어려움
WHERE order_date = '2024-01-01' -- b만 (a 없음!)
WHERE status = 'completed' -- c만
WHERE order_date = '2024-01-01' AND status = 'completed' -- b, c (a 없음!)복합 인덱스 설계 원칙
인덱스를 사용하지 못하는 경우
인덱스가 있어도 사용되지 않는 경우가 많습니다. 이를 이해하는 것이 매우 중요합니다.
-- 1. 인덱스 컬럼에 함수를 적용하면 일반 인덱스 사용이 어려움
SELECT * FROM users WHERE UPPER(email) = 'TEST@EXAMPLE.COM';
-- 해결: 함수 기반 인덱스 또는 컬럼 원본 값으로 비교
-- 2. 날짜 컬럼에 함수를 적용하면 범위 탐색이 어려움
SELECT * FROM orders WHERE YEAR(order_date) = 2024;
-- 해결: 범위 조건으로 변환
SELECT * FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';
-- 3. 암시적 타입 변환
SELECT * FROM users WHERE phone = 01012345678; -- phone이 VARCHAR
-- 해결: 올바른 타입으로 비교
SELECT * FROM users WHERE phone = '01012345678';
-- 4. LIKE 앞부분 와일드카드 (B-tree 기준 일반적으로)
SELECT * FROM users WHERE name LIKE '%철수'; -- 인덱스 사용 불가
SELECT * FROM users WHERE name LIKE '김%'; -- 인덱스 사용 가능
-- 5. OR 조건 (경우에 따라)
SELECT * FROM users WHERE email = 'a@t.co' OR name = '김철수';
-- DBMS가 index merge/bitmap scan을 선택할 수도 있지만 항상 유리하지는 않음
-- 6. NOT, != 조건
SELECT * FROM users WHERE status != 'deleted';
-- 대부분의 행을 선택하면 Full Scan이 더 효율적실행 계획으로 인덱스 확인
-- PostgreSQL
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- Index Scan using idx_users_email on users
-- Index Cond: (email = 'test@example.com')
-- Execution Time: 0.05 ms
-- MySQL
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- type: ref → 인덱스 사용
-- type: ALL → Full Table Scan (인덱스 미사용)
-- 주요 접근 방식 (MySQL)
-- const: PK/유니크 인덱스로 1건 조회
-- ref: 인덱스로 여러 건 조회
-- range: 인덱스 범위 스캔
-- index: 인덱스 전체 스캔
-- ALL: Full Table Scan (항상 최악은 아니며 대량 조회에서는 자연스러운 선택)Covering Index
쿼리에 필요한 모든 컬럼이 인덱스에 포함되어 있으면, 테이블 데이터를 읽지 않고 인덱스만으로 결과를 반환할 수 있습니다. 이를 Covering Index라 합니다. 단, DBMS에 따라 visibility map, MVCC 상태, 포함 컬럼 지원 여부 때문에 실제로 테이블 접근이 완전히 사라지지 않을 수 있습니다.
-- 인덱스: (user_id, order_date)
-- 이 쿼리는 인덱스만으로 응답 가능 (테이블 접근 불필요)
SELECT user_id, order_date FROM orders WHERE user_id = 1;
-- MySQL EXPLAIN에서 "Using index" 표시 → Covering Index 적용
-- PostgreSQL은 Index Only Scan과 Heap Fetches를 함께 확인INCLUDE 절 (PostgreSQL, SQL Server)
인덱스 검색에는 사용하지 않지만 Covering Index를 위해 추가 컬럼을 포함할 수 있습니다.
-- 검색은 user_id로, 결과 컬럼도 인덱스에서 바로 반환
CREATE INDEX idx_orders_covering
ON orders (user_id) INCLUDE (order_date, total_amount);인덱스 설계 실전 예시
인덱스 모니터링
운영 환경에서는 인덱스의 사용 현황을 주기적으로 점검해야 합니다. 사용되지 않는 인덱스는 쓰기 성능만 저하시킬 수 있으므로 삭제 후보가 됩니다. 다만 idx_scan = 0만 보고 즉시 삭제하지 말고, 배치/월말 리포트/장애 대응 쿼리처럼 드물게 필요한 경로가 있는지 확인해야 합니다.
-- 인덱스별 스캔 횟수 확인
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;
-- idx_scan = 0인 인덱스는 삭제 후보
-- 단, 통계 reset 여부, 드문 운영 쿼리, unique/constraint 보조 용도인지 먼저 확인SELECT indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;핵심 정리
다음 절에서는 인덱스의 핵심 자료구조인 B-Tree와 B+Tree를 다루겠습니다.