Theme:

PRIMARY KEY를 INT로 잡느냐 UUID로 잡느냐에 따라 INSERT 성능이 수십 배 차이날 수 있다면, InnoDB는 Primary Key를 어떻게 다루고 있는 걸까요?

InnoDB에서 Primary Key는 단순한 유니크 식별자가 아닙니다. 데이터의 물리적 저장 순서를 결정하는 클러스터형 인덱스이기 때문입니다. 이 구조를 이해하면 PK 설계와 인덱스 전략이 완전히 달라집니다.

개념 정의

클러스터형 인덱스(Clustered Index) 는 인덱스의 리프 노드에 행 데이터 전체가 저장되는 구조입니다. InnoDB에서는 Primary Key가 곧 클러스터형 인덱스입니다.

PLAINTEXT
클러스터형 인덱스: 인덱스 = 데이터 (리프 노드에 행 데이터 전체)
세컨더리 인덱스:  인덱스 → PK 값 (리프 노드에 PK 값만)

한 테이블에 클러스터형 인덱스는 딱 하나만 존재할 수 있습니다. 데이터를 하나의 순서로만 물리적으로 정렬할 수 있기 때문입니다.

InnoDB 페이지 구조

InnoDB는 데이터를 16KB 페이지 단위로 관리합니다.

PLAINTEXT
┌─────────── B+Tree 구조 ───────────┐
│           [루트 노드]               │
│          /    |    \               │
│    [내부 노드] [내부 노드] ...       │
│     /  \      /  \                │
│  [리프]  [리프]  [리프]  [리프]     │
│  ┌───┐  ┌───┐  ┌───┐  ┌───┐     │
│  │실제│  │실제│  │실제│  │실제│     │
│  │데이│  │데이│  │데이│  │데이│     │
│  │터  │  │터  │  │터  │  │터  │     │
│  └───┘  └───┘  └───┘  └───┘     │
│  PK순으로 연결 (양방향 링크드 리스트)  │
└───────────────────────────────────┘
  • 내부 노드(Internal Node): PK 값과 자식 페이지 포인터를 저장
  • 리프 노드(Leaf Node): PK 값과 행 데이터 전체를 저장
  • 리프 노드끼리는 양방향 링크드 리스트로 연결되어 범위 검색에 유리

클러스터형 인덱스의 동작

PK로 검색

SQL
SELECT * FROM users WHERE id = 42;
PLAINTEXT
루트 노드: id=42가 어느 범위인지 확인
  → 내부 노드: 해당 범위의 페이지로 이동
    → 리프 노드: id=42의 행 데이터를 바로 반환

B+Tree 높이가 3이라면 3번의 페이지 접근으로 데이터를 찾습니다. 21억 행 테이블도 보통 B+Tree 높이가 3~4 수준입니다.

PK 범위 검색

SQL
SELECT * FROM users WHERE id BETWEEN 100 AND 200;

리프 노드가 PK 순서로 정렬되어 있고 링크드 리스트로 연결되어 있으므로, id=100의 리프를 찾은 후 순차적으로 읽어나가면 됩니다. 매우 효율적입니다.

세컨더리 인덱스와의 차이

세컨더리 인덱스의 리프 노드에는 행 데이터가 아닌 PK 값이 저장됩니다.

SQL
CREATE INDEX idx_name ON users(name);
PLAINTEXT
세컨더리 인덱스 (idx_name):
  리프 노드: name='김철수' → PK=42

클러스터형 인덱스:
  PK=42 → {id:42, name:'김철수', age:28, email:'...'}

세컨더리 인덱스로 검색하는 과정

SQL
SELECT * FROM users WHERE name = '김철수';
PLAINTEXT
1단계: idx_name에서 name='김철수' 검색 → PK=42 획득
2단계: 클러스터형 인덱스에서 PK=42로 검색 → 행 데이터 반환

이 2단계 과정을 테이블 룩업(Table Lookup) 또는 클러스터 인덱스 룩업이라 합니다. 세컨더리 인덱스 검색이 PK 검색보다 느린 이유입니다.

PK 크기가 세컨더리 인덱스에 미치는 영향

PLAINTEXT
세컨더리 인덱스 리프: [인덱스 컬럼 값] + [PK 값]

PK가 크면(예: UUID 36바이트) 모든 세컨더리 인덱스의 크기도 함께 증가합니다.

PLAINTEXT
INT PK (4바이트):     세컨더리 인덱스 리프 = name + 4바이트
BIGINT PK (8바이트):  세컨더리 인덱스 리프 = name + 8바이트
UUID PK (36바이트):   세컨더리 인덱스 리프 = name + 36바이트

세컨더리 인덱스가 5개 있는 테이블이라면, PK 크기 차이가 5배로 증폭됩니다.

AUTO_INCREMENT vs UUID

AUTO_INCREMENT의 장점

SQL
CREATE TABLE users (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50)
);
  • 순차 삽입: 항상 B+Tree의 끝에 추가되므로 페이지 분할이 거의 없음
  • 작은 크기: 4~8바이트로 세컨더리 인덱스 크기 최소화
  • 범위 검색 효율: 순차적이므로 시간 기반 범위 조회가 빠름

UUID의 문제점

SQL
CREATE TABLE users (
    id CHAR(36) PRIMARY KEY,  -- 'a1b2c3d4-e5f6-...'
    name VARCHAR(50)
);
  • 랜덤 삽입: 이미 꽉 찬 페이지에 삽입 → 페이지 분할 빈번
  • 큰 크기: 36바이트 → 세컨더리 인덱스 비대화
  • 캐시 비효율: 랜덤 접근으로 Buffer Pool 적중률 하락

페이지 분할(Page Split) 시각화

PLAINTEXT
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를 써야 한다면

SQL
-- 방법 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가 없으면 대안을 찾습니다.

PLAINTEXT
1순위: PRIMARY KEY로 지정된 컬럼
2순위: 첫 번째 UNIQUE NOT NULL 인덱스
3순위: InnoDB가 6바이트 내부 Row ID를 자동 생성

3순위의 내부 Row ID는 사용자가 접근할 수 없고, 세컨더리 인덱스의 성능도 떨어집니다. 항상 명시적으로 PK를 지정하는 것이 좋습니다.

복합 PK와 클러스터형 인덱스

SQL
-- 복합 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 순으로 정렬됩니다.

SQL
-- 빠름: 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과 페이지 단편화

삽입/삭제가 빈번하면 페이지 단편화가 발생합니다.

SQL
-- 테이블 단편화 확인
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 지정이 좋습니다
댓글 로딩 중...