11장. 조용히 느려지는 것들 — 따옴표·함수·앞 와일드카드
{/* 삽화 자리 — 열쇠는 자물쇠에 꽂혀 있는데 돌아가지 않는다. 옆에서 사람이 문 전체를 손으로 더듬어 연다 */}
따옴표 하나를 빠뜨렸더니 0.0222ms짜리 조회가 350ms가 됐습니다. 15,766배인데 에러는 안 나고 결과도 똑같이 1행이에요. EXPLAIN의 key에는 인덱스 이름이 그대로 찍혀 있고요.
면접 실전 질문: ① 인덱스를 걸었는데 왜 안 탈까요? ②
LIKE '%abc'는 왜 느린가요? ③key에 인덱스가 찍혀 있으면 안심해도 되나요?
배경 — 10장과 반대 방향의 실패
10장은 옵티마이저가 볼 게 없어서 틀리는 경우였습니다. 통계가 없으니 상수를 꽂았죠.
이 장은 반대입니다. 볼 게 있는데 우리가 못 보게 만드는 경우예요.
인덱스는 멀쩡히 있습니다. 통계도 있고요. 그런데 쿼리를 쓰는 방식 하나 때문에 옵티마이저가 그 인덱스를 제대로 못 씁니다.
가장 나쁜 건 아무도 안 알려준다는 겁니다. 에러가 안 나요. 결과도 맞게 나옵니다. 그냥 느릴 뿐이에요.
스토리 — 정렬이 깨지는 순간
왜 이런 일이 생기는지는 2장으로 돌아가면 보입니다.
인덱스는 정렬된 값입니다. '00000000000000500000'을 찾을 수 있는 건 값들이 그 순서로 놓여 있어서예요. 루트에서 “이보다 작으면 왼쪽”을 판단할 수 있으니까요.
그런데 컬럼에 무언가를 씌우는 순간 그 순서가 무너집니다.
WHERE transaction_no = '...' -- 저장된 값 그대로 비교. 순서를 쓸 수 있다
WHERE LEFT(transaction_no, 20) = '...' -- 함수를 통과한 값의 순서는 모른다LEFT()를 통과한 값이 원래 순서대로 나온다는 보장이 없습니다. 옵티마이저 입장에서는 모든 행에 함수를 적용해보기 전에는 알 수 없어요.
따옴표를 빠뜨리는 것도 같은 일입니다.
📄 문서 기반 (미검증) — MySQL은 문자열과 숫자를 비교할 때 양쪽을 숫자로 맞춥니다. 컬럼도 예외가 아니라서, 컬럼에 CAST(... AS DOUBLE)을 씌운 것과 같아져요. 반대 방향은 안전합니다 — 숫자 컬럼에 '500000'을 넘기면 리터럴 쪽이 변환돼 인덱스를 그대로 탑니다.
LIKE '%abc'도 마찬가지고요. 앞을 모르면 정렬된 값 사이에서 시작점을 찾을 수 없습니다. LIKE 'abc%'는 시작점을 찾을 수 있어서 괜찮습니다.
설명은 여기까지고, 값을 재봅시다.
핵심 — 같은 값을 묻는 데 15,766배
200만 행 테이블에서 SELECT COUNT(*) FROM scan_demo WHERE ...의 WHERE만 바꿔가며 재봤습니다.
조건에 쓰는 컬럼이 transaction_no 하나뿐이라, 인덱스만 읽어도 답이 나오는 커버링 조회입니다. 이게 아래 표를 읽는 열쇠예요.
| 패턴 | type | key | Extra | 결과 | 페이지 요청 | 시간 |
|---|---|---|---|---|---|---|
| 정상 (따옴표 O) | ref | idx_txno | Using where; Using index | 1 | 10 | 0.0222 ms |
| 따옴표 누락 | index | idx_txno | Using where; Using index | 1 | 5,608 | 350 ms |
컬럼에 함수 LEFT() | index | idx_txno | Using where; Using index | 1 | 5,608 | 449 ms |
컬럼에 연산 CONCAT() | index | idx_txno | Using where; Using index | 1 | 5,608 | 463 ms |
앞 와일드카드 LIKE '%...500000' | index | idx_txno | Using where; Using index | 1 | 5,608 | 966 ms |
뒤 와일드카드 LIKE '...50000%' | range | idx_txno | Using where; Using index | 10 | 9 | 0.0228 ms |
OR로 인덱스 밖 컬럼 | ALL | — | Using where | 1 | 44,571 | 660 ms |
✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08. 200만 행, 버퍼풀 1GB로 전부 상주. 재현: ./ch11/silentkillers.sh. 전체 소스: docs/book/code/db-index/ch11/silentkillers.sh)
key에 인덱스가 찍혀 있어도 안심할 수 없습니다
표에서 가장 먼저 봐야 할 건 key 열입니다. 나쁜 패턴 넷 전부 idx_txno가 찍혀 있어요.
“인덱스를 타긴 타네” 하고 넘어가기 딱 좋습니다. 그런데 type을 보면 전부 index예요. 9장에서 배운 그 index입니다 — 인덱스를 처음부터 끝까지 훑는다는 뜻이죠.
왜 ALL이 아니라 index인지도 커버링으로 설명됩니다. 조건에 필요한 컬럼이 인덱스 안에 다 있으니, 옵티마이저가 테이블(31,747페이지)보다 인덱스(4,247페이지)를 훑는 쪽을 골랐어요. 인덱스를 고른 게 조건을 좁히려고가 아니라, 훑을 대상이 더 작아서입니다.
인덱스가 하는 일은 안 읽어도 될 페이지를 안 읽는 것이었습니다(1장). 앞의 넷은 그 일을 안 합니다.
9장의 226,476과 왜 다른가
여기서 앞 장과 안 맞는 숫자가 하나 나옵니다. 9장은 같은 인덱스를 끝까지 훑는 값을 226,476회로 쟀는데, 이 표는 5,608회입니다. 40배 차이예요.
갈라봤습니다.
| 쿼리 | 페이지 요청 |
|---|---|
SELECT COUNT(*) ... FORCE INDEX (idx_txno) (조건 없음, 9장) | 226,476 |
SELECT COUNT(*) ... WHERE transaction_no >= '' (항상 참) | 5,634 |
SELECT COUNT(*) ... WHERE transaction_no = 500000 (1행만 통과) | 5,608 |
✅ 실측 (같은 환경)
가른 건 WHERE 절의 유무입니다. 몇 행이 통과하느냐가 아니에요 — 항상 참인 조건은 200만 행을 다 통과시키는데도 5,634회입니다.
조건이 하나라도 붙으면 Extra에 Using where가 붙고, 그 경로가 페이지를 훨씬 덜 요청합니다. 9장에서 “요청 하나당 8.8행”이라는 실마리를 남기고 원인을 못 밝혔는데, 적어도 그 비싼 경로가 조건 없는 쪽이라는 건 여기서 갈렸습니다. 왜 그 경로가 비싼지는 여전히 못 밝혔고요.
따옴표 하나
WHERE transaction_no = '00000000000000500000' -- 10회, 0.0222 ms
WHERE transaction_no = 500000 -- 5,608회, 350 ms561배 요청, 15,766배 시간. 결과는 둘 다 1행이고 에러도 안 납니다.
거래번호·계좌번호처럼 숫자처럼 생긴 문자열 컬럼에서 잘 납니다. 그리고 빠뜨려도 아무 일이 안 일어나요. 답이 맞게 나오니까요.
뒤 와일드카드는 괜찮습니다
표에서 유일하게 살아남은 패턴이 LIKE '0000000000000050000%'입니다. range로 9회, 0.0228ms예요.
앞부분이 고정돼 있으니 정렬된 값 사이에서 시작점을 찾을 수 있습니다. 와일드카드가 나쁜 게 아니라 앞에 오는 게 나쁩니다.
📄 그래서 LIKE '%검색어%'로 전문 검색을 하려면 다른 도구가 필요합니다 — FULLTEXT 인덱스(한글이면 n-gram 파서)나 별도 검색엔진이요. 이 책이 다루는 축은 아닙니다.
OR 하나가 인덱스를 통째로 버리게 합니다
transaction_no = '...' OR payload = 'zzz' — 앞 조건은 인덱스가 있고 뒤는 없습니다. 결과는 ALL, 44,571회, 660ms.
44,571은 1장에서 잰 200만 행 풀스캔 값과 같은 숫자입니다. 결국 테이블 전체를 읽은 거예요.
한쪽만 인덱스로 좁혀도 다른 쪽은 어차피 다 봐야 하니까요. 어차피 다 볼 거면 인덱스를 타는 의미가 없습니다.
이건 앞의 넷과 성격이 다릅니다. 앞의 넷은 “쓸 수 있는데 못 쓰게 만든” 경우고, 이건 옵티마이저의 정상적인 판단이에요. 고치려면 쿼리가 아니라 인덱스를 손봐야 합니다. 그리고 “인덱스가 있는데도 안 타는 게 옳은” 이 감각이 12장의 본론입니다.
정렬도 같은 규칙을 따릅니다
3장에서 “인덱스 순서와 정렬 요구가 어긋나면 filesort”라고 넘겨뒀습니다. 그 값을 여기서 잽니다.
| 정렬 | type | Extra | 페이지 요청 | 시간 |
|---|---|---|---|---|
| 인덱스 순서대로 | index | Using index | 6 | 0.0365 ms |
반대 방향 (DESC) | index | Backward index scan; Using index | 8 | 0.046 ms |
| 인덱스 밖 컬럼으로 | ALL | Using filesort | 44,571 | 839 ms |
| 범위로 좁히고 다른 컬럼으로 | range | Using index condition; **Using filesort** | 313 | 0.167 ms |
✅ 실측 (같은 환경. SELECT id ... LIMIT 100)
내림차순이 오름차순과 거의 같습니다. 6회 대 8회예요. 3장에서 리프가 이중 연결 리스트라는 걸 봤으니 당연합니다 — 반대 방향으로 걸으면 되니까요. Backward index scan이 그걸 말해줍니다.
📄 다만 이건 100건짜리 커버링 조회입니다. 큰 범위를 역방향으로 훑으면 선읽기가 앞방향을 전제해서 차이가 벌어질 수 있어요. 직접 재보지는 않았습니다.
인덱스 밖 컬럼으로 정렬하면 요청이 6회에서 44,571회로 7,400배, 시간은 0.0365ms에서 839ms로 2만 배가 됩니다. 정렬할 순서가 어디에도 준비돼 있지 않으니, 다 읽어서 직접 정렬하는 수밖에 없어요.
LIMIT 100이 붙어 있는데도 44,571회를 만졌다는 걸 보세요. 전체를 읽어야 합니다. 정렬 자체는 상위 100개만 붙들고 가지만, 어느 100개가 위인지는 다 보기 전엔 모르니까요.
마지막 줄이 3장이 물었던 그 경우입니다. 인덱스로 좁혔는데도 filesort가 붙습니다. 여기서는 좁힌 결과가 100건이라 313회로 싸게 끝났지만, 좁힌 결과가 크면 그만큼 정렬 비용이 붙어요. “인덱스를 탔으니 됐다”가 또 한 번 깨지는 자리입니다.
물렸을 때 고치는 법
| 물린 패턴 | 고치는 법 |
|---|---|
col = 500000 (문자열 컬럼) | 따옴표를 붙인다. 파라미터 바인딩이면 타입을 컬럼과 맞춘다 |
LEFT(col, 20) = ? | 함수를 상수 쪽으로 옮기거나, 표현식을 인덱스로 만든다(함수 인덱스·생성 컬럼) |
LIKE '%abc' | B-tree로는 못 푼다. FULLTEXT나 검색엔진 |
a = ? OR b = ? | 양쪽에 인덱스를 주거나 UNION ALL로 쪼갠다 |
ORDER BY 인덱스밖컬럼 | 정렬 컬럼을 인덱스 뒤에 붙인다 |
📄 문서 기반 (미검증) — 함수 인덱스는 MySQL 8.0.13부터 쓸 수 있습니다. 직접 재보지는 않았습니다.
정리
- 이 장의 패턴들은 공통점이 하나입니다. 에러가 안 나고 결과도 맞습니다. 그냥 느릴 뿐이에요.
- 원인도 하나입니다. 인덱스는 정렬된 값인데, 컬럼에 무언가를 씌우면 그 순서를 쓸 수 없게 됩니다.
- 실측: 같은 값을 묻는 데 정상은 10회 0.0222ms, 따옴표를 빠뜨리면 5,608회 350ms — 15,766배입니다.
key에 인덱스 이름이 찍혀 있어도 안심할 수 없습니다. 나쁜 패턴 넷 다key=idx_txno였어요.type이index면 인덱스를 좁히는 데가 아니라 훑는 데 쓰고 있는 겁니다.- 9장의 226,476과 이 장의 5,608이 40배 다른 건
WHERE절의 유무 때문이었습니다. 항상 참인 조건도 5,634회였어요. - 와일드카드는 앞에 오는 게 문제입니다.
LIKE 'abc%'는 9회,LIKE '%abc'는 5,608회예요. - 정렬도 같은 규칙입니다. 내림차순은
Backward index scan으로 거의 공짜지만, 인덱스 밖 컬럼으로 정렬하면 44,571회입니다. 인덱스로 좁혀도 정렬 컬럼이 다르면filesort가 붙습니다.
생각해볼 질문
LEFT(transaction_no, 20) = ?가 인덱스를 못 타는 건 함수를 통과한 값의 순서를 모르기 때문이었습니다. 그럼 이 조건을 인덱스가 탈 수 있는 형태로 바꾼다면 어떻게 쓸까요?OR가ALL로 떨어진 건 한쪽에 인덱스가 없어서였습니다. 양쪽 다 인덱스가 있으면 어떻게 될까요?- 스크립트에는
transaction_no <> '...'행이 하나 더 있습니다. 결과가 199만 행이라 “같은 값을 묻는” 쿼리들과 나란히 놓을 수 없어 표에서 뺐어요. 그런데 이건 인덱스를 못 타는 게 아니라 좁혀지지 않는 경우입니다. 둘의 차이는 무엇일까요? (12장)
이 장은 우리가 인덱스를 못 쓰게 만든 경우였습니다. 다음 장은 쓸 수 있는데도 옵티마이저가 안 쓰기로 하는 경우입니다. 그리고 그 판단은 대체로 옳습니다.