MSSQL 3.5 · 3부. 설계

인덱스 기초

클러스터형과 비클러스터형이 무엇인지, 만들면 어떤 비용이 생기는지 봅니다. 10만 행짜리 표에서 읽은 페이지를 세어 비교합니다.

예상 학습 시간 22분 난이도 중급
개념 설명

찾는 방법은 두 가지뿐입니다

3.3 에서 외래 키를 다루며 읽은 페이지를 세어 보았습니다. 인덱스가 있는 쪽은 2, 없는 쪽은 11 이었습니다. 그 차이가 어디서 오는지를 이 단원에서 봅니다.

SQL Server 가 조건에 맞는 행을 찾는 방법은 둘입니다. 스캔은 처음부터 끝까지 전부 읽으면서 조건에 맞지 않는 행을 버립니다. 탐색은 정렬된 구조를 따라 그 자리로 바로 들어갑니다. 인덱스는 탐색을 할 수 있게 두는 정렬된 사본입니다.

실습 데이터베이스에도 이미 여러 개가 걸려 있습니다. 무엇이 있는지부터 봅니다.

SQL
SELECT OBJECT_NAME(i.object_id) AS 표, i.name AS 인덱스, i.type_desc AS 종류,
       (SELECT STRING_AGG(c.name + CASE WHEN ic.is_descending_key = 1 THEN ' DESC' ELSE '' END, ', ')
               WITHIN GROUP (ORDER BY ic.key_ordinal)
        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) AS 키열
FROM sys.indexes i
    JOIN sys.tables t ON t.object_id = i.object_id
WHERE t.is_ms_shipped = 0
ORDER BY 표, i.index_id;
결과
인덱스종류키열
BOARDSPK_BOARDSCLUSTEREDnum
BOARDSUX_BOARDS_codeNONCLUSTEREDcode
CATEGORIESPK_CATEGORIESCLUSTEREDnum
CATEGORIESUX_CATEGORIES_nameNONCLUSTEREDboard_num, name
COMMENTSPK_COMMENTSCLUSTEREDnum
COMMENTSIX_COMMENTS_postNONCLUSTEREDpost_num, reg_date
FILESPK_FILESCLUSTEREDnum
FILESIX_FILES_postNONCLUSTEREDpost_num
POSTSPK_POSTSCLUSTEREDnum
POSTSIX_POSTS_listNONCLUSTEREDboard_num, group_num DESC, sort_no
POSTSIX_POSTS_parentNONCLUSTEREDparent_num
USERSPK_USERSCLUSTEREDnum
USERSUX_USERS_user_idNONCLUSTEREDuser_id
13개 가운데 직접 만든 것은 넷입니다. 나머지 아홉은 제약을 걸 때 함께 생겼습니다.

PK_ 로 시작하는 여섯은 기본 키가, UX_ 로 시작하는 셋은 3.4 의 UNIQUE 제약이 만든 것입니다. 고유해야 한다는 규칙을 지키려면 겹치는 값이 있는지 매번 확인해야 하고, 그 확인이 곧 탐색이기 때문입니다. 제약을 걸면 인덱스가 따라온다는 것은 이 까닭입니다.

IX_ 로 시작하는 넷은 lab-setup.sql 이 직접 만든 것입니다. 무엇을 보고 만들었는지는 이 단원 끝에서 다시 봅니다.

인덱스가 하나도 없는 표

실습 데이터베이스는 507행입니다. 이 크기에서는 무엇을 해도 빠르고, 읽은 페이지도 2 와 9 처럼 가까이 붙어 나옵니다. 그래서 이 단원에서만 10만 행짜리 표를 따로 만들어 재 봅니다. 다 보고 나면 지웁니다.

SQL
CREATE TABLE Board.T_IX (
    num   int           NOT NULL,
    code  varchar(20)   NOT NULL,
    title nvarchar(100) NOT NULL,
    hits  int           NOT NULL
);

-- 10만 행을 채웁니다. 값은 모두 행 번호에서 만들어 언제 돌려도 같습니다.
INSERT INTO Board.T_IX (num, code, title, hits)
SELECT TOP (100000)
       ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
       'C' + RIGHT('000000' + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS varchar(6)), 6),
       N'제목 ' + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS nvarchar(10)),
       ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 1000
FROM sys.all_columns a CROSS JOIN sys.all_columns b;

-- 이 표가 디스크에서 어떤 모양인지 봅니다.
SELECT i.type_desc AS 구조, ps.index_level AS 수준,
       ps.page_count AS 페이지, ps.page_count * 8 AS KB
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Board.T_IX'), NULL, NULL, 'DETAILED') ps
    JOIN sys.indexes i ON i.object_id = ps.object_id AND i.index_id = ps.index_id;
결과
구조수준페이지KB
HEAP05664,528
인덱스를 하나도 만들지 않은 표를 힙(heap)이라고 합니다. 순서 없이 놓인 566장입니다.

페이지는 SQL Server 가 읽고 쓰는 단위입니다. 8KB 이고, 한 행만 필요해도 그 행이 든 페이지를 통째로 가져옵니다. 그래서 얼마나 빠른지는 시간보다 몇 장을 읽었는가로 재는 편이 정확합니다. 시간은 다른 작업과 캐시 상태에 따라 흔들리지만 읽은 페이지 수는 그렇지 않습니다.

힙에서 행 하나를 찾아봅니다.

SQL
SET STATISTICS IO ON;
SELECT code, title FROM Board.T_IX WHERE num = 50000;
SET STATISTICS IO OFF;
결과
codetitle
C050000제목 50000
메시지
Table 'T_IX'. Scan count 1, logical reads 566, ...
한 행을 가져오려고 566장을 전부 읽었습니다. 표의 페이지 수와 정확히 같습니다.

순서가 없으니 어디에 있는지 알 방법이 없고, 그래서 전부 읽습니다. 찾는 행이 첫 페이지에 있어도 마찬가지입니다. 조건에 맞는 행이 하나뿐이라는 것을 미리 알 수 없으므로 끝까지 확인해야 합니다.

클러스터형

표 자체를 정렬해 둡니다

클러스터형 인덱스는 표를 키 순서로 저장하는 것입니다. 따로 만드는 사본이 아니라 표 자체의 배치가 바뀝니다. 그래서 표 하나에 하나만 둘 수 있습니다.

SQL
ALTER TABLE Board.T_IX ADD CONSTRAINT PK_T_IX PRIMARY KEY CLUSTERED (num);

SELECT i.type_desc AS 구조, ps.index_level AS 수준,
       ps.page_count AS 페이지, ps.record_count ASFROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Board.T_IX'), NULL, NULL, 'DETAILED') ps
    JOIN sys.indexes i ON i.object_id = ps.object_id AND i.index_id = ps.index_id
ORDER BY ps.index_level;
결과
구조수준페이지
CLUSTERED0566100,000
CLUSTERED11566
수준이 둘로 늘었습니다. 0 은 자료가 든 곳이고, 1 은 그것을 가리키는 곳입니다.

수준 1 의 한 페이지가 수준 0 의 566장을 가리킵니다. 각 항목은 그 페이지의 첫 num 이 얼마인지를 담고 있어, 찾는 값이 어느 페이지에 있는지 한 번에 정해집니다. 이 구조를 B-트리라고 부르고, 수준의 개수를 트리의 높이라고 합니다.

높이가 2 라는 것은 어떤 행이든 2장만 읽으면 닿는다는 뜻입니다. 재 봅니다.

SQL
SET STATISTICS IO ON;
-- 클러스터형 키로 찾습니다.
SELECT code, title FROM Board.T_IX WHERE num = 50000;

-- 키가 아닌 열로 찾습니다.
SELECT num, title FROM Board.T_IX WHERE code = 'C050000';
SET STATISTICS IO OFF;
결과 — 읽은 페이지
조건논리적 읽기방식
num = 500002탐색
code = 'C050000'568스캔
같은 표에서 같은 한 행을 가져옵니다. 조건에 사용한 열이 키인지 아닌지만 다릅니다.

566 이 2 가 되었습니다. 그런데 code 로 찾을 때는 568 로, 힙일 때보다 오히려 두 장 많습니다. 정렬은 num 기준이지 code 기준이 아니기 때문입니다. 인덱스는 만들어 둔 그 순서로만 쓸모가 있습니다.

클러스터형 키는 신중하게 정하십시오. 표를 그 순서로 다시 배치하는 일이고, 뒤에서 보듯 다른 인덱스들이 모두 이 키를 함께 들고 다닙니다. 3.2 에서 int IDENTITY 를 권한 까닭이 여기에도 있습니다.

비클러스터형

따로 두는 정렬된 사본

code 로도 빨리 찾으려면 그 열로 정렬된 것이 하나 더 있어야 합니다. 표는 이미 num 순서로 놓였으므로 다시 배치할 수는 없습니다. 비클러스터형 인덱스는 그래서 사본을 따로 만듭니다.

SQL
CREATE NONCLUSTERED INDEX IX_T_IX_code ON Board.T_IX (code);

SELECT i.name AS 인덱스, i.type_desc AS 종류, ps.index_level AS 수준,
       ps.page_count AS 페이지, ps.page_count * 8 AS KB
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Board.T_IX'), NULL, NULL, 'DETAILED') ps
    JOIN sys.indexes i ON i.object_id = ps.object_id AND i.index_id = ps.index_id
ORDER BY i.index_id, ps.index_level;
결과
인덱스종류수준페이지KB
PK_T_IXCLUSTERED05664,528
PK_T_IXCLUSTERED118
IX_T_IX_codeNONCLUSTERED02602,080
IX_T_IX_codeNONCLUSTERED118
2MB 가 새로 생겼습니다. 표의 절반에 가까운 크기입니다.

비클러스터형 인덱스가 담는 것은 키 열과 클러스터형 키뿐입니다. 여기서는 codenum 입니다. title 이나 hits 는 들어 있지 않습니다. 그래서 무엇을 가져오느냐에 따라 읽는 양이 달라집니다.

SQL
SET STATISTICS IO ON;
-- 인덱스가 담고 있는 열만 가져옵니다.
SELECT num FROM Board.T_IX WHERE code = 'C050000';

-- 담고 있지 않은 열을 더합니다.
SELECT num, title FROM Board.T_IX WHERE code = 'C050000';
SET STATISTICS IO OFF;
결과 — 읽은 페이지
가져오는 열논리적 읽기
num2
num, title4
두 장이 더 들었습니다. 인덱스에서 num 을 알아낸 뒤 표로 다시 들어간 값입니다.

이 되돌아가기를 키 조회(key lookup) 라고 합니다. 실행 계획에 그대로 나옵니다.

SQL
-- 실행하지 않고 계획만 봅니다. 이 문장은 배치에 혼자 있어야 합니다.
SET SHOWPLAN_TEXT ON;
GO
SELECT num, title FROM Board.T_IX WHERE code = 'C050000';
GO
SET SHOWPLAN_TEXT OFF;
결과
|--Nested Loops(Inner Join, OUTER REFERENCES:([T_IX].[num])) |--Index Seek(OBJECT:([T_IX].[IX_T_IX_code]), | SEEK:([T_IX].[code]='C050000') ORDERED FORWARD) |--Clustered Index Seek(OBJECT:([T_IX].[PK_T_IX]), SEEK:([T_IX].[num]=[T_IX].[num]) LOOKUP ORDERED FORWARD)
데이터베이스와 스키마 이름을 줄여 옮겼습니다. 실제 출력은 [MssqlLab].[Board].[T_IX] 처럼 전체 이름으로 나옵니다.

아래에서 위로 읽습니다. Index Seekcode 로 행을 찾고, 그 결과의 num 으로 Clustered Index Seek … LOOKUP 이 표에 다시 들어갑니다. 한 행마다 한 번씩입니다.

임계점

많이 가져오면 인덱스를 버립니다

키 조회가 한 행에 두 장이라면, 100행이면 200장입니다. 표 전체가 566장이므로 어느 지점부터는 그냥 전부 읽는 편이 쌉니다. 그 지점을 찾아봅니다.

가져온 값을 화면에 뿌리면 결과가 길어지므로 변수에 담습니다. 값은 마지막 것만 남지만 읽기는 전부 일어납니다.

SQL
DECLARE @t nvarchar(100);
SET STATISTICS IO ON;
SELECT @t = title FROM Board.T_IX WHERE code = 'C050000';      -- 1건
SELECT @t = title FROM Board.T_IX WHERE code LIKE 'C0500%';    -- 100건
SELECT @t = title FROM Board.T_IX WHERE code LIKE 'C050%';     -- 1,000건
SELECT @t = title FROM Board.T_IX WHERE code LIKE 'C05%';      -- 10,000건
SET STATISTICS IO OFF;
결과 — 읽은 페이지
가져온 행논리적 읽기엔진이 선택한 방식
14인덱스 탐색 + 키 조회
100218인덱스 탐색 + 키 조회
1,000568표 전체 스캔
10,000568표 전체 스캔
1,000건에서 방식이 바뀝니다. 읽기가 568 에서 멈추는 것이 그 표시입니다.

인덱스를 만들었다고 해서 항상 사용되는 것은 아닙니다. SQL Server 는 통계를 보고 몇 행이 나올지 어림한 뒤, 키 조회를 그만큼 하는 비용과 전부 읽는 비용을 비교합니다. 1,000건부터는 후자가 싸다고 판단한 것입니다.

이 지점은 행의 크기와 통계에 따라 달라집니다. 같은 표라도 열이 넓으면 표 전체가 더 두꺼워져 더 늦게 넘어갑니다. 외울 숫자가 아니라 방향을 보십시오 — 조금 가져오면 인덱스가 이기고, 많이 가져오면 스캔이 이깁니다.

포함 열

인덱스에 값을 실어 둡니다

키 조회가 비싼 까닭은 인덱스에 없는 열을 가지러 표까지 다녀오기 때문입니다. 그 열을 인덱스에 함께 넣어 두면 다녀올 일이 없습니다. INCLUDE 가 그것입니다.

SQL
CREATE NONCLUSTERED INDEX IX_T_IX_code_inc ON Board.T_IX (code) INCLUDE (title);

DECLARE @t nvarchar(100);
SET STATISTICS IO ON;
SELECT @t = title FROM Board.T_IX WHERE code = 'C050000';
SELECT @t = title FROM Board.T_IX WHERE code LIKE 'C0500%';
SELECT @t = title FROM Board.T_IX WHERE code LIKE 'C050%';
SELECT @t = title FROM Board.T_IX WHERE code LIKE 'C05%';
SET STATISTICS IO OFF;
결과 — 읽은 페이지
가져온 행포함 열 없음포함 열 있음
143
1002185
1,0005689
10,00056853
두 인덱스가 함께 있는 상태에서 엔진이 포함 열 쪽을 선택했습니다.

568 이 53 이 되었습니다. 표를 한 번도 건드리지 않고 인덱스만 읽어 끝냈기 때문입니다. 이렇게 쿼리가 필요로 하는 열을 인덱스가 모두 담고 있는 상태를 커버링이라고 합니다.

공짜는 아닙니다. 실어 둔 만큼 인덱스가 두꺼워집니다.

SQL
SELECT i.name AS 인덱스, SUM(ps.page_count) AS 페이지, SUM(ps.page_count) * 8 AS KB
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Board.T_IX'), NULL, NULL, 'DETAILED') ps
    JOIN sys.indexes i ON i.object_id = ps.object_id AND i.index_id = ps.index_id
GROUP BY i.name, i.index_id
ORDER BY i.index_id;
결과
인덱스페이지KB담는 것
PK_T_IX5674,536표 전체
IX_T_IX_code2612,088code, num
IX_T_IX_code_inc4843,872code, num, title
열 하나를 실었더니 2,088KB 가 3,872KB 가 되었습니다. 표 자체의 85% 입니다.

포함 열을 늘리면 인덱스가 표를 닮아 갑니다. 전부 실으면 표를 한 벌 더 갖는 것과 같습니다. 무엇을 실을지 정하는 기준은 5.2 에서 다룹니다.

실습 데이터베이스가 이미 그렇게 되어 있습니다

앞에서 본 IX_POSTS_list 가 포함 열을 가진 인덱스입니다.

SQL
-- lab-setup.sql 에 들어 있는 정의입니다.
CREATE NONCLUSTERED INDEX IX_POSTS_list
    ON Board.POSTS (board_num, group_num DESC, sort_no)
    INCLUDE (title, user_num, reg_date, hit_count, depth);

-- 목록 화면이 내는 것과 같은 문장입니다.
SET STATISTICS IO ON;
SELECT TOP (20) num, title, user_num, reg_date, hit_count
FROM Board.POSTS WHERE board_num = 2
ORDER BY group_num DESC, sort_no;

-- 본문을 하나 더합니다. content 는 인덱스에 없습니다.
SELECT TOP (20) num, title, user_num, reg_date, hit_count, content
FROM Board.POSTS WHERE board_num = 2
ORDER BY group_num DESC, sort_no;
SET STATISTICS IO OFF;
결과 — 읽은 페이지
가져오는 열논리적 읽기계획
인덱스가 담은 열만2Index Seek + Top
content 를 더하면14Clustered Index Scan + Sort
507행짜리 표입니다. 크기보다 계획이 달라진 것을 보십시오.

위쪽에는 Sort 가 없습니다. 인덱스가 이미 group_num DESC, sort_no 순서로 놓여 있어 앞에서 20개를 끊어 내면 끝이기 때문입니다. ORDER BY 가 인덱스 순서와 같으면 정렬 자체가 사라집니다.

아래쪽은 인덱스를 아예 사용하지 않았습니다. content 를 가지러 507번 다녀오느니 표를 통째로 읽고 정렬하는 편이 싸다고 판단한 것입니다. 목록 화면에서 본문까지 가져오지 않는 데에는 이런 까닭도 있습니다.

비용

읽기가 빨라진 만큼 쓰기가 느려집니다

인덱스는 사본입니다. 행을 하나 넣으면 표에 한 번, 인덱스마다 한 번씩 더 써야 합니다. 얼마나 늘어나는지 재 봅니다.

시간 대신 트랜잭션 로그에 남은 레코드 수를 셉니다. 실제로 일어난 변경의 개수라서 실행할 때마다 같은 값이 나옵니다.

SQL
-- 같은 모양의 표 둘. 한쪽에만 비클러스터형 인덱스를 셋 겁니다.
CREATE TABLE Board.T_W0 (num int NOT NULL, code varchar(20) NOT NULL,
    title nvarchar(100) NOT NULL, hits int NOT NULL,
    CONSTRAINT PK_T_W0 PRIMARY KEY CLUSTERED (num));

CREATE TABLE Board.T_W3 (num int NOT NULL, code varchar(20) NOT NULL,
    title nvarchar(100) NOT NULL, hits int NOT NULL,
    CONSTRAINT PK_T_W3 PRIMARY KEY CLUSTERED (num));

CREATE NONCLUSTERED INDEX IX_T_W3_code  ON Board.T_W3 (code);
CREATE NONCLUSTERED INDEX IX_T_W3_hits  ON Board.T_W3 (hits) INCLUDE (title);
CREATE NONCLUSTERED INDEX IX_T_W3_title ON Board.T_W3 (title);
GO

DECLARE @rec int, @bytes bigint, @i int;

-- 한 행씩 1,000번 넣습니다. 응용 프로그램이 실제로 하는 방식입니다.
BEGIN TRAN;
SET @i = 1;
WHILE @i <= 1000
BEGIN
    INSERT INTO Board.T_W0 (num, code, title, hits)
    VALUES (@i, 'C' + RIGHT('000000' + CAST(@i AS varchar(6)), 6),
            N'제목 ' + CAST(@i AS nvarchar(10)), @i % 100);
    SET @i += 1;
END
SELECT @rec = database_transaction_log_record_count,
       @bytes = database_transaction_log_bytes_used
FROM sys.dm_tran_database_transactions WHERE database_id = DB_ID();
ROLLBACK;
SELECT N'인덱스 없음' AS 표, @rec AS 로그레코드, @bytes AS 로그바이트;

-- 같은 WHILE 문을 한 번 더 적고 넣는 표만 T_W3 로 바꿔 다시 잽니다.
결과
로그 레코드로그 바이트
인덱스 없음1,002145,936
인덱스 3개4,005553,324
1,000행을 넣은 값입니다. 표에 1,000번, 인덱스 셋에 3,000번을 더 썼습니다.

정확히 네 배입니다. 인덱스를 셋 만들면 삽입 하나가 네 번의 쓰기가 됩니다. 삭제도 같고, 수정은 고친 열을 담은 인덱스만 다시 씁니다.

여기에 공간이 더해집니다. 앞에서 본 Board.T_IX 는 표가 4,536KB 인데 인덱스 둘이 5,960KB 였습니다. 자료보다 인덱스가 큰 상태입니다. 백업도 그만큼 커집니다.

그래서 일단 만들어 두자가 통하지 않습니다. 인덱스는 특정 문장을 위해 만드는 것이고, 그 문장이 얼마나 자주 실행되는지가 판단 기준입니다. 하루에 한 번 도는 통계 쿼리를 위해 초당 수십 번 일어나는 삽입을 네 배로 만들 이유는 없습니다.

사용되지 않는 경우

만들어 두어도 비켜 가는 자리

앞에서 가져오는 행이 많으면 엔진이 인덱스를 버린다고 했습니다. 그 밖에도 문장을 어떻게 적었느냐에 따라 사용되지 못하는 경우가 있습니다.

선행 열부터 맞아야 합니다

IX_POSTS_list 의 키는 board_num, group_num, sort_no 셋입니다. 이 순서가 정렬 순서이므로, 첫 열이 조건에 없으면 어디부터 볼지 정할 수 없습니다.

SQL
SET SHOWPLAN_TEXT ON;
GO
SELECT COUNT(*) FROM Board.POSTS WHERE board_num = 2;   -- 첫 열
GO
SELECT COUNT(*) FROM Board.POSTS WHERE sort_no = 0;      -- 셋째 열
GO
SET SHOWPLAN_TEXT OFF;
결과 — board_num = 2
|--Index Seek(OBJECT:([POSTS].[IX_POSTS_list]), SEEK:([POSTS].[board_num]=(2)) ORDERED FORWARD)
결과 — sort_no = 0
|--Clustered Index Scan(OBJECT:([POSTS].[PK_POSTS]), WHERE:([POSTS].[sort_no]=(0)))
위는 인덱스를 탐색했고 아래는 인덱스를 아예 사용하지 않았습니다. 읽은 페이지는 11 과 14 였습니다.

sort_no 는 그 인덱스의 셋째 열입니다. 담겨는 있지만 정렬은 board_num 부터이므로 어디를 펼쳐야 할지 정할 수 없습니다. 그래서 엔진은 인덱스를 지나치고 표를 처음부터 끝까지 읽으면서 값을 하나하나 확인했습니다. 복합 인덱스의 열 순서를 어떻게 정하는지는 5.2 에서 다룹니다.

열을 함수로 감싸면

WHERE YEAR(reg_date) = 2026 처럼 적으면 reg_date 에 인덱스가 있어도 사용하지 못합니다. 인덱스는 reg_date 값으로 정렬되어 있지 YEAR(reg_date) 값으로 정렬되어 있지 않기 때문입니다. 모든 행에 함수를 적용해 봐야 알 수 있으므로 전부 읽습니다.

범위로 고쳐 적으면 됩니다.

SQL
-- 인덱스를 사용하지 못합니다.
SELECT COUNT(*) FROM Board.POSTS WHERE YEAR(reg_date) = 2026;

-- 같은 결과이고 인덱스를 사용할 수 있습니다.
SELECT COUNT(*) FROM Board.POSTS
WHERE reg_date >= '2026-01-01' AND reg_date < '2027-01-01';

이것을 SARGable 하다고 말합니다. 어떤 표현이 여기에 해당하고 어떻게 고쳐 적는지는 5.4 에서 따로 다룹니다. 지금은 조건에 적은 열을 그대로 두어야 인덱스가 산다는 것만 기억하면 됩니다.

연습

직접 해보기

1. 회원이 쓴 댓글을 찾습니다 난이도 하

Board.COMMENTS 에서 user_num = 7 인 댓글을 세면 지금은 11장을 읽습니다. 2장으로 줄이는 인덱스를 만들어 보세요. 확인한 뒤에는 지웁니다.

CREATE NONCLUSTERED INDEX IX_COMMENTS_user ON Board.COMMENTS (user_num); -- 읽기가 11 에서 2 로 줄어듭니다(회원 7의 댓글은 50건입니다). -- COUNT(*) 는 값을 가져오지 않으므로 키 조회가 일어나지 않습니다. -- 인덱스가 담은 user_num 만으로 끝납니다. -- 3.3 에서 본 것과 같은 인덱스입니다. COMMENTS.user_num 은 외래 키인데 -- lab-setup.sql 이 인덱스를 두지 않았고, 그래서 회원을 지울 때 표 전체를 읽었습니다. DROP INDEX IX_COMMENTS_user ON Board.COMMENTS;
2. 인덱스를 만들었는데도 느립니다 난이도 중

Board.T_IX 에서 SELECT num, title … WHERE hits = 500 은 100건을 냅니다. hits 에 인덱스를 만들어도 읽기가 217 까지밖에 줄지 않습니다. 4 로 만드는 방법을 적어 보세요.

217 가운데 대부분은 title 을 가지러 표에 다녀온 값입니다. 100건이므로 100번입니다.
CREATE NONCLUSTERED INDEX IX_T_IX_hits ON Board.T_IX (hits) INCLUDE (title); -- 인덱스 없음 568 표 전체를 읽습니다 -- (hits) 217 인덱스로 100건을 찾고 title 을 가지러 100번 다녀옵니다 -- INCLUDE 4 인덱스만 읽고 끝냅니다 -- num 은 클러스터형 키라 비클러스터형 인덱스가 이미 들고 있습니다. -- 따로 INCLUDE 에 적지 않아도 됩니다. DROP INDEX IX_T_IX_hits ON Board.T_IX;

이 단원에서 만든 표를 지웁니다. 10만 행이라 그대로 두면 실습 데이터베이스가 무거워집니다.
DROP TABLE Board.T_IX, Board.T_W0, Board.T_W3;

요약
  • 찾는 방법은 스캔(전부 읽기)과 탐색(정렬을 따라 들어가기) 둘입니다. 인덱스는 탐색을 할 수 있게 두는 정렬된 사본입니다.
  • 빠르기는 시간이 아니라 읽은 페이지 수로 잽니다. SET STATISTICS IO ON 입니다.
  • 클러스터형은 표 자체의 순서라 하나만 둘 수 있습니다. 인덱스가 하나도 없는 표는 힙이고, 한 행을 찾는 데도 전부 읽습니다(566장 대 2장).
  • 비클러스터형은 키 열과 클러스터형 키만 담습니다. 없는 열을 가져오려면 표로 되돌아갑니다 — 키 조회입니다.
  • 키 조회가 쌓이면 엔진이 인덱스를 버리고 전부 읽습니다. 이 표에서는 1,000건이 그 지점이었습니다.
  • INCLUDE 로 값을 실어 두면 표에 가지 않습니다(568 대 53). 대신 인덱스가 표만큼 두꺼워집니다.
  • ORDER BY 가 인덱스 순서와 같으면 정렬 자체가 사라집니다. IX_POSTS_list 가 그것을 노린 인덱스입니다.
  • 인덱스 셋을 만들면 삽입 하나가 네 번의 쓰기가 됩니다(로그 레코드 1,002 대 4,005).
  • 만들어도 선행 열이 조건에 없거나 열을 함수로 감싸면 사용되지 못합니다.