SARGable 조건
함수로 감싼 열이 인덱스를 사용하지 못하는 까닭입니다.
조건에 사용할 인덱스를 겁니다
이 단원은 인덱스가 있는데도 사용하지 못하는 경우를 봅니다. 그러려면 먼저 인덱스가 있어야 합니다.
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 을 줄인 것입니다. 같은 결과를 내는 두 조건이 하나는 탐색, 하나는 스캔이 되는 자리를 하나씩 봅니다.
규칙은 한 줄입니다. 인덱스를 건 열을 그대로 두십시오. 열에 함수를 씌우거나 계산을 붙이면 인덱스가 정렬해 둔 값과 비교할 수 없게 됩니다.
날짜를 자르지 말고 범위로 적습니다
가장 자주 보는 자리입니다. "이번 달 글" 을 찾을 때
YEAR 와 MONTH 로
자르고 싶어집니다.
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';
| 조건 | 논리적 읽기 | 계획 |
|---|---|---|
| YEAR · MONTH 로 자름 | 994 | Index Scan(IX_REG) |
| 범위로 적음 | 42 | Index Seek(IX_REG) |
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) 는 됩니다
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 |
"함수를 쓰면 무조건 안 된다" 는 것은 아닙니다.
datetime 을 date 로
바꾸는 것처럼 엔진이 범위로 되돌릴 수 있는 몇 가지는 예외로
처리합니다. 그래도 계획을 열어 확인하는 편이 확실합니다.
SEEK: 에 조건이 들어갔는지만 보면 됩니다(5.2).
앞을 알아야 찾을 수 있습니다
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,734 | Index Scan |
| LIKE N'글 제목 25000%' | 3 | Index Seek |
| LEFT(title, 9) = … | 2,734 | Index Scan |
LIKE N'값%' 는 앞부분이 정해져 있어
범위로 바꿀 수 있습니다. 반대로 N'%값'
은 어디서 시작할지 알 수 없어 다 읽어야 합니다.
LEFT(title, 9) 도 같은 뜻인데 함수라서
막힙니다.
앞에 % 를 붙여야만 찾을 수 있다면 전문 검색이 맞습니다. 인덱스로는 어떻게 해도 다 읽습니다. 5.8 에서 다룹니다.
열에 무엇이든 씌우면 막힙니다
-- 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) = 3 | 871 |
| category_num = 3 | 63 |
| num + 0 = 250000 | 870 |
| num = 250000 + 0 | 3 |
ISNULL 로 감싸는 것이 가장 흔한
실수입니다. NULL 이 무섭다고 감싸는데,
category_num = 3 은 애초에
NULL 인 행을 내보내지 않습니다(1.7).
감쌀 까닭이 없습니다.
NULL 도 함께 찾아야 한다면 조건을 나눠 적으십시오.
-- ISNULL(category_num, 0) IN (0, 3) 대신 WHERE category_num = 3 OR category_num IS NULL
암시적 형 변환
눈에 보이지 않는 함수입니다. 형식이 다른 값과 비교하면 엔진이 한쪽을 변환하는데, 변환되는 쪽이 열이면 막힙니다.
-- 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' | 3 | Index Seek |
| N'CODE100000' | 598 | Index Scan |
nvarchar 가 varchar
보다 우선순위가 높아 varchar 열 쪽이
변환됩니다. 반대로 nvarchar 열에
varchar 값을 비교하면 값 쪽이 변환되므로 문제가
없습니다.
이것은 응용 프로그램에서 자주 생깁니다. ADO.NET 의
SqlParameter 는 문자열을 기본적으로
nvarchar 로 보냅니다. 열이
varchar 라면
SqlDbType.VarChar 를 지정해야 합니다.
코드는 멀쩡한데 서버에서만 느린 대표적인 경우이고,
계획의 CONVERT_IMPLICIT 으로만 드러납니다.
막히지 않는 것들
막힌다고 알려진 것 가운데 실제로는 괜찮은 것들이 있습니다. 외운 규칙 때문에 문장을 어렵게 적는 일이 없도록 확인해 둡니다.
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 는 인덱스를 못 쓴다 | 양쪽 다 인덱스가 있으면 씁니다 |
| int 열에 문자열을 비교하면 막힌다 | int 우선순위가 높아 값 쪽이 변환됩니다 |
| <> 나 NOT IN 은 인덱스를 못 쓴다 | 인덱스를 읽기는 합니다. 대부분이 조건에 맞아 스캔이 될 뿐입니다 |
category_num <> 3 과
NOT IN (1,2,3) 은 둘 다 529장
입니다. 이것은 조건 모양의 문제가 아니라 결과가 표의 대부분이기
때문입니다. 5.2 에서 본 대로 많이 나오면 스캔이
맞습니다.
규칙을 외우지 말고 계획을 보십시오. 조건이
SEEK: 에 들어갔는지 WHERE:
에 붙었는지 한 줄이면 판가름납니다. 이 단원의 표들은 전부 그 한 줄을 보고
적은 것입니다.
문장을 고칠 수 없다면
응용 프로그램이 만드는 문장이라 손댈 수 없을 때가 있습니다. 그때는 3.6 의 계산 열로 살릴 수 있습니다.
-- 앞에서 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,734 | Index Scan(IX_TITLE) |
| 계산 열과 인덱스를 둔 뒤 | 3 | Index Seek(IX_TITLE_HEAD) |
식이 글자까지 똑같아야 알아봅니다.
LEFT(title, 9) 로 만든 계산 열은
SUBSTRING(title, 1, 9) 조건에는 쓰이지 않습니다.
그리고 식이 결정적이어야 합니다(3.6) —
CONVERT(char(7), reg_date, 23) 은
PERSISTED 로 만들 수 없습니다.
먼저 문장을 고칠 수 있는지 보십시오. 계산 열은 열이 하나 늘고 인덱스가 하나 느는 일이며, 저장할 때마다 값을 만들어 넣습니다(5.2). 고칠 수 있는 문장이라면 고치는 편이 언제나 낫습니다.
직접 해보기
아래 넷은 모두 인덱스를 사용하지 못합니다. 같은 결과를 내면서 탐색이 되도록 고쳐 보세요.
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'
응용 프로그램이 Board.T_CODE 에서 코드로
한 건을 찾는데 느립니다. SQL 은 WHERE code = ?
하나뿐이고 인덱스도 있습니다. 무엇을 확인하고 어떻게
고치겠습니까.
이 단원에서 만든 것을 지웁니다.
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장이 됩니다. 다만 식이 글자까지 같아야 알아봅니다.