CHHB stroy
MSSQL 인덱스의 INCLUDE, 이거 모르면 인덱스를 반만 쓰는 거다 본문
쿼리 튜닝하다 보면 "인덱스 걸었는데 왜 안 빨라지지?" 하는 순간이 온다. 실행 계획 열어보면 인덱스를 타긴 타는데, 그 뒤에 Key Lookup이라는 놈이 붙어 있다. 이게 뭔가 하고 파봤더니, 인덱스에서 원하는 데이터를 다 못 가져와서 원본 테이블을 다시 뒤지고 있는 거였다. 인덱스를 탔는데 결국 테이블도 읽는, 이 모순적인 상황.
INCLUDE를 알기 전과 후로 내 인덱스 설계가 완전히 달라졌다. 과장 좀 보태면 MSSQL 인덱스에서 제일 중요한 기능이 INCLUDE라고 생각한다.
먼저 인덱스 구조를 알아야 한다
INCLUDE가 왜 필요한지 이해하려면, MSSQL 인덱스가 내부적으로 어떻게 생겼는지를 알아야 한다.
MSSQL의 Non-Clustered Index는 B-Tree 구조다. 크게 두 부분으로 나뉜다:
- 리프 레벨이 아닌 노드 (상위 노드): 검색 방향을 결정하는 키 값만 들어있다. "여기로 가면 이 범위의 데이터가 있어" 하고 길을 안내하는 역할.
- 리프 레벨 (최하위 노드): 실제 키 값 + 클러스터드 인덱스 키(또는 RID)가 들어있다. 여기서 데이터를 못 찾으면 원본 테이블로 가야 한다.
문제는 SELECT하는 컬럼이 인덱스 키에 없을 때 발생한다.
-- 인덱스
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID
ON Orders (CustomerID);
-- 쿼리
SELECT CustomerID, OrderDate, TotalAmount
FROM Orders
WHERE CustomerID = 12345;
이 쿼리가 실행되면:
IX_Orders_CustomerID인덱스에서CustomerID = 12345를 찾는다 (Index Seek, 빠름)- 근데
OrderDate랑TotalAmount는 인덱스에 없다 - 원본 테이블(클러스터드 인덱스)로 가서 해당 행을 다시 읽는다 (Key Lookup, 느림)
- 결과 합치기 (Nested Loop Join)
Key Lookup이 한두 건이면 괜찮다. 근데 CustomerID = 12345인 행이 1000개면? Key Lookup을 1000번 한다. 인덱스를 안 탄 것보다 오히려 느려지는 경우도 있다. 옵티마이저가 판단하기에 Key Lookup 비용이 너무 크면 아예 인덱스를 안 타고 Table Scan을 선택하기도 한다.
INCLUDE가 뭔데
INCLUDE는 인덱스의 리프 레벨에만 추가 컬럼을 저장하는 기능이다. SQL Server 2005에서 도입됐다.
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID
ON Orders (CustomerID)
INCLUDE (OrderDate, TotalAmount);
이렇게 하면:
CustomerID는 B-Tree의 모든 레벨에서 검색 키로 사용된다OrderDate,TotalAmount는 리프 레벨에만 같이 저장된다- 쿼리가 인덱스만으로 모든 데이터를 가져올 수 있다 → Key Lookup이 사라진다
이런 인덱스를 커버링 인덱스(Covering Index)라고 부른다. 쿼리가 필요한 모든 컬럼을 인덱스가 "커버"하고 있으니까.
INCLUDE vs 키 컬럼에 추가하기
"그러면 그냥 인덱스 키에 컬럼을 더 넣으면 되는 거 아냐?"라는 생각이 들 수 있다.
-- 방법 1: 키 컬럼에 전부 넣기
CREATE NONCLUSTERED INDEX IX_Orders_V1
ON Orders (CustomerID, OrderDate, TotalAmount);
-- 방법 2: INCLUDE 사용
CREATE NONCLUSTERED INDEX IX_Orders_V2
ON Orders (CustomerID)
INCLUDE (OrderDate, TotalAmount);
둘 다 커버링 인덱스가 된다. 근데 차이가 크다.
키 컬럼에 넣으면
- B-Tree의 모든 레벨에 해당 컬럼이 들어간다
- 상위 노드도 커지니까 인덱스 전체 크기가 커진다
- 키 순서대로 정렬되기 때문에 정렬/범위 검색에 사용 가능
- 키 컬럼이 많아지면 INSERT/UPDATE 시 정렬 유지 비용이 증가
INCLUDE로 넣으면
- 리프 레벨에만 데이터가 들어간다
- 상위 노드는 작게 유지되니까 인덱스 전체 크기가 작다
- 검색 키로는 사용 불가 — 정렬이나 WHERE 조건에 쓸 수 없다
- INSERT/UPDATE 성능 영향이 적다
정리하면 이렇다:
WHERE, ORDER BY, GROUP BY, JOIN 조건에 쓰는 컬럼 → 키 컬럼
SELECT에서 가져오기만 하는 컬럼 → INCLUDE이 기준만 기억하면 된다. 검색에 쓰이는 건 키로, 결과에만 필요한 건 INCLUDE로.
실전 예제로 보는 효과
시나리오: 주문 조회
-- 테이블 구조
CREATE TABLE Orders (
OrderID INT IDENTITY PRIMARY KEY, -- 클러스터드 인덱스
CustomerID INT NOT NULL,
OrderDate DATETIME NOT NULL,
Status NVARCHAR(20) NOT NULL,
TotalAmount DECIMAL(18,2) NOT NULL,
ShippingAddr NVARCHAR(200),
Notes NVARCHAR(MAX)
);
-- 자주 실행되는 쿼리
SELECT OrderDate, Status, TotalAmount
FROM Orders
WHERE CustomerID = 12345
ORDER BY OrderDate DESC;
Case 1: INCLUDE 없는 인덱스
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID
ON Orders (CustomerID);
실행 계획:
Index Seek (IX_Orders_CustomerID) → Cost: 15%
↓
Key Lookup (Clustered Index) → Cost: 80% ← 이게 문제
↓
Sort (OrderDate DESC) → Cost: 5%Key Lookup이 전체 비용의 80%를 차지하고 있다. 인덱스는 열심히 타는데 정작 느린 부분은 원본 테이블 읽기.
Case 2: INCLUDE 추가
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID_V2
ON Orders (CustomerID, OrderDate DESC)
INCLUDE (Status, TotalAmount);
실행 계획:
Index Seek (IX_Orders_CustomerID_V2) → Cost: 100%끝. Key Lookup 없음. Sort도 없음 (OrderDate DESC가 키에 있으니까 이미 정렬돼 있다). 완벽한 커버링 인덱스.
여기서 OrderDate를 키 컬럼에 넣은 이유는 ORDER BY에 쓰이기 때문이다. INCLUDE에 넣으면 정렬은 안 되니까 별도 Sort 연산이 필요해진다.
성능 차이 확인하기
-- 실행 전에 통계 초기화
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
-- Case 1 실행
-- 논리적 읽기: 150 (인덱스 3 + 테이블 147)
-- Case 2 실행
-- 논리적 읽기: 3 (인덱스 3)
논리적 읽기가 150에서 3으로 줄었다. 50배 차이. 이게 INCLUDE의 위력이다.
Key Lookup 찾는 방법
실행 계획에서 Key Lookup을 찾으려면:
SSMS에서
1. 쿼리 작성
2. Ctrl + L (예상 실행 계획) 또는 Ctrl + M (실제 실행 계획 포함 실행)
3. 실행 계획에서 "Key Lookup" 노드를 찾는다
4. 해당 노드에 마우스 올리면 "Output List"에 어떤 컬럼을 가져오는지 보인다Output List에 나오는 컬럼들이 바로 INCLUDE에 넣어야 할 후보들이다.
DMV(동적 관리 뷰)로 찾기
-- Key Lookup이 많이 발생하는 인덱스 찾기
SELECT
OBJECT_NAME(s.object_id) AS TableName,
i.name AS IndexName,
s.user_lookups AS KeyLookupCount,
s.user_seeks AS SeekCount,
s.user_scans AS ScanCount,
s.last_user_lookup AS LastLookup
FROM sys.dm_db_index_usage_stats s
JOIN sys.indexes i
ON s.object_id = i.object_id
AND s.index_id = i.index_id
WHERE s.database_id = DB_ID()
AND i.type_desc = 'CLUSTERED'
AND s.user_lookups > 0
ORDER BY s.user_lookups DESC;
user_lookups가 높은 테이블이 Key Lookup이 많이 발생하는 곳이다. 여기가 INCLUDE를 추가해야 할 1순위.
Missing Index DMV 활용
MSSQL은 누락된 인덱스를 알려주는 기능이 있다. 옵티마이저가 "이런 인덱스가 있었으면 좋겠는데..."라고 생각한 것들을 기록해둔다.
SELECT
OBJECT_NAME(mid.object_id) AS TableName,
mid.equality_columns AS EqualityColumns,
mid.inequality_columns AS InequalityColumns,
mid.included_columns AS IncludedColumns,
migs.avg_user_impact AS AvgImpactPercent,
migs.user_seeks AS SeekCount,
'CREATE NONCLUSTERED INDEX IX_'
+ OBJECT_NAME(mid.object_id) + '_Missing'
+ ' ON ' + mid.statement
+ ' (' + ISNULL(mid.equality_columns, '')
+ CASE
WHEN mid.inequality_columns IS NOT NULL
THEN ', ' + mid.inequality_columns
ELSE ''
END
+ ')'
+ CASE
WHEN mid.included_columns IS NOT NULL
THEN ' INCLUDE (' + mid.included_columns + ')'
ELSE ''
END AS CreateIndexStatement
FROM sys.dm_db_missing_index_details mid
JOIN sys.dm_db_missing_index_groups mig
ON mid.index_handle = mig.index_handle
JOIN sys.dm_db_missing_index_group_stats migs
ON mig.index_group_handle = migs.group_handle
WHERE mid.database_id = DB_ID()
ORDER BY migs.avg_user_impact * migs.user_seeks DESC;
이 쿼리가 INCLUDE에 넣어야 할 컬럼까지 알려준다. 단, 이건 참고용이지 무작정 다 만들면 안 된다. 인덱스가 너무 많아지면 쓰기 성능이 떨어진다.
INCLUDE 사용 시 주의사항
1. INCLUDE에 너무 많은 컬럼을 넣지 마라
-- ❌ 이러면 안 된다
CREATE NONCLUSTERED INDEX IX_Orders_Bad
ON Orders (CustomerID)
INCLUDE (OrderDate, Status, TotalAmount, ShippingAddr,
Notes, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy);
INCLUDE 컬럼이 많으면 리프 레벨이 커지고, 그만큼 디스크 공간을 더 쓰고, INSERT/UPDATE 시 인덱스 유지 비용이 올라간다. 자주 실행되는 쿼리에서 필요한 컬럼만 넣어야 한다.
2. NVARCHAR(MAX), VARCHAR(MAX)에 주의
-- 큰 데이터 타입은 INCLUDE에 넣을 수 있지만 신중하게
CREATE NONCLUSTERED INDEX IX_Orders_WithNotes
ON Orders (CustomerID)
INCLUDE (Notes); -- NVARCHAR(MAX)
키 컬럼에는 MAX 타입을 넣을 수 없지만, INCLUDE에는 넣을 수 있다. 이게 INCLUDE의 또 다른 장점이다. 근데 MAX 타입은 데이터가 클 수 있으니까 인덱스 크기가 급격히 커질 수 있다. 정말 필요한 경우에만 쓰자.
3. INCLUDE 컬럼으로 검색할 수 없다
CREATE NONCLUSTERED INDEX IX_Orders_V2
ON Orders (CustomerID)
INCLUDE (OrderDate, TotalAmount);
-- ✅ 이건 인덱스를 탄다
SELECT OrderDate, TotalAmount
FROM Orders
WHERE CustomerID = 12345;
-- ❌ 이건 인덱스의 INCLUDE 컬럼으로 검색 불가
-- OrderDate가 키가 아니라 INCLUDE이니까
SELECT CustomerID, TotalAmount
FROM Orders
WHERE OrderDate = '2024-01-15';
INCLUDE 컬럼은 검색에 사용되지 않는다. WHERE, JOIN, ORDER BY, GROUP BY 조건에 써야 하는 컬럼이면 키 컬럼에 넣어야 한다.
4. 인덱스 크기 확인
-- 인덱스별 크기 확인
SELECT
OBJECT_NAME(i.object_id) AS TableName,
i.name AS IndexName,
i.type_desc AS IndexType,
CAST(SUM(ps.used_page_count) * 8.0 / 1024 AS DECIMAL(10,2)) AS SizeMB,
SUM(ps.row_count) AS RowCount
FROM sys.indexes i
JOIN sys.dm_db_partition_stats ps
ON i.object_id = ps.object_id
AND i.index_id = ps.index_id
WHERE OBJECT_NAME(i.object_id) = 'Orders'
GROUP BY i.object_id, i.name, i.type_desc
ORDER BY SizeMB DESC;
INCLUDE 추가 전후로 인덱스 크기가 얼마나 변하는지 확인하는 습관을 들이자.
자주 만나는 패턴별 인덱스 설계
패턴 1: 목록 조회 + 페이지네이션
-- 쿼리
SELECT OrderID, OrderDate, Status, TotalAmount
FROM Orders
WHERE CustomerID = @CustomerID
AND Status = 'Completed'
ORDER BY OrderDate DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
-- 인덱스
CREATE NONCLUSTERED INDEX IX_Orders_List
ON Orders (CustomerID, Status, OrderDate DESC)
INCLUDE (TotalAmount);
CustomerID와 Status는 WHERE에, OrderDate는 ORDER BY에 쓰이니까 키 컬럼. TotalAmount는 SELECT에서만 필요하니까 INCLUDE. OrderID는 클러스터드 인덱스 키라서 자동으로 포함된다.
패턴 2: 집계 쿼리
-- 쿼리
SELECT
CustomerID,
COUNT(*) AS OrderCount,
SUM(TotalAmount) AS TotalSpent,
MAX(OrderDate) AS LastOrderDate
FROM Orders
WHERE OrderDate >= '2024-01-01'
GROUP BY CustomerID;
-- 인덱스
CREATE NONCLUSTERED INDEX IX_Orders_Stats
ON Orders (OrderDate, CustomerID)
INCLUDE (TotalAmount);
OrderDate는 WHERE 조건이니까 첫 번째 키. CustomerID는 GROUP BY에 쓰이니까 두 번째 키. TotalAmount는 SUM 집계에만 필요하니까 INCLUDE. COUNT(*)와 MAX(OrderDate)는 키 컬럼에서 처리 가능.
패턴 3: JOIN 쿼리
-- 쿼리
SELECT o.OrderID, o.OrderDate, c.CustomerName, c.Email
FROM Orders o
JOIN Customers c ON o.CustomerID = c.CustomerID
WHERE o.Status = 'Pending'
ORDER BY o.OrderDate;
-- Orders 테이블 인덱스
CREATE NONCLUSTERED INDEX IX_Orders_Pending
ON Orders (Status, OrderDate)
INCLUDE (CustomerID);
-- Customers 테이블 인덱스 (이미 PK가 CustomerID면 불필요)
-- CREATE NONCLUSTERED INDEX IX_Customers_Lookup
-- ON Customers (CustomerID)
-- INCLUDE (CustomerName, Email);
패턴 4: EXISTS / IN 서브쿼리
-- 쿼리
SELECT CustomerID, CustomerName
FROM Customers c
WHERE EXISTS (
SELECT 1 FROM Orders o
WHERE o.CustomerID = c.CustomerID
AND o.TotalAmount > 100000
);
-- 인덱스
CREATE NONCLUSTERED INDEX IX_Orders_BigSpender
ON Orders (CustomerID, TotalAmount);
EXISTS 안에서는 SELECT 1이니까 INCLUDE가 필요 없다. 검색 조건인 CustomerID와 TotalAmount만 키 컬럼에 있으면 된다.
필터링된 인덱스 + INCLUDE 조합
특정 조건의 데이터만 인덱스에 넣는 필터링된 인덱스와 INCLUDE를 같이 쓰면 아주 효율적인 인덱스를 만들 수 있다.
-- 활성 주문만 인덱싱 (전체 주문의 5%라면 인덱스가 아주 작아짐)
CREATE NONCLUSTERED INDEX IX_Orders_Active
ON Orders (CustomerID, OrderDate DESC)
INCLUDE (Status, TotalAmount)
WHERE Status IN ('Pending', 'Processing', 'Shipped');
이러면 완료된 주문은 인덱스에 안 들어가니까 인덱스 크기가 확 줄어든다. 근데 필터링된 인덱스는 쿼리의 WHERE 조건이 인덱스 필터와 맞아야 사용된다는 점을 기억하자.
-- ✅ 이 쿼리는 위의 필터링 인덱스를 탄다
SELECT OrderDate, TotalAmount
FROM Orders
WHERE CustomerID = 12345 AND Status = 'Pending';
-- ❌ 이 쿼리는 안 탄다 (Status 조건이 인덱스 필터에 없음)
SELECT OrderDate, TotalAmount
FROM Orders
WHERE CustomerID = 12345 AND Status = 'Cancelled';
인덱스 설계 체크리스트
새 인덱스를 만들기 전에 이 순서로 생각하면 실수가 줄어든다.
1. WHERE 조건에 등호(=)로 비교하는 컬럼 → 키 컬럼 (앞쪽에 배치)
2. WHERE 조건에 범위(>, <, BETWEEN)로 비교하는 컬럼 → 키 컬럼 (등호 뒤에 배치)
3. ORDER BY에 쓰이는 컬럼 → 키 컬럼 (범위 조건 뒤에 배치)
4. GROUP BY에 쓰이는 컬럼 → 키 컬럼 (상황에 따라)
5. SELECT에서 가져오기만 하는 컬럼 → INCLUDE
6. 특정 조건만 자주 검색한다면 → 필터링 인덱스 고려마무리
INCLUDE는 단순해 보이지만 인덱스 성능을 극적으로 바꿀 수 있는 기능이다. Key Lookup 하나 없애는 것만으로 쿼리 속도가 수십 배 빨라지는 경우를 여러 번 봤다.
핵심 정리:
- Key Lookup이 보이면
INCLUDE추가를 먼저 고려하자 - WHERE, ORDER BY, GROUP BY에 쓰는 컬럼은 키 컬럼으로, SELECT에서만 쓰는 컬럼은 INCLUDE로
- INCLUDE에 넣으면 리프 레벨에만 저장돼서 인덱스 크기와 유지 비용이 키 컬럼 추가보다 적다
- Missing Index DMV가 INCLUDE 후보까지 알려주니까 참고하자
- 단, 무작정 컬럼을 많이 넣으면 인덱스가 비대해진다. 자주 실행되는 쿼리 기준으로 설계하자
쿼리 하나 튜닝할 때마다 실행 계획을 열어보는 습관이 중요하다. Key Lookup이 보일 때마다 "이거 INCLUDE로 없앨 수 있나?" 한번씩 생각해보면, 어느새 인덱스 설계가 손에 익게 된다.
궁금한 점이나 다른 튜닝 경험이 있으면 댓글로 공유해주세요! 🙌
'DB' 카테고리의 다른 글
| PostgreSQL, 왜 다들 Postgres 쓰라고 하는지 이제 알겠다 (0) | 2026.05.12 |
|---|---|
| SQL Server PIVOT과 UNPIVOT 완벽 정리 (0) | 2026.05.11 |
| [DB] 트랜잭션 완전 정리 (0) | 2025.09.23 |
| [DB] 데이터베이스 키(KEY) 종류 정리 (0) | 2025.09.23 |