MSSQL LAB
MSSQL 5.2 · 5부. 성능

인덱스 설계와 커버링

포함 열과 복합 인덱스의 열 순서를 정합니다.

예상 학습 시간 26분 난이도 고급
준비

5.1 의 표를 그대로 사용합니다

이 단원도 Board.POSTS_BIG 50만 행을 사용합니다. 아직 만들지 않았다면 lab-bigdata.sql 을 먼저 실행하십시오.

5.1 에서는 계획을 읽었습니다. 이 단원은 그 계획을 바꾸는 쪽입니다. 인덱스를 어떻게 짜면 Scan 이 Seek 으로 바뀌는지, 그리고 그 대가로 무엇을 치르는지 봅니다.

개념 설명

열 순서가 인덱스를 정합니다

인덱스에 열을 여럿 넣는 것을 복합 인덱스라고 합니다. (board_num, user_num) 은 board_num 으로 먼저 정렬하고, 같은 board_num 안에서 user_num 으로 정렬한 것입니다.

정렬 순서가 곧 찾을 수 있는 범위입니다. 앞 열이 정해져야 뒤 열이 모여 있습니다. 앞 열을 모르면 뒤 열은 흩어져 있어 탐색할 수 없습니다.

같은 두 열로 순서만 바꾼 인덱스 둘을 만들어 견줍니다.

SQL
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 = 72323
user_num = 7 만1,11960
board_num = 2 만3761,119
인덱스 크기(페이지)1,1141,114
같은 열, 같은 크기입니다. 순서만 다릅니다. 1,119 는 인덱스를 통째로 읽은 값입니다.

계획을 열어 보면 무엇이 갈렸는지 한 줄로 드러납니다.

SQL
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;
IX_T1 — 1,119장
|--Index Scan(OBJECT:([POSTS_BIG].[IX_T1]), WHERE:([POSTS_BIG].[user_num]=(7)))
IX_T2 — 60장
|--Index Seek(OBJECT:([POSTS_BIG].[IX_T2]), SEEK:([POSTS_BIG].[user_num]=(7)) ORDERED FORWARD)
IX_T1 은 조건이 SEEK 이 아니라 WHERE 에 붙었습니다. 다 읽고 걸러 냈다는 뜻입니다(5.1).

SEEK: 에 조건이 들어가면 그 자리로 바로 들어간 것이고, WHERE: 에 붙으면 다 읽고 걸러 낸 것입니다. 같은 인덱스라도 이 한 글자가 1,119장과 60장을 가릅니다.

그러면 어느 열을 앞에 둡니까

순서어떤 열까닭
1등호(=)로 걸리는 열한 값으로 좁혀야 뒤가 모입니다
2부등호(> BETWEEN)로 걸리는 열범위는 하나만 소용이 있습니다
3ORDER 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건씩입니다.

SQL
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,000Index Seek + Key Lookup3,076
1,500Index Scan(IX_POSTS_BIG_list)4,535
25,000Index Scan(IX_POSTS_BIG_list)4,535
1,000행과 1,500행 사이에서 방법이 바뀝니다. 50만 행의 0.3% 입니다.

엔진이 그 자리에서 갈아탑니다. 1,000행까지는 인덱스로 찾아 표를 1,000번 뒤지는 편이 싸고, 그보다 많아지면 다른 인덱스를 통째로 읽는 편이 낫습니다.

갈아타지 못하게 막으면 무슨 일이 생기는지 봅니다.

SQL
-- 25,000행을 굳이 키 조회로 가져오게 합니다.
SELECT title FROM Board.POSTS_BIG WITH (INDEX(IX_HIT)) WHERE hit_count <= 49;
결과
방법논리적 읽기
엔진이 고른 대로(스캔)4,535
키 조회를 강제76,618
17배입니다. 25,000행을 하나씩 표에서 찾은 값입니다.

인덱스에 열을 실으면 표를 보지 않습니다

INCLUDE 는 찾는 데는 사용하지 않지만 가져올 수는 있는 열을 인덱스에 함께 싣습니다. 조회에 필요한 열이 모두 인덱스 안에 있으면 표를 한 번도 읽지 않습니다. 이것을 커버링이라고 합니다(3.5).

SQL
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,00093,076
25,0001514,535
인덱스 크기
인덱스페이지MB
IX_HIT (hit_count)8666.8
IX_HIT_COVER (hit_count) INCLUDE (title)2,96223.1
3,076장이 9장이 됩니다. 대신 인덱스가 3.4배 커집니다.

제목을 함께 실었으니 인덱스가 그만큼 커집니다. 커버링은 공짜가 아니고, 무엇을 실을지는 실제로 가져오는 열만으로 정해야 합니다. SELECT * 를 커버링하려 들면 인덱스가 표만큼 커집니다.

비교

INCLUDE 와 키 열은 무엇이 다릅니까

(hit_count) INCLUDE (title) 과 (hit_count, title) 은 둘 다 title 을 담습니다. 담기는 자리가 다릅니다.

SQL
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;
INCLUDE — 정렬이 붙습니다
|--Sort(ORDER BY:([title] ASC)) |--Index Seek(OBJECT:([POSTS_BIG].[IX_HIT_COVER]), SEEK:([POSTS_BIG].[hit_count]=(5)) ORDERED FORWARD)
키 열 — 정렬이 없습니다
|--Index Seek(OBJECT:([POSTS_BIG].[IX_HIT_KEY]), SEEK:([POSTS_BIG].[hit_count]=(5)) ORDERED FORWARD)
인덱스가 차지하는 페이지
인덱스맨 아래 층위층
(hit_count) INCLUDE (title)2,9628
(hit_count, title)2,96221
맨 아래 층은 같습니다. 키에 넣으면 위층에도 title 이 실려 21장이 됩니다.
INCLUDE키 열
담기는 자리맨 아래 층만모든 층
찾는 데사용하지 못합니다사용합니다
정렬에사용하지 못합니다사용합니다
길이 제한없습니다키 전체 900바이트
nvarchar(max)실을 수 있습니다넣지 못합니다

찾거나 정렬하는 데 사용할 열은 키에, 가져오기만 하는 열은 INCLUDE 에 둡니다. 위 실험에서 제목으로 정렬할 일이 없다면 INCLUDE 쪽이 위층이 얇아 조금 낫고, 정렬한다면 키에 넣어 Sort 를 없애는 편이 낫습니다.

점검

무엇을 지울지 찾습니다

인덱스는 늘리기는 쉽고 지우기는 어렵습니다. 실제로 사용되는지를 서버가 세어 둡니다.

SQL
-- 세어 둔 것이 보이도록 몇 가지를 실행해 둡니다.
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_BIG0101
IX_POSTS_BIG_list1001
IX_POSTS_BIG_user1000
IX_HIT1001
IX_HIT_COVER0001
IX_HIT_KEY0001
숫자는 무엇을 몇 번 실행했느냐에 따라 쌓입니다. 이 화면과 다를 수 있습니다.

탐색·스캔·키조회가 모두 0 인데 갱신만 올라가는 인덱스가 지울 후보입니다. 위에서는 IX_HIT_COVER 와 IX_HIT_KEY 가 그렇습니다. 읽지는 않는데 저장할 때마다 함께 고쳐지고 있습니다.

이 수치는 서버를 다시 시작하면 지워집니다. 하루 이틀 켜 둔 서버에서 0 이라고 바로 지우면 위험합니다. 월말에만 도는 보고서가 사용하는 인덱스일 수 있습니다. 충분히 오래 켜 둔 뒤에 판단하고, 지우기 전에 정의를 어딘가에 적어 두십시오.

앞이 겹치는 인덱스

(hit_count) 는 (hit_count, title) 안에 이미 들어 있습니다. 앞 열이 같으면 짧은 쪽은 대개 필요 없습니다.

SQL
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_HIThit_countIX_HIT_COVERhit_count
IX_HIThit_countIX_HIT_KEYhit_count, title
IX_HIT_COVERhit_countIX_HIT_KEYhit_count, title
후보를 뽑아 줄 뿐입니다. 지우기 전에 무엇을 싣고 있는지 반드시 확인하십시오.

이 목록을 그대로 믿으면 안 됩니다. 키만 견준 결과이기 때문입니다. 첫 줄에서 IX_HIT 가 지울 후보로 나온 것은 맞지만, 그것은 IX_HIT_COVER 가 같은 키에 title 까지 싣고 있어서입니다. 포함 열이 다르면 답이 달라집니다. 짧은 쪽이 더 작아 빠른 경우도 있습니다.

없어서 아쉬운 인덱스

엔진은 "이런 인덱스가 있었으면 좋았겠다" 를 따로 적어 둡니다.

SQL
-- 인덱스가 없는 열로 몇 번 조회하면 기록이 쌓입니다.
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.72[category_num][hit_count]NULL
시키는 대로 만들어 보면
논리적 읽기
만들기 전7,333
(category_num, hit_count) 를 만든 뒤11
등호 열이 앞, 부등호 열이 뒤입니다. 위에서 정리한 순서 그대로입니다.

이 제안을 그대로 만들지 마십시오. 문장 하나만 보고 적는 것이라 이미 있는 인덱스를 헤아리지 않고, 조금씩 다른 제안이 수십 개씩 쌓입니다. 참고할 것은 "어떤 열로 찾고 있는가" 이지 "이대로 만들라" 가 아닙니다. 대개는 이미 있는 인덱스에 열 하나를 더하는 것으로 끝납니다.

대가

인덱스는 쓰기로 값을 치릅니다

여기까지는 읽기만 보았습니다. 인덱스는 표가 바뀔 때마다 함께 고쳐야 합니다. 인덱스를 늘리면 조회는 빨라지고 저장은 느려집니다.

지금 이 표에는 인덱스가 6개 붙어 있습니다. 1만 행을 넣어 재고, 앞에서 만든 셋을 지운 뒤 다시 재어 견줍니다.

SQL
-- 한 번 실행해 캐시를 채운 뒤, 세 번 재어 범위를 봅니다.
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;
결과 — 1만 행 넣기, 각 3회
인덱스걸린 시간
6개(PK · list · user · hit 셋)143~152 밀리초
3개(hit 셋을 지운 뒤)87~90 밀리초
시간은 장비마다 다릅니다. 1.7배라는 비율을 보십시오. 인덱스 하나당 약 20 밀리초입니다.

고칠 때는 차이가 훨씬 큽니다. 인덱스에 들어 있는 열을 고치면 그 인덱스들도 함께 고쳐야 하지만, 어느 인덱스에도 없는 열은 표만 고치면 됩니다.

SQL
-- 인덱스 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';
--                                    ↑ 어느 인덱스에도 없는 열
결과 — 각 3회
고치는 열걸린 시간
hit_count — 인덱스 셋에 들어 있습니다181~186 밀리초
mod_date — 어느 인덱스에도 없습니다11~12 밀리초
15배입니다. 같은 표, 같은 1만 행인데 어느 열을 고치느냐로 갈립니다.

조회수처럼 자주 바뀌는 열은 인덱스에 넣지 않는 편이 낫습니다. 넣어야 한다면 그 인덱스가 실제로 사용되는지 확인하고 넣으십시오. 읽지 않는데 갱신만 되는 인덱스는 손해만 남습니다.

여기까지 따라 했다면 표를 다시 만드십시오. 되돌린 INSERT 도 자리는 잡아 두므로 표가 조금 커져 있습니다. lab-bigdata.sql 을 다시 실행하면 인덱스까지 처음 상태로 돌아갑니다. 아래 연습은 그 상태에서 잰 값입니다.

연습

직접 해보기

1. 이 목록 화면에 맞는 인덱스 난이도 하

게시판 목록 화면이 아래 문장을 사용합니다. 가장 알맞은 인덱스 하나를 적어 보세요. 어느 열을 키에 두고 어느 열을 포함으로 뺄지 밝히십시오.

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;
CREATE INDEX IX_POSTS_BIG_ex1 ON Board.POSTS_BIG (board_num, category_num, reg_date DESC) INCLUDE (title, user_num, hit_count); -- 키에 셋을 둡니다. -- board_num, category_num 등호로 걸립니다 -- reg_date DESC ORDER BY 를 그대로 받습니다 -- 정렬 방향까지 맞춰야 Sort 가 사라집니다. -- INCLUDE 에 셋을 둡니다. -- title, user_num, hit_count 가져오기만 합니다 -- num 은 적지 않아도 됩니다. 클러스터형 키라 모든 인덱스가 이미 담고 있습니다. SET STATISTICS IO ON; 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; -- logical reads 3 ← 인덱스가 없으면 7,333 입니다 -- 계획에 Sort 도 Key Lookup 도 없습니다. -- |--Top(TOP EXPRESSION:((20))) -- |--Index Seek(OBJECT:([IX_POSTS_BIG_ex1]), -- SEEK:([board_num]=(2) AND [category_num]=(3)) -- ORDERED FORWARD) DROP INDEX IX_POSTS_BIG_ex1 ON Board.POSTS_BIG;
2. 인덱스를 더할 값어치가 있습니까 난이도 중

회원 화면에서 SELECT title … WHERE user_num = ? 가 하루 10번 돕니다. 글쓰기는 하루 5만 건입니다. IX_POSTS_BIG_user 에 title 을 포함으로 더할지 정하고, 판단 근거를 숫자로 적어 보세요.

얻는 것은 조회 한 번에 줄어드는 페이지입니다. 잃는 것은 인덱스가 커지는 것과 저장이 느려지는 것입니다. 둘 다 재 볼 수 있습니다.
-- 얻는 것을 잽니다. SET STATISTICS IO ON; SELECT title FROM Board.POSTS_BIG WHERE user_num = 7; -- logical reads 4,535 ← 지금 CREATE INDEX IX_POSTS_BIG_user2 ON Board.POSTS_BIG (user_num) INCLUDE (title); SELECT title FROM Board.POSTS_BIG WHERE user_num = 7; -- logical reads 161 ← 더한 뒤 -- 하루 10번이므로 45,350 장이 1,610 장이 됩니다. -- 하루에 43,740 장을 아낍니다. -- 잃는 것을 잽니다. -- 인덱스 크기 866 장(6.8MB) → 2,965 장(23.2MB) -- 저장 속도 인덱스 하나가 늘면 1만 행 넣기가 약 20 밀리초 늘어납니다. -- 하루 5만 건이면 100 밀리초입니다. -- 판단: 더합니다. -- 하루 43,740 장을 아끼고 100 밀리초를 더 씁니다. -- 디스크 16MB 는 값이 싼 쪽입니다. -- 반대로 하루 10번이 아니라 한 달에 한 번이라면 더하지 않습니다. -- 아끼는 것은 한 달에 4,374 장뿐인데 -- 저장 비용은 매일 치릅니다. -- 판단 기준은 언제나 같습니다. -- "이 조회가 얼마나 자주 도는가" 대 "이 표에 얼마나 자주 쓰는가" DROP INDEX IX_POSTS_BIG_user2 ON Board.POSTS_BIG;

연습에서 만든 인덱스를 지웠다면 표는 처음 상태 그대로입니다. 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 밀리초입니다.