왜 지금 이 얘기를 하나
마이크로서비스 한다고 PK를 죄다 UUID로 깔아두는 게 요즘 기본값처럼 굳어졌다. ID 충돌 안 나고, 클라이언트에서 미리 생성해도 되고, 분산 환경에서 시퀀스 조율 안 해도 되니까. 그래서 별 생각 없이 SQLite 테이블에도 id TEXT PRIMARY KEY로 UUID4를 박아 넣는다.
그런데 이게 SQLite, 그리고 정도 차이는 있지만 MySIQL/InnoDB 계열에서 의외로 비싼 선택이다. 작은 토이 프로젝트일 땐 안 보이다가, 데이터가 수십만~수백만 행 쌓이고 나서 "삽입이 왜 이렇게 느려졌지?" 하고 뒤늦게 깨닫는다. 오늘 hada.io에 올라온 "SQLite에서 UUID 기본 키의 위험성" 글이 딱 이 지점을 찌른다. 실무에서 자주 밟는 지뢰라 정리해둔다.
핵심: 클러스터드 인덱스에 랜덤 키를 박으면 생기는 일
먼저 SQLite 저장 구조부터. SQLite의 일반 테이블은 내부적으로 rowid라는 64비트 정수를 클러스터드 인덱스 키로 쓴다. 즉 데이터가 rowid 순서대로 B-tree에 물리적으로 정렬되어 저장된다는 뜻이다. INTEGER PRIMARY KEY로 선언하면 그 컬럼이 곧 rowid의 별칭이 된다.
여기서 핵심은 "물리적으로 정렬"이다. 정수 rowid는 보통 autoincrement처럼 단조 증가하니까, 새 행은 항상 B-tree의 맨 오른쪽 끝(가장 큰 키)에 붙는다. 페이지가 가득 차면 새 페이지 하나 만들어 끝에 이어 붙이면 끝이다. 디스크 입장에서도 순차 쓰기에 가깝다.
그런데 UUID4를 PK로 쓰면 어떻게 될까? UUID4는 이름 그대로 랜덤이다. 새로 생성한 값이 기존 키들 사이 어디에 끼어들지 예측이 안 된다. 그래서 삽입할 때마다:
- B-tree에서 이 키가 들어갈 위치를 찾아 내려가고(랜덤 탐색)
- 그 중간 페이지에 끼워 넣어야 하니, 페이지가 꽉 찼으면 페이지 분할(split)이 발생하고
- 트리 균형을 맞추느라 재균형(rebalancing) 비용이 든다
비유하자면 정수 PK는 "맨 뒤에 책 한 권 꽂기"고, 랜덤 UUID는 "책장 중간 아무 데나 책 끼워 넣기"다. 후자는 옆 책들을 계속 밀어내야 한다. 게다가 매번 다른 페이지를 건드리니 페이지 캐시 적중률도 떨어진다.
원문에서 언급하는 수치(정수 rowid 기준 100만 행 삽입 시 대략 초당 100만 건 수준, UUID4는 그보다 현저히 느려짐)는 환경에 따라 달라지므로 그대로 믿기보다 본인 워크로드에서 직접 벤치 떠보는 걸 권한다. 다만 "랜덤 키가 순차 키보다 삽입이 느리다"는 방향성 자체는 B-tree 특성상 분명하다.
흔히 나오는 대안이 WITHOUT ROWID 테이블인데, 이건 오히려 함정이 될 수 있다:
-- rowid 안 쓰고 UUID 자체를 클러스터드 키로
CREATE TABLE events (
id TEXT PRIMARY KEY,
payload TEXT
) WITHOUT ROWID;
이렇게 하면 정수 rowid 오버헤드는 없앨 수 있지만, UUID(텍스트)가 그대로 클러스터드 인덱스 키가 되어 데이터 전체가 UUID 순으로 정렬 저장된다. 랜덤 UUID라면 삽입 시 재균형 문제가 더 직접적으로 터진다. 게다가 텍스트 UUID는 36바이트라 키 자체가 뚱뚱해서 페이지당 들어가는 행 수가 줄고 트리가 깊어진다.
실무 관점: 그럼 어떻게 해야 하나
정답은 워크로드에 따라 갈린다. 내가 실무에서 판단하는 기준은 이렇다.
1) 가장 무난한 선택: 정수 PK + UUID는 별도 유니크 컬럼
외부로 노출할 ID는 UUID로 두되, 물리 저장과 조인은 정수 rowid로 처리한다.
CREATE TABLE users (
id INTEGER PRIMARY KEY, -- rowid, 내부 조인/저장용
public_id TEXT NOT NULL UNIQUE, -- 외부 노출용 UUID
name TEXT
);
삽입 성능은 순차 정수의 이점을 그대로 누리고, UUID는 보조 유니크 인덱스에만 영향을 준다. 다만 이 경우에도 public_id 유니크 인덱스는 랜덤이라 인덱스 갱신 비용은 든다. 그래도 데이터 본체가 흔들리는 것보단 훨씬 싸다.
2) 굳이 UUID를 PK로 써야겠다면: 시간 정렬형 UUID
UUID4 대신 UUIDv7처럼 앞부분에 타임스탬프가 들어간 정렬 가능한 ID를 쓰면 랜덤성이 크게 줄어든다. 시간순으로 거의 단조 증가하니 삽입이 다시 "맨 뒤에 붙기"에 가까워진다. UUID의 분산 친화성은 유지하면서 B-tree 재균형 문제를 완화하는 절충안이다. UUIDv7은 표준(RFC 9562)으로 확정됐고 라이브러리 지원도 늘었으니 신규 설계라면 충분히 검토할 만하다.
3) UUID를 굳이 TEXT로 저장하지 마라
이건 의외로 많이 놓친다. UUID를 TEXT(36자 문자열)로 저장하면 BLOB 16바이트 대비 두 배 이상 공간을 먹고, 비교 연산도 문자열 비교라 느리다. 정 UUID를 키로 쓸 거면 16바이트 BLOB으로 저장하는 걸 고려하라. 가독성은 떨어지지만 인덱스/저장 효율은 확실히 낫다. 디버깅 편의 때문에 TEXT를 고집하다 용량 폭증하는 경우를 봤다.
흔한 실수 정리
- "UUID는 충돌 안 나니까 무조건 좋다"고 PK 박기 → 삽입 성능과 저장 효율을 같이 봐야 한다
- 벤치를 작은 데이터로만 돌려보고 OK 판단 → 문제는 데이터가 수십만 행 넘어 페이지 분할이 빈번해질 때 드러난다
WITHOUT ROWID가 만능인 줄 알고 적용 → 랜덤 키라면 오히려 독이다. 작고 짧은 정렬형 키일 때 이득- UUID를 TEXT로 저장하고 인덱스까지 걸어두고 용량/속도 둘 다 손해
트레이드오프
UUID PK의 진짜 가치는 "분산 환경에서 중앙 조율 없이 ID 생성"이다. 이게 정말 필요한 설계가 아니면(예: 모놀리식 SQLite, 단일 라이터 임베디드 DB) 정수 PK가 거의 항상 유리하다. SQLite는 애초에 임베디드/단일 파일 DB라 분산 ID의 필요성이 약한 경우가 많다는 점도 같이 고려하자.
정리
한 줄 요약: SQLite처럼 클러스터드 인덱스 기반 DB에 랜덤 UUID4를 PK로 박으면 삽입마다 B-tree 재균형이 일어나 느려지고 뚱뚱해진다. 정수 PK를 쓰거나, 꼭 UUID여야 한다면 정렬형 UUIDv7 + BLOB 저장을 검토하라.
- 정수 PK + UUID 유니크 컬럼: 대부분의 임베디드/단일 라이터 상황의 무난한 정답
- UUIDv7 PK: 분산 ID가 꼭 필요하면서 삽입 성능도 챙기고 싶을 때
- UUID4 PK: 정말 필요할 때만, 그리고 BLOB로 저장하고 벤치 떠보고
같은 원리가 InnoDB(MySQL)에도 적용된다. InnoDB도 PK가 클러스터드 인덱스라 랜덤 UUID PK는 똑같이 페이지 분할 지옥을 부른다. SQLite만의 얘기로 들리지만 사실 "클러스터드 인덱스 DB 공통의 함정"으로 이해하는 게 맞다.
참고 자료
'Tech_News' 카테고리의 다른 글
| pg_durable: PostgreSQL 안에서 돌아가는 durable execution, 실무에서 쓸만한가 (1) | 2026.06.07 |
|---|---|
| Java 25 가상 스레드, 진짜로 1만 개를 띄워봤다 - 실무 도입 전 알아야 할 것들 (0) | 2026.06.07 |
| Elixir 1.20, 점진적 타입 시스템이 들어왔다 — 동적 언어에 타입을 붙인다는 것의 실체 (0) | 2026.06.04 |
| github.dev에서 한 번 클릭으로 GitHub 토큰이 털린다 - VSCode 웹뷰 버그 뜯어보기 (0) | 2026.06.04 |
| 인스타그램 계정 탈취 사고로 다시 보는 OAuth/소셜 로그인의 함정 (0) | 2026.06.04 |
