14장. 페이지 분할 — 난수 키가 비싼 진짜 이유
{/* 삽화 자리 — 꽉 채워 쌓은 상자 줄과, 반씩만 담긴 채 두 배로 늘어선 상자 줄 */}
같은 50만 행, 같은 20바이트 키인데 리프 페이지의 평균 여유 공간이 27바이트와 5,603바이트였습니다. 한쪽은 16KB를 꽉 채웠고 다른 쪽은 3분의 1이 공기예요. 그리고 그 공기는 나중에 읽기로 청구됩니다 — 같은 건수를 훑는데 페이지 요청이 2.03배입니다.
면접 실전 질문: ① 페이지 분할이 뭔가요? ② 왜 난수 키가 쓰기에 안 좋나요? ③ UUID를 PK로 쓰면 정확히 뭐가 나빠지나요?
배경 — 13장은 횟수만 셌습니다
13장에서 페이지 분할이 단조 증가는 1,065회, 난수는 1,617회라는 걸 봤습니다. 1.52배요.
그런데 거기서 두 가지가 설명 없이 남았습니다.
첫째, 분할이 왜 비싼가. 횟수를 세긴 했는데 한 번에 무슨 일이 일어나는지는 안 봤어요.
둘째, 그 비용이 어디로 청구되는가. 쓰기가 느려지는 걸로 끝인지, 아니면 다른 데서 받아가는지요.
그리고 13장 질문 1이 남았습니다. 단조 증가는 같은 28바이트(키 20 + PK 8)를 담는 데 행당 36.9바이트를 쓰는데 난수는 62.1바이트를 씁니다. 차이 25.2바이트는 어디로 갔느냐고요.
이 장은 페이지 안을 열어 셋 다 답합니다.
스토리 — 분할이란 무슨 일인가
B+tree 리프는 키 순서대로 정렬돼 있습니다(2·3장). 새 값이 들어오면 아무 데나 붙는 게 아니라 제자리에 끼워야 해요.
단조 증가 키는 제자리가 늘 맨 끝입니다. LPAD(1,20,'0'), LPAD(2,20,'0'), … 순서대로 오니까요. 마지막 페이지에 계속 붙이다가 꽉 차면 오른쪽에 새 페이지를 하나 답니다. 앞의 페이지들은 꽉 찬 채로 다시 안 건드려집니다.
난수 키는 다릅니다. LEFT(MD5(1),20), LEFT(MD5(2),20), … 은 서로 아무 순서가 없어요. 새 값이 트리 한가운데 어느 페이지에 떨어집니다. 그 페이지가 꽉 차 있으면 어떻게 할까요.
쪼갭니다. 새 페이지를 하나 만들고, 원래 페이지의 절반을 거기로 옮기고, 부모 노드에 새 페이지를 가리키는 항목을 추가합니다. 그게 페이지 분할이에요.
그러고 나면 페이지 둘 다 절반만 찬 상태가 됩니다. 이게 핵심입니다.
단조 증가 쪽에서도 “분할”은 집계됩니다. 하지만 성격이 달라요 — 맨 오른쪽에 붙는 게 확실하면 InnoDB는 반으로 나누지 않고 새 페이지에 새 값만 넣습니다. 옮길 게 없고, 앞 페이지는 꽉 찬 채로 남습니다.
같은 이름의 사건인데 결과가 정반대입니다. 한쪽은 꽉 찬 페이지를 남기고 한쪽은 반쯤 빈 페이지를 둘 남겨요.
여기까지가 설명이고, 이제 파일을 열어 확인합니다. 2장에서 .ibd를 직접 파싱했던 그 방식으로요.
페이지 안에서 실제로 쓰인 바이트는 헤더 세 군데로 잡습니다.
slots = int.from_bytes(buf[38:40], 'big') # 디렉터리 슬롯 (슬롯당 2B, 페이지 끝에서 자란다)
heap = int.from_bytes(buf[40:42], 'big') # 레코드 힙이 어디까지 찼나
garb = int.from_bytes(buf[46:48], 'big') # 그중 지워져 버려진 몫
used = heap - garb + 2 * slots + 8 # +8 = FIL 트레일러2장에서 쓴 여유 공간 식(16384 - heap - 8 - 2*slots + garb)과 더하면 정확히 16,384가 됩니다. 같은 페이지를 반대편에서 재는 셈이에요.
핵심 — 여유 공간 27바이트 대 5,603바이트
w_seq와 w_rnd는 같은 50만 행에 같은 폭(20바이트) 키를 갖습니다. 다른 건 값의 순서뿐이에요.
| 리프 페이지 | 페이지당 행 | 평균 여유 공간 | 평균 채움률 | |
|---|---|---|---|---|
단조 증가 (w_seq) | 1,063 | 470 | 27 바이트 | 99.8% |
난수 (w_rnd) | 1,614 | 310 | 5,603 바이트 | 65.8% |
✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08. 50만 행, utf8mb4 CHAR(20) 키. .ibd 파일을 직접 파싱했습니다. 재현: ch13/seed-writecost.sql을 먼저 넣은 뒤 ./ch14/fillfactor.sh. 전체 소스: docs/book/code/db-index/ch14/pagefill.py)
16,384바이트짜리 페이지에 여유가 27바이트입니다. 단조 증가 쪽은 사실상 한 바이트도 안 남기고 채웠어요. 난수 쪽은 5,603바이트가 비어 있고요.
7장에서 같은 두 테이블을 두고 “충전율 64%“라고 쓴 적이 있습니다. 그건 엔트리 수 비율(302 대 470)로 어림한 값이었어요. 여기서 페이지를 열어 바이트로 재보니 65.8%입니다. 다른 방법으로 같은 자리에 도착했습니다.
분할 자국은 분포에 남습니다
평균만 보면 “난수 쪽이 좀 헐겁구나”로 끝납니다. 페이지 하나하나를 세보면 다른 게 보여요.
| 채움률 구간 | 단조 증가 | 난수 |
|---|---|---|
| 10% 미만 (마지막 페이지) | 1 | 1 |
| 40~50% | 0 | 46 |
| 50~60% | 1 | 811 |
| 60~70% | 0 | 281 |
| 70~80% | 0 | 72 |
| 80~90% | 0 | 185 |
| 90~100% | 1,061 | 218 |
✅ 실측 (같은 환경. 재현: ./ch14/fillfactor.sh)
난수 쪽 1,614장 중 811장, 50.2%가 50~60% 구간에 몰려 있습니다.
이게 분할의 지문입니다. 페이지를 반으로 쪼개면 양쪽 다 50%가 되니까요. 그리고 난수 키는 그 뒤에 그 페이지로 돌아올 확률이 낮아서, 50%인 채로 남습니다. 다시 채워질 기회가 잘 안 와요.
단조 증가 쪽은 1,063장 중 1,062장이 90% 이상입니다. 채우고 떠나고, 채우고 떠나고를 반복했으니까요.
📄 문서 기반 (미검증) — B-tree에 무작위 순서로 넣으면 채움률이 이론적으로 ln 2 ≈ 69.3%로 수렴한다고 알려져 있습니다. 여기서 잰 65.8%가 그 근처예요. 다만 이 수렴값을 제가 유도해 확인한 건 아닙니다.
13장이 남긴 25.2바이트는 어디로 갔나
두 인덱스는 같은 28바이트(키 20 + PK 8)를 담는데 행당 36.9바이트와 62.1바이트를 씁니다. 그 차이 25.2바이트를 이제 셋으로 쪼갤 수 있습니다.
| 행당 바이트 | 단조 증가 | 난수 | 정체 |
|---|---|---|---|
| 실제 엔트리 | 34.8 | 34.8 | 키 20 + PK 8 + 레코드 헤더·디렉터리 슬롯 |
| 페이지 안의 빈 공간 | 0.1 | 18.1 | 분할이 남긴 공기 |
| 상위 레벨 + 익스텐트 여유 | 2.0 | 9.2 | 내부 노드, 그리고 할당됐지만 아직 안 쓴 페이지 |
| 합계 | 36.9 | 62.1 |
✅ 실측 (같은 환경. 엔트리는 리프 페이지의 실제 사용 바이트 합 ÷ 50만행 — 페이지 헤더에서 직접 읽은 값이라 채움률을 가정하지 않습니다. 합계는 할당 페이지 × 16,384 ÷ 50만)
엔트리 자체는 34.8바이트로 완전히 똑같습니다. 당연하죠 — 키 폭도 PK도 같으니까요.
차이는 전부 주변에 있었습니다. 18.1바이트는 페이지 안의 공기고, 9.2바이트는 트리 위층과 할당 여유예요. 데이터는 한 바이트도 안 늘었는데 차지하는 자리가 1.68배가 된 겁니다.
엔트리 34.8바이트를 더 쪼개봤습니다. 리프 페이지를 하나 열어 직접 세면 레코드 하나가 정확히 34.00바이트입니다.
길이 바이트 1 B ← CHAR(20) 인데 컬럼이 utf8mb4 라 가변길이로 저장된다
레코드 헤더 5 B
키 20 B
PK 8 B
──────
레코드 34 B
디렉터리 슬롯 몫 ~0.5 B (슬롯 하나가 레코드 4~8개를 맡는다. 실측 4.0~5.7개)
페이지 고정분 몫 ~0.3 B (FIL 헤더 38 + 인덱스 헤더 56 + infimum/supremum 26 = 120B ÷ 470행)
──────
~34.8 B✅ 실측 (같은 환경. w_seq 리프 한 장: 레코드 471개, 슬롯 118개, 사용 16,378바이트)
CHAR(20)인데 21바이트를 먹는 게 눈에 띄실 겁니다. utf8mb4에서는 CHAR도 InnoDB가 가변길이로 저장해서 길이 바이트가 하나 붙습니다. 고정폭인 줄 알았던 타입이 아니었던 거예요.
쓴 것과 차지한 것
13장에서 분할 배율과 리프 배율이 둘 다 1.53으로 같고 할당은 1.68이라는 걸 봤습니다. 파일을 직접 세면 그 관계가 더 선명해집니다.
| 단조 증가 | 난수 | 배율 | |
|---|---|---|---|
| 페이지 분할 횟수 | 1,065 | 1,617 | 1.52배 |
| 실제 쓰인 페이지 (리프+내부) | 1,068 | 1,620 | 1.52배 |
통계가 보고한 size | 1,125 | 1,894 | 1.68배 |
✅ 실측 (같은 환경. 전부 인덱스 1개 조건입니다. mysql.innodb_index_stats와 .ibd 파싱을 나란히)
분할 횟수와 실제 쓰인 페이지가 둘 다 1.52배로 정확히 일치합니다. 분할 한 번이 페이지 하나를 만드니 당연하죠.
(13장의 리프 표에는 1,614장, 여기 파일 파싱에는 1,614장 — 한 장도 안 다릅니다. 통계와 파일이 같은 값을 말해요.)
1.68배는 다른 걸 세고 있었습니다. size는 인덱스에 할당된 페이지를 세는데, InnoDB는 페이지를 64장짜리 익스텐트 단위로 떼어줍니다. 그래서 실제로 쓰는 것보다 늘 여유가 붙어요. 단조 증가는 1,068장 쓰면서 1,125장을 받았고(여유 57장), 난수는 1,620장 쓰면서 1,894장을 받았습니다(여유 274장).
둘 다 참인 값입니다. 1.52배는 “얼마나 썼나”고 1.68배는 “얼마나 차지했나”예요. 디스크를 실제로 먹는 건 뒤쪽이고요.
리두는 1.11배인데, 인덱스 몫만 보면 2.20배입니다
분할이 비싼 이유로 흔히 드는 게 “옮겨 쓰는 양이 늘고 리두 로그도 그만큼 늘어난다”입니다. 재봤습니다.
| 키 모양 | 리두 바이트 | 행당 리두 | 인덱스 몫 | 페이지 분할 | 재구성 |
|---|---|---|---|---|---|
| 인덱스 없음 | 152,872,448 | 305.7 | — | 8,342 | 0 |
| 단조 증가 | 167,627,264 | 335.3 | 29.6 | 9,407 | 0 |
| 난수 | 185,421,824 | 370.8 | 65.1 | 9,959 | 0 |
✅ 실측 (같은 환경. Innodb_os_log_written 증가분, 인덱스 1개 조건. 분할 수치에는 클러스터드 인덱스 몫(8,342)이 포함돼 있습니다. 재현: ./ch14/splitcost.sh)
아래 두 줄만 보면 1.11배입니다(370.8 ÷ 335.3). 작아 보이죠.
그런데 인덱스 없는 줄을 넣으면 그림이 달라집니다. 행당 305.7바이트는 인덱스와 무관하게 나가는 값이에요 — 100바이트짜리 body를 포함한 행 데이터 자체니까요. 그걸 빼면 인덱스가 실제로 청구한 건 29.6바이트와 65.1바이트, 2.20배입니다.
전체가 1.11배로 보인 건 행 데이터가 91%를 먹고 있어서였습니다. 처음엔 이 표를 기준선 없이 두 줄만 놓고 “분할은 리두를 안 늘린다”고 읽었는데, 한 줄 더 재보니 반대였어요.
분할 한 번이 리두에 남기는 양도 나옵니다. 552번 더 쪼개면서 1,780만 바이트가 늘었으니 분할당 약 32KB — 16KB 페이지 두 장 몫입니다. 쪼갠 결과가 페이지 둘이니 앞뒤가 맞고요.
📄 페이지 재구성(index_page_reorg_successful)도 같이 셌는데 세 조건 전부 0이었습니다. 이 실험에서는 관여하지 않았어요.
그래도 이게 분할의 제일 큰 청구서는 아닙니다. 리두는 넣을 때 한 번 나가고 끝이거든요.
청구서는 읽기로 옵니다
분할이 남긴 건 반쯤 빈 페이지입니다. 그리고 페이지는 이 책이 처음부터 세어 온 비용 단위예요(여는 글).
같은 50만 행을 인덱스 순서대로 훑어봤습니다.
| 키 모양 | 읽은 행 | 페이지 요청 | 소요 ms |
|---|---|---|---|
| 단조 증가 | 500,000 | 1,425 | 90.6 |
| 난수 | 500,000 | 2,891 | 95.0 |
✅ 실측 (같은 환경. 커버링 스캔. 시간은 5회 중앙값, 요청은 1회분입니다 — 처음엔 스크립트가 6회 실행분을 누적해 세고 있었고, 그대로 8,550·17,346으로 실었다가 고쳤습니다. 재현: ./ch14/readcost.sh)
같은 건수를 읽는 데 페이지 요청이 2.03배입니다.
쓰기 때 생긴 공기를 읽을 때마다 계속 지고 가는 겁니다. 인덱스는 한 번 만들고 수없이 읽는 물건이니, 이쪽이 진짜 청구서예요.
두 가지를 정직하게 붙여둡니다.
첫째, 시간은 거의 같습니다(90.6ms 대 95.0ms). 데이터가 전부 메모리에 있어서요. 8장에서 봤듯 페이지 요청이 시간이 되려면 디스크를 실제로 긁어야 합니다. 요청이 2배라는 건 버퍼풀을 2배로 먹는다는 뜻이고, 그게 시간으로 바뀌는 건 메모리가 부족해질 때입니다.
둘째, 요청 수(1,425)가 리프 페이지 수(1,063)보다 1.34배 많습니다. 페이지 하나를 요청 한 번으로 끝내지 못하는 몫이 붙는 거예요.
11장에 같은 계수가 있습니다. 조건을 붙인 인덱스 스캔이 4,247페이지짜리 인덱스에 5,634회를 요청했는데, 1.33배입니다. 다른 장·다른 테이블에서 같은 자리로 나왔어요.
셋째, 리프 페이지는 1.52배인데 요청은 2.03배입니다. 페이지 수만으로는 0.5배가 안 채워져요. 페이지당 요청 계수가 단조 1.34, 난수 1.79로 다릅니다. 왜 다른지는 못 밝혔습니다. 확실한 건 방향과 하한선입니다 — 최소 1.52배, 실측 2.03배.
그래서 UUID를 PK로 쓰면
13장 질문 2가 이거였습니다. 이 장은 세컨더리 인덱스로 쟀는데, PK를 난수로 잡으면 뭐가 달라지냐고요.
테이블 자체가 그렇게 됩니다. 6장에서 봤듯 InnoDB에서 테이블은 곧 PK로 정렬된 클러스터드 인덱스예요. PK가 난수면 이 장에서 본 65.8% 채움률이 인덱스가 아니라 데이터 전체에 적용됩니다.
| 세컨더리 인덱스가 난수 | PK가 난수 | |
|---|---|---|
| 부푸는 대상 | 그 인덱스만 | 테이블 전체 |
| 이 실험 규모 (리프 기준) | 1,063 → 1,614장, +551장 = 9MB | 클러스터드 8,334장이 1.5배면 +4,167장 = 68MB 📄 추정 |
| 딸린 효과 | — | 모든 세컨더리 인덱스가 그 PK를 들고 다닌다(7장) |
📄 오른쪽 칸은 재보지 않았습니다. 34바이트짜리 세컨더리 엔트리에서 나온 1.5배를 274바이트 행이 60개 들어가는 클러스터드에 그대로 옮긴 추정이에요. 방향은 맞겠지만 배수는 다를 수 있습니다.
마지막 줄이 특히 큽니다. 세컨더리 인덱스 리프는 PK를 담으니(7장), PK가 길거나 난수면 그 값이 모든 인덱스에 복사돼요. BIGINT 8바이트 대신 CHAR(36) UUID를 쓰면 인덱스마다 행당 28바이트씩 더 붙습니다(ASCII 기준. utf8mb4면 길이 바이트가 하나 더 붙고요).
📄 문서 기반 (미검증) — 그래서 UUID를 꼭 써야 하면 BINARY(16)으로 줄이거나, 시간 순서가 앞에 오는 UUIDv7 같은 변형을 쓰라는 권고가 있습니다. 이 책에서 재보지는 않았습니다.
정리
- 페이지 분할은 꽉 찬 페이지에 값을 끼워야 할 때 일어납니다. 새 페이지를 만들고 절반을 옮기고 부모에 항목을 답니다. 그러고 나면 양쪽 다 절반만 찹니다.
- 단조 증가 키에서도 분할은 집계되지만 성격이 달라요. 맨 오른쪽에 붙는 게 확실하면 나누지 않고 새 페이지에 새 값만 넣습니다. 앞 페이지는 꽉 찬 채 남습니다.
- 실측: 리프 페이지 평균 여유가 27바이트 대 5,603바이트입니다. 채움률 99.8% 대 65.8%예요.
- 분할 자국은 분포에 남습니다. 난수 쪽 리프 1,614장 중 811장(정확히 절반)이 채움률 50~60% 구간입니다. 쪼개진 그대로 남은 거예요.
- 13장 질문 1의 답: 엔트리 자체는 양쪽 다 34.8바이트로 동일합니다. 차이 25.2바이트는 페이지 안의 공기 18.0바이트와 상위 레벨·익스텐트 여유 7.1바이트로 갈립니다(합 25.1, 나머지 0.03은 엔트리 쪽 차). 데이터는 한 바이트도 안 늘고 자리만 1.68배가 됐습니다.
- 분할 횟수와 파일에서 센 실제 사용 페이지가 둘 다 1.52배로 일치합니다. 13장이 통계로 본 리프 1,614장이 파일에서도 1,614장이었고요.
- 리두는 전체로는 1.11배지만, 인덱스 없는 기준선을 빼면 29.6 대 65.1바이트로 2.20배입니다. 전체가 작아 보인 건 행 데이터가 91%를 먹어서예요. 분할 한 번이 리두에 32KB — 페이지 두 장 몫을 남깁니다.
- 청구서는 읽기로 옵니다. 같은 50만 행을 훑는데 페이지 요청이 2.03배입니다(1,425 대 2,891). 다만 전부 메모리에 있으면 시간은 거의 같아요(90.6ms 대 95.0ms) — 요청 2배는 버퍼풀을 2배 먹는다는 뜻이고, 그게 시간이 되는 건 메모리가 모자랄 때입니다.
- PK를 난수로 잡으면 이 모든 게 테이블 전체에 적용되고, 세컨더리 인덱스마다 그 PK가 복사됩니다(7장).
생각해볼 질문
- 난수 키 인덱스는 페이지 요청이 2.03배인데 시간은 같았습니다. 이 차이가 시간으로 나타나려면 무엇이 달라져야 할까요? (8장)
- 난수 쪽에도 채움률 90% 이상인 페이지가 218장 있습니다. 분할이 남긴 자국이 50~60%라면, 이 218장은 어디서 왔을까요?
- 이 장의 실험은 인덱스가 버퍼풀 안에 다 들어가는 규모였습니다. 인덱스가 버퍼풀보다 크면, 난수 키의 삽입은 어떤 새로운 비용을 만들까요? (15장)
여기까지가 난수 키의 청구서입니다. 페이지가 1.52배 늘고, 읽을 때마다 2.03배를 요청하고요.
그런데 13장에서 이상한 걸 하나 봤습니다. 체인지 버퍼가 일곱 조건 전부 한 번도 일하지 않았어요(ibuf 병합 0).
난수 키는 트리 여기저기를 건드립니다. 고칠 페이지가 메모리에 없으면 디스크에서 읽어와야 하고, 체인지 버퍼는 바로 그걸 미뤄주는 장치예요. 그런데 미룰 일이 한 번도 안 생겼습니다. 인덱스가 버퍼풀 안에 다 들어갔으니까요.
인덱스가 정말로 메모리보다 커지면 어떻게 될까요.