클러스터형 인덱스 — 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은 테이블을 잠그므로 운영 중에는 주의가 필요합니다.
주의할 점
UUID PK는 INSERT 성능을 수십 배 떨어뜨릴 수 있다
랜덤 UUID는 이미 꽉 찬 중간 페이지에 삽입을 유발하여 빈번한 페이지 분할이 발생합니다. 100만 행 INSERT 기준으로 AUTO_INCREMENT 대비 4배 이상 느려질 수 있습니다. UUID를 써야 한다면 UUID v7(시간 기반) 또는 UUID_TO_BIN(UUID(), 1)로 순차성을 확보해야 합니다.
PK가 크면 세컨더리 인덱스 전체가 비대해진다
세컨더리 인덱스의 리프 노드에는 PK 값이 저장됩니다. PK가 CHAR(36) UUID라면, 세컨더리 인덱스 5개를 가진 테이블에서 PK 크기 차이가 5배로 증폭됩니다. Buffer Pool 메모리 소비도 비례하여 증가합니다.
PK를 명시하지 않으면 숨겨진 Row ID가 생성된다
InnoDB는 반드시 클러스터형 인덱스가 필요합니다. PK가 없으면 6바이트 내부 Row ID를 자동 생성하는데, 이 값에는 사용자가 접근할 수 없고 세컨더리 인덱스의 효율도 떨어집니다.
정리
| 항목 | 설명 |
|---|---|
| 클러스터형 인덱스 | PK = 데이터 저장 순서, 리프 노드에 행 전체 저장 |
| 세컨더리 인덱스 | 리프 노드에 PK 값 저장, 테이블 룩업 필요 |
| AUTO_INCREMENT | 순차 삽입, 페이지 분할 최소, INSERT 성능 최적 |
| UUID 문제 | 랜덤 삽입 → 페이지 분할 빈번, 크기도 큼 |
| UUID 대안 | UUID v7 또는 BINARY(16) + UUID_TO_BIN(..., 1) |
| PK 미지정 시 | 내부 Row ID 자동 생성, 비권장 |