MSSQL LAB
MSSQL 5.1 · 5부. 성능

실행 계획 읽기

예상 계획과 실제 계획을 보고, 스캔과 탐색이 어떻게 다른지 봅니다.

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

50만 행이 필요합니다

4부까지는 507행짜리 표로 충분했습니다. 읽은 페이지가 2 와 14 로 갈리는 것만 보면 되었기 때문입니다. 5부는 다릅니다. 계획이 왜 그렇게 잡히는지, 무엇을 고치면 달라지는지는 규모가 있어야 숫자로 드러납니다.

lab-bigdata.sql 을 받아 실행하십시오. Board.POSTS 는 그대로 두고 같은 모양의 Board.POSTS_BIG 을 50만 행으로 만듭니다. 앞 단원들의 예제 결과는 그대로 유지됩니다.

SQL
-- lab-setup.sql 로 MssqlLab 을 만든 뒤에 실행합니다.
-- 만드는 데 몇 초 걸립니다.
결과
행수공지회원7의글인덱스제목
500,000166,66625,00062,500
인덱스
인덱스페이지MB
PK_POSTS_BIG7,30557
IX_POSTS_BIG_list4,51935
IX_POSTS_BIG_user8666
100MB 가까이 차지합니다. 5부를 마치면 DROP TABLE Board.POSTS_BIG; 으로 지우십시오.
개념 설명

엔진이 정한 방법을 봅니다

SQL 은 무엇을 원하는지만 적습니다. 어떻게 가져올지는 엔진이 정합니다 — 어느 인덱스를 쓸지, 어떤 순서로 조인할지, 정렬을 어디서 할지. 그 결정을 적어 둔 것이 실행 계획입니다.

계획은 두 가지로 봅니다.

예상 계획실제 계획
실행하지 않습니다합니다
행 수통계로 어림한 값어림값 + 실제로 나온 값
문장으로SET SHOWPLAN_TEXT ONSET STATISTICS PROFILE ON
SSMS 에서Ctrl+LCtrl+M 뒤 실행

둘의 차이가 곧 진단입니다. 어림값과 실제가 크게 어긋나면 엔진이 잘못된 전제로 계획을 골랐다는 뜻이고, 그때부터 느려집니다.

SSMS 를 사용한다면 그래픽 계획을 보는 편이 훨씬 낫습니다. 이 단원이 문장으로 보이는 것은 화면에 옮길 수 있기 때문이고, 실제로 진단할 때는 아이콘 위에 마우스를 올려 예상·실제 행 수를 보는 쪽이 빠릅니다.

읽는 법

다섯 문장을 나란히 놓습니다

같은 표에서 조금씩 다른 다섯 문장을 실행하고 계획과 읽은 페이지를 함께 봅니다.

SQL
SET STATISTICS IO ON;

SELECT num FROM Board.POSTS_BIG WHERE num = 250000;                -- (가)
SELECT num FROM Board.POSTS_BIG WHERE content = N'없는본문';       -- (나)

SELECT TOP (20) num, 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;          -- (라)
SELECT title  FROM Board.POSTS_BIG WHERE user_num = 7;          -- (마)
결과
문장논리적 읽기계획의 핵심
(가) 클러스터형 키로 한 건3Clustered Index Seek
(나) 인덱스가 없는 열로7,333Clustered Index Scan + Filter
(다) 목록 20건3Top + Index Seek
(라) 회원 한 명의 글 세기47Index Seek
(마) 회원 한 명의 글 제목4,535Index Scan
같은 표 50만 행입니다. 3장과 7,333장이 갈립니다.

계획은 오른쪽 아래에서 왼쪽 위로 읽습니다

문장으로 보면 가장 깊이 들여쓴 것이 먼저 실행됩니다. 거기서 나온 행이 위로 올라가면서 걸러지고 묶입니다.

SQL
SET SHOWPLAN_TEXT ON;
GO
SELECT num FROM Board.POSTS_BIG WHERE content = N'없는본문';
GO
SELECT TOP (20) num, title FROM Board.POSTS_BIG
WHERE board_num = 2 ORDER BY group_num DESC, sort_no;
GO
SET SHOWPLAN_TEXT OFF;
결과 (나) — 7,333장
|--Filter(WHERE:([POSTS_BIG].[content]=N'없는본문')) |--Clustered Index Scan(OBJECT:([POSTS_BIG].[PK_POSTS_BIG]))
결과 (다) — 3장
|--Top(TOP EXPRESSION:((20))) |--Index Seek(OBJECT:([POSTS_BIG].[IX_POSTS_BIG_list]), SEEK:([POSTS_BIG].[board_num]=(2)) ORDERED FORWARD)
(나)는 50만 행을 다 읽어 올린 뒤 Filter 가 거릅니다. (다)는 20행만 올라옵니다.

가장 아래에 있는 것이 몇 행을 내보내는가가 그 문장의 값입니다. (나)는 아래에서 50만 행이 올라오고 위에서 0행이 남습니다. 49만 9,999행을 읽어 버린 셈입니다. (다)는 아래에서 20행만 올라옵니다.

자주 보게 되는 연산자

연산자무엇을 합니까보이면
Index Seek정렬을 따라 그 자리로 들어갑니다좋습니다
Index Scan인덱스를 처음부터 끝까지 읽습니다범위가 넓으면 정상입니다
Clustered Index Scan표를 처음부터 끝까지 읽습니다조건이 있는데 보이면 살펴봅니다
Key Lookup인덱스에 없는 열을 표에서 가져옵니다건수가 많으면 문제입니다(3.5)
Filter올라온 행을 걸러 냅니다아래에서 너무 많이 올라온 것입니다
Sort정렬합니다인덱스 순서와 다르다는 뜻입니다
Nested Loops바깥 행마다 안쪽을 찾습니다한쪽이 작을 때 어울립니다
Hash Match해시 표를 만들어 맞춥니다양쪽이 클 때 어울립니다

연산자 이름을 외우는 것보다 "아래에서 몇 행이 올라오는가" 를 보는 것이 먼저입니다. 느린 문장은 대개 필요 없는 행을 너무 많이 읽어 올린 뒤 위에서 버리는 모양을 하고 있습니다.

진단

예상과 실제를 견줍니다

엔진은 몇 행이 나올지 어림한 값을 보고 계획을 고릅니다. 그 어림이 맞으면 계획도 대개 맞습니다. 어긋나면 그때부터 느려집니다.

SET STATISTICS PROFILE ON 은 계획의 단계마다 어림값과 실제로 나온 행 수를 함께 냅니다.

SQL
SET STATISTICS PROFILE ON;
-- 2026년 12월 이후에 올라온 글을 셉니다.
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE reg_date >= '2026-12-01';
SET STATISTICS PROFILE OFF;
결과 — 열이 많아 셋만 옮겼습니다
RowsEstimateRows연산자
19,04120,893.5Index Scan(IX_POSTS_BIG_list)
어림 20,893.5 에 실제 19,041 입니다. 이 정도면 잘 맞은 것입니다.

같은 문장을 지역 변수로 바꾸면 어림이 무너집니다. 4.1 에서 본 변수가 여기서 다시 나옵니다.

SQL
SET STATISTICS PROFILE ON;
DECLARE @d datetime2(0) = '2026-12-01';
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE reg_date >= @d;
SET STATISTICS PROFILE OFF;
결과
적은 방법실제어림차이
값을 그대로19,04120,893.5가깝습니다
지역 변수로19,041150,000여덟 배 부풀렸습니다
150,000 은 50만의 30% 입니다. 통계를 볼 수 없을 때 사용하는 고정 비율입니다.

계획을 세우는 시점에 변수 안의 값을 모르기 때문입니다. 문장이 컴파일될 때 @d 에는 아직 아무것도 들어 있지 않습니다. 그래서 통계를 찾아보지 못하고 "부등호 조건은 대개 30%" 라는 고정 비율을 씁니다.

이 문장에서는 다행히 계획이 같았습니다. 그러나 어림이 여덟 배 어긋나면 조인 방식이나 정렬 위치가 잘못 잡히는 일이 흔합니다. 왜 어긋나는지와 무엇으로 고치는지는 5.3 에서 다룹니다.

어림과 실제를 견주는 것이 진단의 첫걸음입니다. 느린 문장을 만나면 계획을 열고 어느 단계에서 둘이 벌어지는지부터 찾으십시오. 대개 그 아래가 원인입니다.

순서

무엇을 먼저 봅니까

  1. 가장 아래에서 몇 행이 올라오는가. 결과가 20행인데 아래에서 50만 행이 올라온다면 그것이 문제입니다.
  2. 어림과 실제가 벌어지는 단계. 거기가 잘못된 전제가 들어간 자리입니다.
  3. Scan 이 있는데 조건도 있는가. 조건이 있는데 표를 다 읽고 있다면 인덱스가 없거나 사용하지 못하는 모양입니다(3.5 · 5.4).
  4. Key Lookup 이 몇 번 도는가. 3.5 에서 본 대로 건수가 많아지면 스캔보다 비싸집니다.
  5. Sort 가 있는가. 인덱스 순서로 낼 수 있으면 사라집니다 (3.5 의 IX_POSTS_list).

비용 백분율은 참고만 하십시오. 계획에 붙는 백분율은 실제 시간이 아니라 어림값으로 계산한 비용의 비율입니다. 어림이 틀렸다면 그 비율도 틀립니다. 실제로 오래 걸린 자리를 찾으려면 실제 행 수와 STATISTICS IO 를 보는 편이 확실합니다.

연습

직접 해보기

1. 왜 7,333장을 읽습니까 난이도 하

(나) 문장은 결과가 한 건도 없는데 7,333장을 읽었습니다. 계획을 열어 까닭을 설명하고, 읽기를 줄일 방법이 있는지 말해 보세요.

SET SHOWPLAN_TEXT ON; GO SELECT num FROM Board.POSTS_BIG WHERE content = N'없는본문'; GO -- |--Filter(WHERE:([POSTS_BIG].[content]=N'없는본문')) -- |--Clustered Index Scan(OBJECT:([POSTS_BIG].[PK_POSTS_BIG])) -- content 에 인덱스가 없습니다. 그래서 표를 처음부터 끝까지 읽어 올리고, -- 위의 Filter 가 하나하나 비교해 버립니다. 7,333 은 표의 페이지 수입니다. -- 결과가 0 건이라는 것은 다 읽고 나서야 알 수 있습니다. -- 줄이려면 content 에 인덱스를 걸어야 하는데, 권하지 않습니다. -- nvarchar(max) 라 인덱스 키에 넣을 수 없습니다. -- 본문 전체를 담으면 인덱스가 표만큼 커집니다(3.5). -- 그리고 = 이 아니라 '포함' 으로 찾고 싶은 것이 보통입니다. -- 본문을 찾는 일은 전문 검색이 맞습니다. 5.8 에서 다룹니다.
2. 같은 조건인데 47장과 4,535장 난이도 중

(라)와 (마)는 WHERE user_num = 7 로 같은데 읽기가 47장과 4,535장입니다. 계획을 견주어 까닭을 찾고, (마)를 줄이는 방법을 적어 보세요.

IX_POSTS_BIG_user 는 어떤 열을 담고 있습니까. 두 문장이 각각 무엇을 가져옵니까. 3.5 를 떠올리십시오.
-- (라) COUNT(*) — 값을 가져오지 않습니다. -- |--Index Seek(IX_POSTS_BIG_user) 47 장 -- user_num 만 있으면 셀 수 있으므로 인덱스만 읽고 끝납니다. -- (마) title 을 가져옵니다. -- |--Index Scan(IX_POSTS_BIG_list, WHERE user_num=7) 4,535 장 -- IX_POSTS_BIG_user 에는 title 이 없습니다. -- 그것으로 찾으면 25,000 번 키 조회를 해야 하므로(3.5), -- 엔진이 title 을 담고 있는 다른 인덱스를 통째로 읽는 쪽을 골랐습니다. -- 포함 열을 더하면 커버링이 됩니다(3.5). CREATE NONCLUSTERED INDEX IX_POSTS_BIG_user2 ON Board.POSTS_BIG (user_num) INCLUDE (title); SET STATISTICS IO ON; SELECT title FROM Board.POSTS_BIG WHERE user_num = 7; -- logical reads 161 ← 4,535 에서 줄었습니다 DROP INDEX IX_POSTS_BIG_user2 ON Board.POSTS_BIG; -- 47 장까지 내려가지 않는 것은 title 을 실은 만큼 인덱스가 두꺼워졌고 -- 25,000 행의 제목을 실제로 읽어 내보내야 하기 때문입니다. -- "필요한 것만 읽는" 상태에 이르렀고 그 이상 줄일 것이 없습니다. -- 인덱스를 하나 더 만들 값어치가 있는지는 따로 따집니다(3.5 의 쓰기 비용). -- 이 조회가 얼마나 자주 도는지가 판단 기준입니다.

Board.POSTS_BIG5부 내내 사용하므로 지금은 두십시오. 5.8 을 마친 뒤 DROP TABLE Board.POSTS_BIG; 으로 지웁니다.

요약
  • 실행 계획은 엔진이 정한 실행 방법입니다. 예상 계획은 SET SHOWPLAN_TEXT ON, 실제 계획은 SET STATISTICS PROFILE ON 으로 봅니다.
  • 문장으로 보면 가장 깊이 들여쓴 것이 먼저 실행되고 위로 올라갑니다.
  • 가장 아래에서 몇 행이 올라오는지를 먼저 보십시오. 결과가 20행인데 50만 행이 올라온다면 그것이 원인입니다.
  • 50만 행에서 3장과 7,333장이 갈립니다. 조건에 사용한 열에 인덱스가 있느냐 없느냐입니다.
  • 어림과 실제가 벌어지는 단계가 진단의 출발점입니다. 지역 변수를 조건에 사용하면 어림이 20,893.5 에서 150,000 으로 무너집니다 — 컴파일 시점에 값을 모르기 때문입니다(5.3).
  • 계획의 비용 백분율은 어림값으로 계산한 것입니다. 어림이 틀리면 그 비율도 틀립니다.
  • 같은 조건이라도 무엇을 가져오느냐에 따라 계획이 갈립니다(47장과 4,535장). 3.5 의 커버링이 여기서 다시 나옵니다.