인덱스의 리프에 행 데이터가 통째로 들어 있는 구조. 인덱스가 곧 테이블이다. MySQL InnoDB와 PostgreSQL의 가장 큰 구조 차이이고, UUID PK 논쟁의 뿌리다.
두 엔진이 갈리는 지점
[MySQL InnoDB] 테이블 = PK 기준 B+Tree
PK 인덱스 리프 → [행 전체 데이터]
보조 인덱스 리프 → [PK 값] ← 행 위치가 아니라 PK다
- [PostgreSQL] — 테이블 = 순서 없는 힙(heap)
- 모든 인덱스 리프 → [TID = 행의 물리 위치]
- PK조차 별도 인덱스일 뿐이다
InnoDB에서 따라오는 세 가지 결과
① 보조 인덱스 조회는 2단계다.
- WHERE email = 'a@b.com' — (email 보조 인덱스 사용)
- ① email 인덱스 트리를 타고 내려가 PK 값을 얻는다
- ② 그 PK로 PK 트리를 다시 타고 내려가 행을 얻는다
② PK가 크면 모든 보조 인덱스가 함께 커진다. 보조 인덱스 리프가 PK 값을 품기 때문이다. 그래서 PK는 작아야 한다.
③ PK는 단조 증가가 유리하다. 리프가 PK 순으로 정렬돼 있으니, AUTO_INCREMENT처럼 계속 커지는 값은 항상 맨 끝에 추가된다. 반면 랜덤한 UUIDv4를 PK로 쓰면 삽입이 트리 중간 여기저기에서 일어나 페이지 분할과 단편화가 생긴다.
-- 대안: 시간순으로 정렬되는 UUID를 쓴다
-- UUIDv7 · ULID → 앞부분이 타임스탬프라 단조 증가에 가깝다
PostgreSQL의 CLUSTER는 다르다
CLUSTER member USING idx_member_created; -- 한 번 물리 정렬
이건 일회성이다. 이후 삽입은 다시 힙 아무 데나 들어가므로 정렬이 유지되지 않는다. PostgreSQL에 "클러스터형 PK"라는 개념은 없다.
면접 함정
- ❌ "PK 인덱스는 어느 DB나 같다" → InnoDB에서는 데이터 그 자체, PostgreSQL에서는 힙을 가리키는 별도 구조다.
- ❌ "UUID PK는 무조건 나쁘다" → InnoDB에서 랜덤 UUID가 문제이고, PostgreSQL은 힙이라 영향이 훨씬 작다. UUIDv7이면 InnoDB에서도 완화된다.
2단계 조회를 계획에서 확인하기
-- MySQL: 보조 인덱스로 찾고 PK 트리를 다시 타는지 본다
EXPLAIN SELECT name FROM member WHERE email = 'a@b.com';
-- key: idx_email, Extra: (없음) → 2단계 조회 (PK 트리로 한 번 더)
EXPLAIN SELECT id FROM member WHERE email = 'a@b.com';
-- key: idx_email, Extra: Using index → 커버링. PK 가 리프에 있으니 2단계 불필요
두 번째가 InnoDB에서 커버링이 공짜로 되는 경우다. 보조 인덱스 리프가 PK를 이미 품고 있기 때문이다.
PostgreSQL에서 물리 정렬을 흉내 내기
-- 일회성 정렬 (테이블 전체를 다시 쓴다. ACCESS EXCLUSIVE 락)
CLUSTER member USING idx_member_created;
-- 얼마나 정렬돼 있는지 확인 — correlation 이 1에 가까울수록 인덱스 순 = 물리 순
SELECT attname, correlation
FROM pg_stats WHERE tablename = 'member' AND attname = 'created_at';
correlation이 1에 가까우면 인덱스 스캔이 사실상 순차 읽기가 되어 훨씬 빠르다. 시계열처럼 자연히 순서대로 쌓이는 데이터는 별도 조치 없이 이 값이 높다 — BRIN 인덱스가 그런 테이블에서 잘 통하는 이유다.