여는 글 — 조건을 안 걸면 빠릅니다
거래번호로 조회하면 45초가 걸렸습니다. 그런데 조건을 아예 빼면 즉시 나왔어요. 보통은 반대입니다. 조건은 결과를 줄여주니까요.
말이 안 되는 장면
제가 운영하는 QR 결제 중계 플랫폼은 오가는 HTTP 요청·응답을 전부 DB에 적재합니다. 결제 장애가 나면 이 로그가 1차 증거예요. 어느 거래가 어느 단계에서 어떤 응답을 받았는지, 여기 말고는 볼 곳이 없습니다.
그 조회가 45초 걸렸습니다.
이상한 건 따로 있었습니다.
SELECT * FROM request_log; -- 즉시 나온다
SELECT * FROM request_log WHERE transaction_no = ?; -- 45초조건을 붙였더니 느려졌습니다. 조건은 읽을 것을 줄여주는 장치인데요.
이 모순이 이 책의 출발점입니다. 답을 알고 나면 허무할 만큼 단순한데, 모르는 동안은 아무리 봐도 앞뒤가 안 맞습니다.
노트북에서도 똑같이 일어납니다
운영 DB 이야기라 남 일처럼 들릴 수 있으니, 200만 행짜리 테이블을 만들어 같은 장면을 재현했습니다. 조건에 쓰는 컬럼에는 인덱스가 없습니다.
| 쿼리 | 실제로 읽은 행 | 페이지 요청 | 걸린 시간 |
|---|---|---|---|
SELECT * FROM scan_demo LIMIT 500 | 500 | 17 | 0.143 ms |
SELECT * FROM scan_demo WHERE payload = '없는값' | 2,000,000 | 44,571 | 504 ms |
✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08. seed.sql로 CALL seed(2000000) 한 테이블. 워밍업 뒤 5회 실행 후 최솟값이고, 두 쿼리 모두 물리 read 델타 0 — 전부 메모리에 있었습니다. 전체 소스: docs/book/code/db-index/ch00/paradox.sh)
(페이지 요청 = 버퍼풀에 16KB 페이지 하나를 달라고 한 횟수입니다. 서로 다른 페이지의 개수가 아니에요. 1장에서 제대로 뜯어봅니다.)
조건을 뺀 쪽이 3,524배 빠릅니다.
범인은 조건이 아니었습니다
위 표의 첫 줄에 LIMIT 500이 붙어 있는 게 보이시나요. 운영에서는 제가 그걸 붙인 적이 없습니다.
클라이언트가 붙이고 있었습니다. DataGrip을 비롯한 대부분의 DB 클라이언트는 결과 그리드를 보호하려고 실행하는 SELECT에 페이지 크기 제한을 자동으로 답니다. 조건이 있든 없든 똑같이 붙어요.
차이는 그 500행을 채울 수 있느냐였습니다. 조건이 없으면 앞에서부터 아무 500행이나 담고 바로 끝납니다. 조건이 있으면 조건에 맞는 500행을 채워야 하는데, 맞는 행이 1건뿐이면 채울 방법이 없으니 끝까지 갑니다. 같은 LIMIT 500인데 한쪽은 멈추고 한쪽은 못 멈춥니다.
같은 테이블인데 한쪽은 500행, 한쪽은 200만 행을 읽고 있었던 겁니다.
그러니까 이 장면에는 애초에 모순이 없었습니다. “조건이 있냐 없냐”로 보면 말이 안 되지만, “몇 행을 읽느냐”로 보면 당연합니다. 500 대 200만이니까요.
렌즈가 틀렸던 겁니다.
다만 착시가 걷혀도 45초는 그대로 남습니다. 200만 행을 읽는 일 자체는 진짜였으니까요.
관통하는 한 문장
인덱스는 빠르게 읽는 기술이 아니라 덜 읽는 기술입니다. 그리고 그 “덜”의 단위는 행이 아니라 16KB 페이지예요.
방금 본 건 “덜 읽으면 빠르다”까지입니다. 인덱스가 그 “덜”을 어떻게 만드는지는 1·2장이고요. 정작 이 책이 계속 붙들고 갈 건 뒤 절반입니다.
“덜 읽는다”까지는 대부분 동의하실 겁니다. 문제는 얼마나 덜 읽는지를 무엇으로 세느냐예요. 행으로 세면 계속 틀립니다. 1장에서 바로 보여드리는데, 같은 25만 행짜리 테이블이 63MB일 수도 653MB일 수도 있습니다. 행 수는 읽을 양을 알려주지 않아요.
그래서 이 책은 비용을 셀 때 행이 아니라 페이지를 셉니다. 인덱스가 하는 일도 같은 단위로 다시 쓸 수 있습니다 — 읽지 않아도 될 페이지를 안 읽게 만드는 장치.
미리 밝혀둘 게 하나 있어요. 페이지는 읽을 양의 단위이지 시간의 단위가 아닙니다. 데이터가 전부 메모리에 있으면 시간을 끌고 가는 건 행 쪽이에요 — 4장에서 옵티마이저의 비용식을 뜯어보면 96%가 행 값입니다. 페이지가 청구서에 찍히는 건 그걸 디스크에서 가져와야 할 때고요.
이 책이 답하려는 것
네 가지 질문입니다.
- 인덱스는 왜 빠른가 — 그리고 왜 하필 그 모양(B-tree)인가 (1부)
- 페이지가 정확히 무엇인가 — 안에 무엇이 어떻게 들어 있고, 왜 왕복이 생기는가 (2부)
- 인덱스를 걸었는데 왜 안 타는가 — 옵티마이저가 무엇을 보고 판단하는가 (3부)
- 인덱스는 무엇을 청구하는가 — 읽기를 위해 쓰기가 무는 값 (4부)
그리고 마지막으로, 그걸 운영 중인 테이블에 어떻게 밀어 넣는가(5부).
읽는 법
숫자에는 전부 딱지가 붙어 있습니다.
- ✅ 실측 — 제가 직접 돌려 나온 값입니다. MySQL 버전과 환경을 같이 적었고, 대부분 여러 번 돌려 최솟값이나 범위를 씁니다.
- 📄 문서 기반 — 논문이나 문서를 근거로 썼고 직접 계측하지 않은 것입니다. 검증하지 않았다고 밝힙니다.
틀린 측정과 못 찾은 원인을 지우지 않았습니다. 1장에는 100만 행 구간만 페이지당 시간이 20% 높게 나온 게 원인 미상으로 남아 있고, 2장에는 시드를 잘못 만들어 페이지당 엔트리를 4.8로 재놓고 헤맨 이야기와, 파일을 직접 파싱하다 루트를 두 개로 센 이야기가 남아 있습니다. 4장에는 구간 폭을 행 수로 착각해 비율 열을 잘못 적었다가 고친 이야기가 들어가 있고요. 잘 나온 것만 골라 실으면 남은 숫자도 못 믿게 되니까요.
재현할 수 있게 만들었습니다. 모든 실험은 docs/book/code/db-index/에 있고, Docker로 MySQL을 띄워 같은 명령을 돌리면 같은 표가 나옵니다. 여러분 기계에서 시간은 다르게 나올 겁니다 — 그게 정상이에요. 다만 페이지 수는 거의 그대로입니다. 패치 버전이 다르면 몇 페이지쯤 어긋날 수 있지만, 시간처럼 배로 흔들리지는 않아요. 이 책이 시간보다 페이지를 앞세우는 이유가 그것이기도 합니다.
모든 측정은 MySQL 8.0 / InnoDB입니다. 16KB 페이지도 클러스터드 인덱스도 InnoDB 이야기라 다른 엔진 대조는 넣지 않았습니다.
각 장 첫머리에는 그 장이 답하는 면접 질문 세 개를 걸어뒀습니다. 세 개에 답할 수 있으면 그 장은 넘겨도 됩니다.
각 장은 배경 → 스토리 → 핵심 세 걸음으로 갑니다. 급하면 각 장 맨 위의 굵은 한 줄과 맨 아래 정리만 읽어도 됩니다.
누구를 위한 책인가
CREATE INDEX를 쳐봤지만 “그래서 이게 왜 빨라지지?”에 자신 있게 답하지 못하는 백엔드 개발자를 위해 썼습니다. 인덱스를 걸고도 type: ALL이 그대로 나오는 걸 본 적 있다면 더 그렇고요.
정답을 외우는 대신 왜 그렇게 됐는지를 따라가는 쪽을 골랐습니다. 그래서 디스크 한 번 찾아가는 데 0.6초가 걸리던 시절부터 시작합니다.
그때 무엇이 비쌌는지 알면, 오늘의 인덱스가 왜 이 모양인지 설명이 됩니다.
마지막으로, 열어둔 채로 시작하는 숫자 하나.
앞의 재현에서 200만 행을 읽는 데 504밀리초가 걸렸습니다. 운영에서는 같은 성격의 스캔이 41.17초였어요(전체 쿼리는 45.36초). 82배입니다.
두 가지가 겹쳤습니다. 운영 테이블은 2.2GB로 이 실험보다 4.4배 컸고, 무엇보다 데이터가 메모리에 없었습니다. 제 노트북은 초당 988MB를 읽었고 운영은 초당 54MB였어요 — 18배 차이입니다. 같은 “페이지 하나”가 어떤 때는 공짜에 가깝고 어떤 때는 밀리초를 잡아먹습니다.
그런데 순차 읽기치고 54MB/s는 낮습니다. 왜 낮은지까지가 8장이에요.
그 전에 물어야 할 게 있습니다. 인덱스라는 게 아예 없던 시절, 사람들은 대체 무엇을 읽고 있었을까요.