MSSQL LAB
MSSQL 5.2 · 5부. 성능

인덱스 설계와 커버링

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

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

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

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

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

개념 설명

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

인덱스에 열을 여럿 넣는 것을 복합 인덱스라고 합니다. (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_COVERIX_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 밀리초입니다.