![[MSSQL] INCLUDE 컬럼 설계로 Key Lookup 병목을 제거하는 판단 기준](https://blog.kakaocdn.net/dna/w8qSC/dJMcaa0Xq0U/AAAAAAAAAAAAAAAAAAAAAGXpgkFRgrsGNnPxrsEmvZDm_LgcuvKEaUB9rp9XaqRb/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1793458799&allow_ip=&allow_referer=&signature=ilFf4ohuF%2BthuacMkhONtdTuFWQ%3D)
INCLUDE 컬럼: 실무적 정의와 운영상 중요성
MSSQL에서 Key Lookup은 비클러스터형 인덱스(Nonclustered Index)에서 검색 조건을 만족하는 행을 찾은 뒤, SELECT에 필요한 나머지 컬럼을 가져오기 위해 클러스터형 인덱스에 다시 접근하는 연산이다. Heap 테이블에서는 같은 목적의 RID Lookup이 발생한다. 검색 행마다 반복되는 임의 페이지 접근이므로 반환 행이 증가할수록 논리 읽기와 CPU 사용량이 함께 증가한다.
INCLUDE 컬럼은 비클러스터형 인덱스의 리프 레벨에 조회용 컬럼을 저장한다. 검색 키 순서에는 참여하지 않지만 쿼리에 필요한 컬럼을 인덱스 내부에서 모두 반환할 수 있게 한다. 이 상태를 커버링 인덱스(Covering Index)라고 하며, 적절히 설계하면 Key Lookup 연산 자체가 제거된다.
Key Lookup이 몇 번 발생하면 INCLUDE 컬럼을 추가해야 하는지에 대한 고정 임계값은 없다. 실행 계획에서 100회가 표시되더라도 페이지가 버퍼 풀에 있고 쿼리 빈도가 낮으면 영향이 작을 수 있다. 반대로 단일 실행의 Lookup이 많지 않아도 초당 반복 호출되는 핵심 쿼리라면 누적 I/O와 CPU 병목을 유발한다. 따라서 실행 횟수만으로 결정하지 않고 실제 행 수, 연산자 비용 비율, 논리 읽기, 호출 빈도와 쓰기 부하를 함께 판단해야 한다.
Part 1. INCLUDE 기본 문법 및 사용법 (Basic Syntax)
다음 예시는 주문 상태와 주문일을 검색 키로 사용하고, 조회 결과에만 필요한 고객 번호와 합계 금액을 INCLUDE에 배치하는 구조이다. 등호 조건 컬럼과 범위 조건 컬럼은 인덱스 키에 둔다. 출력 전용 컬럼은 INCLUDE에 두어 키 폭과 정렬 부담의 증가를 제한한다.
CREATE NONCLUSTERED INDEX IX_Orders_Status_OrderDate
ON dbo.Orders (OrderStatus, OrderDate)
INCLUDE (CustomerID, TotalAmount);
위 인덱스는 다음 쿼리의 WHERE 조건과 SELECT 목록을 모두 포함한다. 옵티마이저는 비클러스터형 인덱스만으로 결과를 반환할 수 있으므로 별도의 Key Lookup이 필요하지 않다.
SELECT CustomerID, OrderDate, TotalAmount
FROM dbo.Orders
WHERE OrderStatus = @OrderStatus
AND OrderDate >= @StartDate
AND OrderDate < @EndDate;
INCLUDE는 필터링이나 정렬에 필요한 키 컬럼을 대신하지 않는다. CustomerID를 자주 검색하거나 ORDER BY, JOIN 조건에 사용한다면 단순 출력 컬럼이 아니다. 이 경우 실제 쿼리 패턴과 선택도(Selectivity)를 검토하여 인덱스 키 배치를 결정해야 한다.
| 컬럼 역할 | 권장 위치 | 판단 기준 | 주의사항 |
|---|---|---|---|
| 등호 검색 조건 | 인덱스 키 | WHERE, JOIN에서 행 범위를 축소한다 | 복합 키 순서는 주요 쿼리 조건과 선택도를 검토한다 |
| 범위 검색 조건 | 인덱스 키 | 날짜·번호 범위를 탐색한다 | 일반적으로 등호 키 뒤에서 범위 탐색이 시작된다 |
| 출력 전용 컬럼 | INCLUDE | SELECT 결과에만 필요하다 | 폭이 큰 컬럼은 인덱스 크기를 급격히 증가시킨다 |
| 정렬·그룹화 컬럼 | 쿼리별 검토 | Sort 제거 가능성을 실행 계획으로 확인한다 | INCLUDE만으로는 키 순서를 제공하지 않는다 |
Part 2. 내부 동작 메커니즘과 안티 패턴
Key Lookup은 일반적으로 Nested Loops의 내부 입력으로 실행된다. 외부의 Index Seek가 반환한 각 행에 대해 클러스터형 키를 이용하여 기본 데이터 페이지를 다시 찾는다. 실행 계획에서 Key Lookup의 Actual Number of Executions가 외부 입력 행 수에 비례하여 증가하는 이유이다.
실행 계획의 연산자 비용 비율은 옵티마이저의 추정값이며 실측 시간이 아니다. 통계가 오래되었거나 파라미터 스니핑이 발생하면 Estimated Number of Rows와 Actual Number of Rows의 차이가 커지고 비용 비율도 현실을 반영하지 못한다. DBA는 Actual Execution Plan에서 실행 횟수와 실제 행 수를 확인하고 STATISTICS IO, TIME 결과를 함께 수집해야 한다.
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SELECT CustomerID, OrderDate, TotalAmount
FROM dbo.Orders
WHERE OrderStatus = @OrderStatus
AND OrderDate >= @StartDate
AND OrderDate < @EndDate;
SET STATISTICS TIME OFF;
SET STATISTICS IO OFF;
Key Lookup 횟수는 다음 기준으로 해석한다. 이는 절대 임계값이 아니라 조사 우선순위와 설계 결정을 위한 운영 기준이다.
| 관찰 상태 | 판단 | 필수 확인 항목 | 권장 조치 |
|---|---|---|---|
| Lookup이 소수이며 쿼리 호출도 드물다 | 즉시 변경할 근거가 부족하다 | 전체 논리 읽기와 응답 시간 | 기준값을 기록하고 모니터링한다 |
| Lookup 횟수가 실제 반환 행 수와 함께 증가한다 | 행별 반복 접근이 누적된다 | Actual Executions, Actual Rows, Scan Count | INCLUDE 적용 후보로 분류한다 |
| Key Lookup이 계획 비용의 큰 비중을 차지한다 | 우선 조사 대상이다 | 추정 행과 실제 행의 차이, 통계 상태 | 비용 비율만 믿지 말고 I/O를 실측한다 |
| 대표 파라미터에서 논리 읽기가 급증한다 | 운영 병목 가능성이 높다 | 선택도가 다른 파라미터별 STATISTICS IO | 커버링 인덱스와 쿼리 재작성안을 비교한다 |
| 조회 개선보다 쓰기 증가가 크다 | INCLUDE가 불리할 수 있다 | INSERT·UPDATE·DELETE 처리량과 로그 사용량 | 컬럼 축소 또는 별도 조회 전략을 검토한다 |
가장 흔한 안티 패턴은 실행 계획에 Key Lookup이 보인다는 이유만으로 SELECT 목록의 모든 컬럼을 INCLUDE에 추가하는 방식이다. INCLUDE 컬럼이 늘어나면 리프 행의 폭이 커지고 인덱스 페이지 수가 증가한다. 이로 인해 버퍼 풀 점유, 디스크 I/O, 백업 크기와 인덱스 유지 시간이 함께 증가한다.
특히 대형 문자열, 자주 갱신되는 컬럼, 사용 빈도가 낮은 출력 컬럼을 무조건 포함하면 쓰기 증폭이 커진다. 포함 컬럼이 변경될 때마다 해당 비클러스터형 인덱스도 갱신되어야 한다. 읽기 병목 하나를 제거하면서 INSERT와 UPDATE 경로 전체에 새로운 비용을 추가할 수 있으므로 주의가 필요하다.
Part 3. 성능 최적화 및 튜닝 전략
INCLUDE 추가 여부는 동일한 데이터와 대표 파라미터에서 변경 전후를 비교하여 결정한다. 첫 단계는 Actual Execution Plan을 저장하고 Key Lookup의 Actual Number of Executions, Actual Number of Rows, Estimated Number of Rows와 비용 비율을 기록하는 것이다. 캐시가 따뜻한 상태와 실제 운영에 가까운 상태를 구분하고, 선택도가 높은 조건과 낮은 조건을 각각 실행해야 한다.
두 번째 단계는 STATISTICS IO에서 대상 테이블과 인덱스의 logical reads, physical reads, read-ahead reads를 기록하는 것이다. 일반적인 운영 검증에서는 물리 읽기가 캐시 상태에 크게 좌우되므로 논리 읽기를 우선 비교한다. 단, 논리 읽기가 감소하더라도 CPU 시간과 경과 시간이 함께 개선되는지 확인해야 한다.
세 번째 단계는 INCLUDE 인덱스를 비운영 검증 환경에 생성하고 동일한 쿼리를 재실행하는 것이다. Key Lookup 제거 여부와 새로운 Index Seek 또는 Index Scan의 실제 행 수를 확인한다. 커버링 이후에도 읽기가 크다면 검색 조건의 선택도가 낮거나 키 순서가 적절하지 않을 수 있다.
SELECT
i.name AS index_name,
SUM(ps.used_page_count) AS used_pages,
SUM(ps.reserved_page_count) AS reserved_pages
FROM sys.dm_db_partition_stats AS ps
JOIN sys.indexes AS i
ON i.object_id = ps.object_id
AND i.index_id = ps.index_id
WHERE ps.object_id = OBJECT_ID(N'dbo.Orders')
GROUP BY i.name
ORDER BY used_pages DESC;
위 쿼리는 인덱스 적용 전후의 페이지 증가를 확인하는 데 사용한다. 실제 크기는 데이터 분포, 컬럼 자료형, NULL 비율과 압축 설정에 따라 달라지므로 사전에 임의의 증가율을 가정하면 안 된다. 생성 후 used_pages와 reserved_pages를 측정하고 버퍼 풀 및 저장 공간 영향까지 평가해야 한다.
네 번째 단계는 쓰기 성능을 측정하는 것이다. 운영과 유사한 INSERT, UPDATE, DELETE 작업을 동일한 건수와 트랜잭션 크기로 실행하고 경과 시간, CPU, 논리 쓰기, 트랜잭션 로그 증가량을 비교한다. INCLUDE 컬럼 자체가 자주 수정되는 테이블이라면 읽기 개선량이 명확해도 쓰기 비용 때문에 적용하지 않는 결정이 가능하다.
| 결정 항목 | INCLUDE 추가에 유리 | 추가 보류에 유리 |
|---|---|---|
| 호출 빈도 | 핵심 조회가 지속적으로 반복된다 | 간헐적 관리 쿼리이다 |
| Lookup 누적량 | 실제 행 수에 비례해 반복 접근이 커진다 | 실행당 소수이고 누적 호출도 적다 |
| 논리 읽기 | 적용 후 의미 있게 감소한다 | 제거 후에도 거의 변하지 않는다 |
| 포함 컬럼 폭 | 작고 안정적인 출력 컬럼이다 | 대형 또는 빈번히 갱신되는 컬럼이다 |
| 쓰기 부하 | 읽기 중심이며 변경 빈도가 낮다 | 대용량 트랜잭션과 갱신이 집중된다 |
| 기존 인덱스 | 기존 인덱스를 확장해 중복을 줄일 수 있다 | 유사 인덱스가 이미 많고 유지 비용이 크다 |
따라서 Key Lookup이 특정 횟수를 넘었다는 사실만으로 INCLUDE를 추가하지 않는다. 실제 반환 행이 많고, 반복 호출로 누적 부하가 크며, Key Lookup 제거 후 논리 읽기와 CPU가 감소하고, 인덱스 크기 및 쓰기 비용의 증가가 허용되는 경우에 추가하는 것이 유리하다. 이 네 조건을 적용 전후 측정값으로 입증해야 한다.
실행 계획을 해석할 때 조인 연산과 행 수 추정의 관계도 함께 봐야 한다. Nested Loops와 입력 행 수에 관한 기본 원리는 MSSQL INNER JOIN 실행 계획 분석과 튜닝에서 확인할 수 있다. 조인으로 중간 결과가 급증하여 Lookup이 확대되는 사례는 MSSQL CROSS JOIN 성능저하를 방지하는 쿼리 튜닝 및 실행 계획 분석과 연결하여 점검할 필요가 있다.
성능 최적화의 핵심은 Key Lookup 횟수에 임의의 절대 기준을 적용하는 것이 아니라 누적 실행량과 실측 I/O를 기준으로 판단하는 것이다. DBA는 대표 파라미터별 실제 실행 계획을 확인하고, INCLUDE 적용 전후의 논리 읽기·CPU·인덱스 페이지·쓰기 성능을 동일 조건에서 비교해야 한다. 필요한 출력 컬럼만 포함하고 중복 인덱스를 통합해야 조회 병목을 제거하면서 운영 환경의 쓰기 증폭과 자원 고갈을 방지할 수 있다.
'SQL > MSSQL' 카테고리의 다른 글
| [MSSQL] DMV로 미사용·중복 인덱스를 판별하는 방법: user_seeks 해석과 삭제 기준 (0) | 2026.10.10 |
|---|---|
| [MSSQL] 복합 인덱스 선행 컬럼 누락이 Index Scan을 만드는 이유와 키 순서 설계 (0) | 2026.10.06 |
| [MSSQL] Actual Execution Plan의 Estimated Rows 오차 원인과 카디널리티 교정 절차 (0) | 2026.10.04 |
| [MSSQL] GetReparentedValue 사용 방법 및 예시 (0) | 2024.04.23 |
| [MSSQL] IsDescendantOf 사용 방법 및 예시 (0) | 2024.04.23 |
| [MSSQL] GetRoot 사용 방법 및 예시 (1) | 2024.04.22 |
| [MSSQL] GetLevel 사용 방법 및 예시 (0) | 2024.04.22 |
| [MSSQL] GetDescendant 사용 방법 및 예시 (0) | 2024.04.09 |