Skip to Content

11장. 조용히 느려지는 것들 — 따옴표·함수·앞 와일드카드

{/* 삽화 자리 — 열쇠는 자물쇠에 꽂혀 있는데 돌아가지 않는다. 옆에서 사람이 문 전체를 손으로 더듬어 연다 */}

따옴표 하나를 빠뜨렸더니 0.0222ms짜리 조회가 350ms가 됐습니다. 15,766배인데 에러는 안 나고 결과도 똑같이 1행이에요. EXPLAINkey에는 인덱스 이름이 그대로 찍혀 있고요.

면접 실전 질문: ① 인덱스를 걸었는데 왜 안 탈까요? ② 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 하나뿐이라, 인덱스만 읽어도 답이 나오는 커버링 조회입니다. 이게 아래 표를 읽는 열쇠예요.

패턴typekeyExtra결과페이지 요청시간
정상 (따옴표 O)refidx_txnoUsing where; Using index1100.0222 ms
따옴표 누락indexidx_txnoUsing where; Using index15,608350 ms
컬럼에 함수 LEFT()indexidx_txnoUsing where; Using index15,608449 ms
컬럼에 연산 CONCAT()indexidx_txnoUsing where; Using index15,608463 ms
앞 와일드카드 LIKE '%...500000'indexidx_txnoUsing where; Using index15,608966 ms
뒤 와일드카드 LIKE '...50000%'rangeidx_txnoUsing where; Using index1090.0228 ms
OR로 인덱스 밖 컬럼ALLUsing where144,571660 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회입니다.

조건이 하나라도 붙으면 ExtraUsing where가 붙고, 그 경로가 페이지를 훨씬 덜 요청합니다. 9장에서 “요청 하나당 8.8행”이라는 실마리를 남기고 원인을 못 밝혔는데, 적어도 그 비싼 경로가 조건 없는 쪽이라는 건 여기서 갈렸습니다. 왜 그 경로가 비싼지는 여전히 못 밝혔고요.

따옴표 하나

WHERE transaction_no = '00000000000000500000' -- 10회, 0.0222 ms WHERE transaction_no = 500000 -- 5,608회, 350 ms

561배 요청, 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”라고 넘겨뒀습니다. 그 값을 여기서 잽니다.

정렬typeExtra페이지 요청시간
인덱스 순서대로indexUsing index60.0365 ms
반대 방향 (DESC)indexBackward index scan; Using index80.046 ms
인덱스 밖 컬럼으로ALLUsing filesort44,571839 ms
범위로 좁히고 다른 컬럼으로rangeUsing index condition; **Using filesort**3130.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였어요. typeindex면 인덱스를 좁히는 데가 아니라 훑는 데 쓰고 있는 겁니다.
  • 9장의 226,476과 이 장의 5,608이 40배 다른 건 WHERE 절의 유무 때문이었습니다. 항상 참인 조건도 5,634회였어요.
  • 와일드카드는 앞에 오는 게 문제입니다. LIKE 'abc%'는 9회, LIKE '%abc'는 5,608회예요.
  • 정렬도 같은 규칙입니다. 내림차순은 Backward index scan으로 거의 공짜지만, 인덱스 밖 컬럼으로 정렬하면 44,571회입니다. 인덱스로 좁혀도 정렬 컬럼이 다르면 filesort가 붙습니다.

생각해볼 질문

  1. LEFT(transaction_no, 20) = ?가 인덱스를 못 타는 건 함수를 통과한 값의 순서를 모르기 때문이었습니다. 그럼 이 조건을 인덱스가 탈 수 있는 형태로 바꾼다면 어떻게 쓸까요?
  2. ORALL로 떨어진 건 한쪽에 인덱스가 없어서였습니다. 양쪽 다 인덱스가 있으면 어떻게 될까요?
  3. 스크립트에는 transaction_no <> '...' 행이 하나 더 있습니다. 결과가 199만 행이라 “같은 값을 묻는” 쿼리들과 나란히 놓을 수 없어 표에서 뺐어요. 그런데 이건 인덱스를 못 타는 게 아니라 좁혀지지 않는 경우입니다. 둘의 차이는 무엇일까요? (12장)

이 장은 우리가 인덱스를 못 쓰게 만든 경우였습니다. 다음 장은 쓸 수 있는데도 옵티마이저가 안 쓰기로 하는 경우입니다. 그리고 그 판단은 대체로 옳습니다.

12장. 일부러 안 타는 경우 — 선택도와 랜덤 접근 비용

Last updated on