WHERE 조건 처리 순서: sargable predicate와 non-sargable predicate
MySQL WHERE 조건에서 인덱스 탐색이 가능한 sargable predicate와 그렇지 않은 조건의 차이를 실행계획과 운영 관점에서 정리한다.
WHERE 조건 처리 순서: sargable predicate와 non-sargable predicate
WHERE 절은 SQL에서 가장 익숙한 문법이지만, 운영 환경에서 가장 자주 성능 문제를 만드는 영역이기도 하다. 같은 의미처럼 보이는 조건이라도 어떤 표현은 B-Tree 인덱스를 통해 좁은 범위만 읽고, 어떤 표현은 인덱스가 있어도 테이블이나 인덱스 전체를 훑은 뒤 각 행에 함수를 적용한다. 이 차이를 설명하는 핵심 용어가 sargable predicate와 non-sargable predicate다.
sargable은 “Search ARGument ABLE”에서 온 표현으로, 스토리지 엔진이 인덱스의 정렬된 키 값을 이용해 탐색 범위를 만들 수 있는 조건을 뜻한다. 반대로 non-sargable 조건은 논리적으로는 맞더라도 인덱스 키와 상수 범위를 직접 대응시키기 어려워 MySQL이 더 많은 행을 읽고 필터링하게 만든다. 이 글에서는 MySQL 8.0 기준으로 WHERE 조건이 옵티마이저와 실행기에서 어떻게 나뉘어 처리되는지, 어떤 표현이 인덱스 탐색을 방해하는지, 운영자가 쿼리와 스키마를 어떻게 고쳐야 하는지 정리한다.
1. WHERE 조건은 한 줄로 보이지만 여러 단계로 처리된다
MySQL은 WHERE a = 10 AND b > 5 같은 조건을 단순히 왼쪽부터 실행하지 않는다. SQL text는 parser와 resolver를 지나 내부 표현식 트리로 바뀌고, optimizer는 조건을 분해하여 다음 질문에 답한다.
- 어떤 테이블을 먼저 읽을 것인가?
- 어떤 인덱스를 후보로 볼 수 있는가?
- 인덱스의 어느 key part에 equality, range, prefix 조건을 붙일 수 있는가?
- 스토리지 엔진에 내려보낼 수 있는 조건과 SQL layer에서 나중에 평가할 조건은 무엇인가?
- 예상 row 수와 비용을 기준으로 table scan, range scan, ref lookup, index scan 중 무엇을 선택할 것인가?
이때 WHERE 조건은 대략 다음처럼 역할이 갈린다.
flowchart TD
A[SQL WHERE 조건] --> B[조건 정규화와 상수 접기]
B --> C{인덱스 key part와 직접 대응 가능한가?}
C -->|예| D[range/ref access 조건]
C -->|일부만 가능| E[Index Condition Pushdown 또는 residual filter]
C -->|아니오| F[SQL layer filter]
D --> G[스토리지 엔진이 읽을 범위 축소]
E --> H[읽은 인덱스/행 후보를 추가 필터링]
F --> I[더 많은 행을 읽은 뒤 조건 평가]
중요한 점은 SQL에 적힌 조건의 순서가 아니라 조건이 인덱스 키의 정렬 순서와 비교 가능한 형태인지가 access path를 좌우한다는 것이다. created_at >= '2026-07-01'은 날짜 인덱스의 시작 위치를 만들 수 있지만, DATE(created_at) = '2026-07-01'은 각 행의 created_at 값을 함수에 넣어본 뒤에야 참/거짓을 알 수 있다. 전자는 sargable이고 후자는 일반적으로 non-sargable이다.
2. sargable predicate의 기본 조건
B-Tree 인덱스는 정렬된 키 값으로 구성된다. 따라서 MySQL이 인덱스를 효율적으로 쓰려면 WHERE 조건이 인덱스 키 값에 대해 다음과 같은 검색 범위를 만들 수 있어야 한다.
col = 상수또는col IN (...)col > 상수,col >= 상수,col < 상수,col <= 상수col BETWEEN 하한 AND 상한col LIKE 'prefix%'처럼 앞부분이 고정된 문자열 검색- 복합 인덱스에서 왼쪽 key part부터 이어지는 equality/range 조건
반대로 다음 패턴은 자주 non-sargable이 된다.
- 인덱스 컬럼에 함수를 적용한다:
DATE(created_at) = '2026-07-09',LOWER(email) = 'a@example.com' - 인덱스 컬럼을 연산식 안에 넣는다:
amount + tax > 10000 - 타입 변환을 컬럼 쪽에 강제로 유발한다: 문자 컬럼을 숫자와 비교하는 등
- 앞 wildcard를 사용한다:
name LIKE '%kim' - 조건의 선택도를 높일 수 없는 형태로
OR를 섞는다
물론 “non-sargable이면 항상 인덱스를 전혀 쓰지 않는다”는 뜻은 아니다. 다른 조건으로 인덱스 범위를 먼저 줄인 뒤 나머지 조건을 Using where나 ICP로 평가할 수 있다. 그러나 핵심 조건 하나가 non-sargable이면 읽어야 할 후보 행 수가 크게 늘 수 있다.
3. 실습용 데이터와 인덱스 준비
다음 예제는 MySQL 8.0 검증 컨테이너에서 실행 가능한 작은 재현 데이터다. 날짜 컬럼 created_at, 상태 컬럼 status, 금액 컬럼 amount를 가진 주문 테이블을 만들고, 날짜·상태·금액에 인덱스를 둔다.
DROP TABLE IF EXISTS note_predicate_orders;
CREATE TABLE note_predicate_orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
created_at DATETIME NOT NULL,
status VARCHAR(20) NOT NULL,
amount INT NOT NULL,
customer_email VARCHAR(120) NOT NULL,
email_lc VARCHAR(120) NOT NULL,
KEY idx_created_at (created_at),
KEY idx_status_created (status, created_at),
KEY idx_amount (amount),
KEY idx_email_lc (email_lc)
) ENGINE=InnoDB;
INSERT INTO note_predicate_orders (created_at, status, amount, customer_email, email_lc)
WITH RECURSIVE seq AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM seq WHERE n < 1000
)
SELECT TIMESTAMP('2026-07-01') + INTERVAL (n % 10) DAY + INTERVAL (n % 24) HOUR,
CASE WHEN n % 5 = 0 THEN 'CANCELLED' ELSE 'PAID' END,
1000 + (n % 200) * 10,
CONCAT('User', n % 50, '@Example.com'),
LOWER(CONCAT('User', n % 50, '@Example.com'))
FROM seq;
SELECT COUNT(*) AS row_count,
MIN(created_at) AS min_created_at,
MAX(created_at) AS max_created_at
FROM note_predicate_orders;
실행 결과(MySQL 8.0.x):
mysql> DROP TABLE IF EXISTS note_predicate_orders;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE note_predicate_orders (
-> id BIGINT PRIMARY KEY AUTO_INCREMENT,
-> created_at DATETIME NOT NULL,
-> status VARCHAR(20) NOT NULL,
-> amount INT NOT NULL,
-> customer_email VARCHAR(120) NOT NULL,
-> email_lc VARCHAR(120) NOT NULL,
-> KEY idx_created_at (created_at),
-> KEY idx_status_created (status, created_at),
-> KEY idx_amount (amount),
-> KEY idx_email_lc (email_lc)
-> ) ENGINE=InnoDB;
Query OK, 0 rows affected (0.01 sec)
mysql> INSERT INTO note_predicate_orders (created_at, status, amount, customer_email, email_lc)
-> WITH RECURSIVE seq AS (
-> SELECT 1 AS n
-> UNION ALL
-> SELECT n + 1 FROM seq WHERE n < 1000
-> )
-> SELECT TIMESTAMP('2026-07-01') + INTERVAL (n % 10) DAY + INTERVAL (n % 24) HOUR,
-> CASE WHEN n % 5 = 0 THEN 'CANCELLED' ELSE 'PAID' END,
-> 1000 + (n % 200) * 10,
-> CONCAT('User', n % 50, '@Example.com'),
-> LOWER(CONCAT('User', n % 50, '@Example.com'))
-> FROM seq;
Query OK, 1000 rows affected (0.01 sec)
Records: 1000 Duplicates: 0 Warnings: 0
mysql> SELECT COUNT(*) AS row_count,
-> MIN(created_at) AS min_created_at,
-> MAX(created_at) AS max_created_at
-> FROM note_predicate_orders;
+-----------+---------------------+---------------------+
| row_count | min_created_at | max_created_at |
+-----------+---------------------+---------------------+
| 1000 | 2026-07-01 00:00:00 | 2026-07-10 23:00:00 |
+-----------+---------------------+---------------------+
1 row in set (0.00 sec)
이 테이블은 작지만 실행계획 비교에는 충분하다. 운영 환경에서는 row 수가 수천만 건으로 커질수록 sargable 여부가 단순한 미세 최적화가 아니라 지연 시간과 I/O 비용을 결정하는 구조적 차이가 된다.
4. 날짜 함수 조건: 같은 의미처럼 보여도 access type이 달라진다
가장 흔한 실수는 날짜·시간 컬럼에 DATE(), YEAR(), DATE_FORMAT() 같은 함수를 씌우는 것이다. 다음 두 조건은 “2026-07-05 날짜의 주문”이라는 의미로 비슷해 보이지만, 인덱스 관점에서는 다르다.
EXPLAIN
SELECT id, created_at, status, amount
FROM note_predicate_orders
WHERE DATE(created_at) = '2026-07-05';
EXPLAIN
SELECT id, created_at, status, amount
FROM note_predicate_orders
WHERE created_at >= '2026-07-05 00:00:00'
AND created_at < '2026-07-06 00:00:00';
실행 결과(MySQL 8.0.x):
mysql> EXPLAIN SELECT ... WHERE DATE(created_at) = '2026-07-05';
+----+-------------+-----------------------+------+---------------+------+-------+-------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-----------------------+------+---------------+------+-------+-------------+
| 1 | SIMPLE | note_predicate_orders | ALL | NULL | NULL | 1001 | Using where |
+----+-------------+-----------------------+------+---------------+------+-------+-------------+
mysql> EXPLAIN SELECT ... WHERE created_at >= '2026-07-05 00:00:00' AND created_at < '2026-07-06 00:00:00';
+----+-------------+-----------------------+-------+----------------+----------------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-----------------------+-------+----------------+----------------+------+-----------------------+
| 1 | SIMPLE | note_predicate_orders | range | idx_created_at | idx_created_at | 100 | Using index condition |
+----+-------------+-----------------------+-------+----------------+----------------+------+-----------------------+
첫 번째 쿼리는 각 행의 created_at을 DATE()로 변환한 뒤 비교해야 한다. idx_created_at이 있어도 “2026-07-05에 해당하는 인덱스 시작 위치와 끝 위치”를 직접 만들기 어렵다. 두 번째 쿼리는 인덱스에 저장된 원래 DATETIME 값에 대해 닫힌 하한과 열린 상한을 주므로 range scan이 가능하다.
운영 쿼리에서는 날짜 조건을 다음 원칙으로 작성하는 것이 안전하다.
- 컬럼 쪽에는 함수를 두지 않는다.
- 하루 범위는
>= 시작시각 AND < 다음날 시작시각으로 표현한다. - 월 범위도
>= 월초 AND < 다음달 월초로 표현한다. - 애플리케이션 타임존과 DB 저장 타임존이 다르면 경계값을 애플리케이션 또는 SQL에서 명시적으로 맞춘다.
Aurora MySQL에서도 이 원칙은 같다. Aurora의 스토리지는 분산 구조이지만 SQL optimizer가 범위를 만들지 못하면 더 많은 page와 row 후보를 읽어야 한다. Aurora에서 read replica를 여러 대 둔다고 해도 non-sargable 조건으로 인한 불필요한 읽기 증폭은 사라지지 않는다.
5. 복합 인덱스에서는 “왼쪽부터 어디까지 범위를 만들 수 있는가”가 중요하다
복합 인덱스 (status, created_at)이 있을 때 다음 쿼리는 status = 'PAID'라는 equality 조건과 created_at range 조건을 함께 사용하기 쉽다.
EXPLAIN
SELECT id, created_at, status, amount
FROM note_predicate_orders
WHERE status = 'PAID'
AND created_at >= '2026-07-03 00:00:00'
AND created_at < '2026-07-04 00:00:00';
EXPLAIN
SELECT id, created_at, status, amount
FROM note_predicate_orders
WHERE status = 'PAID'
AND DATE(created_at) = '2026-07-03';
실행 결과(MySQL 8.0.x):
mysql> EXPLAIN SELECT ... WHERE status = 'PAID' AND created_at >= ... AND created_at < ...;
+----+-------------+-----------------------+-------+-----------------------------------+----------------+------+------------------------------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-----------------------+-------+-----------------------------------+----------------+------+------------------------------------+
| 1 | SIMPLE | note_predicate_orders | range | idx_created_at,idx_status_created | idx_created_at | 100 | Using index condition; Using where |
+----+-------------+-----------------------+-------+-----------------------------------+----------------+------+------------------------------------+
mysql> EXPLAIN SELECT ... WHERE status = 'PAID' AND DATE(created_at) = '2026-07-03';
+----+-------------+-----------------------+------+--------------------+--------------------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-----------------------+------+--------------------+--------------------+------+-----------------------+
| 1 | SIMPLE | note_predicate_orders | ref | idx_status_created | idx_status_created | 800 | Using index condition |
+----+-------------+-----------------------+------+--------------------+--------------------+------+-----------------------+
두 번째 쿼리는 status 조건으로는 인덱스의 앞부분을 사용할 수 있지만, created_at key part는 함수 때문에 range 조건으로 쓰기 어렵다. 결과적으로 status='PAID'인 더 넓은 범위를 읽고 DATE(created_at) 필터를 나중에 적용할 수 있다. 데이터 분포상 PAID가 대부분이라면 이 차이는 매우 커진다.
복합 인덱스 설계와 WHERE 작성은 따로 볼 수 없다. 인덱스의 왼쪽 key part에 equality 조건을 주고, 그 다음 key part에 range 조건을 주는 쿼리 패턴이 자주 나오면 좋은 후보가 된다. 그러나 range key part에 함수를 씌우면 설계 의도가 무너진다.
6. 문자열 정규화와 저장된 검색 키
대소문자 무시 검색을 위해 LOWER(customer_email) = 'user7@example.com'처럼 쓰는 쿼리도 자주 보인다. collation이 대소문자를 구분하지 않는다면 애초에 함수가 필요 없을 수 있다. 대소문자·공백·정규화 규칙을 명확히 관리해야 한다면 정규화된 검색 키를 별도 컬럼, generated column, 또는 expression index로 관리하는 방식을 검토할 수 있다.
다음 예제에서는 email_lc 컬럼에 소문자로 정규화된 이메일을 저장하고 인덱스를 둔다. 그러면 검색 조건은 함수 결과가 아니라 저장된 검색 키에 대한 equality 비교가 되므로 sargable하게 만들 수 있다.
EXPLAIN
SELECT id, customer_email
FROM note_predicate_orders
WHERE LOWER(customer_email) = 'user7@example.com';
EXPLAIN
SELECT id, customer_email
FROM note_predicate_orders
WHERE email_lc = 'user7@example.com';
SELECT id, customer_email, email_lc
FROM note_predicate_orders
WHERE email_lc = 'user7@example.com'
ORDER BY id
LIMIT 5;
실행 결과(MySQL 8.0.x):
mysql> EXPLAIN SELECT ... WHERE LOWER(customer_email) = 'user7@example.com';
+----+-------------+-----------------------+------+---------------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-----------------------+------+---------------+------+------+-------------+
| 1 | SIMPLE | note_predicate_orders | ALL | NULL | NULL | 1001 | Using where |
+----+-------------+-----------------------+------+---------------+------+------+-------------+
mysql> EXPLAIN SELECT ... WHERE email_lc = 'user7@example.com';
+----+-------------+-----------------------+------+---------------+--------------+------+-------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-----------------------+------+---------------+--------------+------+-------+
| 1 | SIMPLE | note_predicate_orders | ref | idx_email_lc | idx_email_lc | 20 | NULL |
+----+-------------+-----------------------+------+---------------+--------------+------+-------+
mysql> SELECT id, customer_email, email_lc ... LIMIT 5;
+-----+-------------------+-------------------+
| id | customer_email | email_lc |
+-----+-------------------+-------------------+
| 7 | User7@Example.com | user7@example.com |
| 57 | User7@Example.com | user7@example.com |
| 107 | User7@Example.com | user7@example.com |
| 157 | User7@Example.com | user7@example.com |
| 207 | User7@Example.com | user7@example.com |
+-----+-------------------+-------------------+
5 rows in set (0.00 sec)
MySQL 8.0에는 expression index와 generated column도 있으므로, 애플리케이션에서 별도 저장 컬럼을 직접 관리하지 않는 설계도 가능하다. 다만 운영 표준에서는 정규화 규칙, unique 제약, 데이터 보정 절차, DDL 비용을 함께 검토해야 한다. 특히 여러 애플리케이션이 같은 DB를 쓰는 환경에서는 “정규화된 검색 키”를 스키마에 명확히 드러내는 편이 장애 분석과 인덱스 사용 확인에 유리하다.
Aurora MySQL에서도 정규화 컬럼, generated column, secondary index 사용 원칙은 Community MySQL과 대체로 같다. 다만 쓰기 부하가 큰 테이블에 검색용 컬럼과 인덱스를 추가하면 redo/undo, page split, replica apply 지연, storage I/O 비용이 늘 수 있으므로 online DDL 방식과 배포 시간을 별도로 검토해야 한다.
7. 숫자 연산 조건은 기준값을 상수 쪽으로 이동한다
숫자 컬럼에 연산을 적용하는 조건도 흔한 non-sargable 패턴이다. 예를 들어 amount + 100 > 2500은 각 행의 amount를 읽어 100을 더한 뒤 비교해야 한다. 같은 의미를 amount > 2400처럼 바꾸면 인덱스 range 조건을 만들 수 있다.
EXPLAIN
SELECT id, amount
FROM note_predicate_orders
WHERE amount + 100 > 2500;
EXPLAIN
SELECT id, amount
FROM note_predicate_orders
WHERE amount > 2400;
실행 결과(MySQL 8.0.x):
mysql> EXPLAIN SELECT ... WHERE amount + 100 > 2500;
+----+-------------+-----------------------+-------+---------------+------------+------+--------------------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-----------------------+-------+---------------+------------+------+--------------------------+
| 1 | SIMPLE | note_predicate_orders | index | NULL | idx_amount | 1001 | Using where; Using index |
+----+-------------+-----------------------+-------+---------------+------------+------+--------------------------+
mysql> EXPLAIN SELECT ... WHERE amount > 2400;
+----+-------------+-----------------------+-------+---------------+------------+------+--------------------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+-----------------------+-------+---------------+------------+------+--------------------------+
| 1 | SIMPLE | note_predicate_orders | range | idx_amount | idx_amount | 295 | Using where; Using index |
+----+-------------+-----------------------+-------+---------------+------------+------+--------------------------+
모든 표현식을 단순히 옮길 수 있는 것은 아니다. 세금, 환율, 소수점 반올림, 타임존 변환처럼 비가역적이거나 업무 규칙이 섞인 계산은 별도 컬럼, generated column, 배치 집계 테이블을 고려해야 한다. 그러나 단순 산술식은 가능한 한 컬럼을 그대로 두고 상수 쪽을 변환하는 것이 기본이다.
8. 실행계획에서 무엇을 확인해야 하는가
sargable 여부를 볼 때 EXPLAIN에서 특히 확인할 항목은 다음과 같다.
type:ALL은 full table scan,range는 범위 탐색,ref는 비고유 인덱스 equality lookup에 가깝다.possible_keys: 조건상 후보가 될 수 있는 인덱스다. 후보에 있어도 선택되지 않을 수 있다.key: 실제 선택된 인덱스다.rows: 옵티마이저가 읽을 것으로 추정한 row 수다.filtered: 읽은 row 중 WHERE 통과 비율 추정이다.Extra:Using where,Using index condition,Using index등의 힌트를 준다.
다음 진단 쿼리는 현재 테이블의 인덱스 정의를 확인하고, 위 예제에서 optimizer가 볼 수 있는 access path를 점검할 때 사용할 수 있다.
SHOW INDEX FROM note_predicate_orders;
SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'note_predicate_orders'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
실행 결과(MySQL 8.0.x):
mysql> SHOW INDEX FROM note_predicate_orders;
+-----------------------+--------------------+--------------+-------------+------------+
| Table | Key_name | Seq_in_index | Column_name | Index_type |
+-----------------------+--------------------+--------------+-------------+------------+
| note_predicate_orders | PRIMARY | 1 | id | BTREE |
| note_predicate_orders | idx_created_at | 1 | created_at | BTREE |
| note_predicate_orders | idx_status_created | 1 | status | BTREE |
| note_predicate_orders | idx_status_created | 2 | created_at | BTREE |
| note_predicate_orders | idx_amount | 1 | amount | BTREE |
| note_predicate_orders | idx_email_lc | 1 | email_lc | BTREE |
+-----------------------+--------------------+--------------+-------------+------------+
mysql> SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME ...;
+-----------------------+--------------------+--------------+-------------+
| TABLE_NAME | INDEX_NAME | SEQ_IN_INDEX | COLUMN_NAME |
+-----------------------+--------------------+--------------+-------------+
| note_predicate_orders | idx_amount | 1 | amount |
| note_predicate_orders | idx_created_at | 1 | created_at |
| note_predicate_orders | idx_email_lc | 1 | email_lc |
| note_predicate_orders | idx_status_created | 1 | status |
| note_predicate_orders | idx_status_created | 2 | created_at |
| note_predicate_orders | PRIMARY | 1 | id |
+-----------------------+--------------------+--------------+-------------+
rows는 실제 row 수가 아니라 추정값이다. 통계가 오래되었거나 데이터 분포가 심하게 치우친 경우 sargable 조건이어도 잘못된 인덱스를 고를 수 있다. 그런 경우에는 ANALYZE TABLE, histogram, 복합 인덱스 재설계, 쿼리 조건 순서가 아니라 조건 형태 자체의 개선을 함께 봐야 한다.
9. 장애와 성능 문제로 이어지는 대표 패턴
9.1 배치 작업의 날짜 함수 조건
일별 정산 배치에서 WHERE DATE(created_at) = CURRENT_DATE - INTERVAL 1 DAY 형태를 사용하면 데이터가 적을 때는 문제가 보이지 않는다. 그러나 테이블이 커지면 매일 전체 날짜 범위를 훑는 작업이 되고, buffer pool을 밀어내며, 운영 트래픽의 latency까지 올릴 수 있다. 날짜 배치는 반드시 명시적 시작·종료 경계값을 계산하여 range scan으로 만들어야 한다.
9.2 검색 API의 편의 함수
사용자 검색 API에서 LOWER(name) LIKE LOWER(CONCAT('%', ?, '%')) 같은 조건을 쓰면 일반 B-Tree 인덱스로는 앞 wildcard와 함수 적용을 동시에 처리하기 어렵다. prefix 검색인지, exact 검색인지, 전문 검색이 필요한지 요구사항을 나눠야 한다. exact 또는 prefix 검색은 정규화 컬럼과 적절한 collation으로 해결하고, 중간 문자열 검색은 FULLTEXT index나 별도 검색 엔진을 검토해야 한다.
9.3 타입 불일치
문자열 컬럼 order_no를 숫자 literal과 비교하면 암묵적 형 변환이 발생할 수 있다. 이런 변환은 인덱스 사용을 방해하거나 예상하지 못한 비교 결과를 낳는다. 애플리케이션 바인딩 타입과 DB 컬럼 타입을 맞추고, 문자열 식별자는 문자열로 비교해야 한다.
9.4 OR 조건의 과도한 결합
WHERE status = 'PAID' OR created_at >= ...처럼 서로 다른 컬럼 조건을 OR로 묶으면 인덱스 선택이 어려워질 수 있다. MySQL은 index merge를 선택할 수도 있지만 항상 빠른 것은 아니다. 의미상 분리 가능한 조건이면 UNION ALL로 나누거나, 업무 조건을 재정의하여 한쪽 인덱스 경로가 명확해지도록 설계한다.
10. 운영 관점의 튜닝 절차
sargable 문제를 의심할 때는 다음 순서로 접근한다.
- 느린 쿼리 원문과 바인딩 값을 확보한다. placeholder만 있는 SQL로는 타입 변환과 선택도를 판단하기 어렵다.
EXPLAIN으로 access type, key, rows, Extra를 확인한다.- WHERE 조건 중 인덱스 컬럼에 함수·연산·암묵 변환이 적용된 부분을 찾는다.
- 같은 의미를 컬럼 원형 비교와 상수 범위로 바꿀 수 있는지 검토한다.
- 복합 인덱스의 왼쪽 key part와 equality/range 패턴이 맞는지 확인한다.
- rewrite 후
EXPLAIN과 실제 실행 시간, 읽은 row 수 지표를 비교한다. - rewrite만으로 부족하면 generated column, expression index, histogram, 인덱스 재설계를 검토한다.
Aurora MySQL에서는 Performance Insights, Enhanced Monitoring, slow query log, performance_schema를 함께 본다. 다만 도구가 보여주는 대기 이벤트가 io/table/sql/handler나 CPU 증가로 나타나더라도 원인은 WHERE 조건이 불필요하게 많은 row를 읽게 만든 것일 수 있다. 쿼리 형태와 실행계획을 먼저 확인해야 한다.
11. 체크리스트
- 날짜·시간 컬럼에
DATE(),YEAR(),DATE_FORMAT() - 하루·월·분기 범위를
>= 시작 AND < 다음 시작 - 문자열 exact 검색에서 불필요하게
LOWER(),TRIM() -
EXPLAIN의type,key,rows,Extra
12. 결론
WHERE 조건 최적화의 핵심은 “조건을 몇 개 줄였는가”가 아니라 “스토리지 엔진이 읽을 범위를 얼마나 좁게 만들 수 있는가”다. sargable predicate는 인덱스의 정렬된 키 공간에 직접 매핑되어 읽기 범위를 줄이고, non-sargable predicate는 같은 의미라도 더 많은 후보 행을 읽게 만든다. 날짜 함수, 문자열 정규화 함수, 컬럼 산술식, 타입 불일치, 앞 wildcard는 운영 환경에서 반복적으로 문제가 되는 패턴이다.
다음 단계에서는 이러한 조건 형태가 복합 인덱스의 key part 순서, cardinality, histogram, join order와 어떻게 결합되는지 계속 살펴볼 필요가 있다. WHERE 조건을 sargable하게 만드는 습관은 단일 쿼리 튜닝을 넘어 스키마 설계와 애플리케이션 API 설계의 기본 원칙이다.