클러스터형 인덱스 — PK가 데이터 정렬을 결정하는 이유
PRIMARY KEY를 INT로 잡느냐 UUID로 잡느냐에 따라 INSERT 성능이 수십 배 차이날 수 있다면, InnoDB는 Primary Key를 어떻게 다루고 있는 걸까요?
InnoDB에서 Primary Key는 단순한 유니크 식별자가 아닙니다. 데이터의 물리적 저장 순서를 결정하는 클러스터형 인덱스이기 때문입니다. 이 구조를 이해하면 PK 설계와 인덱스 전략이 완전히 달라집니다.
개념 정의
클러스터형 인덱스(Clustered Index) 는 인덱스의 리프 노드에 행 데이터 전체가 저장되는 구조입니다. InnoDB에서는 Primary Key가 곧 클러스터형 인덱스입니다.
클러스터형 인덱스: 인덱스 = 데이터 (리프 노드에 행 데이터 전체)
세컨더리 인덱스: 인덱스 → PK 값 (리프 노드에 PK 값만)
한 테이블에 클러스터형 인덱스는 딱 하나만 존재할 수 있습니다. 데이터를 하나의 순서로만 물리적으로 정렬할 수 있기 때문입니다.
InnoDB 페이지 구조
InnoDB는 데이터를 16KB 페이지 단위로 관리합니다.
┌─────────── B+Tree 구조 ───────────┐
│ [루트 노드] │
│ / | \ │
│ [내부 노드] [내부 노드] ... │
│ / \ / \ │
│ [리프] [리프] [리프] [리프] │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │실제│ │실제│ │실제│ │실제│ │
│ │데이│ │데이│ │데이│ │데이│ │
│ │터 │ │터 │ │터 │ │터 │ │
│ └───┘ └───┘ └───┘ └───┘ │
│ PK순으로 연결 (양방향 링크드 리스트) │
└───────────────────────────────────┘
- 내부 노드(Internal Node): PK 값과 자식 페이지 포인터를 저장
- 리프 노드(Leaf Node): PK 값과 행 데이터 전체를 저장
- 리프 노드끼리는 양방향 링크드 리스트로 연결되어 범위 검색에 유리
클러스터형 인덱스의 동작
PK로 검색
SELECT * FROM users WHERE id = 42;
루트 노드: id=42가 어느 범위인지 확인
→ 내부 노드: 해당 범위의 페이지로 이동
→ 리프 노드: id=42의 행 데이터를 바로 반환
B+Tree 높이가 3이라면 3번의 페이지 접근으로 데이터를 찾습니다. 21억 행 테이블도 보통 B+Tree 높이가 3~4 수준입니다.
PK 범위 검색
SELECT * FROM users WHERE id BETWEEN 100 AND 200;
리프 노드가 PK 순서로 정렬되어 있고 링크드 리스트로 연결되어 있으므로, id=100의 리프를 찾은 후 순차적으로 읽어나가면 됩니다. 매우 효율적입니다.
세컨더리 인덱스와의 차이
세컨더리 인덱스의 리프 노드에는 행 데이터가 아닌 PK 값이 저장됩니다.
CREATE INDEX idx_name ON users(name);
세컨더리 인덱스 (idx_name):
리프 노드: name='김철수' → PK=42
클러스터형 인덱스:
PK=42 → {id:42, name:'김철수', age:28, email:'...'}
세컨더리 인덱스로 검색하는 과정
SELECT * FROM users WHERE name = '김철수';
1단계: idx_name에서 name='김철수' 검색 → PK=42 획득
2단계: 클러스터형 인덱스에서 PK=42로 검색 → 행 데이터 반환
이 2단계 과정을 테이블 룩업(Table Lookup) 또는 클러스터 인덱스 룩업이라 합니다. 세컨더리 인덱스 검색이 PK 검색보다 느린 이유입니다.
PK 크기가 세컨더리 인덱스에 미치는 영향
세컨더리 인덱스 리프: [인덱스 컬럼 값] + [PK 값]
PK가 크면(예: UUID 36바이트) 모든 세컨더리 인덱스의 크기도 함께 증가합니다.
INT PK (4바이트): 세컨더리 인덱스 리프 = name + 4바이트
BIGINT PK (8바이트): 세컨더리 인덱스 리프 = name + 8바이트
UUID PK (36바이트): 세컨더리 인덱스 리프 = name + 36바이트
세컨더리 인덱스가 5개 있는 테이블이라면, PK 크기 차이가 5배로 증폭됩니다.
AUTO_INCREMENT vs UUID
AUTO_INCREMENT의 장점
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50)
);
- 순차 삽입: 항상 B+Tree의 끝에 추가되므로 페이지 분할이 거의 없음
- 작은 크기: 4~8바이트로 세컨더리 인덱스 크기 최소화
- 범위 검색 효율: 순차적이므로 시간 기반 범위 조회가 빠름
UUID의 문제점
CREATE TABLE users (
id CHAR(36) PRIMARY KEY, -- 'a1b2c3d4-e5f6-...'
name VARCHAR(50)
);
- 랜덤 삽입: 이미 꽉 찬 페이지에 삽입 → 페이지 분할 빈번
- 큰 크기: 36바이트 → 세컨더리 인덱스 비대화
- 캐시 비효율: 랜덤 접근으로 Buffer Pool 적중률 하락
페이지 분할(Page Split) 시각화
AUTO_INCREMENT (순차 삽입):
[1,2,3,4,5] → [1,2,3,4,5][6,7,8,9,10] → 끝에만 추가, 분할 최소
UUID (랜덤 삽입):
[a,c,f,h,k] → 'b' 삽입 → [a,b,c] [f,h,k] → 기존 페이지를 분할!
데이터 이동 + 상위 노드 업데이트 + 공간 낭비
UUID를 써야 한다면
-- 방법 1: UUID v7 (시간 기반 정렬 가능, 2024년 표준화)
-- 앞부분이 타임스탬프이므로 대체로 순차적
-- 방법 2: BINARY(16)으로 저장하여 크기 절약
CREATE TABLE users (
id BINARY(16) PRIMARY KEY,
name VARCHAR(50)
);
-- UUID 문자열 → BINARY 변환
INSERT INTO users VALUES (UUID_TO_BIN(UUID(), 1), '김철수');
-- UUID_TO_BIN의 두 번째 인자 1: 시간 부분을 앞으로 재배치 (순차성 향상)
SELECT BIN_TO_UUID(id, 1) AS id, name FROM users;
성능 비교 (대략적 벤치마크)
| PK 타입 | INSERT 100만 행 | 인덱스 크기 | 페이지 분할 |
|---|---|---|---|
| INT AUTO_INCREMENT | ~30초 | 작음 | 거의 없음 |
| BIGINT AUTO_INCREMENT | ~35초 | 중간 | 거의 없음 |
| UUID (CHAR 36) | ~120초 | 매우 큼 | 매우 빈번 |
| UUID (BINARY 16) | ~80초 | 중간 | 빈번 |
| UUID v7 (BINARY 16) | ~45초 | 중간 | 적음 |
PK가 없으면 어떻게 되는가
InnoDB는 반드시 클러스터형 인덱스가 필요하므로, PK가 없으면 대안을 찾습니다.
1순위: PRIMARY KEY로 지정된 컬럼
2순위: 첫 번째 UNIQUE NOT NULL 인덱스
3순위: InnoDB가 6바이트 내부 Row ID를 자동 생성
3순위의 내부 Row ID는 사용자가 접근할 수 없고, 세컨더리 인덱스의 성능도 떨어집니다. 항상 명시적으로 PK를 지정하는 것이 좋습니다.
복합 PK와 클러스터형 인덱스
-- 복합 PK: (user_id, order_date) 순서로 물리적 정렬
CREATE TABLE user_orders (
user_id INT NOT NULL,
order_date DATE NOT NULL,
amount DECIMAL(10,2),
PRIMARY KEY (user_id, order_date)
);
이 경우 데이터가 user_id 먼저, 그 안에서 order_date 순으로 정렬됩니다.
-- 빠름: PK 선두 컬럼으로 검색
SELECT * FROM user_orders WHERE user_id = 1;
-- 빠름: PK 전체 컬럼으로 범위 검색
SELECT * FROM user_orders WHERE user_id = 1 AND order_date >= '2026-01-01';
-- 느림: PK 두 번째 컬럼만으로 검색 (최좌선 접두사 미충족)
SELECT * FROM user_orders WHERE order_date >= '2026-01-01';
OPTIMIZE TABLE과 페이지 단편화
삽입/삭제가 빈번하면 페이지 단편화가 발생합니다.
-- 테이블 단편화 확인
SELECT TABLE_NAME, DATA_LENGTH, DATA_FREE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'my_db' AND TABLE_NAME = 'users';
-- DATA_FREE가 크면 단편화가 심한 것
-- 단편화 해소 (테이블 재구성)
ALTER TABLE users ENGINE=InnoDB;
-- 또는
OPTIMIZE TABLE users;
주의: OPTIMIZE TABLE은 테이블을 잠그므로 운영 중에는 주의가 필요합니다.
정리
- InnoDB에서 Primary Key = 클러스터형 인덱스이며, 리프 노드에 행 데이터 전체가 저장됩니다
- 세컨더리 인덱스는 PK 값을 통해 클러스터형 인덱스를 다시 탐색하는 테이블 룩업이 필요합니다
- PK 크기가 크면 모든 세컨더리 인덱스도 비대해지므로, 가능한 작은 PK를 선택합니다
- AUTO_INCREMENT는 순차 삽입으로 페이지 분할이 최소화되어 INSERT 성능이 좋습니다
- UUID를 써야 한다면 BINARY(16) + UUID_TO_BIN(..., 1) 으로 크기와 순차성을 개선합니다
- PK를 명시하지 않으면 InnoDB가 내부 Row ID를 생성하지만, 항상 명시적 PK 지정이 좋습니다