5장. 모든 것은 16KB다 — 페이지·행·오버플로
{/* 삽화 자리 — 16KB 상자 하나를 열어 단면을 보여준다. 양쪽에서 자라 가운데서 만나는 빈 공간 */}
이 스키마에서는 행이 8,105바이트까지 페이지 안에 들어갑니다. 8,106바이트부터는 값이 밖으로 나가요. 그 한 바이트를 넘는 순간 클러스터드 리프가 1,001장에서 7장으로 쪼그라듭니다.
면접 실전 질문: ① InnoDB 페이지 안에는 무엇이 들어 있나요? ② 행이 페이지보다 크면 어떻게 되나요? ③
TEXT나JSON컬럼은 항상 따로 저장되나요?
배경 — 1부 내내 쓰고도 안 열어본 상자
1장에서 이 책이 셀 단위를 16KB 페이지로 정했습니다. 2장에서는 그 페이지에 엔트리가 471개 들어가는 걸 셌습니다. 3장에서는 페이지끼리 이어진 포인터를 따라 걸었고요. 4장에서는 옵티마이저의 비용식 안에까지 페이지가 들어 있었습니다.
그런데 16KB 안이 어떻게 생겼는지는 한 번도 안 봤습니다.
이 장에서 상자를 엽니다. 그리고 열어보면 자연스럽게 따라오는 질문이 하나 있어요 — 행이 페이지보다 크면 어떻게 되나.
이건 저에게 개인적인 질문이기도 합니다. 제가 이 경계를 잘못 잡은 적이 있거든요. 그 얘기를 하려면 먼저 상자 안을 봐야 합니다.
스토리 — 페이지 하나의 단면
InnoDB 페이지 하나는 이렇게 생겼습니다.
0 ┌─────────────────────────────┐
│ FIL 헤더 38바이트 │ 체크섬, 페이지 번호, 좌우 이웃(3장), 페이지 종류
38 ├─────────────────────────────┤
│ 페이지 헤더 56바이트 │ 레코드 수, 레벨(2장), 인덱스 ID, 힙 꼭대기
94 ├─────────────────────────────┤
│ infimum / supremum │ 이 페이지의 가상 최소·최대 레코드
├─────────────────────────────┤
│ 사용자 레코드 │ ↓ 위에서 아래로 자란다
│ │
│ ── 빈 공간 ── │ 둘이 여기서 만나면 페이지가 꽉 찬 것
│ │
│ 페이지 디렉터리 │ ↑ 아래에서 위로 자란다
16376 ├─────────────────────────────┤
│ FIL 트레일러 8바이트 │
16384 └─────────────────────────────┘📄 문서 기반 (미검증) — 각 영역의 크기는 MySQL 문서와 소스 기준입니다. 다만 이 책은 이미 이 구조를 여러 번 실제로 읽었습니다 — 2장에서 PAGE_LEVEL(오프셋 64)과 PAGE_N_RECS(54)를, 3장에서 FIL_PAGE_PREV(8)와 FIL_PAGE_NEXT(12)를 파일에서 직접 꺼냈죠. 전부 위 그림의 앞쪽 94바이트 안에 있습니다.
두 가지만 짚고 갑시다.
빈 공간이 가운데 있고 양쪽에서 자랍니다. 레코드는 위에서 아래로 쌓이고, 그 레코드를 빨리 찾기 위한 디렉터리는 아래에서 위로 쌓입니다. 둘이 만나면 그 페이지는 꽉 찬 거예요. 2장에서 세컨더리 리프에 남은 공간이 평균 8바이트였다는 게 이 간격을 잰 값입니다.
맨 끝에 트레일러가 있습니다. 여기에 페이지 앞부분과 대조할 값이 들어 있어요. 페이지를 쓰는 도중에 전원이 나가면 앞뒤가 안 맞게 되고, 그걸로 “찢어진 페이지”를 알아냅니다.
그런데 행이 이 상자보다 크면
TEXT나 JSON을 쓰다 보면 행 하나가 페이지에 안 들어갈 만큼 커질 수 있습니다. 그러면 InnoDB는 큰 값을 페이지 밖으로 내보내고, 행에는 그 값이 어디 있는지 가리키는 포인터만 남깁니다. 이걸 오버플로라고 합니다.
여기서 흔한 오해가 하나 있어요. “큰 컬럼은 항상 따로 저장된다”는 게 아닙니다. 조건이 있고, 그 조건이 생각보다 훨씬 큽니다.
이걸 처음 틀린 건 이 책이 아니라 제 블로그 글이었습니다. 2.1KB짜리 JSON을 읽을 때 행마다 페이지 접근이 하나씩 더 붙길래 오버플로 탓으로 썼거든요. (그 글에도 정정을 달아뒀습니다.)
1장에서 그 설명을 뒤집긴 했습니다. 다만 근거가 간접적이었어요 — 클러스터드가 653MB나 된다는 크기 하나였습니다. 경계가 정확히 어디인지는 재본 적이 없었습니다.
그럼 경계가 정확히 어디인지 재보면 됩니다. 한 바이트씩 늘려가면서요.
핵심 — 한 바이트가 가르는 자리
VARBINARY 컬럼의 폭만 바꿔가며 2,000행짜리 테이블을 만들고, 파일에서 페이지를 종류별로 셌습니다. VARBINARY를 쓴 건 문자셋 때문에 바이트 수가 흔들리지 않게 하기 위해서입니다.
ROW_FORMAT=DYNAMIC을 명시한 것도 같은 이유예요. 📄 문서 기반 (미검증) — 예전 포맷(COMPACT)은 밖으로 뺀 값의 앞부분 768바이트를 리프에 남겨둡니다. 그러면 아래 표의 “7페이지”가 안 나와요. MySQL 8.0의 기본값이 DYNAMIC이라 대개 신경 쓸 일은 없지만, 오래된 테이블에서 재현하면 숫자가 다를 수 있습니다.
CREATE TABLE ovf (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
payload VARBINARY(8097) NOT NULL
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;| payload | 행 크기 | 클러스터드 리프 | 리프당 행 | 오버플로 페이지 |
|---|---|---|---|---|
| 1,000 | 1,008 | 138 | 14.5 | 0 |
| 4,000 | 4,008 | 668 | 3.0 | 0 |
| 8,000 | 8,008 | 975 | 2.1 | 0 |
| 8,097 | 8,105 | 1,001 | 2.0 | 0 |
| 8,098 | 8,106 | 7 | 286 | 1,998 |
| 8,099 | 8,107 | 7 | 286 | 1,995 |
| 12,000 | 12,008 | 7 | 286 | 2,000 |
✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08. 2,000행. 행 크기는 payload + BIGINT PK 8바이트. 페이지 수는 .ibd 파일의 FIL_PAGE_TYPE(오프셋 24)으로 종류를 가르고, PAGE_LEVEL(오프셋 64)로 리프만 골라 세었습니다. 위층 노드는 어느 경우든 1~3장뿐이라 표에서 뺐어요. 재현: WIDTHS="1000 4000 8000 8096 8097 8098 8099 12000" ./ch05/overflow.sh. 전체 소스: docs/book/code/db-index/ch05/overflow.sh · docs/book/code/db-index/ch05/pagetypes.py)
한 바이트가 가릅니다. 8,097에서는 값이 페이지 안에 있고, 8,098에서는 밖으로 나갑니다.
경계가 8KB인 이유
규칙은 “행이 페이지에 들어가느냐”가 아니라 “한 페이지에 행이 최소 두 개는 들어가느냐”입니다. 레코드 하나만 들어가는 페이지를 허용하면 페이지를 쪼개도 진전이 없거든요. 그래서 한계가 16KB가 아니라 8KB 언저리입니다.
페이지의 절반은 8,192바이트인데 실측 경계(8,105)는 그보다 87바이트 아래예요. 이 87이 어디서 오는지 맞춰보면 이렇습니다.
쓸 수 있는 공간 = 16,384 − 120(supremum 끝) − 8(FIL 트레일러) − 4(디렉터리 슬롯 2개)
= 16,252
레코드 상한 = 16,252 ÷ 2 = 8,126 ← 이 값보다 작아야 인라인
이 표의 레코드 = payload + 28
(레코드 헤더 5 + 길이 2 + PK 8 + 트랜잭션용 고정 13)
payload 8,097 → 8,125 < 8,126 → 인라인
payload 8,098 → 8,126 = 8,126 → 오버플로📄 문서 기반 (미검증) — 상수 120·28은 MySQL 소스 기준이고 직접 확인하지는 않았습니다. 다만 실측 경계와 1바이트도 안 어긋납니다. 120은 2장에서 레코드 힙이 시작하던 바로 그 오프셋이고요.
표의 8,000 줄이 그 규칙을 그대로 보여줍니다. 행이 8,008바이트일 때 2,000행이 1,004페이지를 씁니다 — 페이지당 정확히 2행이에요. 한 행만 더 크면 두 개가 안 들어가고, 그 순간 오버플로가 시작됩니다.
넘어가면 무슨 일이 벌어지나
경계를 넘는 순간 두 가지가 동시에 일어납니다.
첫째, 클러스터드 인덱스가 무너집니다. 1,004페이지가 8페이지로 줄어요. 리프에 8KB짜리 값 대신 포인터만 남으니 페이지당 250행이 들어갑니다.
둘째, 행마다 오버플로 페이지가 하나씩 붙습니다. 2,000행에 1,995~2,000페이지예요.
딱 떨어지지 않는 건 저도 못 밝혔습니다. 경계에 걸린 폭에서 몇 행이 인라인으로 남는데 왜인지 모르겠어요. 그리고 이 1:1도 이 실험의 값이 8~12KB라 LOB 페이지 한 장에 다 들어가서 생긴 결과입니다. 값이 더 크면 한 행이 여러 장을 씁니다.
그런데 이걸 재앙으로만 읽으면 안 됩니다.
값을 안 읽는 조회는 오히려 크게 싸집니다. SELECT id는 리프 1,001장이 아니라 7장만 훑으면 되니까요. 값을 읽는 조회만 행마다 다른 페이지로 한 번 더 나갑니다. 오버플로는 비용이 사라지는 게 아니라 옮겨가는 것입니다 — 값을 실제로 쓰는 쪽으로요.
파일 전체를 종류별로 세면 이렇게 보입니다.
# payload 12,000 기준
$ docker run --rm -v db-index_mysql-data:/d:ro -v "$PWD":/w:ro python:3-alpine \
python3 /w/ch05/pagetypes.py /d/bookdb/ovf.ibd
TYPE_24 2000 ← 오버플로로 빠진 값
ALLOCATED 292 ← 아직 안 쓰는 빈 페이지
INDEX 8 ← 클러스터드 인덱스 전체
FSP_HDR 1
XDES 1
INODE 1
SDI 1테이블이 차지한 공간의 거의 전부가 인덱스 밖에 있습니다. 데이터는 2,000페이지에 흩어져 있고, 그걸 가리키는 B+tree는 8페이지뿐이에요.
📄 문서 기반 (미검증) — 종류 번호 24는 MySQL 8.0이 큰 값을 저장할 때 쓰는 LOB 계열 페이지입니다. 예전에 쓰이던 BLOB(10번)이 아니에요. 정확한 상수 이름은 소스로 확인하지 않았고, 여기서 확실한 건 행 수와 정확히 같은 개수로 생긴다는 관측입니다.
그래서 1장의 그 JSON은
1장에서 문제가 된 JSON 값은 약 2.1KB였습니다. 경계인 8,105바이트의 4분의 1이에요. 오버플로 조건에 애초에 안 걸립니다.
실제로 1장에서 잰 값이 그걸 뒷받침합니다. 25만 행짜리 그 테이블은 DATA_LENGTH가 653MB, 행당 2,739바이트였어요. 값이 페이지 밖으로 빠졌다면 클러스터드 리프에는 포인터만 남아 20MB도 안 됐어야 합니다. 방금 본 표의 마지막 줄들처럼요.
그럼 행마다 붙던 그 페이지 접근 하나는 뭐였을까요. 1장이 세운 프리페치 캐시 가설은 아직 소스로 확인 못 했습니다. 확정된 건 오버플로가 아니라는 것 하나예요.
정리
- InnoDB 페이지 16KB는
FIL 헤더 38+페이지 헤더 56+infimum/supremum+레코드+빈 공간+페이지 디렉터리+FIL 트레일러 8로 구성됩니다. 빈 공간은 가운데 있고 양쪽에서 자랍니다. - 이 책은 이미 이 구조를 여러 번 직접 읽었습니다 — 2장의
PAGE_LEVEL, 3장의FIL_PAGE_NEXT가 전부 앞쪽 94바이트 안에 있습니다. - 진짜 규칙은 한 페이지에 행이 둘은 들어가야 한다입니다. 레코드 상한이 8,126바이트고, 이 표의 스키마에서는 행 8,105바이트까지 인라인이었어요(실측). 컬럼 구성이 다르면 경계도 움직입니다.
- 넘어가면 클러스터드 인덱스가 1,004페이지에서 8페이지로 무너지고, 행마다 오버플로 페이지가 하나씩 붙습니다.
- “큰 컬럼은 항상 따로 저장된다”는 틀립니다. 2.1KB짜리
JSON은 경계의 4분의 1이라 페이지 안에 그대로 있습니다.
생각해볼 질문
- 오버플로가 걸린 테이블은 클러스터드 인덱스가 8페이지뿐이었습니다. 그럼
SELECT id FROM ovf는 빨라질까요, 느려질까요? 그리고SELECT payload는요? - 경계가 “한 페이지에 행이 둘은 들어가야 한다”에서 나왔습니다. 그럼
innodb_page_size를 64KB로 키우면 경계도 같이 커질까요? - 이 장의 클러스터드 인덱스는 리프에 행 전체를 담고 있었습니다. 그런데 왜 하필 PK 순서로 담을까요? 다른 순서면 안 되는 걸까요? (6장)