인덱스 설계와 커버링
포함 열과 복합 인덱스의 열 순서를 정합니다.
5.1 의 표를 그대로 사용합니다
이 단원도 Board.POSTS_BIG 50만 행을 사용합니다.
아직 만들지 않았다면
lab-bigdata.sql 을 먼저
실행하십시오.
5.1 에서는 계획을 읽었습니다. 이 단원은 그 계획을
바꾸는 쪽입니다. 인덱스를 어떻게 짜면 Scan
이 Seek 으로 바뀌는지, 그리고 그 대가로 무엇을
치르는지 봅니다.
열 순서가 인덱스를 정합니다
인덱스에 열을 여럿 넣는 것을 복합 인덱스라고 합니다.
(board_num, user_num) 은
board_num 으로 먼저 정렬하고, 같은 board_num 안에서 user_num 으로
정렬한 것입니다.
정렬 순서가 곧 찾을 수 있는 범위입니다. 앞 열이 정해져야 뒤 열이 모여 있습니다. 앞 열을 모르면 뒤 열은 흩어져 있어 탐색할 수 없습니다.
같은 두 열로 순서만 바꾼 인덱스 둘을 만들어 견줍니다.
CREATE INDEX IX_T1 ON Board.POSTS_BIG (board_num, user_num); CREATE INDEX IX_T2 ON Board.POSTS_BIG (user_num, board_num); GO SET STATISTICS IO ON; -- 어느 인덱스를 사용할지 직접 지정해 견줍니다. SELECT COUNT(*) FROM Board.POSTS_BIG WITH (INDEX(IX_T1)) WHERE user_num = 7; SELECT COUNT(*) FROM Board.POSTS_BIG WITH (INDEX(IX_T2)) WHERE user_num = 7;
| 조건 | IX_T1 (board_num, user_num) | IX_T2 (user_num, board_num) |
|---|---|---|
| board_num = 2 AND user_num = 7 | 23 | 23 |
| user_num = 7 만 | 1,119 | 60 |
| board_num = 2 만 | 376 | 1,119 |
| 인덱스 크기(페이지) | 1,114 | 1,114 |
계획을 열어 보면 무엇이 갈렸는지 한 줄로 드러납니다.
SET SHOWPLAN_TEXT ON; GO SELECT COUNT(*) FROM Board.POSTS_BIG WITH (INDEX(IX_T1)) WHERE user_num = 7; GO SELECT COUNT(*) FROM Board.POSTS_BIG WITH (INDEX(IX_T2)) WHERE user_num = 7; GO SET SHOWPLAN_TEXT OFF;
SEEK: 에 조건이 들어가면 그 자리로 바로
들어간 것이고, WHERE: 에 붙으면
다 읽고 걸러 낸 것입니다. 같은 인덱스라도 이 한 글자가
1,119장과 60장을 가릅니다.
그러면 어느 열을 앞에 둡니까
| 순서 | 어떤 열 | 까닭 |
|---|---|---|
| 1 | 등호(=)로 걸리는 열 | 한 값으로 좁혀야 뒤가 모입니다 |
| 2 | 부등호(> BETWEEN)로 걸리는 열 | 범위는 하나만 소용이 있습니다 |
| 3 | ORDER BY 에 사용하는 열 | 정렬을 없앨 수 있습니다(3.5) |
| 밖 | 가져오기만 하는 열 | INCLUDE 로 뺍니다 |
등호 열이 여럿이면 값의 가짓수가 많은 것을 앞에 둡니다.
이 표에서 board_num 은 3가지,
user_num 은 20가지입니다. 20가지 쪽이 한 번에
더 좁힙니다. 위 표에서 board_num 만으로 찾을 때
376장이 나온 것은 1/3 만 남기 때문이고,
user_num 은 1/20 이라 60장입니다.
실제로는 "무엇으로 자주 찾는가" 가 가짓수보다 앞섭니다.
게시판 목록은 언제나 board_num 으로 먼저
좁힙니다. 가짓수가 3가지뿐이어도 그 열이 앞에 와야 합니다.
인덱스에 없는 열을 가져올 때
3.5 에서 본 키 조회가 여기서 규모를 만나 숫자로 드러납니다. 인덱스에 없는 열을 가져오려면 찾은 행마다 표를 한 번씩 더 읽어야 합니다.
hit_count 에만 인덱스를 걸고, 건수를 바꿔 가며
재 봅니다. 이 표의 hit_count 는 0~999 가 고르게
들어 있어 값마다 500건씩입니다.
CREATE INDEX IX_HIT ON Board.POSTS_BIG (hit_count); GO SET STATISTICS IO ON; SELECT title FROM Board.POSTS_BIG WHERE hit_count <= 1; -- 1,000행 SELECT title FROM Board.POSTS_BIG WHERE hit_count <= 2; -- 1,500행 SELECT title FROM Board.POSTS_BIG WHERE hit_count <= 49; -- 25,000행
| 나오는 행 | 엔진이 고른 방법 | 논리적 읽기 |
|---|---|---|
| 1,000 | Index Seek + Key Lookup | 3,076 |
| 1,500 | Index Scan(IX_POSTS_BIG_list) | 4,535 |
| 25,000 | Index Scan(IX_POSTS_BIG_list) | 4,535 |
엔진이 그 자리에서 갈아탑니다. 1,000행까지는 인덱스로 찾아 표를 1,000번 뒤지는 편이 싸고, 그보다 많아지면 다른 인덱스를 통째로 읽는 편이 낫습니다.
갈아타지 못하게 막으면 무슨 일이 생기는지 봅니다.
-- 25,000행을 굳이 키 조회로 가져오게 합니다. SELECT title FROM Board.POSTS_BIG WITH (INDEX(IX_HIT)) WHERE hit_count <= 49;
| 방법 | 논리적 읽기 |
|---|---|
| 엔진이 고른 대로(스캔) | 4,535 |
| 키 조회를 강제 | 76,618 |
인덱스에 열을 실으면 표를 보지 않습니다
INCLUDE 는 찾는 데는 사용하지 않지만
가져올 수는 있는 열을 인덱스에 함께 싣습니다. 조회에 필요한 열이
모두 인덱스 안에 있으면 표를 한 번도 읽지 않습니다.
이것을 커버링이라고 합니다(3.5).
CREATE INDEX IX_HIT_COVER ON Board.POSTS_BIG (hit_count) INCLUDE (title); GO SET STATISTICS IO ON; SELECT title FROM Board.POSTS_BIG WHERE hit_count <= 1; -- 1,000행 SELECT title FROM Board.POSTS_BIG WHERE hit_count <= 49; -- 25,000행
| 나오는 행 | 인덱스만 | 커버링 전 |
|---|---|---|
| 1,000 | 9 | 3,076 |
| 25,000 | 151 | 4,535 |
| 인덱스 | 페이지 | MB |
|---|---|---|
| IX_HIT (hit_count) | 866 | 6.8 |
| IX_HIT_COVER (hit_count) INCLUDE (title) | 2,962 | 23.1 |
제목을 함께 실었으니 인덱스가 그만큼 커집니다. 커버링은
공짜가 아니고, 무엇을 실을지는 실제로 가져오는 열만으로
정해야 합니다. SELECT * 를 커버링하려 들면
인덱스가 표만큼 커집니다.
INCLUDE 와 키 열은 무엇이 다릅니까
(hit_count) INCLUDE (title) 과
(hit_count, title) 은 둘 다 title 을 담습니다.
담기는 자리가 다릅니다.
CREATE INDEX IX_HIT_KEY ON Board.POSTS_BIG (hit_count, title); GO -- 정렬까지 요구해 봅니다. SET SHOWPLAN_TEXT ON; GO SELECT title FROM Board.POSTS_BIG WITH (INDEX(IX_HIT_COVER)) WHERE hit_count = 5 ORDER BY title; GO SELECT title FROM Board.POSTS_BIG WITH (INDEX(IX_HIT_KEY)) WHERE hit_count = 5 ORDER BY title; GO SET SHOWPLAN_TEXT OFF;
| 인덱스 | 맨 아래 층 | 위층 |
|---|---|---|
| (hit_count) INCLUDE (title) | 2,962 | 8 |
| (hit_count, title) | 2,962 | 21 |
| INCLUDE | 키 열 | |
|---|---|---|
| 담기는 자리 | 맨 아래 층만 | 모든 층 |
| 찾는 데 | 사용하지 못합니다 | 사용합니다 |
| 정렬에 | 사용하지 못합니다 | 사용합니다 |
| 길이 제한 | 없습니다 | 키 전체 900바이트 |
| nvarchar(max) | 실을 수 있습니다 | 넣지 못합니다 |
찾거나 정렬하는 데 사용할 열은 키에, 가져오기만 하는 열은
INCLUDE 에 둡니다. 위 실험에서 제목으로 정렬할 일이 없다면
INCLUDE 쪽이 위층이 얇아 조금 낫고, 정렬한다면 키에 넣어
Sort 를 없애는 편이 낫습니다.
무엇을 지울지 찾습니다
인덱스는 늘리기는 쉽고 지우기는 어렵습니다. 실제로 사용되는지를 서버가 세어 둡니다.
-- 세어 둔 것이 보이도록 몇 가지를 실행해 둡니다. BEGIN TRAN; UPDATE TOP (1000) Board.POSTS_BIG SET hit_count = hit_count + 1; ROLLBACK; SELECT COUNT(*) FROM Board.POSTS_BIG WHERE hit_count = 5; SELECT TOP (20) title FROM Board.POSTS_BIG WHERE board_num = 2 ORDER BY group_num DESC, sort_no; SELECT COUNT(*) FROM Board.POSTS_BIG WHERE user_num = 7; GO SELECT i.name AS 인덱스, ISNULL(s.user_seeks, 0) AS 탐색, ISNULL(s.user_scans, 0) AS 스캔, ISNULL(s.user_lookups, 0) AS 키조회, ISNULL(s.user_updates, 0) AS 갱신 FROM sys.indexes i LEFT JOIN sys.dm_db_index_usage_stats s ON s.object_id = i.object_id AND s.index_id = i.index_id AND s.database_id = DB_ID() WHERE i.object_id = OBJECT_ID('Board.POSTS_BIG') ORDER BY i.index_id;
| 인덱스 | 탐색 | 스캔 | 키조회 | 갱신 |
|---|---|---|---|---|
| PK_POSTS_BIG | 0 | 1 | 0 | 1 |
| IX_POSTS_BIG_list | 1 | 0 | 0 | 1 |
| IX_POSTS_BIG_user | 1 | 0 | 0 | 0 |
| IX_HIT | 1 | 0 | 0 | 1 |
| IX_HIT_COVER | 0 | 0 | 0 | 1 |
| IX_HIT_KEY | 0 | 0 | 0 | 1 |
탐색·스캔·키조회가 모두 0 인데 갱신만 올라가는 인덱스가 지울
후보입니다. 위에서는 IX_HIT_COVER 와
IX_HIT_KEY 가 그렇습니다. 읽지는 않는데
저장할 때마다 함께 고쳐지고 있습니다.
이 수치는 서버를 다시 시작하면 지워집니다. 하루 이틀 켜 둔 서버에서 0 이라고 바로 지우면 위험합니다. 월말에만 도는 보고서가 사용하는 인덱스일 수 있습니다. 충분히 오래 켜 둔 뒤에 판단하고, 지우기 전에 정의를 어딘가에 적어 두십시오.
앞이 겹치는 인덱스
(hit_count) 는
(hit_count, title) 안에 이미 들어 있습니다.
앞 열이 같으면 짧은 쪽은 대개 필요 없습니다.
WITH K AS ( SELECT i.index_id, i.name, STUFF((SELECT ', ' + c.name FROM sys.index_columns ic JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 ORDER BY ic.key_ordinal FOR XML PATH('')), 1, 2, '') AS keys FROM sys.indexes i WHERE i.object_id = OBJECT_ID('Board.POSTS_BIG') AND i.index_id > 1 ) SELECT a.name AS 가려지는것, a.keys AS 그키, b.name AS 가리는것, b.keys AS 이키 FROM K a JOIN K b ON a.index_id <> b.index_id AND b.keys LIKE a.keys + '%' ORDER BY a.name;
| 가려지는것 | 그키 | 가리는것 | 이키 |
|---|---|---|---|
| IX_HIT | hit_count | IX_HIT_COVER | hit_count |
| IX_HIT | hit_count | IX_HIT_KEY | hit_count, title |
| IX_HIT_COVER | hit_count | IX_HIT_KEY | hit_count, title |
이 목록을 그대로 믿으면 안 됩니다. 키만 견준 결과이기
때문입니다. 첫 줄에서 IX_HIT 가 지울 후보로 나온
것은 맞지만, 그것은 IX_HIT_COVER 가 같은 키에
title 까지 싣고 있어서입니다. 포함 열이 다르면 답이
달라집니다. 짧은 쪽이 더 작아 빠른 경우도 있습니다.
없어서 아쉬운 인덱스
엔진은 "이런 인덱스가 있었으면 좋았겠다" 를 따로 적어 둡니다.
-- 인덱스가 없는 열로 몇 번 조회하면 기록이 쌓입니다. SELECT COUNT(*) FROM Board.POSTS_BIG WHERE category_num = 3 AND hit_count > 900; SELECT COUNT(*) FROM Board.POSTS_BIG WHERE category_num = 5 AND hit_count > 900; GO SELECT CAST(gs.avg_user_impact AS decimal(5,1)) AS 개선율, gs.user_seeks AS 요청횟수, id.equality_columns AS 등호열, id.inequality_columns AS 부등호열, id.included_columns AS 포함열 FROM sys.dm_db_missing_index_group_stats gs JOIN sys.dm_db_missing_index_groups g ON g.index_group_handle = gs.group_handle JOIN sys.dm_db_missing_index_details id ON id.index_handle = g.index_handle WHERE id.object_id = OBJECT_ID('Board.POSTS_BIG') ORDER BY gs.avg_user_impact DESC;
| 개선율 | 요청횟수 | 등호열 | 부등호열 | 포함열 |
|---|---|---|---|---|
| 98.7 | 2 | [category_num] | [hit_count] | NULL |
| 논리적 읽기 | |
|---|---|
| 만들기 전 | 7,333 |
| (category_num, hit_count) 를 만든 뒤 | 11 |
이 제안을 그대로 만들지 마십시오. 문장 하나만 보고 적는 것이라 이미 있는 인덱스를 헤아리지 않고, 조금씩 다른 제안이 수십 개씩 쌓입니다. 참고할 것은 "어떤 열로 찾고 있는가" 이지 "이대로 만들라" 가 아닙니다. 대개는 이미 있는 인덱스에 열 하나를 더하는 것으로 끝납니다.
인덱스는 쓰기로 값을 치릅니다
여기까지는 읽기만 보았습니다. 인덱스는 표가 바뀔 때마다 함께 고쳐야 합니다. 인덱스를 늘리면 조회는 빨라지고 저장은 느려집니다.
지금 이 표에는 인덱스가 6개 붙어 있습니다. 1만 행을 넣어 재고, 앞에서 만든 셋을 지운 뒤 다시 재어 견줍니다.
-- 한 번 실행해 캐시를 채운 뒤, 세 번 재어 범위를 봅니다. BEGIN TRAN; DECLARE @t datetime2(7) = SYSDATETIME(); INSERT INTO Board.POSTS_BIG (board_num, category_num, user_num, parent_num, group_num, depth, sort_no, title, content, hit_count, reg_date) SELECT TOP (10000) board_num, category_num, user_num, NULL, group_num, depth, sort_no, title, content, hit_count, reg_date FROM Board.POSTS_BIG ORDER BY num; PRINT CONCAT(DATEDIFF(millisecond, @t, SYSDATETIME()), ' ms'); ROLLBACK;
| 인덱스 | 걸린 시간 |
|---|---|
| 6개(PK · list · user · hit 셋) | 143~152 밀리초 |
| 3개(hit 셋을 지운 뒤) | 87~90 밀리초 |
고칠 때는 차이가 훨씬 큽니다. 인덱스에 들어 있는 열을 고치면 그 인덱스들도 함께 고쳐야 하지만, 어느 인덱스에도 없는 열은 표만 고치면 됩니다.
-- 인덱스 6개인 상태에서 1만 행을 고칩니다. UPDATE TOP (10000) Board.POSTS_BIG SET hit_count = hit_count + 1; -- ↑ 인덱스 셋에 들어 있는 열 UPDATE TOP (10000) Board.POSTS_BIG SET mod_date = '2026-06-01'; -- ↑ 어느 인덱스에도 없는 열
| 고치는 열 | 걸린 시간 |
|---|---|
| hit_count — 인덱스 셋에 들어 있습니다 | 181~186 밀리초 |
| mod_date — 어느 인덱스에도 없습니다 | 11~12 밀리초 |
조회수처럼 자주 바뀌는 열은 인덱스에 넣지 않는 편이 낫습니다. 넣어야 한다면 그 인덱스가 실제로 사용되는지 확인하고 넣으십시오. 읽지 않는데 갱신만 되는 인덱스는 손해만 남습니다.
여기까지 따라 했다면 표를 다시 만드십시오. 되돌린
INSERT 도 자리는 잡아 두므로 표가 조금 커져
있습니다. lab-bigdata.sql 을 다시
실행하면 인덱스까지 처음 상태로 돌아갑니다. 아래 연습은 그 상태에서 잰
값입니다.
직접 해보기
게시판 목록 화면이 아래 문장을 사용합니다. 가장 알맞은 인덱스 하나를 적어 보세요. 어느 열을 키에 두고 어느 열을 포함으로 뺄지 밝히십시오.
SELECT TOP (20) num, title, user_num, reg_date, hit_count FROM Board.POSTS_BIG WHERE board_num = 2 AND category_num = 3 ORDER BY reg_date DESC;
회원 화면에서 SELECT title … WHERE user_num = ?
가 하루 10번 돕니다. 글쓰기는 하루 5만 건입니다.
IX_POSTS_BIG_user 에 title 을 포함으로 더할지 정하고, 판단
근거를 숫자로 적어 보세요.
연습에서 만든 인덱스를 지웠다면 표는 처음 상태 그대로입니다.
Board.POSTS_BIG 은 5부 내내 사용하므로 두십시오.
- 복합 인덱스는 앞 열부터 정렬됩니다. 같은 두 열로 순서만 바꾸면 60장과 1,119장으로 갈립니다. 크기는 1,114장으로 같습니다.
- 계획에서 조건이
SEEK:에 들어가면 찾아 들어간 것이고,WHERE:에 붙으면 다 읽고 걸러 낸 것입니다. - 열 순서는 등호 → 부등호 → 정렬 순입니다. 등호 열이 여럿이면 자주 사용하는 것을 앞에 두고, 그다음이 값의 가짓수입니다.
- 인덱스에 없는 열을 가져오면 행마다 표를 한 번씩 더 읽습니다(키 조회). 50만 행에서 1,000행까지는 키 조회, 1,500행부터는 스캔으로 엔진이 갈아탑니다.
- 25,000행을 키 조회로 강제하면 76,618장, 스캔은 4,535장입니다.
INCLUDE로 커버링하면 3,076장이 9장이 됩니다. 대신 인덱스가 866장에서 2,962장으로 커집니다.- 찾거나 정렬할 열은 키에, 가져오기만 할 열은 INCLUDE 에 둡니다. 키에 넣으면 위층에도 실려 조금 더 두꺼워지지만
Sort를 없앨 수 있습니다. sys.dm_db_index_usage_stats에서 읽기가 0 이고 갱신만 오르는 인덱스가 지울 후보입니다. 서버를 다시 시작하면 지워지므로 성급히 판단하지 마십시오.- 누락 인덱스 제안은 참고만 하십시오. 문장 하나만 보고 적는 것이라 이미 있는 인덱스를 헤아리지 않습니다.
- 인덱스는 쓰기로 값을 치릅니다. 3개와 6개가 1만 행 넣기에서 90 밀리초와 148 밀리초, 인덱스에 든 열을 고치는 것과 아닌 것이 184 밀리초와 12 밀리초입니다.