Skip to Content

5장. 모든 것은 16KB다 — 페이지·행·오버플로

{/* 삽화 자리 — 16KB 상자 하나를 열어 단면을 보여준다. 양쪽에서 자라 가운데서 만나는 빈 공간 */}

이 스키마에서는 행이 8,105바이트까지 페이지 안에 들어갑니다. 8,106바이트부터는 값이 밖으로 나가요. 그 한 바이트를 넘는 순간 클러스터드 리프가 1,001장에서 7장으로 쪼그라듭니다.

면접 실전 질문: ① InnoDB 페이지 안에는 무엇이 들어 있나요? ② 행이 페이지보다 크면 어떻게 되나요? ③ TEXTJSON 컬럼은 항상 따로 저장되나요?


배경 — 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바이트였다는 게 이 간격을 잰 값입니다.

맨 끝에 트레일러가 있습니다. 여기에 페이지 앞부분과 대조할 값이 들어 있어요. 페이지를 쓰는 도중에 전원이 나가면 앞뒤가 안 맞게 되고, 그걸로 “찢어진 페이지”를 알아냅니다.

그런데 행이 이 상자보다 크면

TEXTJSON을 쓰다 보면 행 하나가 페이지에 안 들어갈 만큼 커질 수 있습니다. 그러면 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,0001,00813814.50
4,0004,0086683.00
8,0008,0089752.10
8,0978,1051,0012.00
8,0988,10672861,998
8,0998,10772861,995
12,00012,00872862,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이라 페이지 안에 그대로 있습니다.

생각해볼 질문

  1. 오버플로가 걸린 테이블은 클러스터드 인덱스가 8페이지뿐이었습니다. 그럼 SELECT id FROM ovf는 빨라질까요, 느려질까요? 그리고 SELECT payload는요?
  2. 경계가 “한 페이지에 행이 둘은 들어가야 한다”에서 나왔습니다. 그럼 innodb_page_size를 64KB로 키우면 경계도 같이 커질까요?
  3. 이 장의 클러스터드 인덱스는 리프에 행 전체를 담고 있었습니다. 그런데 왜 하필 PK 순서로 담을까요? 다른 순서면 안 되는 걸까요? (6장)

6장. 테이블은 사실 인덱스다 — 클러스터드 인덱스

Last updated on