6장. 테이블은 사실 인덱스다 — 클러스터드 인덱스
{/* 삽화 자리 — 색인 카드마다 본문 주소가 아니라 “책 제목”이 통째로 적혀 있다. 제목이 길수록 카드 뭉치가 두꺼워진다 */}
PK를 BIGINT에서 CHAR(36)으로 바꿨더니 손대지도 않은 세컨더리 인덱스가 17.6MB에서 31.7MB로 늘었습니다. 세컨더리 인덱스가 PK를 통째로 들고 다니기 때문입니다.
면접 실전 질문: ① 클러스터드 인덱스가 뭔가요? ② PK를 안 만들면 어떻게 되나요? ③ PK를 크게 잡으면 다른 인덱스에 무슨 일이 생기나요?
배경 — 5장이 남긴 질문
5장에서 행 하나가 페이지 안에 어떻게 들어가는지 봤습니다. 그리고 마지막에 물었죠 — 왜 하필 PK 순서로 담을까요.
당연한 것처럼 넘어가기 쉬운데, 당연하지 않습니다. 다른 방법이 있거든요.
많은 데이터베이스는 힙(heap)을 씁니다. 행을 들어온 순서대로 아무 데나 쌓아두고, 인덱스는 “그 행이 파일 몇 번째 바이트에 있다”는 주소를 들고 있어요. 인덱스와 데이터가 따로 삽니다.
InnoDB는 그렇게 하지 않습니다. 테이블 자체가 PK로 정렬된 B+tree이고, 행은 그 트리의 리프에 들어 있습니다. 데이터를 따로 보관하는 곳이 아예 없어요.
이걸 클러스터드 인덱스라고 부릅니다. 이름이 좀 헷갈리는데, “인덱스가 하나 더 있다”가 아니라 “테이블이 곧 그 인덱스다” 라는 뜻입니다.
2장에서 이미 그 증거를 봤습니다. 클러스터드 리프에 페이지당 63행이 들어갔죠 — 리프에 행 전체가 있으니 그만큼밖에 안 들어간 겁니다. 세컨더리 인덱스가 471개를 담은 것과 대비됐고요.
스토리 — 이 선택이 만드는 두 가지
테이블을 PK 순서 트리로 만들면 좋은 게 하나, 불편한 게 하나 생깁니다.
좋은 것부터. PK로 조회하면 트리를 한 번만 내려가면 끝납니다. 리프에 도착하는 순간 행이 거기 있으니까요. 주소를 받아 다시 어딘가로 갈 필요가 없습니다.
범위 조회도 유리합니다. PK가 연속된 구간은 물리적으로도 붙어 있으니, 3장에서 본 리프 체인을 그대로 타면 됩니다. WHERE id BETWEEN 100 AND 200이 싼 이유예요.
불편한 것. 세컨더리 인덱스가 문제입니다.
세컨더리 인덱스 리프에는 무엇을 담아야 할까요? 행의 물리 주소를 담을 수는 없습니다. 페이지가 쪼개지면(2장) 행이 다른 페이지로 옮겨가는데, 그때마다 모든 세컨더리 인덱스의 주소를 고쳐야 하니까요.
그래서 InnoDB는 주소 대신 PK 값을 담습니다. 페이지가 어디로 옮겨가든 PK 값은 그대로니까요.
그럼 PK 자체를 UPDATE 하면요? 그 행의 세컨더리 엔트리를 인덱스마다 전부 다시 씁니다. PK를 “안 바뀌는 값”으로 잡으라는 조언은 못 바꿔서가 아니라 비싸서입니다.
세컨더리 인덱스 리프: [ txno = '...0500000' | PK = 12345 ]
│
클러스터드 인덱스: PK 12345 로 다시 탐색 → 행여기서 5장이 남긴 질문에 답이 나옵니다. 왜 하필 PK 순서냐고요? 트리는 어떤 키로든 정렬할 수 있습니다. 다만 세컨더리가 가리킬 수 있으려면 그 키가 행을 유일하게 식별하고 잘 안 바뀌는 값이어야 해요. 그게 바로 PK의 정의입니다. 순서를 PK로 정한 게 아니라, 그 조건을 만족하는 값을 PK라고 부르는 겁니다.
이 구조가 두 가지를 동시에 결정합니다. 하나는 왕복 — 실제 행을 보려면 클러스터드를 다시 내려가야 합니다(7장). 다른 하나가 이 장의 주제입니다.
PK가 크면 모든 세컨더리 인덱스가 커집니다. 엔트리마다 PK를 통째로 복사해 들고 있으니까요.
PK를 안 만들면
여기서 자연스러운 질문 하나. 테이블에 PK를 안 만들면 어떻게 될까요?
InnoDB는 클러스터드 인덱스 없이는 테이블을 만들 수 없습니다. 그래서 몰래 하나 만듭니다. NOT NULL인 유니크 인덱스가 있으면 그걸 쓰고, 그것도 없으면 6바이트짜리 숨은 순번(rowid)을 붙여 그걸로 트리를 세웁니다.
이름도 있어요. GEN_CLUST_INDEX입니다. 조회할 수도 없고 SHOW CREATE TABLE에도 안 나오지만, information_schema.INNODB_INDEXES에는 보입니다(SHOW INDEX에는 안 나옵니다).
그럼 세 경우를 나란히 놓고 재보면 됩니다.
핵심 — PK 크기가 어디까지 번지는가
PK만 바꿨습니다. 세컨더리 인덱스는 손도 안 댔고요. 행 수·컬럼·세컨더리 인덱스가 전부 같고 PK만 다른 테이블 셋을 만들었습니다.
-- 셋 다 아래 두 컬럼과 세컨더리 인덱스가 동일하다. PK 만 다르다.
-- txno CHAR(20) NOT NULL, body CHAR(100) NOT NULL, INDEX idx_txno (txno)
CREATE TABLE pk_small (id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, ...);
CREATE TABLE pk_char36 (id CHAR(36) NOT NULL PRIMARY KEY, ...);
CREATE TABLE pk_none (/* PK 없음 */ ...);| 테이블 | 클러스터드 인덱스 이름 | 클러스터드 페이지 | 세컨더리 페이지 | 세컨더리 크기 | 리프당 엔트리 | 엔트리 (실측) | 엔트리 (예측) |
|---|---|---|---|---|---|---|---|
pk_small (BIGINT 8B) | PRIMARY | 4,967 | 1,125 | 17.6 MB | 470 | 34.6 B | 34 B |
pk_char36 (CHAR(36) 36B) | PRIMARY | 5,939 | 2,026 | 31.7 MB | 255 | 63.7 B | 62 B |
pk_none (PK 없음) | GEN_CLUST_INDEX | 4,903 | 1,060 | 16.6 MB | 500 | 32.5 B | 32 B |
✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08. 각 50만 행. 페이지 수는 mysql.innodb_table_stats의 sum_of_other_index_sizes, 리프 수는 mysql.innodb_index_stats의 n_leaf_pages. 엔트리 크기는 5장에서 구한 가용 공간 16,252를 리프당 엔트리로 나눈 값입니다. 예측 열은 5장의 공식 키 21 + PK + 레코드 헤더 5입니다.
36바이트 키는 LPAD(id,36,'0')으로 만든 단조 증가 문자열이지 무작위 UUID가 아닙니다. 이 장은 크기 효과만 떼어 보려고 일부러 순서를 지켰어요. 무작위 키가 따로 청구하는 값은 13·14장에서 잽니다.
세컨더리 페이지 수가 리프보다 60여 장 많은 건 할당 기준이라 상위 노드와 예비 페이지가 섞여서입니다. 세 테이블 모두 60~65장으로 일정해 비교에는 영향이 없습니다.
전체 소스: docs/book/code/db-index/ch06/pkcost.sh)
PK를 8바이트에서 36바이트로 바꿨을 뿐인데 세컨더리 인덱스가 1.80배가 됐습니다. 세컨더리 인덱스는 손도 안 댔는데요.
숫자가 PK 크기를 그대로 따라갑니다
엔트리 크기 열을 보면 우연이 아니라는 게 드러납니다.
PK 36바이트 − PK 8바이트 = 28바이트 차이
엔트리 63.73 − 34.58 = 29.15바이트 차이
PK 8바이트 − PK 6바이트 = 2바이트 차이
엔트리 34.58 − 32.50 = 2.08바이트 차이PK가 커진 만큼 거의 그대로 엔트리가 커집니다. 세컨더리 인덱스 엔트리 안에 PK가 통째로 들어 있다는 걸 이보다 깔끔하게 보여주긴 어렵습니다.
실측이 예측보다 매번 0.5~1.7바이트 큰 것도 설명이 됩니다. 이 역산은 리프가 100% 찼다고 가정하니 항상 조금 크게 나와요.
pk_small의 34.6바이트는 2장에서 잰 34.0바이트와 0.6바이트 차이로 만납니다. 2장은 레코드 힙 구간을 직접 읽었고 여기서는 가용 공간 상한으로 나눴으니 역산 쪽이 살짝 클 수밖에 없어요. 그 편향까지 포함해 1.7% 안에서 맞았습니다.
클러스터드는 왜 1.2배만 커졌나
표를 보면 세컨더리는 1.80배인데 클러스터드는 5,939 ÷ 4,967 = 1.20배입니다. 같은 28바이트가 얹혔는데 왜 다를까요.
분모가 다르기 때문입니다.
세컨더리 엔트리 34 B → 62 B (28 이 얹히면 1.82배)
클러스터드 행 146 B → 174 B (같은 28 인데 1.19배)
(PK 8 + txno 20 + body 100 + 헤더 5 + 고정 13)예측 1.19배, 실측 1.20배. PK를 키우면 모든 인덱스가 커지지만, 원래 엔트리가 작은 인덱스일수록 더 크게 다칩니다.
PK 없는 테이블이 제일 작습니다
표에서 눈에 걸리는 건 pk_none입니다. 세컨더리 인덱스가 셋 중 가장 작아요(16.6MB). 숨은 rowid가 6바이트라 BIGINT 8바이트보다도 작으니까요.
그렇다고 PK를 빼라는 말은 전혀 아닙니다. 그 6바이트는 조회할 수 없는 값이에요. 그 테이블은 특정 행을 직접 가리킬 방법이 없습니다.
📄 문서 기반 (미검증) — PK 없는 테이블은 행 기반 복제에서 replica가 UPDATE·DELETE 한 건마다 대상 행을 찾느라 테이블을 훑습니다. gh-ost 같은 온라인 스키마 변경 도구는 아예 거부하고요. 직접 재보지는 않았습니다.
여기서 짚을 건 다른 겁니다. PK를 안 만들어도 클러스터드 인덱스는 반드시 생깁니다. 선택지는 “만들까 말까”가 아니라 “내가 정할까 InnoDB가 정하게 둘까”예요.
그래서 UUID를 PK로 쓰면
실무에서 가장 자주 만나는 형태로 옮겨봅시다.
36바이트 PK에 세컨더리 인덱스가 다섯 개 있다면 인덱스 총량이 이렇게 됩니다.
pk_small 4,967 + 5 × 1,125 = 10,592 페이지
pk_char36 5,939 + 5 × 2,026 = 16,069 페이지 → 1.52배세컨더리만 보면 1.80배인데 클러스터드까지 합치면 1.52배예요. 클러스터드는 1.20배밖에 안 커지니까요.
그리고 1.80이라는 배수 자체가 상수가 아닙니다. 엔트리가 키 + PK + 5니까 배수는 세컨더리 키 폭이 정합니다.
| 세컨더리 키 | BIGINT PK | CHAR(36) PK | 배수 |
|---|---|---|---|
BIGINT (8B) | 21 B | 49 B | 2.33배 |
CHAR(20) (이 실험) | 34 B | 62 B | 1.82배 |
| 복합키 (60B) | 73 B | 101 B | 1.38배 |
세컨더리 키가 좁을수록 타격이 큽니다. PK가 엔트리에서 차지하는 비중이 커지니까요. 어느 쪽이든 버퍼풀을 더 먹고(8장) 디스크에서 더 읽어와야 합니다.
줄이는 방법도 같은 원리에서 나옵니다. UUID를 CHAR(36) 문자열 대신 BINARY(16)으로 저장하면 PK가 36바이트에서 16바이트로 줄고, 모든 세컨더리 인덱스가 같이 줄어듭니다.
📄 문서 기반 (미검증) — BINARY(16) 저장은 이 실험에서 직접 재지 않았습니다. 다만 위 표가 보여주는 관계(엔트리 크기 = 키 + PK + 고정 오버헤드)가 그대로라면 산술적으로 따라옵니다.
그리고 36바이트 키에는 크기 말고 문제가 하나 더 있습니다. 실무의 UUID는 값이 무작위라 삽입 위치가 매번 달라지는데, 그게 무엇을 청구하는지는 13·14장에서 따로 잽니다.
클러스터드가 지는 자리
이 설계가 늘 유리한 건 아닙니다. 힙 방식이 더 나은 자리가 셋 있어요.
세컨더리 조회는 반드시 왕복합니다. 힙은 주소를 들고 있으니 한 번에 가는데, InnoDB는 PK를 받아 트리를 다시 내려가야 합니다. 그 값이 얼마인지가 바로 다음 장입니다.
물리 정렬 순서는 하나뿐입니다. PK 순서로 붙어 있는 이득(3장의 리프 체인)은 PK 범위 조회만 받습니다. 두 번째로 자주 쓰는 정렬 기준은 그 혜택을 못 받아요.
PK가 크거나 무작위면 벌금이 한꺼번에 청구됩니다. 이 장에서 본 크기 효과가 모든 세컨더리 인덱스에 동시에 걸리고, 무작위성은 거기에 분할 비용을 더합니다(13·14장).
정리
- InnoDB에는 데이터를 따로 두는 힙이 없습니다. 테이블 자체가 PK로 정렬된 B+tree이고 행은 그 리프에 삽니다. 이게 클러스터드 인덱스입니다.
- 그래서 PK 조회는 트리를 한 번만 내려가면 끝납니다. PK 범위 조회도 3장의 리프 체인을 그대로 탑니다.
- 세컨더리 인덱스는 행의 주소 대신 PK 값을 담습니다. 페이지가 쪼개져도 안 변하는 값이라서요.
- 그 대가로 PK가 크면 모든 세컨더리 인덱스가 커집니다. 실측에서 PK를 8바이트에서 36바이트로 바꾸자 세컨더리 인덱스가 1.80배가 됐고, 엔트리 크기 증가분(29.2바이트)이 PK 증가분(28바이트)을 거의 그대로 따라갔습니다.
- PK를 안 만들면 InnoDB가 6바이트짜리 숨은 순번(
GEN_CLUST_INDEX)으로 트리를 세웁니다. 클러스터드 인덱스는 반드시 존재하고, 정하는 사람만 바뀝니다.
생각해볼 질문
- 세컨더리 인덱스 리프에는 PK가 들어 있습니다. 그렇다면
WHERE txno = ?로 찾은 뒤SELECT *를 하면 페이지를 몇 번 더 만져야 할까요? (7장) pk_char36는 클러스터드 인덱스도 1.2배 커졌습니다. 세컨더리가 1.8배 커진 것에 비하면 덜한데, 왜 차이가 날까요?- PK 조회가 한 번에 끝나는 건 리프에 행이 있기 때문입니다. 그런데 5장에서 본 것처럼 값이 오버플로로 빠졌다면, 그때도 한 번에 끝날까요?