19장. 나아졌다는 걸 어떻게 증명하나 — 전후 측정의 규율
{/* 삽화 자리 — 같은 저울에 같은 물건을 스무 번 올리는 사람. 바늘은 매번 다른 곳을 가리키는데, 옆에 놓인 추의 개수는 늘 똑같다 */}
같은 쿼리를 스무 번 돌렸습니다. 시간은 60.7ms에서 114ms까지 81% 흔들렸어요. 그런데 페이지 요청은 스무 번 전부 385로 한 자리도 안 변했습니다. 그리고 캐시가 차가운 첫 실행은 2.7배 느렸고, 논리 요청조차 1.7배 달랐습니다.
면접 실전 질문: ① 성능 개선을 어떻게 증명하나요? ② 몇 번 재야 하나요? ③ 시간 말고 뭘 봐야 하나요?
배경 — 넣었으니 빨라졌겠죠
18장까지 왔으면 인덱스는 이미 들어가 있습니다. 이제 누가 묻습니다 — 얼마나 좋아졌어요?
여기서 대부분 한 번 재서 답합니다. 그 한 번이 무엇인지가 이 장이에요.
한 번이 하필 운이 좋았다면요. 아니면 앞서 같은 쿼리를 돌려둬서 데워진 상태였다면요. 아니면 그 숫자가 애초에 다른 걸 재고 있었다면요.
이 책은 열여덟 장에 걸쳐 수치를 냈습니다. 그 과정에서 제가 저지른 측정 실패가 열세 건이에요. 이 장은 그걸 정리합니다.
스토리 — 세 가지 함정
첫째, 한 번만 잽니다. 개선 전에 한 번, 후에 한 번. 두 숫자를 나란히 놓고 배수를 냅니다. 그 두 번이 각각 얼마나 흔들리는 값인지는 안 봅니다.
둘째, 데워진 걸 잽니다. 개선 후 쿼리는 보통 두 번째 이후에 잽니다 — 한 번 돌려보고 “오, 빨라졌네” 한 다음 다시 재니까요. 그런데 두 번째 실행은 원래 빠릅니다.
셋째가 제일 무섭습니다. 다른 걸 잽니다. 숫자는 정확한데 그 숫자가 내가 생각한 것을 안 재고 있는 경우요.
여는 글이 그 사례였습니다. 조건을 뺀 쿼리가 3,524배 빨랐는데, 원인이 조건이 아니라 **클라이언트가 자동으로 붙인 LIMIT 500**이었어요. 조건이 없으면 아무 500행이나 담고 끝나고, 조건이 있으면 맞는 500행을 못 채워 끝까지 갑니다. 숫자는 맞았고 해석이 틀렸습니다.
셋 다 겪었습니다. 하나씩 재보죠.
핵심 — 시간은 81% 흔들리고 페이지는 0% 흔들립니다
같은 쿼리를 20회 돌리며 매번 소요 시간과 페이지 요청을 같이 받았습니다.
| 쿼리 | 시간 최소 | 중앙 | 최대 | 시간 진폭 | 요청 | 요청 진폭 |
|---|---|---|---|---|---|---|
| 인덱스 없는 조회 | 387 ms | 413 | 474 | 21.1% | 44,571 | 0.0% |
| 인덱스 범위 조회 | 60.7 ms | 65.7 | 114 | 81.1% | 385 | 0.0% |
| 커버링 스캔 | 381 ms | 412 | 479 | 23.8% | 5,634 | 0.0% |
✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08. scan_demo 200만 행, 버퍼풀 1GiB, 워밍업 뒤 20회. 진폭 = (최대 − 최소) ÷ 중앙값입니다. 그리고 두 열은 같은 실행이 아니에요 — 요청은 평문 실행의 카운터 델타, 시간은 그 뒤 EXPLAIN ANALYZE의 값이라 계측 오버헤드가 붙습니다. 재현: ./ch19/variance.sh. 전체 소스: docs/book/code/db-index/ch19/variance.sh)
요청 진폭이 0.0%입니다. “거의 같다”가 아니라 스무 번 전부 같은 값이에요.
여는 글에서 “시간은 다르게 나올 겁니다 — 그게 정상이에요. 다만 페이지 수는 거의 그대로입니다”라고 썼습니다. 그 “거의”는 다른 기계·다른 패치 버전을 염두에 둔 유보였어요. 같은 기계에서 스무 번을 재면 ‘거의’조차 아닙니다 — 한 자리도 안 움직여요. (기계가 바뀌면 이 문장도 다시 재야 하고요.)
그리고 시간 진폭을 보세요. 범위 조회는 81% 흔들립니다. 60.7ms와 114ms 사이 어느 값이든 나올 수 있어요. 개선 전후를 각각 한 번씩만 재면, 1.9배 차이를 아무것도 안 바꾸고 만들어낼 수 있습니다.
이게 이 책이 페이지를 앞세우는 이유입니다. 시간은 결과고 페이지는 원인이에요. 원인은 안 흔들립니다. 단, 조건이 하나 붙습니다. 그건 다음 절에서요.
(표의 44,571은 여는 글에서 본 값이고 5,634는 11장에서 본 값입니다. 다른 장 다른 스크립트에서 같은 숫자가 다시 나왔어요.)
차가운 첫 실행은 2.7배 느립니다
컨테이너를 재기동해 버퍼풀을 비우고 같은 쿼리를 연달아 돌렸습니다.
| 소요 | 논리 요청 | 물리 read | read-ahead | Innodb_data_reads | |
|---|---|---|---|---|---|
| 1회차 (차가움) | 791.2 ms | 72,996 | 149 | 28,315 | 28,464 |
| 2회차 | 308.1 ms | 44,571 | 0 | 0 | 0 |
| 3회차 | 298.1 ms | 44,571 | 0 | 0 | 0 |
| 워밍업 뒤 | 304.1 / 295.8 ms | 44,571 | 0 | 0 | 0 |
✅ 실측 (같은 환경. 여는 글의 그 풀스캔입니다. 카운터 읽기와 쿼리를 한 세션에서 처리했어요 — 따로 접속해 읽었더니 그 사이 백그라운드 활동이 섞여 값이 튀었습니다. 재현: ./ch19/coldwarm.sh)
첫 실행이 3회차(298.1ms) 대비 2.7배 느립니다. 아무것도 안 고쳤는데요.
그리고 논리 요청조차 다릅니다 — 72,996 대 44,571로 1.64배예요. 페이지를 버퍼풀에 처음 올리는 작업이 요청으로 잡히기 때문입니다.
그러니 앞에서 “요청 진폭 0.0%“라고 한 건 데워진 상태에서만 참입니다. 조건을 붙여야 하는 문장이었어요.
그리고 여기서 한 건 더 틀렸습니다
이 표의 물리 read 열을 처음엔 세 열 없이 149 하나만 놓고 이렇게 썼습니다.
“버퍼풀을 비웠는데 물리 read가 240밖에 안 됩니다. 8장에서 밝힌 대로 Docker Desktop for Mac은 호스트 페이지 캐시가 뒤에 있어서 상당수가 진짜 디스크가 아니에요.”
틀렸습니다. 카운터를 잘못 읽고 있었어요.
Innodb_buffer_pool_reads는 포그라운드 스레드가 직접 가져온 페이지만 셉니다. 백그라운드 read-ahead가 미리 올려둔 페이지는 거기 안 들어가고 Innodb_buffer_pool_read_ahead에 따로 잡혀요. 그리고 순차 풀스캔은 linear read-ahead의 교과서적 발동 조건입니다.
숫자가 그대로 말해줍니다.
물리 read 149
read-ahead 28,315
───────
합계 28,464 = Innodb_data_reads 28,464한 자리도 안 틀리고 맞습니다. 3만 페이지 가까이 디스크에서 올라왔는데, 제가 본 카운터에는 149만 찍혀 있었어요.
그러니 2.7배는 “호스트 캐시 때문에 디스크를 거의 안 갔다”가 아닙니다. 디스크를 2만 8천 번 갔고, 제가 그걸 못 보고 있었을 뿐입니다.
이게 이 장이 말하려는 함정의 셋째 — 다른 걸 잰 것 — 의 열세 번째 사례고, 하필 이 장에서 나왔습니다.
이 책이 저지른 실패들
여는 글에서 “틀린 측정과 못 찾은 원인을 지우지 않았다”고 했습니다. 열여덟 장을 쓰는 동안 쌓인 게 이만큼입니다.
| 장 | 무엇을 틀렸나 | 종류 | 그 장에 남아 있나 |
|---|---|---|---|
| 여는 글 | 조건 때문에 느린 줄 알았는데 LIMIT 500 착시 | 다른 걸 쟀다 | ✅ |
| 1장 | AVG_ROW_LENGTH를 썼는데 그건 추정 행 수로 나눈 값 | 다른 걸 쟀다 | ✅ |
| 2장 | 시드를 잘못 만들어 페이지당 엔트리를 4.8로 재놓고 헤맴 | 데이터가 틀렸다 | ✅ |
| 2장 | 파일을 파싱하다 빌드 잔재를 루트로 세어 트리가 둘이 됨 | 다른 걸 쟀다 | ✅ |
| 4장 | 구간 폭을 행 수로 착각해 비율 열을 잘못 적음 | 다른 걸 쟀다 | ✅ |
| 9장 | 범위 리터럴이 17자인데 20자인 줄 알았음 | 데이터가 틀렸다 | 고친 값만 |
| 12장 | 표마다 ”%“의 정의가 달라 두 표가 충돌 | 정의가 흔들렸다 | 고친 값만 |
| 13장 | 누적 평균의 상승을 “인덱스끼리 방해한다”로 읽음 | 없는 현상을 만들었다 | ✅ |
| 14장 | 스크립트가 6회 실행분을 누적하는데 1회로 읽음 | 다른 걸 쟀다 | ✅ |
| 15장 | ”행 열 개에 아홉 번”이라 썼는데 열 행에 한 번 | 산술이 틀렸다 | 고친 값만 |
| 17장 | 전 행이 같은 값인 컬럼에 인덱스를 만들어 재놓고 대표값처럼 씀 | 다른 걸 쟀다 | 고친 값만 |
| 17장 | 대량 적재 직후를 기준선으로 삼음 | 데워지지 않은 걸 쟀다 | ✅ |
| 19장 | read-ahead 페이지가 다른 카운터에 잡히는 걸 모르고 “디스크를 거의 안 갔다”고 읽음 | 다른 걸 쟀다 | ✅ (바로 위) |
열세 건입니다. 그리고 이 중 **절반이 “다른 걸 쟀다”**예요.
마지막 열은 정직하게 밝힙니다. 네 건은 출간 전에 잡혀서 본문에는 고친 값만 남았습니다. 여는 글에서 “지우지 않았다”고 했으니 그 넷은 여기서만 볼 수 있어요. 나머지는 해당 장을 펴면 그 자리에 그대로 있습니다.
숫자가 틀린 경우는 적었습니다. 대부분 숫자는 맞는데 그 숫자가 내가 생각한 것을 안 재고 있었어요. 13장이 제일 심했습니다 — 1,614 → 1,620 → 1,629라는 숫자는 정확했는데, 그게 누적 평균이라는 걸 몰라서 있지도 않은 현상을 만들어냈습니다.
그래서 “몇 번 재느냐”보다 “무엇을 재고 있느냐”가 먼저입니다. 반복 측정은 첫 번째 함정만 막아줍니다.
그래서 이렇게 잽니다
| 단계 | 하는 일 | 무엇을 막나 |
|---|---|---|
| 0 | 무엇을 재는지 한 문장으로 쓴다. “이 쿼리가 요청하는 페이지 수” 같은 식으로 | 다른 걸 재는 것 |
| 1 | 워밍업을 먼저 돌리고 버린다 | 차가운 첫 실행 |
| 2 | 5회 이상 재고 중앙값을 쓴다. 최소·최대도 같이 적는다 | 한 번만 재는 것 |
| 3 | 시간과 함께 페이지 요청을 기록한다 | 시간의 흔들림 |
| 4 | 전후를 같은 조건·같은 스키마로 잰다. 순서를 뒤집어 한 번 더 확인한다 | 순서 효과 |
| 5 | 환경을 적는다 — 버전·행 수·데이터 크기·버퍼풀 | 나중에 비교 못 하는 것 |
3번이 이 책의 핵심 처방입니다. 페이지 요청이 안 줄었는데 시간만 줄었다면, 줄어든 건 측정 조건이지 쿼리가 아닙니다.
거꾸로 페이지 요청이 44,571에서 385로 줄었다면, 시간이 얼마로 나오든 읽는 양은 확실히 줄어든 겁니다.
운영 수치는 아직 비어 있습니다
정직하게 밝힐 게 하나 있습니다.
이 책의 모든 실측은 제 노트북의 Docker 컨테이너에서 나왔습니다. 여는 글의 45.36초와 41.17초만 운영 값이고, 그건 개선 전 수치예요.
인덱스를 넣은 뒤의 운영 수치는 아직 없습니다(2026-08 기준). 개발환경에서 1,881ms가 0.108ms가 됐다는 값은 있지만(출처), 그건 운영이 아닙니다.
그러니 이 책이 보여준 건 “이 구조에서 이렇게 된다”이지 “우리 서비스가 이만큼 좋아졌다”가 아닙니다. 후자를 쓰려면 위 여섯 단계를 운영에서 다시 밟아야 하고, 그게 아직 안 됐습니다.
이 장을 쓰면서 제일 부끄러운 게 이 문단입니다. 측정 규율을 여섯 단계로 정리해놓고 정작 가장 중요한 측정을 안 했으니까요.
정리
- 같은 쿼리를 20회 돌리면 시간은 21~81% 흔들리고 페이지 요청은 0.0% 흔들립니다. 스무 번 전부 같은 값이에요.
- 범위 조회는 60.7ms와 114ms 사이를 오갑니다. 전후를 한 번씩만 재면 아무것도 안 바꾸고 1.9배를 만들어낼 수 있어요.
- 물리 read 카운터 하나만 보면 안 됩니다. 차가운 풀스캔에서
Innodb_buffer_pool_reads는 149인데read-ahead가 28,315였어요. 합이Innodb_data_reads28,464와 정확히 맞습니다. - 차가운 첫 실행은 2.7배 느립니다. 논리 요청조차 72,996 대 44,571로 1.64배 다르고요 — “요청은 안 흔들린다”는 데워진 상태에서만 참입니다.
- 이 책이 열여덟 장 동안 저지른 측정 실패가 열세 건이고, 그중 **절반이 “다른 걸 쟀다”**입니다. 숫자가 틀린 경우는 오히려 적었어요. 열세 번째는 이 장에서 나왔습니다 —
read-ahead페이지가 다른 카운터에 잡히는 걸 모르고 “디스크를 거의 안 갔다”고 읽었어요. - 그래서 “몇 번 재느냐”보다 “무엇을 재고 있느냐”가 먼저입니다. 반복 측정은 첫 번째 함정만 막습니다.
- 측정 순서: ⓪무엇을 재는지 쓴다 → ①워밍업을 버린다 → ②5회 이상 중앙값 → ③시간과 페이지를 같이 → ④같은 조건으로, 순서를 뒤집어 확인 → ⑤환경을 적는다.
- 페이지 요청이 안 줄었는데 시간만 줄었다면, 줄어든 건 측정 조건이지 쿼리가 아닙니다.
- 📄 이 책의 운영 TO-BE 수치는 아직 비어 있습니다. 개선 전 45.36초만 운영 값이에요.
생각해볼 질문
- 페이지 요청이 그대로인데 시간이 절반이 됐습니다. 무엇이 달라진 걸까요? 후보를 셋 이상 대보세요.
- 반대로 페이지 요청이 절반인데 시간이 그대로입니다. 이건 개선일까요, 아닐까요? (8장을 떠올려보세요)
- 18장 질문 3이 “MDL 대기와 빌드 구간 지연을 운영에서 어떻게 구분해 관측하나”였습니다. 이 장의 여섯 단계 중 어느 것이 그 구분에 쓰일까요?
- 이 장의 여섯 단계를 운영에서 밟으려면 무엇이 더 필요할까요? 노트북에서는 쉬웠던 것 중 운영에서 안 되는 게 무엇일까요?
이제 인덱스에 관해 할 이야기는 거의 다 했습니다. 왜 빠른지(1부), 무엇을 읽는지(2부), 왜 안 타는지(3부), 무엇을 청구하는지(4부), 어떻게 넣고 증명하는지(5부)요.
그런데 이 책이 계속 피해온 질문이 하나 있습니다.
인덱스로 안 되는 건 뭔가요?
여는 글의 그 테이블은 계속 자라고 있었습니다. 인덱스를 걸어 조회를 45초에서 밀리초로 줄였다고 해서, 그 테이블이 100GB가 되는 걸 막지는 못합니다.