CHHB stroy

MSSQL 인덱스의 INCLUDE, 이거 모르면 인덱스를 반만 쓰는 거다 본문

DB

MSSQL 인덱스의 INCLUDE, 이거 모르면 인덱스를 반만 쓰는 거다

CHHB 2026. 5. 19. 16:33

쿼리 튜닝하다 보면 "인덱스 걸었는데 왜 안 빨라지지?" 하는 순간이 온다. 실행 계획 열어보면 인덱스를 타긴 타는데, 그 뒤에 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;

이 쿼리가 실행되면:

  1. IX_Orders_CustomerID 인덱스에서 CustomerID = 12345를 찾는다 (Index Seek, 빠름)
  2. 근데 OrderDateTotalAmount는 인덱스에 없다
  3. 원본 테이블(클러스터드 인덱스)로 가서 해당 행을 다시 읽는다 (Key Lookup, 느림)
  4. 결과 합치기 (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);

CustomerIDStatus는 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가 필요 없다. 검색 조건인 CustomerIDTotalAmount만 키 컬럼에 있으면 된다.


필터링된 인덱스 + 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로 없앨 수 있나?" 한번씩 생각해보면, 어느새 인덱스 설계가 손에 익게 된다.

궁금한 점이나 다른 튜닝 경험이 있으면 댓글로 공유해주세요! 🙌