MSSQL 5.4 · 5부. 성능

SARGable 조건

함수로 감싼 열이 인덱스를 사용하지 못하는 까닭입니다.

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

조건에 사용할 인덱스를 겁니다

이 단원은 인덱스가 있는데도 사용하지 못하는 경우를 봅니다. 그러려면 먼저 인덱스가 있어야 합니다.

SQL
CREATE INDEX IX_REG   ON Board.POSTS_BIG (reg_date);
CREATE INDEX IX_TITLE ON Board.POSTS_BIG (title);
CREATE INDEX IX_CAT   ON Board.POSTS_BIG (category_num);
GO

SARGable 은 "인덱스로 찾아 들어갈 수 있는 조건" 을 가리키는 말입니다. Search ARGument ABLE 을 줄인 것입니다. 같은 결과를 내는 두 조건이 하나는 탐색, 하나는 스캔이 되는 자리를 하나씩 봅니다.

규칙은 한 줄입니다. 인덱스를 건 열을 그대로 두십시오. 열에 함수를 씌우거나 계산을 붙이면 인덱스가 정렬해 둔 값과 비교할 수 없게 됩니다.

날짜

날짜를 자르지 말고 범위로 적습니다

가장 자주 보는 자리입니다. "이번 달 글" 을 찾을 때 YEARMONTH 로 자르고 싶어집니다.

SQL
SET STATISTICS IO ON;

-- 자른 것
SELECT COUNT(*) FROM Board.POSTS_BIG
WHERE YEAR(reg_date) = 2026 AND MONTH(reg_date) = 12;

-- 범위로 적은 것
SELECT COUNT(*) FROM Board.POSTS_BIG
WHERE reg_date >= '2026-12-01' AND reg_date < '2027-01-01';
결과 — 같은 19,041건입니다
조건논리적 읽기계획
YEAR · MONTH 로 자름994Index Scan(IX_REG)
범위로 적음42Index Seek(IX_REG)
계획
|--Index Scan(OBJECT:([POSTS_BIG].[IX_REG]), WHERE:(datepart(year,[reg_date])=(2026) AND datepart(month,[reg_date])=(12))) |--Index Seek(OBJECT:([POSTS_BIG].[IX_REG]), SEEK:([reg_date] > [Expr] AND [reg_date] < [Expr]) ORDERED FORWARD)
앞은 50만 행을 하나씩 datepart 로 계산해 봅니다. 뒤는 그 구간으로 바로 들어갑니다.

YEAR(reg_date)인덱스에 없는 값입니다. 인덱스는 reg_date 로 정렬해 두었지 YEAR(reg_date) 로 정렬해 두지 않았습니다. 그래서 다 읽어 하나씩 계산해 보는 수밖에 없습니다.

마지막 날을 어떻게 적습니까

BETWEEN '2026-12-01' AND '2026-12-31' 로 적으면 12월 31일 00시 00분 00초까지만 들어옵니다. 그날 낮에 올라온 글이 빠집니다. >= 시작일< 다음 달 1일 로 적는 것이 안전합니다.

적는 법문제
BETWEEN '2026-12-01' AND '2026-12-31'31일 00:00 이후가 빠집니다
<= '2026-12-31 23:59:59'초 아래 자리가 빠집니다
>= '2026-12-01' AND < '2027-01-01'빠지는 것이 없습니다

예외 — CAST(… AS date) 는 됩니다

SQL
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE CAST(reg_date AS date) = '2026-12-01';
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE reg_date >= '2026-12-01' AND reg_date < '2026-12-02';
결과
조건논리적 읽기
CAST(reg_date AS date) = '2026-12-01'10
범위로 적음7
계획 — 엔진이 범위로 바꿔 줍니다
|--Nested Loops(Inner Join) |--Compute Scalar(DEFINE:(…=GetRangeThroughConvert(…))) | |--Constant Scan |--Index Seek(OBJECT:([POSTS_BIG].[IX_REG]), SEEK:([reg_date] > [Expr] AND [reg_date] < [Expr]))
GetRangeThroughConvert 가 그날의 시작과 끝을 만들어 줍니다.

"함수를 쓰면 무조건 안 된다" 는 것은 아닙니다. datetimedate 로 바꾸는 것처럼 엔진이 범위로 되돌릴 수 있는 몇 가지는 예외로 처리합니다. 그래도 계획을 열어 확인하는 편이 확실합니다. SEEK: 에 조건이 들어갔는지만 보면 됩니다(5.2).

문자열

앞을 알아야 찾을 수 있습니다

SQL
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE title LIKE N'%인덱스';
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE title LIKE N'글 제목 25000%';
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE LEFT(title, 9) = N'글 제목 25000';
결과
조건논리적 읽기계획
LIKE N'%인덱스'2,734Index Scan
LIKE N'글 제목 25000%'3Index Seek
LEFT(title, 9) = …2,734Index Scan
뒤에만 % 를 붙였을 때의 계획
|--Index Seek(OBJECT:([POSTS_BIG].[IX_TITLE]), SEEK:([title] >= N'글 제목 25000' AND [title] < N'글 제목 2500¼'), WHERE:([title] like N'글 제목 25000%') ORDERED FORWARD)
엔진이 LIKE 를 범위 조건으로 바꿔 줍니다. 마지막 글자를 하나 올려 상한을 만듭니다.

LIKE N'값%'앞부분이 정해져 있어 범위로 바꿀 수 있습니다. 반대로 N'%값' 은 어디서 시작할지 알 수 없어 다 읽어야 합니다. LEFT(title, 9)같은 뜻인데 함수라서 막힙니다.

앞에 % 를 붙여야만 찾을 수 있다면 전문 검색이 맞습니다. 인덱스로는 어떻게 해도 다 읽습니다. 5.8 에서 다룹니다.

감싸기

열에 무엇이든 씌우면 막힙니다

SQL
-- NULL 을 다루려고 감싼 것
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE ISNULL(category_num, 0) = 3;
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE category_num = 3;

-- 계산이 어느 쪽에 있느냐
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE num + 0 = 250000;
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE num = 250000 + 0;
결과
조건논리적 읽기
ISNULL(category_num, 0) = 3871
category_num = 363
num + 0 = 250000870
num = 250000 + 03
계산이 열 쪽에 있으면 막히고, 값 쪽에 있으면 그대로입니다. 값 쪽 계산은 컴파일할 때 끝납니다.

ISNULL 로 감싸는 것이 가장 흔한 실수입니다. NULL 이 무섭다고 감싸는데, category_num = 3 은 애초에 NULL 인 행을 내보내지 않습니다(1.7). 감쌀 까닭이 없습니다.

NULL 도 함께 찾아야 한다면 조건을 나눠 적으십시오.

SQL
-- ISNULL(category_num, 0) IN (0, 3) 대신
WHERE category_num = 3 OR category_num IS NULL

암시적 형 변환

눈에 보이지 않는 함수입니다. 형식이 다른 값과 비교하면 엔진이 한쪽을 변환하는데, 변환되는 쪽이 열이면 막힙니다.

SQL
-- varchar 열을 가진 표를 하나 만듭니다. 20만 행입니다.
CREATE TABLE Board.T_CODE (
    num  int IDENTITY PRIMARY KEY,
    code varchar(20) NOT NULL
);
INSERT INTO Board.T_CODE (code)
SELECT TOP (200000) CONCAT('CODE',
    RIGHT('000000' + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS varchar(10)), 6))
FROM Board.POSTS_BIG;
CREATE INDEX IX_CODE ON Board.T_CODE (code);
GO

SET STATISTICS IO ON;
SELECT COUNT(*) FROM Board.T_CODE WHERE code = 'CODE100000';   -- varchar
SELECT COUNT(*) FROM Board.T_CODE WHERE code = N'CODE100000';  -- nvarchar
결과
비교하는 값논리적 읽기계획
'CODE100000'3Index Seek
N'CODE100000'598Index Scan
N 을 붙였을 때의 계획
|--Index Scan(OBJECT:([T_CODE].[IX_CODE]), WHERE:(CONVERT_IMPLICIT(nvarchar(20),[code],0)=[@1]))
열 쪽에 CONVERT_IMPLICIT 이 붙었습니다. 200배입니다.

nvarcharvarchar 보다 우선순위가 높아 varchar 열 쪽이 변환됩니다. 반대로 nvarchar 열에 varchar 값을 비교하면 값 쪽이 변환되므로 문제가 없습니다.

이것은 응용 프로그램에서 자주 생깁니다. ADO.NET 의 SqlParameter 는 문자열을 기본적으로 nvarchar 로 보냅니다. 열이 varchar 라면 SqlDbType.VarChar 를 지정해야 합니다. 코드는 멀쩡한데 서버에서만 느린 대표적인 경우이고, 계획의 CONVERT_IMPLICIT 으로만 드러납니다.

되짚기

막히지 않는 것들

막힌다고 알려진 것 가운데 실제로는 괜찮은 것들이 있습니다. 외운 규칙 때문에 문장을 어렵게 적는 일이 없도록 확인해 둡니다.

SQL
SET STATISTICS IO ON;

-- OR 로 묶은 것과 나눠 적은 것
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE user_num = 7 OR category_num = 3;

SELECT COUNT(*) FROM (
    SELECT num FROM Board.POSTS_BIG WHERE user_num = 7
    UNION
    SELECT num FROM Board.POSTS_BIG WHERE category_num = 3) t;

-- int 열에 문자열을 비교
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE num = '250000';
결과
조건논리적 읽기
OR 로 묶음110
UNION 으로 나눔110
num = '250000'3
OR 의 계획
|--Merge Join(Concatenation) |--Index Seek(OBJECT:([POSTS_BIG].[IX_POSTS_BIG_user]), SEEK:([user_num]=(7))) |--Index Seek(OBJECT:([POSTS_BIG].[IX_CAT]), SEEK:([category_num]=(3)))
엔진이 알아서 인덱스 둘을 따로 찾아 합칩니다. 손으로 나눌 까닭이 없습니다.
흔히 듣는 말실제
OR 는 인덱스를 못 쓴다양쪽 다 인덱스가 있으면 씁니다
int 열에 문자열을 비교하면 막힌다int 우선순위가 높아 값 쪽이 변환됩니다
<>NOT IN 은 인덱스를 못 쓴다인덱스를 읽기는 합니다. 대부분이 조건에 맞아 스캔이 될 뿐입니다

category_num <> 3NOT IN (1,2,3) 은 둘 다 529장 입니다. 이것은 조건 모양의 문제가 아니라 결과가 표의 대부분이기 때문입니다. 5.2 에서 본 대로 많이 나오면 스캔이 맞습니다.

규칙을 외우지 말고 계획을 보십시오. 조건이 SEEK: 에 들어갔는지 WHERE: 에 붙었는지 한 줄이면 판가름납니다. 이 단원의 표들은 전부 그 한 줄을 보고 적은 것입니다.

구제

문장을 고칠 수 없다면

응용 프로그램이 만드는 문장이라 손댈 수 없을 때가 있습니다. 그때는 3.6 의 계산 열로 살릴 수 있습니다.

SQL
-- 앞에서 2,734장이 걸렸던 조건입니다.
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE LEFT(title, 9) = N'글 제목 25000';

-- 같은 식을 계산 열로 두고 인덱스를 겁니다.
ALTER TABLE Board.POSTS_BIG ADD title_head AS (LEFT(title, 9)) PERSISTED;
GO
CREATE INDEX IX_TITLE_HEAD ON Board.POSTS_BIG (title_head);
GO

-- 문장은 한 글자도 고치지 않았습니다.
SELECT COUNT(*) FROM Board.POSTS_BIG WHERE LEFT(title, 9) = N'글 제목 25000';
결과
논리적 읽기계획
계산 열이 없을 때2,734Index Scan(IX_TITLE)
계산 열과 인덱스를 둔 뒤3Index Seek(IX_TITLE_HEAD)
계획
|--Index Seek(OBJECT:([POSTS_BIG].[IX_TITLE_HEAD]), SEEK:([title_head]=N'글 제목 25000') ORDERED FORWARD)
문장에 title_head 를 적지 않았는데도 엔진이 알아보고 사용합니다.

식이 글자까지 똑같아야 알아봅니다. LEFT(title, 9) 로 만든 계산 열은 SUBSTRING(title, 1, 9) 조건에는 쓰이지 않습니다. 그리고 식이 결정적이어야 합니다(3.6) — CONVERT(char(7), reg_date, 23)PERSISTED 로 만들 수 없습니다.

먼저 문장을 고칠 수 있는지 보십시오. 계산 열은 열이 하나 늘고 인덱스가 하나 느는 일이며, 저장할 때마다 값을 만들어 넣습니다(5.2). 고칠 수 있는 문장이라면 고치는 편이 언제나 낫습니다.

연습

직접 해보기

1. 이 조건들을 고쳐 보세요 난이도 하

아래 넷은 모두 인덱스를 사용하지 못합니다. 같은 결과를 내면서 탐색이 되도록 고쳐 보세요.

SQL
WHERE DATEDIFF(day, reg_date, '2026-12-01') = 0
WHERE hit_count * 2 > 1000
WHERE UPPER(title) LIKE N'글 제목%'
WHERE reg_date + 1 >= '2026-12-02'
-- (1) 날짜 차이를 0 으로 비교하는 것은 "그날" 이라는 뜻입니다. WHERE reg_date >= '2026-12-01' AND reg_date < '2026-12-02' -- (2) 계산을 값 쪽으로 옮깁니다. WHERE hit_count > 500 -- (3) 이 데이터베이스의 데이터 정렬은 CI 라 대소문자를 가리지 않습니다. -- UPPER 로 감쌀 까닭이 없습니다. WHERE title LIKE N'글 제목%' -- CS 데이터 정렬이라면 계산 열을 두거나 열 자체를 CI 로 두어야 합니다. -- (4) 열에서 값 쪽으로 옮깁니다. WHERE reg_date >= '2026-12-01' -- 모두 같은 규칙 하나입니다. -- 인덱스를 건 열은 왼쪽에 그대로 두고, -- 계산은 오른쪽(값 쪽)에서 끝냅니다.
2. 코드로 찾는데 느립니다 난이도 중

응용 프로그램이 Board.T_CODE 에서 코드로 한 건을 찾는데 느립니다. SQL 은 WHERE code = ? 하나뿐이고 인덱스도 있습니다. 무엇을 확인하고 어떻게 고치겠습니까.

SQL 문장만 봐서는 알 수 없습니다. 실제로 서버에 무엇이 도착했는지를 보십시오.
-- 1. 계획을 봅니다. CONVERT_IMPLICIT 이 열 쪽에 붙었는지가 전부입니다. SET SHOWPLAN_TEXT ON; GO SELECT num FROM Board.T_CODE WHERE code = N'CODE100000'; GO -- |--Index Scan(OBJECT:([T_CODE].[IX_CODE]), -- WHERE:(CONVERT_IMPLICIT(nvarchar(20),[code],0)=[@1])) -- ↑ 열이 변환되고 있습니다 -- 2. 어떤 형식으로 들어오는지 확인합니다. -- 캐시에 남은 문장에서 매개 변수 선언을 볼 수 있습니다. SELECT t.text FROM sys.dm_exec_cached_plans p CROSS APPLY sys.dm_exec_sql_text(p.plan_handle) t WHERE t.text LIKE '%T_CODE%' AND t.text NOT LIKE '%dm_exec%'; -- (@1 nvarchar(4000))SELECT COUNT(*) FROM [Board].[T_CODE] WHERE [code]=@1 -- ↑ nvarchar 로 왔습니다 -- 3. 고칩니다. 셋 가운데 하나입니다. -- (가) 보내는 쪽에서 형식을 맞춥니다. 가장 낫습니다. -- var p = cmd.Parameters.Add("@code", SqlDbType.VarChar, 20); -- p.Value = code; -- (나) 열을 nvarchar 로 바꿉니다. -- 저장 공간이 두 배가 되고 표 전체를 다시 만들어야 합니다. -- (다) 손댈 수 없다면 계산 열을 둡니다. -- ALTER TABLE Board.T_CODE -- ADD code_n AS (CAST(code AS nvarchar(20))) PERSISTED; -- CREATE INDEX IX_CODE_N ON Board.T_CODE (code_n); -- 엔진이 CONVERT_IMPLICIT 을 이 계산 열로 알아보지는 않으므로 -- 문장도 code_n 으로 고쳐야 합니다. 마지막 수단입니다. -- 20만 행에서 3장과 598장이고, 행이 늘수록 벌어집니다.

이 단원에서 만든 것을 지웁니다.
DROP INDEX IX_TITLE_HEAD ON Board.POSTS_BIG;
ALTER TABLE Board.POSTS_BIG DROP COLUMN title_head;
DROP INDEX IX_REG ON Board.POSTS_BIG; DROP INDEX IX_TITLE ON Board.POSTS_BIG;
DROP INDEX IX_CAT ON Board.POSTS_BIG; DROP TABLE Board.T_CODE;

요약
  • SARGable 은 인덱스로 찾아 들어갈 수 있는 조건입니다. 규칙은 하나입니다 — 인덱스를 건 열을 왼쪽에 그대로 두십시오.
  • 날짜는 자르지 말고 범위로 적습니다. YEAR·MONTH 로 자르면 994장, 범위로 적으면 42장입니다.
  • 마지막 날은 <= '…23:59:59' 가 아니라 < 다음 날 로 적습니다. 빠지는 것이 없습니다.
  • LIKE N'값%'범위로 바뀌어 3장, LIKE N'%값'2,734장입니다. 앞에 % 가 필요하면 전문 검색입니다(5.8).
  • ISNULL 로 감싸면 63장이 871장이 됩니다. = 3 은 애초에 NULL 을 내보내지 않으므로 감쌀 까닭이 없습니다.
  • 계산은 값 쪽에서 끝냅니다. num + 0 = 250000 은 870장, num = 250000 + 0 은 3장입니다.
  • 암시적 형 변환은 눈에 보이지 않는 함수입니다. varchar 열에 N'…' 을 비교하면 열 쪽이 변환되어 3장이 598장이 됩니다. ADO.NET 은 문자열을 nvarchar 로 보내므로 SqlDbType.VarChar 를 지정하십시오.
  • 예외가 있습니다. CAST(… AS date)LIKE '값%' 는 엔진이 범위로 바꿔 줍니다. OR 도 양쪽에 인덱스가 있으면 둘 다 사용합니다.
  • 규칙을 외우지 말고 계획을 보십시오. 조건이 SEEK: 에 들어갔는지 WHERE: 에 붙었는지가 전부입니다.
  • 문장을 고칠 수 없으면 계산 열에 인덱스를 걸어 살릴 수 있습니다(3.6). 2,734장이 3장이 됩니다. 다만 식이 글자까지 같아야 알아봅니다.