13장. 인덱스 개수는 비용의 단위가 아니다
{/* 삽화 자리 — 같은 크기 상자 세 개를 나란히 쌓는 사람과, 하나뿐인데 모양이 제각각이라 자리를 못 잡는 상자 */}
인덱스를 3개에서 1개로 줄이면 인덱스가 쓰는 자리가 정확히 3분의 1이 됩니다. 그런데 그 1개의 키를 단조 증가에서 난수로 바꾸면, 개수는 그대로인데 자리가 1.68배로 늘어요. 제가 예전에 “난수 키 하나가 단조 증가 키 둘보다 비싸다”고 썼는데, 재보니 1.5개 자리였습니다.
면접 실전 질문: ① 인덱스를 많이 걸면 왜 쓰기가 느려지나요? ② PK를 UUID로 잡으면 뭐가 문제인가요? ③ 인덱스 몇 개까지 괜찮나요?
배경 — 제가 쓴 문장을 재판대에 올립니다
3부는 읽는 쪽이었습니다. 4부는 그 청구서고, 첫 장은 제가 진 빚부터 갚습니다.
거래 로그 테이블에 인덱스 11개를 넣기로 정하고 쓴 글이 있습니다. 그 본문에는 이렇게 적었어요.
“인덱스 개수는 쓰기 비용의 단위가 아니었습니다. 같은 3개라도 키가 단조 증가냐 난수냐가 훨씬 크게 갈립니다.”
— 인덱스는 공짜가 아닙니다 본문
그런데 같은 글의 부제와 요약 설명에는 이렇게 썼습니다.
“같은 3개라도 난수 키 하나가 나머지 둘보다 비싸고…” — 부제
“CUID 난수 키 하나가 단조 증가 키 둘을 합친 것보다 비싸고…” —
description
세 문장의 무게가 다릅니다. 본문은 방향만 말해요. 부제의 “나머지 둘보다”는 “각각보다”로도 읽히고요. 그런데 요약의 “둘을 합친 것보다”는 배수를 못 박습니다.
그 글 본문 어디에도 이 배수의 근거는 한 줄도 없습니다. 요약에서만 세게 말한 거예요. 이 장은 그 센 쪽을 잽니다.
둘 다 재보지 않고 썼습니다. 이 장이 그걸 잽니다.
스토리 — 개수와 모양을 따로 움직입니다
“개수가 단위인가, 모양이 단위인가”에 답하려면 둘을 따로 움직여야 합니다.
같은 50만 행을 조건만 바꿔 넣었습니다. 개수 축으로 0·1·2·3개, 모양 축으로 단조 증가와 난수요.
-- 단조 증가 키: id 를 그대로 문자열로. 늘 오른쪽 끝에 붙는다
seq1 = LPAD(id, 20, '0')
-- 난수 키: MD5 해시. 값의 순서가 id 와 아무 상관이 없다
rnd1 = LEFT(MD5(id), 20)난수를 RAND()가 아니라 MD5(id)로 만든 건 재현 때문입니다. 값의 순서는 id와 완전히 무관하면서 — 그게 난수 키의 성질이죠 — 몇 번을 돌려도 같은 값이 나옵니다.
그리고 두 키의 폭을 똑같이 CHAR(20)으로 맞췄습니다. 처음엔 난수 쪽을 CHAR(25)로 잡았다가 다시 만들었어요. 크기가 다르면 나중에 나오는 차이가 모양 때문인지 길이 때문인지 못 가립니다. 둘 다 ASCII라 실제로도 20바이트고요. 이 장이 재려는 건 키의 모양이지 키의 크기가 아닙니다.
(잘라낸 폭 축은 16장에서 다시 만납니다. 커버링 인덱스가 정확히 폭을 사서 왕복을 파는 거래니까요.)
재는 건 여섯입니다.
| 재는 것 | 어떻게 |
|---|---|
| 소요 시간 | INSERT ... SELECT 벽시계, 3회 중앙값 |
| 페이지 분할 | INNODB_METRICS의 index_page_splits 증가분 |
| 리프 페이지 | 세컨더리 인덱스의 n_leaf_pages — 실제로 쓴 양 |
| 할당 페이지 | 세컨더리 인덱스의 size — 실제로 차지한 양 |
| 물리 read | Innodb_buffer_pool_reads 증가분 |
| ibuf 병합 | 체인지 버퍼가 실제로 일했는지 |
리프와 할당을 나눠 센 게 중요합니다. InnoDB는 페이지를 64장짜리 익스텐트 단위로 떼어주기 때문에, 할당된 양은 실제로 쓴 양보다 대개 큽니다. 둘을 뭉치면 나중에 배율이 어긋나요.
핵심 — 개수는 곱하기고, 모양은 계수입니다
| 조건 | 개수 | 모양 | 소요 ms | 분할 | 리프 | 할당 | 행당 바이트 | 물리 read | ibuf 병합 |
|---|---|---|---|---|---|---|---|---|---|
| 인덱스 없음 | 0 | — | 1,741 | 8,342 | 0 | 0 | — | 1 | 0 |
| 단조 증가 1개 | 1 | 단조 증가 | 2,972 | 9,407 | 1,063 | 1,125 | 36.9 | 0 | 0 |
| 단조 증가 2개 | 2 | 단조 증가 | 4,146 | 10,472 | 2,126 | 2,250 | 36.9 | 3 | 0 |
| 단조 증가 3개 | 3 | 단조 증가 | 5,432 | 11,537 | 3,189 | 3,375 | 36.9 | 6 | 0 |
| 난수 1개 | 1 | 난수 | 3,276 | 9,959 | 1,614 | 1,894 | 62.1 | 383 | 0 |
| 난수 2개 | 2 | 난수 | 5,104 | 11,587 | 3,240 | 3,787 | 62.0 | 6 | 0 |
| 난수 3개 | 3 | 난수 | 6,794 | 13,237 | 4,886 | 5,682 | 62.1 | 6 | 0 |
✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08. 50만 행, 테이블 131MB / 행당 274바이트, 버퍼풀 1GB. 시간은 3회 중앙값입니다. 분할·리프·할당은 결정론적이라 1회고요. 물리 read와 ibuf 병합은 결정론적이지 않으며 3회 중 마지막 회 값입니다. 컬럼은 utf8mb4 CHAR(20). 재현: docker compose exec -T mysql mysql -uroot -pbookroot bookdb < ch13/seed-writecost.sql 뒤 ./ch13/writecost.sh. 전체 소스: docs/book/code/db-index/ch13/writecost.sh)
먼저 공간입니다. 리프 페이지 열을 보세요.
단조 증가: 1,063 → 2,126 → 3,189 (정확히 1,063 × 개수, 오차 0)
난수 : 1,614 → 3,240 → 4,886 (개수당 1,614 → 1,620 → 1,629)단조 증가는 오차가 0입니다. 다만 이건 발견이 아니라 산술이에요 — seq1·seq2·seq3는 폭도 분포도 삽입 순서도 같은 쌍둥이 인덱스라, 같은 걸 세 벌 만들면 세 배가 되는 게 당연합니다.
난수 쪽은 개수당 1,614 → 1,620 → 1,629로 조금씩 커집니다. 처음엔 이걸 “난수 인덱스가 여럿이면 서로 방해한다”로 읽었어요. 틀렸습니다.
셋을 하나씩 따로 걸어 재봤습니다.
| 혼자 걸었을 때 리프 페이지 | 1번 키 | 2번 키 | 3번 키 |
|---|---|---|---|
단조 증가 (seq1/seq2/seq3) | 1,063 | 1,063 | 1,063 |
난수 (rnd1/rnd2/rnd3) | 1,614 | 1,626 | 1,646 |
✅ 실측 (같은 환경)
간섭은 0입니다. rnd2는 혼자 있든 셋이 같이 있든 1,626장이에요. 셋을 다 걸었을 때의 4,886장도 1,614 + 1,626 + 1,646을 그대로 더한 값입니다.
seq* 셋은 LPAD(i)·LPAD(i+100만)·LPAD(i+200만)이라 진짜 쌍둥이지만, rnd* 셋은 MD5(i)·MD5(i+100만)·MD5(i+200만) — 서로 다른 세 개의 난수 수열입니다. 같은 규칙으로 뽑아도 결과는 조금씩 달라요. 제가 본 “1,614 → 1,620 → 1,629”는 그 셋의 누적 평균이었습니다. rnd3부터 더했다면 1,646 → 1,636 → 1,629로 감속처럼 보였을 거고요.
여기서 배울 건 난수 키의 성질이 아니라 측정의 성질입니다. 평균은 없는 추세를 만들어냅니다.
이제 시간입니다. 여기서 모델이 깨집니다.
| 인덱스 없음을 뺀 증가분 | 1개 | 2개 | 3개 | 3개일 때 배수 |
|---|---|---|---|---|
| 단조 증가 | 1,231 ms | 2,405 ms | 3,691 ms | 3.00배 |
| 난수 | 1,535 ms | 3,363 ms | 5,053 ms | 3.29배 |
단조 증가는 정확히 3배인데 난수는 3.29배입니다. 공간은 셋을 그대로 더한 값이었는데 시간은 조금 더 늘었어요. 왜 그런지는 못 밝혔습니다. 3회 중앙값이라도 시간은 구조 수치만큼 안정적이지 않아서, 이 0.29를 현상이라고 부르기엔 이릅니다.
배율은 인덱스 1개 조건으로 봅니다. 방금 봤듯 난수 셋은 뽑기 운이 조금씩 달라서, 셋을 평균 내면 그 운이 배율에 섞이거든요.
| 인덱스 1개 조건 | 단조 증가 | 난수 | 배율 |
|---|---|---|---|
| 소요 시간 | 1,231 ms | 1,535 ms | 1.25배 |
| 페이지 분할 | 1,065 회 | 1,617 회 | 1.52배 |
| 리프 페이지 | 1,063 | 1,614 | 1.52배 |
| 할당 페이지 | 1,125 | 1,894 | 1.68배 |
| 행당 바이트 | 36.9 | 62.1 | 1.68배 |
분할 배율(1.52)과 리프 배율(1.52)이 정확히 같습니다. 당연합니다 — 분할 한 번이 페이지 하나를 만드니까요. 할당이 1.68로 더 큰 건 익스텐트 여유가 섞여서고요. 1.52배는 “얼마나 썼나”, 1.68배는 “얼마나 차지했나”입니다. 둘 다 참이고, 디스크를 먹는 건 뒤쪽입니다.
12장이 남긴 1,125가 여기 있습니다
12장 마지막 질문이 이거였습니다. 같은 50만 행인데 rt_random의 인덱스는 1,959페이지, rt_ordered는 1,125페이지였다고요.
이 장의 표에 1,125가 그대로 있습니다.
같은 값이 나오는 건 사실 당연합니다. 7장의 rt_ordered도 LPAD(id,20,'0')을 CHAR(20)에 50만 행 넣은 거라 구성이 사실상 같아요. 그래도 다른 장·다른 스크립트에서 같은 숫자가 다시 나온 건 재현성 확인이 됩니다.
난수 쪽은 조금 다릅니다. 12장의 rt_random은 1,959였고 이 장은 1,894예요. 3.4% 차이인데, 방금 본 대로 난수 키는 뽑기마다 조금씩 다르니 그럴 만합니다. 그래서 대조에는 단조 증가 쪽만 씁니다.
답은 페이지 분할입니다. 난수 키는 1,617번 쪼개졌고 단조 증가는 1,065번이었어요. 쪼개면 페이지가 늘고 반쯤 빈 페이지가 남습니다. 그 안이 14장입니다.
체인지 버퍼는 관여하지 않았습니다 — 이번엔 재서 압니다
여기서 의심이 하나 생깁니다. 이 실험은 버퍼풀 1GB에 테이블이 131MB라 전부 메모리에 있습니다. 난수 키가 진짜 비싼 건 디스크를 랜덤하게 긁을 때인데, 그게 안 잡힌 것 아닐까요.
InnoDB에는 그걸 완화하는 장치도 있습니다. 체인지 버퍼요 — 고칠 페이지가 메모리에 없으면 나중으로 미뤄 모아서 처리합니다.
표의 마지막 두 열이 답입니다. ibuf 병합이 일곱 조건 전부 0이고, 물리 read도 대부분 한 자리입니다.
체인지 버퍼는 한 번도 일하지 않았습니다. 미룰 일이 없었으니까요 — 고칠 페이지가 늘 메모리에 있었습니다. 그러니 이 장의 배율은 체인지 버퍼가 가려준 값이 아닙니다.
(난수 1개 조건에서만 물리 read가 383입니다. 다른 조건은 0~6이에요. 왜 그 줄만 튀는지는 못 밝혔습니다. 병합은 0이라 체인지 버퍼와는 무관합니다.)
대신 이건 순수하게 메모리 안에서 잰 값입니다. 인덱스가 버퍼풀보다 커지면 다른 그림이고, 그건 15장입니다.
순서를 뒤집어도 같았습니다
측정 순서가 결과를 만들었을 수도 있습니다. 단조 증가를 먼저 재고 난수를 나중에 쟀으니, 앞 조건이 남긴 더티 페이지 정리가 뒤에 얹혔을 수 있어요. 방향이 결론과 같아서 위험한 종류의 오류입니다.
순서를 통째로 뒤집어 다시 돌렸습니다(REVERSE=1).
| 정방향 | 역방향 | |
|---|---|---|
| 난수 3개 분할 / 리프 / 할당 | 13,237 / 4,886 / 5,682 | 13,237 / 4,886 / 5,682 |
| 단조 3개 분할 / 리프 / 할당 | 11,537 / 3,189 / 3,375 | 11,537 / 3,189 / 3,375 |
| 인덱스 1개당 시간 배율 | 1.37배 | 1.40배 |
✅ 실측 (같은 환경. 재현: REVERSE=1 ./ch13/writecost.sh)
구조 수치는 한 자리도 안 움직입니다. 시간 배율도 1.37과 1.40으로 같은 자리예요. 순서 효과는 없었습니다.
이건 이 책이 계속 말해온 것과도 맞습니다 — 시간은 기계마다 다르지만 페이지 수는 그대로라고요(여는 글).
그래서 제 문장은 어떻게 됐나
축을 나눠서 답해야 합니다. 두 축의 성격이 다르거든요.
구조 축은 규모와 무관합니다. 순서를 뒤집어도 한 자리도 안 변했고요.
버퍼풀도 바꿔봤습니다. 15장에서 쓸 400만 행 테이블을 128MB·512MB·1GB 세 크기에서 넣어보면, 단조 증가 인덱스의 페이지 수가 세 번 다 정확히 8,566장입니다. 난수 쪽은 14,784~14,848로 한 익스텐트(64페이지)만큼 흔들리고요.
(다만 index_page_splits는 인스턴스 전역 카운터라 다른 테이블의 백그라운드 작업까지 셉니다. 그래서 확정 판정의 근거로는 리프와 할당 페이지를 씁니다.)
여기서는 확정적으로 판정할 수 있습니다.
| 인덱스 없음을 뺀 증가분 | 페이지 분할 | 리프 페이지 | 할당 페이지 | 소요 시간 |
|---|---|---|---|---|
| 난수 1개 | 1,617 | 1,614 | 1,894 | 1,535 ms |
| 단조 증가 2개 | 2,130 | 2,126 | 2,250 | 2,405 ms |
| 어느 쪽이 비싼가 | 단조 2개 | 단조 2개 | 단조 2개 | 단조 2개 |
네 축 전부 제 문장이 틀렸습니다. 난수 1개는 단조 증가 1.5~1.7개 자리예요.
구조 축(분할·리프·할당)은 규모가 바뀌어도 유지됩니다. 방금 확인했으니까요.
시간 축도 이 조건에서는 확정입니다. 다만 제가 그 문장을 쓴 자리는 이 조건이 아니었어요. 붙은 조건이 셋입니다.
-
전부 메모리 안입니다. 제가 저 문장을 쓴 운영 테이블은 4.6GB였고요.
-
50만 행을 한 트랜잭션으로 넣었습니다. 운영은 결제 요청마다 한 건씩 커밋하는 핫패스예요.
-
INSERT ... SELECT는 더티 페이지를 버퍼풀에 남긴 채 끝납니다. 난수 키가 흩뿌린 더티 페이지를 디스크로 내려보내는 값이 벽시계에서 빠져 있습니다. 난수 쪽에 유리한 측정이에요. -
전부 메모리 안입니다. 제가 저 문장을 쓴 운영 테이블은 4.6GB였고요.
-
50만 행을 한 트랜잭션으로 넣었습니다. 운영은 결제 요청마다 한 건씩 커밋하는 핫패스예요.
-
INSERT ... SELECT는 더티 페이지(고쳐놓고 아직 디스크에 안 쓴 페이지)를 버퍼풀에 남긴 채 끝납니다. 난수 키가 흩뿌린 그 페이지들을 디스크로 내려보내는 값이 벽시계에서 빠져 있어요. 난수 쪽에 유리한 측정입니다.
그러니 “원문 조건에서도 틀렸는가”는 15장으로 넘깁니다. 조건이 다른 측정으로 남의 조건을 판정할 수는 없으니까요. 거기서 인덱스가 버퍼풀보다 커지는 규모로 다시 잽니다.
재판은 여기까지입니다. 판결 하나는 확정, 하나는 15장으로 이송입니다.
이 책이 딱지(✅/📄)를 붙이는 이유가 이겁니다. 저 문장에는 딱지가 없었고, 그래서 저조차 그게 추론인지 측정인지 나중에 구분하지 못했습니다.
페이지 분할이 없어도 8,342번은 일어납니다
표 첫 줄, 인덱스가 하나도 없는데 페이지 분할이 8,342회입니다.
테이블 자체가 인덱스이기 때문입니다(6장). PK로 정렬된 클러스터드 인덱스에 50만 행을 넣으니, 그 트리도 자라면서 쪼개져요.
그런데 여기 id는 BIGINT 단조 증가입니다. 오른쪽 끝에만 붙는데 왜 8천 번이나 쪼개질까요.
확인해봤습니다. 같은 테이블의 클러스터드 인덱스 리프 페이지가 8,334장입니다.
테이블 131MB ÷ 16KB = 8,384 페이지 (그중 리프가 8,334장, 실측)
분할 8,342 ≈ 리프 페이지 8,334✅ 실측 (같은 환경)
분할 횟수가 만들어진 페이지 수와 거의 같습니다. 그러니 이 8,342는 반으로 쪼갠 게 아니라 대부분 오른쪽에 새 페이지를 하나씩 단 것이에요. 맨 끝에 붙는 게 확실하면 InnoDB는 반으로 나누지 않고 새 페이지에 새 값만 넣는데, 그 경로도 분할로 집계됩니다.
그리고 이건 모든 조건에 똑같이 깔려 있습니다. 단조 증가 1·2·3개의 분할이 8,342 + 1,065×개수로 오차 없이 떨어지는 게 그 증거예요. 그래서 이 장은 계속 8,342를 빼고 증가분만 봤습니다.
그래서 실무에서는
| 상황 | 판단 |
|---|---|
| 인덱스 개수를 줄일지 고민 중 | 공간은 개수에 그대로 비례한다. 3개면 3배, 예측이 쉽다 |
| PK를 UUID/CUID로 잡을지 고민 중 | 여기가 진짜 결정 지점이다. PK는 클러스터드 인덱스라 테이블 전체가 그 모양을 따른다(6장) |
| 이미 난수 키를 쓰고 있다 | 자리는 1.5~1.7배다(리프 1.52 · 할당 1.68). 시간은 조건에 따라 달라서 15장에서 잰다 |
| 쓰기가 느려졌는데 원인을 모르겠다 | 인덱스 개수부터 세지 말고 키 모양부터 보라 |
두 번째 줄이 제일 중요합니다. 이 장은 세컨더리 인덱스로 쟀는데, PK를 난수로 잡으면 테이블 자체가 그렇게 됩니다. 세컨더리 인덱스 하나가 부푸는 게 아니라 데이터 전체가요.
정리
- 공간은 개수에 그대로 비례합니다. 리프 페이지가 1,063 → 2,126 → 3,189로 오차 0이에요. 다만 이건 발견이 아니라 산술입니다 — 세 인덱스가 쌍둥이니까요.
- 모양은 계수를 바꿉니다. 인덱스 1개 기준으로 페이지 분할 1.52배, 리프 페이지 1.52배, 할당 페이지 1.68배, 시간 1.25배입니다.
- 분할 배율과 리프 배율이 정확히 같습니다(둘 다 1.52). 분할 한 번이 페이지 하나를 만드니까요. 1.68은 익스텐트 할당까지 세는 값이라 더 큽니다 — **1.52는 “얼마나 썼나”, 1.68은 “얼마나 차지했나”**입니다.
- “난수 인덱스는 여럿이면 서로 방해한다”고 읽었다가 틀렸습니다. 셋을 따로 재보니
rnd11,614 ·rnd21,626 ·rnd31,646으로 간섭이 0이었어요. 제가 본 증가는 서로 다른 난수 수열의 누적 평균이었습니다. 평균은 없는 추세를 만들어냅니다. - 체인지 버퍼는 한 번도 일하지 않았습니다.
ibuf 병합이 일곱 조건 전부 0이에요. 이 배율은 체인지 버퍼에 가려진 값이 아니라 순수하게 메모리 안에서 잰 값입니다. - 순서를 뒤집어도 구조 수치는 한 자리도 안 움직였습니다. 시간 배율도 1.37과 1.40으로 같은 자리예요.
- 제가 쓴 “난수 키 하나가 단조 증가 키 둘보다 비싸다”는 네 축 전부 틀렸습니다. 분할·리프·할당·시간 다 난수 1개가 단조 2개보다 쌌어요. 구조 축은 규모와 무관하니 확정이고요.
- 다만 “원문 조건에서도 틀렸는가”는 안 잽니다. 이 측정은 메모리 안·단일 트랜잭션·더티 페이지 write-back 제외라 원문 조건(4.6GB·행당 커밋)과 다릅니다. 그건 15장 몫입니다.
- 인덱스가 없어도 페이지 분할은 8,342회 일어납니다. 클러스터드 리프가 8,334장인 것과 거의 같아요 — 쪼갠 게 아니라 오른쪽에 단 겁니다.
생각해볼 질문
- 단조 증가는 같은 28바이트(키 20 + PK 8)를 담는 데 행당 36.9바이트를, 난수는 62.1바이트를 씁니다. 차이 25.2바이트는 어디로 갔을까요? (14장)
- 이 장은 세컨더리 인덱스로 쟀습니다. PK를 난수로 잡으면 무엇이 달라질까요? 테이블이 곧 클러스터드 인덱스라는 6장을 떠올려보세요.
- 난수 키 셋은 같은
MD5규칙으로 만들었는데 리프가 1,614·1,626·1,646으로 달랐습니다. 단조 증가 셋은 1,063으로 똑같았고요. 왜 한쪽만 뽑기마다 흔들릴까요?
이 장은 횟수만 셌습니다. 난수 키가 1,617번 쪼개졌고 단조 증가가 1,065번 쪼개졌다고요.
그런데 왜 쪼개면 페이지가 늘까요. 그리고 쪼개고 나면 그 페이지 안은 어떻게 생겼을까요. 행당 25.2바이트가 어디로 갔는지도 아직 모릅니다.
다음 장은 페이지를 열어 봅니다.