MSSQL LAB
MSSQL 5.3 · 5부. 성능

통계와 매개 변수 스니핑

계획이 어긋나는 까닭과 OPTION RECOMPILE 을 봅니다.

예상 학습 시간 24분 난이도 고급
개념 설명

어림값은 통계에서 옵니다

5.1 에서 계획마다 붙어 있던 어림한 행 수를 보았습니다. 엔진은 표를 세어 보고 그 값을 내는 것이 아닙니다. 미리 만들어 둔 요약을 봅니다. 그 요약이 통계입니다.

통계는 두 가지를 담습니다.

담는 것무엇어디에 쓰입니까
히스토그램값의 분포를 최대 200구간으로 나눈 것= 3 처럼 값이 정해진 조건
밀도서로 다른 값이 몇 가지인지값을 모를 때(변수·조인)
SQL
-- 이 표에 통계가 무엇이 있는지 봅니다.
SELECT s.name AS 통계, s.auto_created AS 자동생성,
       sp.rows AS 통계가아는행수, sp.steps AS 구간,
       sp.modification_counter AS 바뀐행
FROM sys.stats s
    CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) sp
WHERE s.object_id = OBJECT_ID('Board.POSTS_BIG')
ORDER BY s.stats_id;
결과
통계자동생성아는 행수구간
PK_POSTS_BIGnum0NULLNULL
IX_POSTS_BIG_listboard_num, group_num, sort_no0500,0003
IX_POSTS_BIG_useruser_num0500,00020
_WA_Sys_00000009_…title1500,000175
인덱스를 만들면 통계가 함께 생깁니다. _WA_Sys_ 로 시작하는 것은 조회할 때 엔진이 스스로 만든 것입니다.

인덱스 통계는 인덱스를 만들 때 함께 생깁니다. 인덱스가 없는 열이라도 조건에 사용하면 엔진이 그 자리에서 통계를 만듭니다 (_WA_Sys_).

PK_POSTS_BIGNULL 인 것은 표가 비어 있을 때 만들어진 뒤 아직 한 번도 채워지지 않았기 때문입니다. num 으로 한 번 찾아 보면 바로 채워집니다.

SQL
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE num <= 100 OPTION (RECOMPILE);
그 뒤에 다시 보면
통계아는 행수구간
PK_POSTS_BIG500,000120
필요해지는 순간 만들어집니다.

히스토그램을 직접 봅니다

SQL
DBCC SHOW_STATISTICS('Board.POSTS_BIG', 'IX_POSTS_BIG_user') WITH HISTOGRAM;
DBCC SHOW_STATISTICS('Board.POSTS_BIG', 'IX_POSTS_BIG_user') WITH DENSITY_VECTOR;
히스토그램 — 20줄 가운데 넷
RANGE_HI_KEYRANGE_ROWSEQ_ROWSDISTINCT_RANGE_ROWS
10.025,000.00
20.025,000.00
30.025,000.00
200.025,000.00
밀도
All densityAverage LengthColumns
0.054.0user_num
0.0000028.0user_num, num
회원이 20명이라 20구간이 나왔고, 밀도 0.05 는 1/20 입니다.
RANGE_HI_KEY이 구간의 마지막 값
EQ_ROWS그 값과 정확히 같은 행이 몇 개인지
RANGE_ROWS앞 구간과 이 값 사이에 몇 행이 있는지
DISTINCT_RANGE_ROWS그 사이에 서로 다른 값이 몇 가지인지
All density1 을 값의 가짓수로 나눈 것

WHERE user_num = 7 의 어림 25,000 은 히스토그램의 EQ_ROWS 를 그대로 읽은 값입니다. 구간이 200개뿐이므로 값이 수천 가지인 열은 여러 값이 한 구간에 묶입니다. 그때는 AVG_RANGE_ROWS 로 나눠 어림하므로 값마다 다르게 나오지 않습니다.

5.1 에서 지역 변수를 사용했을 때 어림이 150,000 이 된 것은 히스토그램을 아예 보지 못했기 때문입니다. 컴파일할 때 값을 모르면 히스토그램에서 찾을 자리가 없습니다.

낡음

통계가 낡으면 어림이 무너집니다

통계는 만든 순간의 사진입니다. 표가 바뀌어도 저절로 따라오지 않습니다. 회원 7의 글을 10만 건 넣어 보겠습니다.

SQL
-- 자동 갱신을 잠시 꺼서 낡은 상태를 만듭니다.
ALTER DATABASE MssqlLab SET AUTO_UPDATE_STATISTICS OFF;
GO

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 (100000) board_num, category_num, 7, NULL,
    group_num, depth, sort_no, title, content, hit_count, reg_date
FROM Board.POSTS_BIG ORDER BY num;
GO

SET STATISTICS PROFILE ON;
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE user_num = 7;
SET STATISTICS PROFILE OFF;
결과
통계가 아는 행수실제 행수회원 7
넣기 전500,000500,00025,000
넣은 뒤500,000600,000125,000
그 상태에서 조회하면
어림실제
낡은 통계로30,000125,000
UPDATE STATISTICS125,428125,000
30,000 은 25,000 에 늘어난 비율(600,000÷500,000)을 곱한 값입니다. 넷째 배가 어긋났습니다.

어림이 넷째 배 작으면 계획이 잘못 잡힙니다. 3만 행인 줄 알고 세운 계획으로 12만 5천 행을 처리하게 되고, 그 문장이 조인이나 정렬을 안고 있으면 메모리도 3만 행 몫만 잡습니다.

SQL
-- 하나만 다시 만듭니다.
UPDATE STATISTICS Board.POSTS_BIG IX_POSTS_BIG_user;

-- 표의 통계를 모두 다시 만듭니다.
UPDATE STATISTICS Board.POSTS_BIG;

-- 표본이 아니라 전부 읽어 만듭니다. 정확하지만 오래 걸립니다.
UPDATE STATISTICS Board.POSTS_BIG WITH FULLSCAN;

-- 데이터베이스 전체를 훑어 낡은 것만 다시 만듭니다.
EXEC sp_updatestats;
갱신 뒤 어림이 125,428 이 된 까닭
실제는 125,000 인데 어림은 125,428.91 입니다. UPDATE STATISTICS 는 기본적으로 표본만 읽기 때문입니다. WITH FULLSCAN 을 붙이면 전부 읽어 정확해집니다.
표본으로도 0.3% 안쪽입니다. 대개 이것으로 충분합니다.

자동 갱신은 언제 일어납니까

평소에는 엔진이 알아서 다시 만듭니다. 다만 그 시점이 중요합니다.

SQL
ALTER DATABASE MssqlLab SET AUTO_UPDATE_STATISTICS ON;
GO

-- 3만 행을 더 넣습니다. 임계(약 2만 4천)를 넘습니다.
-- 그리고 같은 문장을 다시 조회합니다.
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE user_num = 8;
GO

-- 이번에는 계획을 새로 만들게 합니다.
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE user_num = 8 OPTION (RECOMPILE);
결과
통계가 아는 행수바뀐 행갱신 시각
3만 행을 넣고 그냥 조회600,00030,000그대로
RECOMPILE 로 조회630,0000바뀌었습니다
임계를 넘겨도 계획을 재사용하는 동안에는 갱신되지 않습니다.

자동 갱신은 계획을 새로 만들 때 확인합니다. 캐시에 있는 계획을 그대로 사용하는 동안에는 통계가 낡았는지 아무도 보지 않습니다. 자주 도는 문장일수록 계획이 오래 남으므로, 대량으로 넣거나 지운 뒤에는 손으로 갱신해 주는 편이 안전합니다.

임계는 대략 √(1000 × 행수) 입니다. 60만 행이면 약 2만 4천 행입니다. 표가 커질수록 비율로는 작아지므로 큰 표일수록 자동 갱신에 기대기 어렵습니다. 야간 작업으로 UPDATE STATISTICS 를 도는 곳이 많은 것도 이 때문입니다.

스니핑

먼저 부른 쪽이 계획을 정합니다

프로시저는 처음 실행될 때 계획을 만들어 두고 다음부터 그것을 재사용합니다(4.2). 이때 엔진은 그 첫 호출에 들어온 매개 변수 값을 보고 계획을 세웁니다. 이것을 매개 변수 스니핑이라고 합니다.

대개는 도움이 됩니다. 실제 값을 보고 히스토그램을 찾으니 어림이 정확해집니다. 문제는 그다음 호출이 아주 다른 값을 가지고 올 때입니다.

SQL
CREATE OR ALTER PROCEDURE Board.P_UPTO
    @n int
AS
BEGIN
    SET NOCOUNT ON;
    -- 화면에 40만 행을 뿌리지 않으려고 변수에 담습니다. 읽는 양은 같습니다.
    DECLARE @s nvarchar(200);
    SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @n;
END
GO

-- 캐시를 비우고 작은 값부터 부릅니다.
DBCC FREEPROCCACHE;
GO
SET STATISTICS IO ON;
EXEC Board.P_UPTO @n = 100;
EXEC Board.P_UPTO @n = 400000;
결과 — 논리적 읽기
먼저 부른 값@n = 100@n = 400000
100 을 먼저55,858
400000 을 먼저4,5354,535
각각의 최적값54,535
100건을 가져오는 데 4,535장을 읽습니다. 907배입니다.

두 계획이 무엇인지 보면 까닭이 분명합니다.

SQL
SET SHOWPLAN_TEXT ON;
GO
DECLARE @s nvarchar(200); SELECT @s = title FROM Board.POSTS_BIG WHERE num <= 100;
GO
DECLARE @s nvarchar(200); SELECT @s = title FROM Board.POSTS_BIG WHERE num <= 400000;
GO
SET SHOWPLAN_TEXT OFF;
100 일 때
|--Clustered Index Seek(OBJECT:([POSTS_BIG].[PK_POSTS_BIG]), SEEK:([POSTS_BIG].[num] <= (100)) ORDERED FORWARD)
400000 일 때
|--Index Scan(OBJECT:([POSTS_BIG].[IX_POSTS_BIG_list]), WHERE:([POSTS_BIG].[num]<=(400000)))
앞은 표에서 앞부분만 잘라 읽고, 뒤는 더 얇은 인덱스를 통째로 읽습니다.

둘 다 자기 값에는 맞는 계획입니다. 100건이면 표 앞부분 다섯 장만 읽으면 되고, 40만 건이면 표(7,305장)보다 IX_POSTS_BIG_list(4,519장)를 통째로 읽는 편이 쌉니다. 어긋난 값에 재사용되는 것이 문제입니다.

증상이 "어제까지 잘 되던 것이 오늘 갑자기 느려짐" 으로 나타납니다. 서버를 다시 시작했거나, 통계가 갱신되어 계획이 새로 만들어졌거나, 캐시에서 밀려났을 때 그 순간 누가 어떤 값으로 불렀느냐에 따라 계획이 달라지기 때문입니다. 코드는 그대로인데 속도만 바뀝니다.

해법

네 가지 방법이 있습니다

SQL
-- 아래는 P_UPTO 안의 SELECT 를 바꿔 적은 것입니다.
-- 그대로 실행하지 말고 프로시저 본문에 넣어 견주십시오.

-- (1) 문장마다 새로 컴파일합니다.
SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @n OPTION (RECOMPILE);

-- (2) 정해 둔 값으로 계획을 만듭니다.
SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @n
OPTION (OPTIMIZE FOR (@n = 400000));

-- (3) 값을 안 본 것으로 치고 평균으로 만듭니다.
SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @n
OPTION (OPTIMIZE FOR UNKNOWN);

-- (4) 지역 변수에 옮겨 담습니다. (3)과 같은 결과가 됩니다.
DECLARE @local int = @n;
SELECT @s = title FROM Board.POSTS_BIG WHERE num <= @local;
결과 — 논리적 읽기
방법@n = 100@n = 400000
그냥 두면(100 을 먼저 부른 경우)55,858
(1) RECOMPILE54,535
(3) OPTIMIZE FOR UNKNOWN55,858
(4) 지역 변수55,858
각각의 최적값54,535
RECOMPILE 만 두 값 모두 최적입니다. (3)과 (4)는 어림 150,000 으로 고정된 계획 하나를 씁니다.

RECOMPILE 은 값을 치릅니다. 매번 계획을 새로 만들기 때문입니다.

SQL
-- 같은 프로시저를 1,000번씩 부릅니다.
DECLARE @i int = 1, @t datetime2(7) = SYSDATETIME();
WHILE @i <= 1000 BEGIN EXEC Board.P_UPTO @n = 100; SET @i += 1; END
PRINT CONCAT(DATEDIFF(millisecond, @t, SYSDATETIME()), ' ms');
결과 — 1,000번, 각 3회
걸린 시간
계획을 재사용30~32 밀리초
OPTION (RECOMPILE)354~360 밀리초
11배입니다. 읽는 양이 아니라 계획을 만드는 값입니다.
방법언제 씁니까대가
RECOMPILE값에 따라 건수가 크게 달라질 때호출마다 컴파일
OPTIMIZE FOR (값)흔한 값을 알고 있을 때그 값이 바뀌면 다시 손대야 합니다
OPTIMIZE FOR UNKNOWN어느 쪽으로도 치우치지 않게양쪽 다 최적은 아닙니다
지역 변수(권하지 않습니다)의도가 코드에 드러나지 않습니다

지역 변수로 옮겨 담는 것은 문법이 아니라 부작용입니다. 읽는 사람은 왜 옮겨 담았는지 알 수 없습니다. 같은 효과를 원한다면 OPTION (OPTIMIZE FOR UNKNOWN) 을 적으십시오. 무엇을 의도했는지가 코드에 남습니다.

먼저 스니핑이 원인인지부터 확인하십시오. 같은 프로시저가 어떤 값에서만 느리다면 스니핑입니다. 어느 값에서나 느리다면 인덱스나 문장 자체의 문제이고(5.2 · 5.4), 그때 RECOMPILE 을 붙여 봐야 컴파일 비용만 늘어납니다.

연습

직접 해보기

1. 어느 열에 통계가 없습니까 난이도 하

Board.POSTS_BIG 에서 통계가 아직 만들어지지 않은 열을 찾아 보세요. 그다음 그 열로 한 번 조회하고 다시 확인해 보십시오.

-- 통계가 걸려 있는 열을 먼저 뽑습니다. SELECT DISTINCT c.name FROM sys.stats s JOIN sys.stats_columns sc ON sc.object_id = s.object_id AND sc.stats_id = s.stats_id JOIN sys.columns c ON c.object_id = sc.object_id AND c.column_id = sc.column_id WHERE s.object_id = OBJECT_ID('Board.POSTS_BIG'); -- num, board_num, group_num, sort_no, user_num, title, category_num -- 표의 모든 열에서 빼면 남는 것이 없는 열입니다. SELECT c.name FROM sys.columns c WHERE c.object_id = OBJECT_ID('Board.POSTS_BIG') AND NOT EXISTS ( SELECT 1 FROM sys.stats s JOIN sys.stats_columns sc ON sc.object_id = s.object_id AND sc.stats_id = s.stats_id WHERE s.object_id = c.object_id AND sc.column_id = c.column_id); -- parent_num, depth, content, hit_count, reg_date, mod_date -- reg_date 로 한 번 조회합니다. SELECT COUNT(*) FROM Board.POSTS_BIG WHERE reg_date >= '2026-12-01'; -- 다시 확인하면 _WA_Sys_ 로 시작하는 통계가 생겨 있습니다. -- 조건에 사용한 열에는 엔진이 스스로 만듭니다. -- content 는 nvarchar(max) 라 만들지 않습니다. -- 5.1 에서 이 조회의 어림이 20,893.5 였던 것도 -- 그때 만들어진 이 통계에서 나온 값입니다.
2. 이 프로시저는 왜 값마다 다릅니까 난이도 중

회원별 글 목록을 내는 프로시저입니다. 어떤 회원 번호로 먼저 부르느냐에 따라 뒤가 느려지는지 확인하고, 고칠 방법을 골라 근거를 적어 보세요.

SQL
CREATE OR ALTER PROCEDURE Board.P_USER_POSTS
    @user int
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @s nvarchar(200);
    SELECT @s = title FROM Board.POSTS_BIG WHERE user_num = @user;
END
이 표의 회원은 20명이고 모두 25,000건씩입니다. 히스토그램을 다시 보십시오.
-- 어떤 회원으로 불러도 읽기가 같습니다. DBCC FREEPROCCACHE; SET STATISTICS IO ON; EXEC Board.P_USER_POSTS @user = 3; -- logical reads 4,535 EXEC Board.P_USER_POSTS @user = 7; -- logical reads 4,535 -- 스니핑 문제가 아닙니다. 이 표는 회원마다 25,000건으로 고르기 때문입니다. -- 히스토그램의 EQ_ROWS 가 20줄 모두 25,000 이었습니다. -- 어느 값으로 계획을 세우든 같은 계획이 나옵니다. -- RECOMPILE 을 붙여도 나아지지 않습니다. 컴파일 비용만 늘어납니다. -- 4,535 는 IX_POSTS_BIG_list 를 통째로 읽은 값이고, -- 원인은 IX_POSTS_BIG_user 에 title 이 없다는 것입니다(5.2). CREATE INDEX IX_POSTS_BIG_user2 ON Board.POSTS_BIG (user_num) INCLUDE (title); EXEC Board.P_USER_POSTS @user = 7; -- logical reads 161 DROP INDEX IX_POSTS_BIG_user2 ON Board.POSTS_BIG; -- 여기서 배울 것은 순서입니다. -- 1. 값마다 다른가 아니면 언제나 느린가를 먼저 봅니다. -- 2. 언제나 느리면 인덱스와 문장을 봅니다(5.2 · 5.4). -- 3. 값마다 다를 때만 스니핑을 의심합니다. -- 실제 회원 표라면 글이 5만 건인 사람과 두 건인 사람이 섞이므로 -- 그때는 스니핑이 맞습니다. 이 표가 고를 뿐입니다.

이 단원에서 만든 것을 지웁니다.
DROP PROCEDURE Board.P_UPTO, Board.P_USER_POSTS;
통계 실험으로 표가 63만 행이 되어 있습니다. lab-bigdata.sql 을 다시 실행해 50만 행으로 되돌리십시오.

요약
  • 어림값은 통계에서 옵니다. 히스토그램은 값의 분포를 최대 200구간으로, 밀도는 값이 몇 가지인지를 담습니다.
  • 인덱스를 만들면 통계가 함께 생기고, 인덱스가 없는 열도 조건에 사용하면 엔진이 스스로 만듭니다(_WA_Sys_).
  • WHERE user_num = 7 의 어림 25,000 은 히스토그램의 EQ_ROWS 를 그대로 읽은 값입니다.
  • 통계는 만든 순간의 사진입니다. 10만 행을 넣고 갱신하지 않으면 어림이 30,000, 실제는 125,000 이 됩니다.
  • 자동 갱신은 계획을 새로 만들 때 확인합니다. 캐시된 계획을 재사용하는 동안에는 임계를 넘겨도 갱신되지 않습니다. 대량 작업 뒤에는 UPDATE STATISTICS 를 손으로 도십시오.
  • 매개 변수 스니핑은 첫 호출의 값으로 만든 계획을 그대로 재사용하는 것입니다. 100 을 먼저 부르면 40만이 5,858장, 400000 을 먼저 부르면 100건이 4,535장(최적 5장)입니다.
  • 코드는 그대로인데 속도만 바뀌는 증상이면 스니핑을 의심하십시오. 계획이 새로 만들어지는 순간 누가 어떤 값으로 불렀느냐로 갈립니다.
  • 고치는 방법은 RECOMPILE · OPTIMIZE FOR (값) · OPTIMIZE FOR UNKNOWN 입니다. RECOMPILE 만 두 값 모두 최적이지만 1,000번에 30밀리초가 355밀리초가 됩니다.
  • 지역 변수로 옮겨 담는 것도 같은 효과를 내지만 의도가 코드에 남지 않으므로 OPTIMIZE FOR UNKNOWN 을 적으십시오.
  • 값마다 다른지 언제나 느린지를 먼저 가리십시오. 언제나 느리면 스니핑이 아니라 인덱스나 문장의 문제입니다.