인덱스는 SQL Server 성능 튜닝의 가장 핵심적인 요소 이다. 인덱스를 잘못 설계하면 아무리 좋은 하드웨어를 써도 쿼리가 느리고, 잘 설계하면 적은 리소스로도 빠른 응답을 얻을 수 있다.
이 글에서는 인덱스의 기본 개념부터 내부 구조(B-Tree), 실행 계획 읽는 법, 복합 인덱스 설계, 커버링 인덱스 등을 간단히 다룬다.
인덱스는 책의 색인(Index) 과 같다. 책에서 "트랜잭션"이라는 단어를 찾고 싶을 때:
데이터베이스에서도 마찬가지다:
[인덱스]
├── 인덱스 키 (검색 기준이 되는 컬럼)
└── 포인터 (실제 데이터 위치를 가리킴)
예를 들어 userId 컬럼에 인덱스가 있다면:
인덱스 키 → 데이터 위치
userId: 1 → Page 10, Row 3
userId: 2 → Page 15, Row 7
userId: 3 → Page 10, Row 5
...
SQL Server의 인덱스는 B-Tree(Balanced Tree) 구조로 저장된다. 정확히는 B+Tree 를 사용한다.
[Root Node]
/ | \
/ | \
[Intermediate] [Intermediate] [Intermediate]
/ \ / \ / \
/ \ / \ / \
[Leaf] [Leaf] [Leaf] [Leaf] [Leaf] [Leaf]
↕ ↕ ↕ ↕ ↕ ↕
(Data) (Data) (Data) (Data) (Data) (Data)
| 레벨 | 이름 | 역할 |
|---|---|---|
| Root Level | 루트 노드 | 검색의 시작점, 단 1개 |
| Intermediate Level | 중간 노드 | 하위 노드로 가는 길 안내 |
| Leaf Level | 리프 노드 | 실제 데이터 또는 데이터 위치 포함 |
userId = 57 을 찾는 과정:
1. Root Node 시작
- "57은 50보다 크고 100보다 작다" → 중간 노드 B로 이동
2. Intermediate Node B
- "57은 55보다 크고 60보다 작다" → Leaf Node X로 이동
3. Leaf Node X
- userId = 57 데이터 발견
이 과정은 O(log n) 의 시간 복잡도를 가진다. 100만 건의 데이터도 약 3~4번의 탐색 으로 찾을 수 있다.
로그 스케일의 마법:
| 데이터 건수 | 트리 깊이 (대략) | 필요한 페이지 읽기 |
|---|---|---|
| 1,000 | 2 | 2~3회 |
| 100,000 | 3 | 3~4회 |
| 10,000,000 | 4 | 4~5회 |
| 1,000,000,000 | 5 | 5~6회 |
10억 건의 데이터도 5~6번의 페이지 읽기로 찾을 수 있다.
SQL Server의 각 노드는 8KB 페이지 이다. 한 페이지에 수백 개의 키를 저장할 수 있어서 트리가 매우 넓고 얕다.
물리적으로 데이터를 정렬해서 저장 한다.
[Clustered Index on userId]
Leaf Level = 실제 데이터 페이지
┌────────────────────────────────────┐
│ userId: 1 | name: "Kim" | ... │
│ userId: 2 | name: "Lee" | ... │
│ userId: 3 | name: "Park" | ... │
└────────────────────────────────────┘
↕ (Doubly Linked List)
┌────────────────────────────────────┐
│ userId: 4 | name: "Choi" | ... │
│ userId: 5 | name: "Jung" | ... │
│ ... │
└────────────────────────────────────┘
특징:
별도의 인덱스 구조 를 만들고, 실제 데이터 위치를 가리킨다.
[Non-Clustered Index on email]
Leaf Level = 인덱스 키 + Row Locator
┌─────────────────────────────────────────┐
│ email: "a@test.com" → Row Locator 1 │
│ email: "b@test.com" → Row Locator 2 │
│ email: "c@test.com" → Row Locator 3 │
└─────────────────────────────────────────┘
↓
실제 데이터로 이동
Row Locator의 종류:
특징:
| 구분 | Clustered Index | Non-Clustered Index |
|---|---|---|
| 개수 | 테이블당 1개 | 테이블당 여러 개 |
| 리프 노드 | 실제 데이터 | 인덱스 키 + Row Locator |
| 물리적 정렬 | O (데이터 자체 정렬) | X (별도 구조) |
| 추가 조회 | 불필요 | Key Lookup 필요할 수 있음 |
| 주 용도 | PK, 범위 검색, JOIN 키 | 자주 검색하는 컬럼 |
데이터를 INSERT 할 때 해당 페이지가 가득 차 있으면 SQL Server는 페이지 분할 을 수행한다.
[Before - 페이지가 꽉 참]
Page 10: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[Insert userId = 5.5]
[After - 페이지 분할 발생]
Page 10: [1, 2, 3, 4, 5]
Page 15: [5.5, 6, 7, 8, 9, 10] ← 새 페이지 할당
이 모든 작업이 INSERT 성능을 저하 시킨다.
-- 나쁜 예: GUID를 PK/Clustered로 사용
CREATE TABLE BadTable (
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
...
)
GUID(NEWID())는 랜덤한 값 이다. 새 데이터가 테이블 중간 어딘가에 삽입되어 페이지 분할이 빈번하게 발생 한다.
-- 좋은 예: IDENTITY(자동증가)를 PK/Clustered로 사용
CREATE TABLE GoodTable (
Id INT IDENTITY(1,1) PRIMARY KEY,
...
)
IDENTITY는 항상 끝에 추가 되므로 페이지 분할이 거의 없다.
GUID를 써야 한다면
NEWSEQUENTIALID()를 사용하자. 순차적인 GUID를 생성해준다.
Index Seek:
Index Scan:
[Index Seek]
Root → Intermediate → Leaf (특정 위치) → 필요한 데이터만
[Index Scan]
Leaf 첫 페이지 → 다음 페이지 → ... → 마지막 페이지 (전체 스캔)
| 테스트 케이스 | 작업 유형 | Logical Reads | CPU Time | Rows |
|---|---|---|---|---|
| PK 조회 | Clustered Index Seek | 3 | 0 ms | 1 |
| 날짜 범위 조회 (커버링) | Index Seek | 470 | 0 ms | 98,562 |
| 함수 사용 (YEAR) | Clustered Index Scan | 4,800 | 142 ms | 99,812 |
Seek이 Scan보다 Logical Reads가 10배 이상 적다.
Clustered Index가 없는 테이블(Heap) 에서 발생:
-- Heap 테이블 (PK 없음)
SELECT * FROM HeapTable WHERE SomeColumn = 'value'
-- 결과: Table Scan (전체 테이블 읽기)
Seek이 항상 좋고 Scan이 항상 나쁜 것은 아니다. 전체 데이터의 대부분을 가져올 때는 Scan이 더 효율적 일 수 있다.
Non-Clustered Index로 검색했는데, SELECT 절에 인덱스에 없는 컬럼이 있으면 Key Lookup이 발생한다.
-- 인덱스: (email)
-- 쿼리:
SELECT email, name, phone FROM Users WHERE email = 'test@test.com'
실행 과정:
1. Non-Clustered Index에서 email로 검색 (Index Seek)
2. Row Locator로 실제 데이터 페이지 접근 (Key Lookup)
3. name, phone 가져옴
[실행 계획]
Index Seek (email) → Key Lookup (name, phone 가져오기)
5% 95%
Key Lookup이 대부분의 비용을 차지한다.
행이 많아지면:
커버링 인덱스 는 쿼리에 필요한 모든 컬럼을 인덱스에 포함 시켜 Key Lookup을 제거한다.
-- 기존 인덱스 (Key Lookup 발생)
CREATE INDEX IX_Users_Email ON Users (email)
-- 커버링 인덱스 (Key Lookup 제거)
CREATE INDEX IX_Users_Email_Covering ON Users (email)
INCLUDE (name, phone)
인덱스 키 vs INCLUDE 컬럼 차이:
| 구분 | 인덱스 키 | INCLUDE 컬럼 |
|---|---|---|
| 정렬 | O (B-Tree 정렬에 사용) | X |
| 검색 조건 | WHERE, JOIN, ORDER BY | SELECT 절에만 사용 |
| 중간 노드에 저장 | O | X |
| 리프 노드에 저장 | O | O |
CREATE INDEX IX_Example ON Table (KeyCol1, KeyCol2)
INCLUDE (IncludeCol1, IncludeCol2)
KeyCol1, KeyCol2 : 검색/정렬에 사용IncludeCol1, IncludeCol2 : 데이터만 저장 (Key Lookup 방지)Before (Key Lookup 있음):
Logical Reads: 317
Elapsed Time: 느림
After (커버링 인덱스):
Logical Reads: 5
Elapsed Time: 빠름
Logical Reads가 60배 이상 감소
커버링 인덱스는 강력하지만, 인덱스 크기가 커진다. INSERT/UPDATE/DELETE 성능에 영향을 줄 수 있으므로 자주 사용하는 쿼리에만 적용하자.
여러 컬럼을 하나의 인덱스로 묶은 것이다.
CREATE INDEX IX_Composite ON Users (lastName, firstName)
복합 인덱스는 "전화번호부"와 같다:
전화번호부 정렬: 성(lastName) → 이름(firstName)
김철수 → O (성으로 검색 가능)
철수 → X (성 없이 이름만으로 검색 불가)
예시:
-- 인덱스: (lastName, firstName)
-- 1. 두 컬럼 모두 사용 → Index Seek ✅
WHERE lastName = 'Kim' AND firstName = 'Chulsoo'
-- 2. 첫 번째 컬럼만 사용 → Index Seek ✅
WHERE lastName = 'Kim'
-- 3. 두 번째 컬럼만 사용 → Index Scan ❌
WHERE firstName = 'Chulsoo'
세 번째 케이스가 Scan인 이유:
lastName 기준으로 먼저 정렬됨firstName만으로는 시작점을 찾을 수 없음권장 순서:
1. 등호(=) 조건 컬럼 (선택도 높은 것부터)
2. 범위 조건 컬럼 (>, <, BETWEEN)
3. ORDER BY 컬럼
4. SELECT 절 컬럼 (INCLUDE로)
예시 쿼리:
SELECT orderId, orderDate, total
FROM Orders
WHERE customerId = @customerId -- 등호 조건
AND status = 'Pending' -- 등호 조건
AND orderDate > @startDate -- 범위 조건
ORDER BY orderDate DESC
최적의 인덱스:
CREATE INDEX IX_Orders_Optimal
ON Orders (customerId, status, orderDate DESC)
INCLUDE (total)
이유:
customerId, status : 등호 조건 → 앞에orderDate : 범위 조건 + ORDER BY → 그 다음total : SELECT에서만 사용 → INCLUDE흔히 "선택도(Selectivity) 높은 컬럼을 앞에" 라고 하지만, 이건 등호 조건일 때만 해당된다.
| 컬럼 | 고유 값 개수 | 선택도 |
|---|---|---|
| customerId | 10,000 | 높음 |
| status | 5 | 낮음 |
| gender | 2 | 매우 낮음 |
등호 조건이라면 customerId를 앞에 두는 것이 좋다.
하지만 범위 조건이라면 이야기가 다르다:
-- customerId가 범위 조건이면?
WHERE customerId > 100 AND status = 'Active'
이 경우 (status, customerId) 순서가 더 나을 수 있다.
컬럼 순서는 쿼리 패턴 에 따라 결정해야 한다. 단순히 선택도만 보지 말고, 실제 쿼리의 조건 유형을 분석하자.
-- 나쁜 예: 컬럼마다 인덱스
CREATE INDEX IX_Col1 ON Table (Col1)
CREATE INDEX IX_Col2 ON Table (Col2)
CREATE INDEX IX_Col3 ON Table (Col3)
...
문제점:
-- 나쁜 예: 성별에 단독 인덱스
CREATE INDEX IX_Gender ON Users (gender) -- 값: M, F 뿐
성별은 값이 2개뿐이라 인덱스를 타도 전체의 50%를 읽어야 한다. 이런 경우 SQL Server는 Table Scan을 선택할 수 있다.
-- 나쁜 예: 함수 사용
WHERE YEAR(orderDate) = 2024 -- Index Scan 발생
-- 좋은 예: 범위 조건으로 변경
WHERE orderDate >= '2024-01-01' AND orderDate < '2025-01-01' -- Index Seek
컬럼에 함수를 적용하면 인덱스를 사용할 수 없다 (SARGable 하지 않음).
-- 나쁜 예
SELECT * FROM Orders WHERE customerId = 123
-- 좋은 예
SELECT orderId, orderDate, total FROM Orders WHERE customerId = 123
SELECT *는 커버링 인덱스 활용을 막고 Key Lookup을 유발한다.
SQL Server 인덱스의 핵심을 정리하면
1. 기본 구조
2. B-Tree
3. 실행 계획
4. 복합 인덱스
5. 균형
인덱스는 "만능 해결책"이 아니다. 쿼리 패턴을 이해하고, 실행 계획을 확인하며, 지속적으로 모니터링 하는 것이 진정한 성능 튜닝이다.