InnoDB clustered index: Primary Key가 데이터 배치를 결정하는 원리
InnoDB clustered index에서 Primary Key가 행의 물리적 배치, secondary index 구조, 페이지 분할, 조회 비용에 어떤 영향을 주는지 운영 관점에서 정리한다.
1. 왜 clustered index를 이해해야 하는가
MySQL에서 애플리케이션 개발자는 보통 테이블을 행의 집합으로 생각한다. 그러나 InnoDB는 테이블을 추상적인 행 묶음으로 저장하지 않는다. Primary Key 기준으로 정렬된 B+Tree의 leaf page 자체가 곧 테이블 데이터다. 이 구조를 InnoDB의 clustered index라고 부른다.
이 사실은 단순한 내부 구현 디테일이 아니다. 실제 운영에서 다음 문제들이 모두 clustered index와 연결된다.
- 왜 Primary Key가 랜덤하면 INSERT 성능이 흔들리고 페이지 분할이 늘어나는가
- 왜 긴 Primary Key는 secondary index까지 비대하게 만드는가
- 왜 secondary index만으로 원하는 컬럼을 다 읽지 못하면 추가 random lookup이 발생하는가
- 왜
OPTIMIZE TABLE이나 테이블 재구성이 필요한 단편화 문제를 PK 설계 단계에서 상당 부분 예방할 수 있는가 - 왜 Aurora MySQL에서도 스토리지 계층은 다르지만 InnoDB 논리 구조 이해가 여전히 중요한가
많은 운영 장애는 “인덱스가 있느냐 없느냐”보다 “어떤 Primary Key를 골랐느냐”에서 시작된다. 이 글은 clustered index의 내부 원리와 실무적 함의를 함께 설명한다.
2. clustered index의 정의
InnoDB에서 clustered index는 다음 의미를 가진다.
- 테이블의 실제 행 데이터가 clustered index leaf page에 저장된다.
- leaf page는 Primary Key 순서로 정렬된다.
- 따라서 Primary Key는 단순한 논리 키가 아니라 행 배치 순서를 결정한다.
- Primary Key가 없으면 InnoDB는 적절한 unique not null key를 선택하거나, 그것도 없으면 내부 hidden row id를 사용한다.
다시 말해 InnoDB에서는 “테이블”과 “Primary Key B+Tree”를 분리해서 생각하면 오해가 생긴다. MyISAM처럼 데이터 파일과 인덱스 파일이 별개인 구조를 떠올리면 안 된다. InnoDB에서는 clustered index leaf가 곧 데이터다.
3. 큰 그림: secondary index가 clustered index를 다시 찾는 구조
secondary index를 함께 이해해야 clustered index의 의미가 선명해진다.
flowchart TD
Q[쿼리 실행] --> O{어떤 인덱스로 접근하는가}
O -->|Primary Key 조건| PK[clustered index 탐색]
O -->|secondary index 조건| SK[secondary index 탐색]
SK --> PKLOOKUP[secondary leaf에 저장된 PK 값 획득]
PKLOOKUP --> PK
PK --> ROW[leaf page에서 실제 행 읽기]
핵심은 secondary index leaf page에 행 전체가 아니라 secondary key + Primary Key 값이 저장된다는 점이다. 따라서 secondary index로 후보 행을 찾은 뒤에도, 필요한 컬럼이 secondary index에 모두 들어 있지 않으면 Primary Key를 이용해 clustered index를 다시 찾아가야 한다.
이 동작은 흔히 bookmark lookup 또는 clustered index lookup처럼 설명된다. 이름보다 중요한 것은 비용 구조다.
- secondary index만으로 끝나는 쿼리: 상대적으로 저렴하다.
- secondary index에서 많은 행을 찾고 매번 clustered index를 다시 읽는 쿼리: random I/O와 buffer pool 압박이 커진다.
- Primary Key가 길수록 secondary index leaf record 자체가 커진다.
4. InnoDB가 실제로 무엇을 Primary Key로 삼는가
운영 중에는 “테이블에 PK를 안 만들었는데도 InnoDB가 잘 돌아간다”는 이유로 Primary Key 설계를 가볍게 보는 경우가 있다. 하지만 InnoDB는 clustered index 없이 동작하지 않는다. 다만 사용자가 명시하지 않았을 뿐이다.
우선순위는 대략 다음과 같다.
- 사용자가 정의한
PRIMARY KEY - 모든 컬럼이
NOT NULL인 첫 번째UNIQUE인덱스 - 위 둘 다 없으면 InnoDB 내부 hidden row id
Primary Key를 명시하지 않으면 다음 문제가 생긴다.
- 행 배치 기준이 애플리케이션에 명시적으로 드러나지 않는다.
- replication, CDC, 애플리케이션 upsert, row 기반 변경 추적에서 식별 비용이 커진다.
- hidden row id는 사용자가 제어할 수 없고 secondary index에도 함께 따라다닌다.
- 결국 “설계하지 않은 clustered index”를 떠안게 된다.
따라서 InnoDB 운영에서는 PK를 생략하지 않는 것이 기본 원칙이다.
5. Primary Key가 행 배치를 결정한다는 말의 정확한 의미
행이 디스크에 물리적으로 100% 연속 배치된다는 뜻으로 오해하면 안 된다. InnoDB는 page, extent, split, merge, free space 관리, redo/undo, purge의 영향을 받기 때문에 시간이 지나면 완전한 순차 배열은 깨진다. 그러나 중요한 것은 B+Tree의 논리적 순서와 leaf page 구성 기준이 Primary Key라는 점이다.
이 구조의 결과는 다음과 같다.
AUTO_INCREMENT처럼 단조 증가하는 PK는 보통 트리의 오른쪽 끝으로 INSERT가 몰린다.- UUID v4 같은 완전 랜덤 PK는 트리 전역 여러 페이지에 분산 삽입될 수 있다.
- 랜덤 삽입이 많아지면 page split, page reorganization, dirty page 증가, buffer pool churn이 커진다.
- 범위 조회는 PK 순서에 맞는 데이터에서 매우 유리하다.
즉, Primary Key는 단순 식별자가 아니라 쓰기 패턴과 읽기 지역성(locality)을 함께 결정하는 구조적 선택이다.
6. 메타데이터로 보는 clustered index의 기본 구조
다음 예제는 InnoDB 테이블에서 PRIMARY와 secondary index가 함께 존재하는 가장 기본적인 형태를 보여준다.
DROP TABLE IF EXISTS clustered_index_demo;
CREATE TABLE clustered_index_demo (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
status VARCHAR(20) NOT NULL,
payload VARCHAR(100) NOT NULL,
PRIMARY KEY (id),
KEY ix_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB;
SELECT INDEX_NAME,
SEQ_IN_INDEX,
COLUMN_NAME,
NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'clustered_index_demo'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
DROP TABLE clustered_index_demo;
실행 결과(MySQL 8.0.46):
mysql> DROP TABLE IF EXISTS clustered_index_demo;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE clustered_index_demo (
-> id BIGINT NOT NULL,
-> tenant_id BIGINT NOT NULL,
-> created_at DATETIME NOT NULL,
-> status VARCHAR(20) NOT NULL,
-> payload VARCHAR(100) NOT NULL,
-> PRIMARY KEY (id),
-> KEY ix_tenant_created (tenant_id, created_at)
-> ) ENGINE=InnoDB;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT INDEX_NAME,
-> SEQ_IN_INDEX,
-> COLUMN_NAME,
-> NON_UNIQUE
-> FROM information_schema.STATISTICS
-> WHERE TABLE_SCHEMA = DATABASE()
-> AND TABLE_NAME = 'clustered_index_demo'
-> ORDER BY INDEX_NAME, SEQ_IN_INDEX;
+-------------------+--------------+-------------+------------+
| INDEX_NAME | SEQ_IN_INDEX | COLUMN_NAME | NON_UNIQUE |
+-------------------+--------------+-------------+------------+
| ix_tenant_created | 1 | tenant_id | 1 |
| ix_tenant_created | 2 | created_at | 1 |
| PRIMARY | 1 | id | 0 |
+-------------------+--------------+-------------+------------+
3 rows in set (0.00 sec)
mysql> DROP TABLE clustered_index_demo;
Query OK, 0 rows affected (0.01 sec)
이 결과에서 PRIMARY는 clustered index이고, ix_tenant_created는 secondary index다. 그러나 중요한 사실은 메타데이터 목록에 직접 드러나지 않는다. PRIMARY는 단순히 “유일 인덱스 하나”가 아니라 실제 테이블 row 저장 순서를 결정하는 축이다.
7. secondary index가 왜 Primary Key를 다시 들고 다니는가
다음 예제는 secondary index를 타고 들어가더라도 실제로는 Primary Key가 함께 필요하다는 점을 보여주기 위한 작은 재현이다.
DROP TABLE IF EXISTS clustered_lookup_demo;
CREATE TABLE clustered_lookup_demo (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
status VARCHAR(20) NOT NULL,
payload VARCHAR(100) NOT NULL,
PRIMARY KEY (id),
KEY ix_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB;
INSERT INTO clustered_lookup_demo
VALUES
(1001, 10, '2026-05-27 09:00:00', 'OPEN', '주문 접수'),
(1002, 10, '2026-05-27 09:01:00', 'PAID', '결제 완료'),
(1003, 10, '2026-05-27 09:02:00', 'PACKED', '포장 완료'),
(2001, 20, '2026-05-27 09:03:00', 'OPEN', '신규 주문');
EXPLAIN SELECT payload
FROM clustered_lookup_demo
WHERE tenant_id = 10
ORDER BY created_at;
SELECT id, tenant_id, created_at, payload
FROM clustered_lookup_demo
WHERE tenant_id = 10
ORDER BY created_at;
DROP TABLE clustered_lookup_demo;
실행 결과(MySQL 8.0.46):
mysql> DROP TABLE IF EXISTS clustered_lookup_demo;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE clustered_lookup_demo (
-> id BIGINT NOT NULL,
-> tenant_id BIGINT NOT NULL,
-> created_at DATETIME NOT NULL,
-> status VARCHAR(20) NOT NULL,
-> payload VARCHAR(100) NOT NULL,
-> PRIMARY KEY (id),
-> KEY ix_tenant_created (tenant_id, created_at)
-> ) ENGINE=InnoDB;
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO clustered_lookup_demo
-> VALUES
-> (1001, 10, '2026-05-27 09:00:00', 'OPEN', '주문 접수'),
-> (1002, 10, '2026-05-27 09:01:00', 'PAID', '결제 완료'),
-> (1003, 10, '2026-05-27 09:02:00', 'PACKED', '포장 완료'),
-> (2001, 20, '2026-05-27 09:03:00', 'OPEN', '신규 주문');
Query OK, 4 rows affected (0.01 sec)
Records: 4 Duplicates: 0 Warnings: 0
mysql> EXPLAIN SELECT payload
-> FROM clustered_lookup_demo
-> WHERE tenant_id = 10
-> ORDER BY created_at;
+----+-------------+-----------------------+------------+------+-------------------+-------------------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------------------+------------+------+-------------------+-------------------+---------+-------+------+----------+-------+
| 1 | SIMPLE | clustered_lookup_demo | NULL | ref | ix_tenant_created | ix_tenant_created | 8 | const | 3 | 100.00 | NULL |
+----+-------------+-----------------------+------------+------+-------------------+-------------------+---------+-------+------+----------+-------+
1 row in set (0.00 sec)
mysql> SELECT id, tenant_id, created_at, payload
-> FROM clustered_lookup_demo
-> WHERE tenant_id = 10
-> ORDER BY created_at;
+------+-----------+---------------------+---------------+
| id | tenant_id | created_at | payload |
+------+-----------+---------------------+---------------+
| 1001 | 10 | 2026-05-27 09:00:00 | 주문 접수 |
| 1002 | 10 | 2026-05-27 09:01:00 | 결제 완료 |
| 1003 | 10 | 2026-05-27 09:02:00 | 포장 완료 |
+------+-----------+---------------------+---------------+
3 rows in set (0.00 sec)
mysql> DROP TABLE clustered_lookup_demo;
Query OK, 0 rows affected (0.00 sec)
이 예제에서 옵티마이저는 ix_tenant_created를 사용할 가능성이 높다. 하지만 payload는 secondary index에 포함되어 있지 않으므로 secondary index leaf에서 PK 값을 얻은 뒤 clustered index를 다시 조회해야 한다. 데이터 건수가 커질수록 이 추가 lookup 비용은 무시하기 어렵다.
실무에서는 다음 질문으로 이어진다.
- 조회 패턴상 covering index가 필요한가
SELECT *가 과도하게 많아 clustered lookup을 남발하는가- PK가 너무 길어 secondary index를 비대하게 만드는가
- 자주 쓰는 정렬과 필터가 clustered order와 얼마나 어긋나는가
8. Primary Key 형태에 따라 INSERT 경로가 달라진다
clustered index의 가장 큰 운영상 차이는 INSERT 패턴에서 드러난다.
8.1 단조 증가 PK
예: BIGINT AUTO_INCREMENT
장점:
- 대체로 B+Tree 오른쪽 끝에 쓰기가 집중된다.
- page split 빈도가 상대적으로 낮다.
- buffer pool과 redo log 관점에서 쓰기 지역성이 좋다.
- secondary index leaf에 저장되는 PK 값도 작고 고정 길이여서 인덱스 효율이 좋다.
주의점:
- 매우 높은 동시 INSERT 환경에서는 마지막 페이지 경합이 관찰될 수 있다.
- 샤딩·멀티리전·비동기 병합 구조에서는 단순 증가 PK만으로 전역 고유성 설계가 어려울 수 있다.
8.2 랜덤 PK
예: UUID v4 문자열
장점:
- 외부 노출 ID로 쓰기 쉽고 분산 생성이 간단하다.
- ID 예측이 어렵다.
단점:
- INSERT가 트리 전역 여러 위치에 분산된다.
- page split과 random write 성향이 강해진다.
- PK 길이가 길어 clustered leaf row와 모든 secondary index leaf가 함께 비대해진다.
- buffer pool에 실리는 유효 레코드 수가 줄어든다.
8.3 절충형 PK
예: snowflake 계열 정수, 시간 정렬형 UUID v7, ULID를 바이너리로 저장
이 접근은 “분산 생성 가능성”과 “삽입 지역성” 사이 절충을 노린다. 완벽한 답은 아니지만 완전 랜덤 PK보다 clustered index에 훨씬 유리한 경우가 많다.
9. 긴 Primary Key가 secondary index를 비싸게 만드는 이유
InnoDB secondary index leaf에는 secondary key와 함께 Primary Key 값이 저장된다. 따라서 PK가 길수록 secondary index도 함께 커진다. 이 점은 많은 시스템에서 과소평가된다.
예를 들어 다음 두 설계를 비교해 보자.
- 설계 A:
PRIMARY KEY (id BIGINT) - 설계 B:
PRIMARY KEY (order_uuid CHAR(36))
secondary index가 5개 있는 큰 테이블이라면, 설계 B는 행 데이터만 느려지는 것이 아니라 secondary index 5개 leaf page에도 더 큰 PK 조각이 반복 저장된다. 그 결과는 다음과 같다.
- 인덱스 크기 증가
- buffer pool 효율 저하
- cache miss 시 I/O 증가
- rebuild와 backup 시간 증가
- DML 시 인덱스 유지 비용 증가
따라서 외부 API 식별자와 내부 clustered PK를 분리하는 설계가 실용적일 때가 많다. 예를 들어 내부 PK는 BIGINT, 외부 노출용 UUID는 별도 unique key로 두는 방식이다.
10. clustered index와 범위 조회의 상관관계
clustered index는 범위 조회에서 특히 강력하다. Primary Key 순서와 조회 패턴이 맞아떨어지면 leaf page를 비교적 연속적으로 읽을 수 있기 때문이다.
예를 들어 시계열 데이터에서 다음 조건이 자주 나온다고 하자.
WHERE id BETWEEN ...WHERE created_at BETWEEN ...- 최근 데이터 순차 스캔
이때 PK 설계가 실제 조회 패턴과 얼마나 정렬 방향을 공유하는지가 중요하다. 다만 여기서 흔한 실수는 모든 조회 패턴을 clustered order에 맞추려고 하는 것이다. clustered index는 테이블당 하나뿐이다. 따라서 가장 핵심적인 쓰기 패턴과 주요 조회 패턴을 기준으로 결정해야 한다.
11. page split과 단편화는 왜 운영 비용이 되는가
clustered index에서 삽입 위치가 기존 page 중간으로 자주 들어오면 page split이 발생할 수 있다. 이때 InnoDB는 페이지를 둘로 나누고 레코드를 재배치해야 한다. split 자체는 정상 동작이지만, 빈번해지면 다음 비용이 누적된다.
- 추가 페이지 할당
- 더 많은 dirty page 생성
- redo log 기록 증가
- leaf page 밀도 저하
- 범위 조회 시 더 많은 페이지 접근
특히 랜덤 PK 대량 INSERT, 잦은 중간 삽입, 넓은 row payload 조합에서는 split 영향이 더 잘 드러난다.
운영에서 이것이 곧바로 “항상 재구성해야 한다”는 뜻은 아니다. 중요한 것은 split을 사후 정리보다 사전 설계로 줄이는 것이다. PK를 바꾸는 비용은 크지만, 잘못 설계된 clustered index를 서비스 중에 뒤늦게 교정하는 비용은 더 크다.
12. Aurora MySQL에서는 무엇이 같고 무엇이 다른가
Aurora MySQL은 스토리지 복제와 내구성 계층이 Community MySQL과 다르다. redo 처리, 분산 스토리지, 장애 복구 시간 특성에서 차이가 있다. 그러나 InnoDB 논리 구조 자체가 사라지는 것은 아니다.
여전히 중요한 사실은 다음과 같다.
- clustered index leaf가 데이터 저장의 논리 기준이다.
- PK 길이는 secondary index 크기에 영향을 준다.
- 랜덤 PK는 page split과 cache locality에 불리하다.
- covering index 여부는 lookup 비용을 좌우한다.
즉, Aurora를 쓴다고 해서 “PK 설계는 덜 중요하다”는 결론은 틀리다. 달라지는 것은 물리 스토리지 계층의 구현이지, InnoDB가 B+Tree로 데이터를 조직하는 원리는 아니다.
13. 실무 점검 SQL: 테이블의 PK와 secondary index 구성을 먼저 확인하라
운영 중 특정 테이블의 clustered index 영향을 점검할 때는 먼저 PK와 secondary index 구조를 같이 보는 것이 좋다.
DROP TABLE IF EXISTS clustered_audit_demo;
CREATE TABLE clustered_audit_demo (
id BIGINT NOT NULL,
order_no VARCHAR(30) NOT NULL,
customer_id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
total_amount DECIMAL(12,2) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY ix_customer_created (customer_id, created_at)
) ENGINE=InnoDB;
SELECT INDEX_NAME,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS index_columns,
MAX(NON_UNIQUE) AS non_unique
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'clustered_audit_demo'
GROUP BY INDEX_NAME
ORDER BY INDEX_NAME;
DROP TABLE clustered_audit_demo;
실행 결과(MySQL 8.0.46):
mysql> DROP TABLE IF EXISTS clustered_audit_demo;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE clustered_audit_demo (
-> id BIGINT NOT NULL,
-> order_no VARCHAR(30) NOT NULL,
-> customer_id BIGINT NOT NULL,
-> created_at DATETIME NOT NULL,
-> total_amount DECIMAL(12,2) NOT NULL,
-> PRIMARY KEY (id),
-> UNIQUE KEY uk_order_no (order_no),
-> KEY ix_customer_created (customer_id, created_at)
-> ) ENGINE=InnoDB;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT INDEX_NAME,
-> GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS index_columns,
-> MAX(NON_UNIQUE) AS non_unique
-> FROM information_schema.STATISTICS
-> WHERE TABLE_SCHEMA = DATABASE()
-> AND TABLE_NAME = 'clustered_audit_demo'
-> GROUP BY INDEX_NAME
-> ORDER BY INDEX_NAME;
+---------------------+-------------------------+------------+
| INDEX_NAME | index_columns | non_unique |
+---------------------+-------------------------+------------+
| ix_customer_created | customer_id, created_at | 1 |
| PRIMARY | id | 0 |
| uk_order_no | order_no | 0 |
+---------------------+-------------------------+------------+
3 rows in set (0.00 sec)
mysql> DROP TABLE clustered_audit_demo;
Query OK, 0 rows affected (0.00 sec)
이 정도 기본 점검만으로도 다음 판단이 가능하다.
- PK가 짧고 안정적인가
- 외부 식별자까지 PK에 직접 넣어 비대하게 만들고 있지 않은가
- secondary index가 PK lookup을 너무 자주 유발할 구조는 아닌가
- 정렬·범위 조회와 clustered order가 얼마나 맞는가
14. 흔한 오해
오해 1. Primary Key는 유일성만 보장하면 된다
틀렸다. InnoDB에서는 유일성뿐 아니라 저장 순서, split 패턴, secondary index 크기, lookup 비용까지 결정한다.
오해 2. UUID를 PK로 써도 요즘 스토리지는 빨라서 문제 없다
부분적으로만 맞다. 빠른 스토리지는 증상을 완화할 수 있지만, B+Tree split과 cache locality 문제 자체를 없애지는 못한다. 특히 secondary index 비대화는 스토리지 속도만으로 해결되지 않는다.
오해 3. PK가 없으면 InnoDB가 알아서 좋은 키를 만든다
InnoDB는 clustered index를 만들지만, 그것이 애플리케이션과 운영에 좋은 설계라는 뜻은 아니다. hidden row id는 통제 불가능한 기본값일 뿐이다.
오해 4. secondary index만 잘 만들면 PK는 아무거나 괜찮다
틀렸다. secondary index는 결국 PK를 들고 다닌다. 따라서 PK의 폭과 성질은 secondary index 비용에도 직접 반영된다.
15. 설계 체크리스트
다음 항목은 InnoDB 테이블 설계 검토 시 clustered index 관점에서 반드시 확인할 만한 기준이다.
- 모든 InnoDB 테이블에 명시적
PRIMARY KEY
16. 결론
InnoDB에서 Primary Key는 단순 식별자가 아니다. clustered index의 정렬 기준이자 행 배치 기준이며, secondary index 구조와 조회 비용까지 결정하는 핵심 설계 축이다.
좋은 clustered index 설계는 다음 특징을 가진다.
- 짧다.
- 안정적이다.
- 가능하면 단조 증가 또는 시간 정렬형이다.
- 실제 조회·쓰기 패턴과 크게 어긋나지 않는다.
반대로 잘못된 PK 설계는 시간이 갈수록 page split, 단편화, secondary index 비대화, buffer pool 비효율, lookup 증가로 돌아온다. 인덱스 튜닝은 사후에 할 수 있어도 clustered index의 방향 자체는 테이블 구조에 깊게 박힌다. 그래서 InnoDB 설계에서 가장 먼저 검토해야 할 인덱스는 secondary index가 아니라 Primary Key다.
다음 글에서는 이 논의를 이어서, InnoDB secondary index가 Primary Key를 참조하는 방식과 그 비용 구조를 더 구체적으로 다룬다.