인덱스(Index) 동작 원리와 쿼리 최적화 전략 완벽 가이드
인덱스(Index) 동작 원리와 쿼리 최적화 전략 완벽 가이드
데이터베이스에서 SELECT 쿼리의 성능은 결국 “얼마나 적은 데이터를 읽는가”로 결정된다. 1000만 행의 테이블에서 1행을 찾을 때, 인덱스 없이 전체를 스캔하면 1000만 행을 읽어야 한다. 인덱스가 있으면 B+Tree를 따라 3~4번의 디스크 접근으로 원하는 행을 찾을 수 있다. 수백만 배의 차이다.
하지만 인덱스는 마법이 아니다. 잘못 설계하면 오히려 성능을 저하시킨다. 인덱스가 있는데도 사용되지 않는 경우, 인덱스가 너무 많아 INSERT/UPDATE가 느려지는 경우, 복합 인덱스의 컬럼 순서가 잘못된 경우. 이런 문제를 피하려면 인덱스의 내부 동작 원리를 정확히 이해해야 한다.
이 글은 B+Tree의 구조와 왜 DB가 B+Tree를 선택했는지, 클러스터드/세컨더리 인덱스의 내부 구조, 복합 인덱스와 최좌선 접두사 규칙, 커버링 인덱스, 인덱스가 사용되지 않는 경우들, EXPLAIN 읽는 법, JPA에서의 인덱스 활용, 그리고 실무 튜닝 전략까지를 다룬다.
1. 인덱스란 무엇인가
1.1 정의
인덱스는 테이블의 데이터를 빠르게 검색하기 위해 별도로 유지하는 정렬된 자료구조다.
책의 목차(Table of Contents)가 “3장 → 47페이지”처럼 원하는 내용의 위치를 빠르게 찾아주는 것과 같다. 인덱스 없이 데이터를 찾으려면 책의 첫 페이지부터 끝까지 한 장씩 넘겨봐야 한다(Full Table Scan). 인덱스가 있으면 목차를 보고 바로 해당 페이지로 이동한다.
하지만 이 비유에는 한계가 있다. 실제 인덱스는 단순한 “목록”이 아니라, B+Tree라는 균형 트리 자료구조로 구현되며, 데이터의 삽입/삭제 시 자동으로 균형을 유지한다.
1.2 인덱스가 없는 경우: Full Table Scan
1
2
-- member 테이블에 인덱스가 없는 상태
SELECT * FROM member WHERE name = '김철수';
인덱스가 없으면 DB는 member 테이블의 모든 행을 처음부터 끝까지 읽으면서 name = '김철수'인 행을 찾는다. 이것이 Full Table Scan(전체 테이블 스캔)이다.
1
2
3
4
5
6
7
8
9
10
[Full Table Scan]
데이터 페이지 1: [이영희, 박민수, 정수진, ...] → 전부 읽음
데이터 페이지 2: [최동현, 김철수, 한지민, ...] → 전부 읽음 (김철수 발견!)
데이터 페이지 3: [강동원, 송혜교, 김철수, ...] → 전부 읽음 (또 김철수!)
...
데이터 페이지 N: [...] → 마지막까지 전부 읽음
→ N개의 데이터 페이지를 모두 읽어야 한다
→ 1000만 행이면 1000만 행을 모두 스캔
1.3 인덱스가 있는 경우
1
2
3
-- name 컬럼에 인덱스가 있는 상태
CREATE INDEX idx_member_name ON member(name);
SELECT * FROM member WHERE name = '김철수';
1
2
3
4
5
6
7
8
9
10
11
12
[인덱스 검색]
B+Tree 인덱스:
루트 노드: [김, 박, 이]
│
중간 노드: [... 김대현, 김수진, 김철수 ...]
│
리프 노드: [김철수 → PK=5], [김철수 → PK=127]
→ 루트 → 중간 → 리프: 3번의 노드 접근으로 '김철수'의 위치를 찾음
→ PK 5, 127로 실제 데이터 행을 조회
→ 전체 1000만 행 중 필요한 행만 정확히 접근
1.4 인덱스의 트레이드오프
인덱스는 공짜가 아니다. 다음과 같은 비용이 발생한다.
| 항목 | 인덱스 없음 | 인덱스 있음 |
|---|---|---|
| SELECT (검색) | 느림 (Full Scan) | 빠름 (Tree 탐색) |
| INSERT | 빠름 (끝에 추가) | 느림 (인덱스 트리 갱신) |
| UPDATE | 보통 | 느림 (인덱스 키 변경 시 트리 재구성) |
| DELETE | 보통 | 느림 (인덱스 엔트리 삭제 + 트리 재구성) |
| 저장 공간 | 데이터만 | 데이터 + 인덱스 공간 |
인덱스는 “읽기 성능을 높이는 대신 쓰기 성능을 낮추는” 트레이드오프다. 인덱스가 많을수록 INSERT/UPDATE/DELETE가 느려진다. 각 쓰기 연산마다 모든 인덱스를 갱신해야 하기 때문이다.
2. B-Tree와 B+Tree 심층
2.1 왜 Hash가 아닌 B-Tree 계열인가
“데이터를 빠르게 찾는 자료구조”라면 Hash Table이 O(1)로 가장 빠르다. 그런데 왜 DB 인덱스는 Hash가 아닌 B-Tree 계열을 사용하는가?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Hash Index의 한계]
Hash: key → hash(key) → bucket → 값
장점:
- 등호(=) 검색: O(1) — 매우 빠름
단점:
- 범위 검색 불가: WHERE age > 20 → 해시로는 "20보다 큰 값"을 찾을 수 없다
- 정렬 불가: ORDER BY age → 해시는 정렬 순서를 보장하지 않는다
- 부등호 불가: WHERE age >= 20 AND age <= 30
- LIKE 앞부분 일치 불가: WHERE name LIKE '김%'
- 부분 키 검색 불가: (a, b) 복합 인덱스에서 a만으로 검색 불가
→ 실무 쿼리의 대부분은 범위 검색, 정렬, LIKE가 포함되어 있다
→ Hash Index로는 이런 쿼리를 처리할 수 없다
1
2
3
4
5
6
7
8
9
10
11
[B+Tree Index의 강점]
B+Tree: key → 정렬된 트리 탐색 → 값
- 등호(=) 검색: O(log N) — 충분히 빠름
- 범위 검색: WHERE age > 20 → 20을 찾은 후 리프 노드를 순차 탐색
- 정렬: ORDER BY age → 이미 정렬되어 있으므로 추가 정렬 불필요
- LIKE 앞부분 일치: WHERE name LIKE '김%' → '김'을 찾은 후 순차 탐색
- 복합 인덱스: (a, b)에서 a만으로 검색 가능 (최좌선 접두사 규칙)
→ SQL에서 필요한 거의 모든 검색 패턴을 지원한다
MySQL InnoDB는 Hash Index도 Adaptive Hash Index라는 형태로 내부적으로 자동 생성하여 사용하지만, 사용자가 명시적으로 생성하는 인덱스는 B+Tree다.
2.2 B-Tree의 기본 구조
B-Tree(Balanced Tree)는 자식 노드를 2개 이상 가질 수 있는 균형 트리다. 이진 탐색 트리(BST)가 자식을 최대 2개 가지는 것과 달리, B-Tree는 수백~수천 개의 자식을 가질 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
[B-Tree (차수 3: 각 노드에 최대 2개의 키, 3개의 자식)]
[30, 60] ← 루트 노드
/ | \
[10, 20] [40, 50] [70, 80] ← 리프 노드
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
데이터 데이터 데이터 ← B-Tree는 모든 노드에 데이터
키 35 검색:
1. 루트 [30, 60] → 30 < 35 < 60 → 가운데 자식으로
2. [40, 50] → 35 < 40 → 왼쪽 자식으로
3. 리프에서 35를 찾음
2.3 B+Tree가 B-Tree보다 DB에 적합한 이유
B+Tree는 B-Tree를 데이터베이스에 최적화한 변형이다. 핵심 차이점 세 가지:
차이 1: 데이터가 리프 노드에만 있다
1
2
3
4
5
6
7
8
9
10
[B-Tree]
[30|DATA, 60|DATA] ← 내부 노드에도 데이터가 있다
/ | \
[10|DATA, 20|DATA] [40|DATA] [70|DATA, 80|DATA]
[B+Tree]
[30, 60] ← 내부 노드에는 키만 있다
/ | \ ← 데이터가 없으므로 더 많은 키를 넣을 수 있다!
[10|D, 20|D, 30|D] → [40|D, 50|D, 60|D] → [70|D, 80|D]
↑ 리프 노드에만 데이터 ↑ 링크드 리스트로 연결
내부 노드에 데이터가 없으면 같은 크기의 노드에 더 많은 키를 저장할 수 있다. 키가 많으면 분기(fanout)가 늘어나고, 분기가 많으면 트리의 높이가 낮아진다. 트리 높이가 낮으면 디스크 I/O 횟수가 줄어든다.
차이 2: 리프 노드가 링크드 리스트로 연결되어 있다
1
2
3
4
5
6
7
8
9
10
11
[B+Tree의 리프 노드 연결]
리프1 [10, 20, 30] → 리프2 [40, 50, 60] → 리프3 [70, 80, 90]
범위 검색: WHERE age BETWEEN 25 AND 65
1. 트리 탐색으로 25 이상인 첫 번째 리프를 찾는다 → 리프1의 30
2. 리프1에서 30을 가져온다
3. 링크를 따라 리프2로 이동 → 40, 50, 60을 가져온다
4. 링크를 따라 리프3으로 이동 → 65 이하이므로 탐색 종료
→ 트리를 다시 올라갈 필요 없이 리프 노드만 순차적으로 읽으면 된다!
B-Tree에서 범위 검색을 하려면 중위 순회(inorder traversal)를 해야 하고, 이는 내부 노드를 반복적으로 방문해야 한다. B+Tree는 리프 노드의 링크만 따라가면 되므로 범위 검색에 극적으로 효율적이다.
차이 3: 모든 키가 리프 노드에 존재한다
B-Tree에서는 키 30이 내부 노드에만 있을 수 있다. B+Tree에서는 내부 노드의 30이 리프 노드에도 반드시 존재한다. 이 덕분에 모든 검색이 항상 리프 노드까지 내려간다. 검색 경로의 길이가 일정하므로 성능이 예측 가능하다.
2.4 B+Tree의 높이와 실제 저장 용량
InnoDB의 페이지 크기는 기본 16KB다. B+Tree의 각 노드가 하나의 페이지에 대응한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[B+Tree 높이별 저장 가능 행 수 계산]
전제:
- 페이지 크기: 16KB = 16,384 bytes
- 내부 노드의 키 크기: BIGINT(8 bytes) + 자식 포인터(6 bytes) = 14 bytes
- 내부 노드 하나에 들어가는 키 수: 16,384 / 14 ≈ 1,170개
- 리프 노드의 행 크기: 평균 100 bytes (컬럼에 따라 다름)
- 리프 노드 하나에 들어가는 행 수: 16,384 / 100 ≈ 160행
높이 1 (루트만):
행 수: 160
디스크 I/O: 1회
높이 2 (루트 + 리프):
행 수: 1,170 × 160 = 187,200 (약 18만)
디스크 I/O: 2회
높이 3 (루트 + 중간 + 리프):
행 수: 1,170 × 1,170 × 160 = 219,024,000 (약 2.2억!)
디스크 I/O: 3회
높이 4:
행 수: 1,170^3 × 160 ≈ 2,562억
디스크 I/O: 4회
높이 3의 B+Tree로 약 2억 행을 커버할 수 있다. 디스크 I/O 3회로 2억 행 중 원하는 1행을 찾을 수 있다는 뜻이다. 게다가 루트 노드는 거의 항상 메모리(Buffer Pool)에 캐시되어 있으므로, 실제 디스크 I/O는 2회 이하인 경우가 대부분이다.
이것이 B+Tree가 데이터베이스에 적합한 핵심 이유다. 수억 건의 데이터에서도 2~3번의 디스크 접근으로 원하는 행을 찾을 수 있다.
3. 클러스터드 인덱스 (Clustered Index)
3.1 정의
클러스터드 인덱스는 테이블 데이터 자체가 인덱스 순서로 물리적으로 정렬되어 저장되는 인덱스다. 테이블당 하나만 존재할 수 있다. 데이터의 물리적 정렬 순서는 하나뿐이기 때문이다.
InnoDB에서는 테이블 = 클러스터드 인덱스다. InnoDB의 테이블은 PK를 기준으로 B+Tree로 구성된다. 이를 IOT(Index Organized Table)라 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[InnoDB 클러스터드 인덱스 구조]
[내부 노드: PK 값들]
/ | \
[PK 1~100] [PK 101~200] [PK 201~300]
↓ ↓ ↓
리프 노드 리프 노드 리프 노드
┌──────────┐ ┌──────────┐ ┌──────────┐
│PK=1 │ │PK=101 │ │PK=201 │
│name=김철수│ │name=박민수│ │name=최동현│
│age=25 │ │age=30 │ │age=22 │
│team_id=1 │ │team_id=2 │ │team_id=3 │
├──────────┤ ├──────────┤ ├──────────┤
│PK=2 │ │PK=102 │ │PK=202 │
│name=이영희│ │... │ │... │
│age=28 │ │ │ │ │
│team_id=1 │ │ │ │ │
└──────────┘ └──────────┘ └──────────┘
↑ 리프 노드 = 실제 데이터 행!
리프 노드가 곧 데이터 행이다. 클러스터드 인덱스에서 PK로 검색하면 리프 노드에 도달하는 순간 모든 컬럼 데이터를 바로 읽을 수 있다. 추가적인 디스크 접근이 필요 없다.
3.2 클러스터드 인덱스의 선택 기준
InnoDB는 다음 순서로 클러스터드 인덱스를 선택한다.
1
2
3
1. PRIMARY KEY가 있으면 → PK가 클러스터드 인덱스
2. PK가 없고 UNIQUE NOT NULL 컬럼이 있으면 → 그 컬럼이 클러스터드 인덱스
3. 둘 다 없으면 → InnoDB가 내부적으로 GEN_CLUST_INDEX(6바이트 Row ID)를 생성
3번의 경우 개발자가 Row ID에 접근할 수 없으므로, 테이블에는 반드시 PK를 정의하는 것이 좋다.
3.3 PK 선택과 성능
클러스터드 인덱스의 PK 선택은 테이블 전체의 성능에 영향을 미친다.
AUTO_INCREMENT PK: 최적
1
2
3
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
AUTO_INCREMENT는 항상 증가하는 순차 값을 생성한다. 새로운 행은 항상 B+Tree의 맨 끝 리프 노드에 추가된다.
1
2
리프: [1, 2, 3] → [4, 5, 6] → [7, 8, 9] → [10, 11, NEW]
↑ 항상 끝에 추가
맨 끝에 순서대로 추가되므로 페이지 분할(Page Split)이 거의 발생하지 않는다. 가장 효율적인 삽입 패턴이다.
UUID PK: 성능 문제
1
2
3
@Id
private String id = UUID.randomUUID().toString();
// "550e8400-e29b-41d4-a716-446655440000" 같은 랜덤 문자열
UUID는 완전히 랜덤한 값이다. 새로운 행이 B+Tree의 임의의 위치에 삽입된다.
1
2
3
리프: [10ab..., 22fc..., 35da...] → [44bc..., NEW(3f2e...), 55ef...]
↑ 중간에 삽입!
→ 페이지 분할 발생!
중간에 삽입하면 기존 페이지의 데이터를 반으로 나눠야 한다. 이것이 페이지 분할(Page Split)이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[페이지 분할 과정]
원래 페이지: [A, B, C, D, E] (가득 참)
새 데이터 C' (C와 D 사이에 삽입해야 함)
1. 새 페이지를 할당한다
2. 원래 페이지의 절반을 새 페이지로 이동
원래 페이지: [A, B, C']
새 페이지: [D, E]
3. 상위 노드의 포인터를 갱신한다
비용:
- 새 페이지 할당: 디스크 I/O
- 데이터 이동: 디스크 I/O
- 상위 노드 갱신: 디스크 I/O
- 페이지 사용률 50%로 하락 → 공간 낭비
UUID PK를 사용하면 INSERT마다 랜덤한 위치에 삽입 → 빈번한 페이지 분할 → INSERT 성능 저하 + 공간 낭비 + B+Tree 높이 증가가 발생한다.
| PK 전략 | INSERT 성능 | 공간 효율 | PK 크기 |
|---|---|---|---|
| AUTO_INCREMENT (BIGINT) | 최적 (순차 삽입) | 최적 (분할 없음) | 8 bytes |
| UUID (CHAR 36) | 느림 (랜덤 삽입) | 나쁨 (분할 빈번) | 36 bytes |
| UUID v7 (시간 순서) | 양호 (거의 순차) | 양호 | 36 bytes |
UUID를 PK로 사용해야 하는 경우(분산 환경 등)에는 UUID v7(시간 기반 정렬 가능한 UUID)을 사용하면 순차 삽입에 가까운 패턴을 얻을 수 있다.
4. 세컨더리 인덱스 (Secondary Index / Non-Clustered Index)
4.1 구조
세컨더리 인덱스는 클러스터드 인덱스와 별도로 유지되는 인덱스다. 리프 노드에 실제 데이터 행이 아니라 PK 값을 저장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
[세컨더리 인덱스: idx_member_name]
[내부 노드: name 값들]
/ | \
[가~나] [다~마] [바~하]
↓ ↓ ↓
리프 노드 리프 노드 리프 노드
┌────────┐ ┌────────┐ ┌────────┐
│김철수→PK5│ │박민수→PK3│ │이영희→PK2│
│김현수→PK9│ │박지성→PK7│ │정수진→PK4│
└────────┘ └────────┘ └────────┘
↑ 리프 노드 = (인덱스 키, PK 값)
실제 데이터는 없다!
4.2 더블 룩업 (Double Lookup)
세컨더리 인덱스로 검색하면 두 번의 트리 탐색이 필요하다.
1
SELECT * FROM member WHERE name = '김철수';
1
2
3
4
5
6
7
8
9
10
11
12
13
[더블 룩업 과정]
1단계 — 세컨더리 인덱스(idx_member_name) 탐색:
B+Tree에서 '김철수'를 찾는다
→ 리프 노드에서 PK = 5를 얻는다
2단계 — 클러스터드 인덱스(PK) 탐색:
PK = 5로 클러스터드 인덱스 B+Tree를 탐색한다
→ 리프 노드에서 실제 데이터 행을 읽는다
→ {id: 5, name: "김철수", age: 25, team_id: 1}
총 디스크 I/O: (세컨더리 인덱스 높이) + (클러스터드 인덱스 높이)
= 3 + 3 = 6회 (최악의 경우)
이 더블 룩업 때문에 세컨더리 인덱스 검색은 PK 검색보다 느리다. 이것이 커버링 인덱스가 중요한 이유다 — 커버링 인덱스를 사용하면 2단계(클러스터드 인덱스 탐색)를 건너뛸 수 있다.
4.3 왜 세컨더리 인덱스는 PK를 저장하는가
MyISAM은 세컨더리 인덱스에 행의 물리적 주소(파일 오프셋)를 저장한다. InnoDB는 PK 값을 저장한다.
InnoDB가 PK 값을 저장하는 이유:
1
2
3
4
5
6
7
8
9
10
11
12
13
[물리적 주소를 저장하면 발생하는 문제]
클러스터드 인덱스에서 페이지 분할이 일어나면
→ 데이터 행의 물리적 위치가 바뀐다
→ 모든 세컨더리 인덱스의 포인터를 갱신해야 한다!
→ 세컨더리 인덱스가 10개면 10개 전부 갱신
[PK 값을 저장하면]
클러스터드 인덱스에서 페이지 분할이 일어나도
→ PK 값은 바뀌지 않는다
→ 세컨더리 인덱스를 갱신할 필요가 없다!
→ 대신 검색 시 PK로 한 번 더 탐색해야 한다 (더블 룩업)
쓰기 시 인덱스 갱신 비용 절감 vs 읽기 시 더블 룩업 비용의 트레이드오프에서, InnoDB는 쓰기 비용 절감을 선택했다.
5. 복합 인덱스 (Composite Index)
5.1 복합 인덱스의 정렬 원리
복합 인덱스는 여러 컬럼을 조합한 하나의 인덱스다. 정렬은 첫 번째 컬럼 → 두 번째 컬럼 → 세 번째 컬럼 순서로 이루어진다.
1
CREATE INDEX idx_member_team_age ON member(team_id, age);
1
2
3
4
5
6
7
8
9
10
11
12
[복합 인덱스 (team_id, age)의 정렬 상태]
team_id | age | PK
--------|-----|----
1 | 20 | 7
1 | 25 | 1 ← team_id=1 내에서 age 순으로 정렬
1 | 28 | 2
2 | 22 | 5
2 | 30 | 3 ← team_id=2 내에서 age 순으로 정렬
2 | 35 | 8
3 | 24 | 4
3 | 27 | 6 ← team_id=3 내에서 age 순으로 정렬
team_id로 먼저 정렬하고, team_id가 같은 행들은 age로 정렬한다. 전체적으로 age만 보면 정렬되어 있지 않다 (20, 25, 28, 22, 30, 35, 24, 27).
5.2 최좌선 접두사 규칙 (Leftmost Prefix Rule)
복합 인덱스 (A, B, C)에서 인덱스를 사용할 수 있는 조건:
1
2
3
4
5
6
7
8
✅ WHERE A = 1 → A만 사용 (인덱스 사용 O)
✅ WHERE A = 1 AND B = 2 → A+B 사용 (인덱스 사용 O)
✅ WHERE A = 1 AND B = 2 AND C = 3 → A+B+C 모두 사용 (인덱스 사용 O)
✅ WHERE A = 1 AND C = 3 → A만 사용 (B를 건너뛰었으므로 C는 인덱스 활용 안 됨)
❌ WHERE B = 2 → 인덱스 사용 불가
❌ WHERE C = 3 → 인덱스 사용 불가
❌ WHERE B = 2 AND C = 3 → 인덱스 사용 불가
왜 그런가? 복합 인덱스의 정렬 구조를 보면 이해할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
인덱스 (team_id, age):
team_id=1에서 age가 정렬되어 있다 (20, 25, 28)
team_id=2에서 age가 정렬되어 있다 (22, 30, 35)
team_id=3에서 age가 정렬되어 있다 (24, 27)
전체에서 age만 보면? (20, 25, 28, 22, 30, 35, 24, 27) → 정렬 안 되어 있음!
따라서:
WHERE team_id = 1 AND age = 25 → team_id=1 그룹 내에서 age=25 탐색 ✅
WHERE age = 25 → 전체에서 age=25를 찾으려면 Full Scan ❌
5.3 복합 인덱스 컬럼 순서 결정
복합 인덱스의 컬럼 순서는 쿼리 패턴에 따라 결정해야 한다.
규칙: 등호(=) 조건 → 범위 조건 → 정렬 컬럼
1
2
3
4
5
-- 대표 쿼리
SELECT * FROM order
WHERE status = 'COMPLETED'
AND created_at >= '2026-01-01'
ORDER BY total_amount DESC;
1
2
3
4
5
6
7
8
9
최적 인덱스: (status, created_at, total_amount)
이유:
1. status = 'COMPLETED' → 등호 조건, 인덱스의 첫 번째 컬럼으로 정확히 매칭
2. created_at >= '2026-01-01' → 범위 조건, 두 번째 컬럼에서 범위 탐색
3. total_amount DESC → 정렬, 세 번째 컬럼이 이미 정렬되어 있으면 filesort 불필요
잘못된 순서: (created_at, status, total_amount)
→ created_at이 범위 조건이므로, 그 뒤의 status는 인덱스를 활용할 수 없다!
범위 조건 뒤의 컬럼은 인덱스를 활용하지 못한다. 이것이 가장 중요한 규칙이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[범위 조건이 인덱스 활용을 끊는 이유]
인덱스 (A, B): A로 정렬 → A가 같으면 B로 정렬
A=1, B 정렬: (1,10), (1,20), (1,30)
A=2, B 정렬: (2,15), (2,25)
A=3, B 정렬: (3,5), (3,35)
WHERE A = 1 AND B > 15:
→ A=1인 구간에서 B > 15 탐색 → (1,20), (1,30) ✅
WHERE A > 1 AND B > 15:
→ A > 1인 구간: (2,15), (2,25), (3,5), (3,35)
→ 이 구간에서 B는 정렬되어 있지 않다! (15, 25, 5, 35)
→ B > 15 조건에 인덱스를 활용할 수 없다!
5.4 카디널리티와 컬럼 순서
카디널리티(Cardinality)는 컬럼의 고유 값 개수다.
1
2
3
4
gender: M, F → 카디널리티 2 (낮음)
status: PENDING, COMPLETED, CANCELLED → 카디널리티 3 (낮음)
email: 고유값 수백만 → 카디널리티 높음
created_at: 고유값 수백만 → 카디널리티 높음
“카디널리티가 높은 컬럼을 앞에 놓아라”는 흔한 조언이 있지만, 실제로는 쿼리 패턴이 더 중요하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 이 쿼리가 빈번하다면:
SELECT * FROM order WHERE status = 'PENDING' AND email = 'kim@test.com';
-- 카디널리티 기준: (email, status) — email이 카디널리티 높으니까
-- 쿼리 패턴 기준: (status, email) 또는 (email, status) 둘 다 OK
-- → 둘 다 등호 조건이므로 순서 무관
-- 하지만 이 쿼리도 자주 사용한다면:
SELECT * FROM order WHERE status = 'PENDING';
-- (status, email) 인덱스는 이 쿼리에도 사용 가능 (최좌선 접두사)
-- (email, status) 인덱스는 이 쿼리에 사용 불가!
-- → 쿼리 패턴을 기준으로 (status, email)이 더 범용적이다
6. 커버링 인덱스 (Covering Index)
6.1 정의
커버링 인덱스는 쿼리에 필요한 모든 컬럼이 인덱스에 포함되어 있어, 클러스터드 인덱스(실제 테이블)에 접근하지 않고 인덱스만으로 쿼리를 처리할 수 있는 인덱스다.
1
2
3
-- 인덱스: (team_id, age)
-- 쿼리에 필요한 컬럼: team_id, age → 모두 인덱스에 있다!
SELECT team_id, age FROM member WHERE team_id = 1;
1
2
3
4
5
6
7
8
[커버링 인덱스 동작]
일반 인덱스 검색 (더블 룩업):
세컨더리 인덱스 탐색 → PK 획득 → 클러스터드 인덱스 탐색 → 데이터 반환
커버링 인덱스 검색:
세컨더리 인덱스 탐색 → 인덱스 리프 노드에서 바로 데이터 반환!
↑ 클러스터드 인덱스 접근 불필요! (더블 룩업 제거)
6.2 EXPLAIN에서 확인
1
EXPLAIN SELECT team_id, age FROM member WHERE team_id = 1;
| id | type | key | Extra |
|---|---|---|---|
| 1 | ref | idx_member_team_age | Using index |
Extra: Using index가 커버링 인덱스가 적용되었다는 의미다.
6.3 커버링 인덱스의 성능 차이
1
2
3
4
5
6
7
8
9
10
11
12
-- 테이블: member (1000만 행)
-- 인덱스: (status, created_at)
-- 커버링 인덱스 적용 (status, created_at만 조회)
SELECT status, created_at FROM member WHERE status = 'ACTIVE';
-- → Using index, 0.3초
-- 커버링 인덱스 미적용 (* 조회)
SELECT * FROM member WHERE status = 'ACTIVE';
-- → 더블 룩업 발생, 2.1초
-- 약 7배 차이!
SELECT *는 인덱스에 없는 컬럼(name, email 등)도 필요하므로 클러스터드 인덱스에 접근해야 한다. 커버링 인덱스의 효과를 누리려면 필요한 컬럼만 SELECT해야 한다.
6.4 JPA에서 커버링 인덱스 활용
JPA의 findAll()은 SELECT *를 생성하므로 커버링 인덱스를 활용할 수 없다. DTO Projection을 사용해야 한다.
1
2
3
4
5
6
7
8
9
// 커버링 인덱스 활용 불가 (SELECT *)
List<Member> members = memberRepository.findByStatus("ACTIVE");
// → SELECT m.id, m.name, m.age, m.email, ... FROM member m WHERE m.status = ?
// 커버링 인덱스 활용 가능 (필요한 컬럼만)
@Query("SELECT m.status, m.createdAt FROM Member m WHERE m.status = :status")
List<Object[]> findStatusAndCreatedAt(@Param("status") String status);
// → SELECT m.status, m.created_at FROM member m WHERE m.status = ?
// → Using index!
7. 인덱스가 사용되지 않는 경우들
인덱스를 만들어두었는데 쿼리에서 사용되지 않는 경우가 많다. 옵티마이저가 인덱스를 사용하지 않기로 판단하거나, 인덱스를 사용할 수 없는 구조인 경우다.
7.1 인덱스 컬럼에 함수/연산 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- ❌ 인덱스 사용 불가
SELECT * FROM member WHERE YEAR(created_at) = 2026;
-- created_at 컬럼에 YEAR() 함수를 적용 → 인덱스 키 값이 변환됨
-- 인덱스에는 '2026-03-09 10:00:00' 형태로 저장되어 있는데
-- YEAR()로 변환한 2026과 비교할 수 없다
-- ✅ 해결: 범위 조건으로 변환
SELECT * FROM member
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
-- ❌ 인덱스 사용 불가
SELECT * FROM member WHERE age + 1 = 26;
-- ✅ 해결: 연산을 반대편으로
SELECT * FROM member WHERE age = 25;
MySQL 8.0+에서는 함수 기반 인덱스로 해결할 수 있다:
1
2
3
-- 함수 기반 인덱스 (MySQL 8.0+)
CREATE INDEX idx_year_created ON member((YEAR(created_at)));
SELECT * FROM member WHERE YEAR(created_at) = 2026; -- 인덱스 사용 가능!
7.2 암묵적 타입 변환
1
2
3
4
5
6
7
8
9
10
-- phone_number가 VARCHAR 타입인 경우
-- ❌ 인덱스 사용 불가
SELECT * FROM member WHERE phone_number = 01012345678;
-- 숫자 01012345678과 문자열 '01012345678'을 비교
-- MySQL은 문자열을 숫자로 변환하여 비교한다
-- → phone_number 컬럼에 함수(CAST)가 적용되는 것과 같은 효과
-- → 인덱스 사용 불가!
-- ✅ 해결: 타입을 맞춘다
SELECT * FROM member WHERE phone_number = '01012345678';
이것은 JPA에서도 주의해야 한다. 파라미터 바인딩 시 타입이 컬럼 타입과 일치해야 한다.
7.3 LIKE 앞쪽 와일드카드
1
2
3
4
5
6
7
8
9
10
11
-- ✅ 인덱스 사용 가능: 앞부분 일치
SELECT * FROM member WHERE name LIKE '김%';
-- '김'으로 시작하는 범위를 B+Tree에서 찾을 수 있다
-- ❌ 인덱스 사용 불가: 앞쪽 와일드카드
SELECT * FROM member WHERE name LIKE '%철수';
-- '?철수'인 값을 정렬된 트리에서 찾을 수 없다
-- → Full Table Scan
-- ❌ 인덱스 사용 불가
SELECT * FROM member WHERE name LIKE '%철%';
앞쪽 와일드카드 검색이 필요하면 Full-Text Index 또는 Elasticsearch 같은 별도의 검색 엔진을 사용해야 한다.
7.4 OR 조건
1
2
3
4
5
6
7
8
9
10
-- ❌ 인덱스 효율이 떨어질 수 있음
SELECT * FROM member WHERE name = '김철수' OR age = 25;
-- name에 인덱스가 있고 age에 인덱스가 있어도,
-- OR로 연결되면 두 인덱스를 각각 탐색한 후 합쳐야 한다 (Index Merge)
-- 옵티마이저가 Full Scan이 더 효율적이라고 판단할 수 있다
-- ✅ 해결: UNION으로 분리
SELECT * FROM member WHERE name = '김철수'
UNION
SELECT * FROM member WHERE age = 25;
7.5 NOT, !=, <> 조건
1
2
3
4
5
6
7
-- ❌ 인덱스 비효율적 (대부분 Full Scan)
SELECT * FROM member WHERE status != 'DELETED';
-- 'DELETED'가 아닌 행이 대부분이라면 Full Scan이 더 효율적
-- 옵티마이저가 인덱스를 사용하지 않기로 판단
-- ✅ 해결: 긍정 조건으로 변환
SELECT * FROM member WHERE status IN ('ACTIVE', 'SUSPENDED', 'PENDING');
7.6 데이터 분포와 옵티마이저 판단
인덱스가 있어도 조건에 해당하는 행이 전체의 20~30% 이상이면 옵티마이저가 Full Scan을 선택한다.
1
2
3
4
5
6
7
8
9
10
-- gender 컬럼: M, F (카디널리티 2)
-- M이 500만 행, F가 500만 행
CREATE INDEX idx_gender ON member(gender);
SELECT * FROM member WHERE gender = 'M';
-- 1000만 행 중 500만 행(50%)을 가져와야 한다
-- 인덱스 사용: 500만 번의 더블 룩업 = 500만 × 2 = 1000만 I/O
-- Full Scan: 1000만 행을 순차 읽기 = 훨씬 빠름!
-- → 옵티마이저가 Full Scan을 선택한다
인덱스는 선택도(Selectivity)가 높을 때 효과적이다. 선택도 = 조건에 맞는 행 / 전체 행. 선택도가 낮을수록(소수의 행만 선택) 인덱스가 효과적이다.
7.7 인덱스 사용 불가 정리
| 원인 | 예시 | 해결 |
|---|---|---|
| 컬럼에 함수/연산 | YEAR(col) = 2026 |
col >= '2026-01-01' |
| 타입 불일치 | varchar_col = 12345 |
varchar_col = '12345' |
| 앞쪽 와일드카드 | LIKE '%keyword' |
Full-Text Index |
| OR 조건 | col1 = 1 OR col2 = 2 |
UNION 분리 |
| NOT/!= | status != 'DELETED' |
IN 긍정 조건 |
| 낮은 선택도 | 50% 이상 매칭 | 인덱스 불필요 |
| 최좌선 접두사 위반 | (A,B,C) 인덱스에서 B만 |
별도 인덱스 또는 재설계 |
8. 실행 계획 (EXPLAIN) 읽는 법
8.1 EXPLAIN 기본 사용
1
EXPLAIN SELECT * FROM member WHERE name = '김철수';
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | member | ref | idx_name | idx_name | 402 | const | 3 | 100.00 | NULL |
8.2 type 컬럼 (접근 방식) — 가장 중요
type은 테이블에 어떻게 접근하는지를 나타낸다. 위에서 아래로 갈수록 성능이 나쁘다.
1
2
3
4
5
6
7
system → 테이블에 행이 1개 (시스템 테이블)
const → PK/UNIQUE로 1행 조회. WHERE id = 1
eq_ref → JOIN에서 PK/UNIQUE로 1행씩 매칭
ref → 인덱스로 여러 행 조회. WHERE status = 'ACTIVE'
range → 인덱스 범위 검색. WHERE age BETWEEN 20 AND 30
index → 인덱스 Full Scan (테이블 Full Scan보다는 나음)
ALL → Full Table Scan (최악)
실무 기준:
const,eq_ref,ref: 양호range: 대부분 양호, 범위가 너무 넓으면 주의index: 커버링 인덱스가 아니면 비효율적ALL: 반드시 개선 필요 (대용량 테이블에서 치명적)
8.3 Extra 컬럼 핵심 값
1
2
3
4
5
Using index → 커버링 인덱스! 테이블 접근 없이 인덱스만으로 처리 ✅
Using where → WHERE 조건을 스토리지 엔진이 아닌 MySQL 서버에서 필터링
Using index condition → ICP(Index Condition Pushdown) 적용 ✅
Using temporary → 임시 테이블 사용 ⚠️ (GROUP BY, DISTINCT 등)
Using filesort → 추가 정렬 필요 ⚠️ (ORDER BY를 인덱스로 처리 못 함)
Using filesort와 Using temporary가 동시에 나타나면 성능 문제의 신호다. 인덱스를 조정하여 해결할 수 있는지 검토해야 한다.
8.4 Index Condition Pushdown (ICP)
1
2
-- 인덱스: (name, age)
SELECT * FROM member WHERE name LIKE '김%' AND age > 25;
ICP 없이:
1
2
3
4
1. 인덱스에서 name LIKE '김%'인 모든 행의 PK를 가져온다 (100행)
2. 100행 각각에 대해 클러스터드 인덱스에서 전체 데이터를 읽는다
3. MySQL 서버에서 age > 25 조건을 필터링한다 (30행만 남음)
→ 불필요하게 70행의 클러스터드 인덱스 접근이 발생!
ICP 적용:
1
2
3
4
5
1. 인덱스에서 name LIKE '김%' AND age > 25인 행의 PK만 가져온다
(인덱스에 age가 포함되어 있으므로 인덱스 레벨에서 필터링 가능!)
→ 30행
2. 30행에 대해서만 클러스터드 인덱스 접근
→ 70행의 불필요한 클러스터드 인덱스 접근을 제거!
8.5 EXPLAIN ANALYZE (MySQL 8.0+)
1
2
3
4
EXPLAIN ANALYZE
SELECT m.name, t.name
FROM member m JOIN team t ON m.team_id = t.id
WHERE m.age > 25;
1
2
3
→ Nested loop inner join (cost=4.5 rows=3) (actual time=0.05..0.08 rows=3 loops=1)
→ Index range scan on m using idx_age (cost=2.5 rows=3) (actual time=0.03..0.04 rows=3 loops=1)
→ Single-row index lookup on t using PRIMARY (id=m.team_id) (cost=0.5 rows=1) (actual time=0.01..0.01 rows=1 loops=3)
EXPLAIN ANALYZE는 실제로 쿼리를 실행하고 실측값(actual)을 보여준다. 예상(estimated)과 실제의 차이를 확인할 수 있어 튜닝에 유용하다.
9. 인덱스 설계 실무 전략
9.1 WHERE 절 분석 → 인덱스 후보 도출
1
2
3
4
5
6
7
-- 애플리케이션의 주요 쿼리를 수집한다
-- 쿼리 1: 회원 검색
SELECT * FROM member WHERE status = 'ACTIVE' AND team_id = 3;
-- 쿼리 2: 주문 조회
SELECT * FROM order WHERE member_id = 5 AND status = 'PENDING' ORDER BY created_at DESC;
-- 쿼리 3: 상품 검색
SELECT * FROM product WHERE category_id = 2 AND price BETWEEN 10000 AND 50000;
분석:
1
2
3
쿼리 1: (status, team_id) 또는 (team_id, status)
쿼리 2: (member_id, status, created_at) — 등호+등호+정렬
쿼리 3: (category_id, price) — 등호+범위
9.2 ORDER BY와 인덱스
인덱스가 쿼리의 ORDER BY와 같은 순서로 정렬되어 있으면, 추가 정렬(filesort) 없이 인덱스 순서대로 결과를 반환할 수 있다.
1
2
3
4
5
6
7
8
9
10
-- 인덱스: (status, created_at)
-- ✅ 인덱스 순서와 일치 → filesort 없음
SELECT * FROM order WHERE status = 'PENDING' ORDER BY created_at;
-- ✅ DESC도 가능 (역순 스캔)
SELECT * FROM order WHERE status = 'PENDING' ORDER BY created_at DESC;
-- ❌ 인덱스 순서와 불일치 → filesort 발생
SELECT * FROM order WHERE status = 'PENDING' ORDER BY total_amount;
9.3 GROUP BY와 인덱스
1
2
3
4
5
6
7
8
9
10
11
-- 인덱스: (team_id, status)
-- ✅ GROUP BY가 인덱스 순서와 일치 → Using index for group-by
SELECT team_id, status, COUNT(*)
FROM member
GROUP BY team_id, status;
-- ❌ GROUP BY 순서가 다름 → temporary + filesort
SELECT status, team_id, COUNT(*)
FROM member
GROUP BY status, team_id;
9.4 JOIN과 인덱스
JOIN의 성능은 조인 컬럼에 인덱스가 있는지에 크게 의존한다.
1
2
3
4
SELECT m.name, t.name
FROM member m
JOIN team t ON m.team_id = t.id
WHERE t.name = '개발팀';
1
2
3
4
5
6
7
8
9
[인덱스가 없는 경우]
1. team 테이블에서 name = '개발팀' Full Scan → 1행 찾음 (team_id = 1)
2. member 테이블에서 team_id = 1 Full Scan → 전체 행 스캔!
→ member에 1000만 행이면 1000만 행 스캔
[member.team_id에 인덱스가 있는 경우]
1. team 테이블에서 name = '개발팀' 조회 (team.name에 인덱스 있으면 더 빠름)
2. member 테이블에서 team_id = 1 인덱스 검색 → 해당 행만 정확히 접근!
→ 수십 건만 읽음
FK(Foreign Key) 컬럼에는 반드시 인덱스를 걸어야 한다. JPA에서 @ManyToOne의 FK 컬럼에는 인덱스가 자동으로 생성되지 않는다 (InnoDB는 FK 제약조건을 걸면 자동 생성하지만, JPA의 @JoinColumn만으로는 보장되지 않는다).
9.5 인덱스 개수의 적정선
1
2
3
4
5
6
7
8
9
테이블당 인덱스 권장 개수: 3~5개
인덱스가 너무 많으면:
- INSERT/UPDATE/DELETE마다 모든 인덱스를 갱신해야 한다
- 인덱스 저장 공간이 커진다
- 옵티마이저가 최적 인덱스를 선택하는 데 시간이 걸린다
인덱스가 너무 적으면:
- 자주 사용되는 쿼리에서 Full Scan이 발생한다
9.6 불필요한 인덱스 식별
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- MySQL에서 인덱스 사용 통계 확인
SELECT
table_name,
index_name,
stat_value AS pages,
stat_description
FROM mysql.innodb_index_stats
WHERE database_name = 'mydb'
AND stat_name = 'n_leaf_pages';
-- 사용되지 않는 인덱스 확인 (performance_schema)
SELECT
object_schema,
object_name,
index_name,
count_read,
count_write
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
AND count_read = 0
ORDER BY count_write DESC;
-- count_read = 0이면 읽기에 한 번도 사용되지 않은 인덱스!
-- 쓰기(count_write)만 발생 → 삭제 후보
10. JPA/Hibernate에서의 인덱스 활용
10.1 @Index 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Entity
@Table(
name = "member",
indexes = {
@Index(name = "idx_member_name", columnList = "name"),
@Index(name = "idx_member_team_age", columnList = "team_id, age"),
@Index(name = "idx_member_status_created",
columnList = "status, created_at DESC")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_member_email", columnNames = "email")
}
)
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
private String email;
private String status;
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
ddl-auto: create 또는 update를 사용하면 Hibernate가 CREATE INDEX DDL을 자동 생성한다. 운영 환경에서는 validate를 사용하고, DDL은 직접 관리하는 것이 안전하다.
10.2 JPA가 생성하는 SQL과 인덱스
Spring Data JPA의 메서드 이름 쿼리가 어떤 SQL을 생성하는지 이해해야 인덱스를 설계할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// findByStatus → SELECT * FROM member WHERE status = ?
List<Member> findByStatus(String status);
// 인덱스: (status) 또는 status를 포함하는 복합 인덱스
// findByTeamIdAndAgeGreaterThan → SELECT * FROM member WHERE team_id = ? AND age > ?
List<Member> findByTeamIdAndAgeGreaterThan(Long teamId, int age);
// 인덱스: (team_id, age) — 등호+범위 순서
// findByNameContaining → SELECT * FROM member WHERE name LIKE '%keyword%'
List<Member> findByNameContaining(String keyword);
// ⚠️ LIKE '%keyword%' → 인덱스 사용 불가! Full Scan!
// findByStatusOrderByCreatedAtDesc
// → SELECT * FROM member WHERE status = ? ORDER BY created_at DESC
List<Member> findByStatusOrderByCreatedAtDesc(String status);
// 인덱스: (status, created_at) → filesort 없이 처리 가능
10.3 Querydsl에서의 인덱스 친화적 쿼리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public List<MemberDto> searchMembers(MemberSearchCondition condition) {
return queryFactory
.select(Projections.constructor(MemberDto.class,
member.id,
member.name,
member.status
))
// DTO Projection으로 커버링 인덱스 활용 가능
.from(member)
.where(
statusEq(condition.getStatus()), // 등호
createdAtGoe(condition.getStartDate()), // 범위
createdAtLoe(condition.getEndDate()) // 범위
)
.orderBy(member.createdAt.desc())
.offset(condition.getOffset())
.limit(condition.getLimit())
.fetch();
}
// 최적 인덱스: (status, created_at)
// + SELECT에 id, name, status만 → 커버링 인덱스 고려
// → (status, created_at, id, name) 인덱스면 커버링 가능
10.4 생성된 SQL 확인 후 EXPLAIN
1
2
3
4
5
6
7
8
9
10
11
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
SQL 로그에서 실제 생성되는 SQL을 복사하여 EXPLAIN을 실행한다.
1
2
3
4
5
6
7
-- Hibernate가 생성한 SQL을 복사
EXPLAIN SELECT m.id, m.name, m.status
FROM member m
WHERE m.status = 'ACTIVE'
AND m.created_at >= '2026-01-01'
ORDER BY m.created_at DESC
LIMIT 20;
type이 ALL이면 인덱스 추가, Using filesort가 있으면 인덱스 컬럼 순서 조정, Using index가 나오면 최적 상태다.
11. 슬로우 쿼리 분석과 튜닝
11.1 슬로우 쿼리 로그 설정
1
2
3
4
-- MySQL 슬로우 쿼리 로그 활성화
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 1초 이상 걸리는 쿼리를 기록
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-query.log';
1
2
3
4
5
# Spring Boot에서 설정
spring:
datasource:
hikari:
leak-detection-threshold: 2000 # 2초 이상 커넥션 점유 시 경고
11.2 실무 튜닝 사례
Before: 슬로우 쿼리
1
2
3
4
5
6
7
8
9
-- 주문 내역 조회 (3.2초)
SELECT o.*, m.name, d.address
FROM orders o
JOIN member m ON o.member_id = m.id
JOIN delivery d ON o.delivery_id = d.id
WHERE o.status = 'COMPLETED'
AND o.created_at >= '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 20;
1
2
3
4
5
6
EXPLAIN 결과:
| table | type | key | rows | Extra |
|-------|------|------|---------|----------------|
| o | ALL | NULL | 5000000 | Using filesort |
| m | eq_ref| PRIMARY | 1 | |
| d | eq_ref| PRIMARY | 1 | |
문제: orders 테이블이 ALL(Full Scan) + filesort
After: 인덱스 추가
1
CREATE INDEX idx_orders_status_created ON orders(status, created_at);
1
2
3
4
5
6
EXPLAIN 결과:
| table | type | key | rows | Extra |
|-------|-------|---------------------------|------|-----------------------|
| o | range | idx_orders_status_created | 45 | Using index condition |
| m | eq_ref| PRIMARY | 1 | |
| d | eq_ref| PRIMARY | 1 | |
- type:
ALL→range(인덱스 범위 검색) - rows:
5,000,000→45 - Extra:
Using filesort제거 (인덱스 순서로 이미 정렬) - 실행 시간: 3.2초 → 0.01초
12. 인덱스 관련 고급 주제
12.1 유니크 인덱스 vs 일반 인덱스
1
2
CREATE UNIQUE INDEX uk_email ON member(email);
CREATE INDEX idx_name ON member(name);
읽기 성능 차이:
- 유니크 인덱스: 1건을 찾으면 즉시 탐색 종료
- 일반 인덱스: 1건을 찾은 후 다음 행도 확인 (같은 값이 더 있을 수 있으므로)
- 차이는 미미하다. InnoDB는 페이지 단위로 읽으므로 다음 행 확인 비용이 거의 없다.
쓰기 성능 차이:
- 유니크 인덱스: INSERT 시 중복 체크를 해야 한다. 이 과정에서 Change Buffer를 사용할 수 없다.
- 일반 인덱스: Change Buffer에 변경을 기록하고 나중에 반영할 수 있다 (지연 쓰기).
- 쓰기가 빈번한 경우 일반 인덱스가 유리하다.
결론: 비즈니스 로직상 유일성이 필요하면 유니크 인덱스, 아니면 일반 인덱스. 성능만을 위해 유니크 인덱스를 선택하지는 않는다.
12.2 접두사 인덱스 (Prefix Index)
1
2
3
4
5
-- VARCHAR(255) 전체를 인덱스 키로 사용하면 인덱스가 커진다
CREATE INDEX idx_email ON member(email); -- 전체 (최대 255 bytes)
-- 앞 20자만 인덱스 키로 사용
CREATE INDEX idx_email_prefix ON member(email(20)); -- 20 bytes만
접두사 인덱스의 장점: 인덱스 크기가 작아진다 → 메모리에 더 많은 인덱스를 캐시 → I/O 감소 접두사 인덱스의 단점: 접두사가 같은 행을 구별할 수 없다 → 커버링 인덱스 불가, ORDER BY 불가
1
2
3
4
5
6
7
8
9
10
11
-- 최적 접두사 길이 찾기
SELECT
COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS sel_5,
COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS sel_10,
COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS sel_15,
COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS sel_20,
COUNT(DISTINCT email) / COUNT(*) AS sel_full
FROM member;
-- 결과: sel_5=0.72, sel_10=0.95, sel_15=0.99, sel_20=1.00
-- → 15자면 선택도 99%, 20자면 100% → 15~20자가 적정
12.3 내림차순 인덱스 (MySQL 8.0+)
MySQL 8.0 이전에는 모든 인덱스가 오름차순이었다. DESC를 사용해도 내부적으로는 ASC 인덱스를 역순 스캔했다.
1
2
3
4
5
6
7
8
9
10
-- MySQL 8.0+: 실제 내림차순 인덱스
CREATE INDEX idx_created_desc ON order(created_at DESC);
-- 복합 인덱스에서 정렬 방향 혼합
CREATE INDEX idx_mixed ON order(status ASC, created_at DESC);
-- 이 인덱스가 최적인 쿼리:
SELECT * FROM order
WHERE status = 'PENDING'
ORDER BY created_at DESC;
12.4 인덱스와 Lock의 관계
InnoDB의 행 레벨 락은 인덱스 레코드에 건다. 이것은 매우 중요한 특성이다.
1
2
3
4
5
6
7
8
9
10
11
-- 인덱스가 있는 경우
-- idx_status 인덱스 존재
SELECT * FROM member WHERE status = 'ACTIVE' FOR UPDATE;
-- → status = 'ACTIVE'인 인덱스 레코드에만 락
-- 인덱스가 없는 경우
-- status에 인덱스 없음
SELECT * FROM member WHERE status = 'ACTIVE' FOR UPDATE;
-- → 인덱스가 없으므로 Full Table Scan
-- → 스캔하는 모든 행에 락! (사실상 테이블 락)
-- → 다른 트랜잭션이 member 테이블에 아무것도 할 수 없다!
인덱스가 없으면 FOR UPDATE가 테이블 전체를 잠그게 된다. 이것은 동시성을 극적으로 떨어뜨린다. WHERE 조건에 사용되는 컬럼에 인덱스가 있어야 필요한 행에만 락을 걸 수 있다.
이 내용은 트랜잭션 격리 수준 글에서 다룬 Gap Lock, Next-Key Lock과 직접적으로 연결된다. REPEATABLE READ에서 FOR UPDATE를 사용하면 인덱스 레코드에 Next-Key Lock이 걸리고, 인덱스가 없으면 모든 행의 클러스터드 인덱스 레코드에 락이 걸린다.
13. 인덱스 설계 실무 체크리스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[인덱스 설계 단계]
1단계 — 쿼리 수집
✅ 애플리케이션의 주요 쿼리를 수집했는가?
✅ 슬로우 쿼리 로그를 활성화했는가?
2단계 — 인덱스 후보 도출
✅ WHERE 절의 등호/범위 조건 컬럼을 식별했는가?
✅ ORDER BY, GROUP BY 컬럼을 식별했는가?
✅ JOIN 조건의 FK 컬럼에 인덱스가 있는가?
3단계 — 복합 인덱스 설계
✅ 등호 조건 → 범위 조건 → 정렬 컬럼 순서를 지켰는가?
✅ 최좌선 접두사 규칙을 고려하여 다양한 쿼리를 커버하는가?
✅ 커버링 인덱스 가능성을 검토했는가?
4단계 — 검증
✅ EXPLAIN으로 type이 ALL이 아닌지 확인했는가?
✅ Extra에 Using filesort/Using temporary가 없는지 확인했는가?
✅ rows가 합리적인 수치인가?
5단계 — 유지보수
✅ 사용되지 않는 인덱스를 주기적으로 확인하고 제거하는가?
✅ 테이블당 인덱스가 5개를 넘지 않는가?
✅ 인덱스 크기를 모니터링하고 있는가?
[인덱스를 만들지 말아야 하는 경우]
❌ 카디널리티가 극도로 낮은 컬럼 (boolean, gender)
❌ 거의 조회되지 않는 컬럼
❌ 쓰기가 99%, 읽기가 1%인 테이블
❌ 행 수가 매우 적은 테이블 (수백 행 이하 → Full Scan이 더 빠름)
결론
인덱스는 데이터베이스 성능의 근간이다. 인덱스 없는 1000만 행 테이블의 Full Scan은 수십 초가 걸리지만, 적절한 인덱스가 있으면 밀리초 단위로 결과를 얻을 수 있다.
B+Tree가 DB 인덱스의 표준인 이유는 범위 검색, 정렬, 부분 키 검색 등 SQL의 다양한 검색 패턴을 모두 지원하기 때문이다. 높이 3의 B+Tree로 2억 행을 커버할 수 있으며, 루트 노드가 메모리에 캐시되면 실제 디스크 I/O는 2회 이하다.
클러스터드 인덱스는 InnoDB 테이블의 물리적 저장 구조 자체다. PK로 검색하면 리프 노드에서 바로 모든 데이터를 읽을 수 있다. AUTO_INCREMENT PK가 성능상 최적인 이유는 순차 삽입으로 페이지 분할을 최소화하기 때문이다. UUID PK는 랜덤 삽입으로 페이지 분할을 유발한다.
세컨더리 인덱스는 리프 노드에 PK 값을 저장하므로, 검색 시 더블 룩업(세컨더리 → 클러스터드)이 발생한다. 커버링 인덱스는 이 더블 룩업을 제거하여 극적인 성능 향상을 제공한다. SELECT * 대신 필요한 컬럼만 조회해야 커버링 인덱스를 활용할 수 있다.
복합 인덱스의 컬럼 순서는 쿼리 패턴에 맞춰야 한다. 등호 조건 → 범위 조건 → 정렬 컬럼 순서가 기본 원칙이다. 범위 조건 뒤의 컬럼은 인덱스를 활용하지 못한다는 점을 반드시 기억해야 한다.
인덱스가 사용되지 않는 경우들(함수 적용, 타입 변환, 앞쪽 와일드카드, OR, 낮은 선택도)을 숙지하고, EXPLAIN으로 반드시 검증해야 한다. type이 ALL이면 인덱스 추가, Using filesort가 있으면 인덱스 순서 조정, Using index가 나오면 최적 상태다.
마지막으로, 인덱스가 없으면 InnoDB의 행 레벨 락이 사실상 테이블 락으로 동작한다는 점을 기억해야 한다. 인덱스는 단순히 검색 성능뿐 아니라 동시성(Lock 범위)에도 직접적인 영향을 미친다. 트랜잭션의 격리 수준, N+1 문제, 영속성 컨텍스트와 함께 인덱스를 종합적으로 이해할 때 비로소 JPA/DB 성능 최적화의 전체 그림이 완성된다.