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은 테이블을 잠그므로 운영 중에는 주의가 필요합니다.

주의할 점

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 자동 생성, 비권장
댓글 로딩 중...