데이터베이스 PK로 UUIDv7가 UUIDv4를 이기는 이유
약 10년간의 통념은 "PK는 auto-increment 정수, UUID는 DB 성능을 망친다"였습니다. 맞는 결론이었지만 "UUID는 느리다"는 일반화가 과했습니다. 진짜 범인은 UUIDv4의 완전한 무작위성이었지 UUID 자체가 아니었습니다. UUIDv7(RFC 9562, 2024년 5월에 RFC 4122를 대체)은 상위 비트에 Unix-millisecond 타임스탬프를, 하위에 랜덤 비트를 둡니다. 1ms 간격으로 생성된 두 값은 시간으로 정렬되고, 같은 ms 안에서 생성된 두 값은 그 ms 내에서 무작위로 정렬됩니다. 삽입 패턴이 완전히 달라집니다.
이게 중요한 이유는 B-tree입니다. PostgreSQL, MySQL/InnoDB, SQL Server 모두 PK 순서로 B-tree(B+ tree)에 행을 저장합니다. 기존 모든 키보다 큰 PK 값을 insert하면 항상 가장 오른쪽 리프에 닿습니다. 그 페이지는 buffer pool에 hot 상태이고, 다음 insert도 같은 페이지에 들어가서 디스크 IO가 거의 필요 없습니다. UUIDv4는 매 insert가 무작위 리프에 닿습니다 — 매번 디스크에서 새 페이지를 끌어오고, 더럽히고, 다른 페이지를 evict하고, dirty page를 다시 써내야 합니다. 인덱스가 RAM에 다 들어가지 않을 만큼 큰 테이블에서는 UUIDv4 insert가 auto-increment 대비 10~50배 느릴 수 있고, 무작위 insert가 트리 곳곳에서 page split을 일으켜 B-tree가 심하게 fragment 됩니다.
UUIDv7은 이걸 제거합니다. insert가 시간 순서이므로 인덱스의 작은 hot 영역에 들어갑니다 — 항상 가장 오른쪽 리프는 아니지만(랜덤 하위 비트로 약간의 지터), 캐시에 머무르는 작은 워킹셋입니다. PlanetScale, AWS, Percona의 벤치마크 모두 modern InnoDB와 Postgres에서 UUIDv7 INSERT 처리량이 bigint auto-increment 대비 10~20% 이내라는 결과를 냅니다. UUIDv4를 괴롭히던 fragmentation도 시간상 가까운 키들이 항상 모이기 때문에 사라집니다.
실무 주의 한 가지. InnoDB는 UUID를 16바이트 BINARY로 저장하는 게 정석입니다 — 36자 하이픈 hex 문자열 말고 BINARY(16)으로. hex 형태는 36바이트(하이픈 빼면 32)로 2배 이상 저장 공간을 쓰고, 인덱스 footprint도 2배 이상 — 페이지당 키 수가 줄고 IO가 그만큼 늘어납니다. Postgres에는 네이티브 uuid 타입(16바이트)이 있으니 쓰세요. 사람 가독성과 저장 공간의 정답은 binary로 저장하고 API 경계에서만 hex로 렌더링하는 것입니다.
// UUIDv4 layout: 122 random bits, 6 fixed bits
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
^^^^ ^^^^
version 4 variant bits
// UUIDv7 layout: 48-bit Unix ms timestamp + 74 random bits
ttttttttttttt-tttt-7xxx-yxxx-xxxxxxxxxxxx
^^^^^^^^^^^^^ ^^^^ ^^^^
unix_ts_ms rand-A rand-B
+ version + variant
// Postgres
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- PG18+
...
);
// MySQL — store as BINARY(16), not VARCHAR(36)
CREATE TABLE orders (
id BINARY(16) PRIMARY KEY,
...
);
// JS — generate UUIDv7 (no deps)
function uuidv7() {
const ts = Date.now();
const tsHex = ts.toString(16).padStart(12, '0');
const rand = crypto.getRandomValues(new Uint8Array(10));
rand[0] = (rand[0] & 0x0f) | 0x70; // version 7
rand[2] = (rand[2] & 0x3f) | 0x80; // variant
const r = [...rand].map(b => b.toString(16).padStart(2, '0')).join('');
return `${tsHex.slice(0,8)}-${tsHex.slice(8)}-${r.slice(0,4)}-${r.slice(4,8)}-${r.slice(8)}`;
}UUID 충돌 확률 — 생일 역설을 숫자로
"UUID 충돌 확률은 얼마나 되나요?"는 모든 팀이 한 번은 묻는 질문입니다. 답의 근거는 생일 역설입니다. UUIDv4는 122 랜덤 비트(나머지 6 비트는 version·variant로 고정)를 가지므로 네임스페이스는 2^122 ≈ 5.3 × 10^36 가지. 직관은 "이 정도 슬롯이면 충돌은 불가능"이라고 말하지만, 생일 역설에 따르면 충돌 위험은 N이 아니라 sqrt(N)에 비례합니다 — 첫 충돌 50% 확률에 도달하려면 약 sqrt(2^122) ≈ 2^61 ≈ 2.3 × 10^18개를 생성해야 합니다.
구체적으로 — 초당 10억 개의 UUIDv4를 100년간 생성해도 충돌 확률은 10^-9 자릿수입니다. 총 1조 개를 만들었을 때 그중 어떤 두 개가 같을 확률은 약 10^-15 — 올해 운석에 맞을 확률보다 100만 배 낮습니다. 단일 애플리케이션에서는 사실상 0입니다. 수십 년 동안 극단적 규모로 생성하는 여러 독립 시스템에서 나온 UUID들을 합쳤을 때만 수학이 흥미로워지고, 그 경우에도 실무 답은 "전략 하나 정하고 절대 걱정하지 마라"입니다.
현대 UUID 계열의 버전 표는 대략 이렇습니다 —
v1 (1988): 타임스탬프 + MAC 주소. 호스트별 고유하지만 MAC과 시각이 노출됨. 거의 사장.
v3 / v5: namespace + name의 결정적 해시. 같은 입력 → 같은 UUID. 파생 ID에 유용.
v4: 122 랜덤 비트. 2010~2024년 지배적 선택.
v6: v1과 비슷하나 정렬 가능한 바이트 순서. 틈새.
v7 (RFC 9562, 2024): 48비트 Unix ms + 74 랜덤 비트. 신규 시스템 강력 추천.
v8: 커스텀. v7로 안 되는 경우에만.
실무 교훈: UUID 포맷을 직접 깎지 마세요. 6개의 reserved bit(version + variant) 덕분에 도구와 DB가 UUID를 식별합니다. v4를 만들면서 version 비트를 잘못 세팅하면 다른 버전으로 해석되어 비교·정렬·드라이버 수준 최적화가 깨집니다. 항상 검증된 라이브러리 또는 위의 표준 공식을 쓰세요.
// Birthday paradox: probability of ANY collision among n values
// drawn from a space of size N is ≈ 1 - exp(-n^2 / (2N))
// For UUIDv4: N = 2^122
// n = 1 billion → P ≈ 4.7 × 10^-20
// n = 1 trillion (10^12) → P ≈ 4.7 × 10^-14
// 50% collision threshold: n ≈ 2.3 × 10^18
// Detect the version of a UUID
function uuidVersion(uuid) {
// version is the first hex digit of the third group
return parseInt(uuid[14], 16);
}
uuidVersion('018fb1c2-...-7abc-...'); // 7
uuidVersion('a1b2c3d4-...-4abc-...'); // 4
// Deterministic UUIDv5 (SHA-1 of namespace + name)
// Same name → same UUID, useful for migration mapping
import { v5 as uuidv5 } from 'uuid';
const NS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // DNS namespace
uuidv5('example.com', NS); // always "cfbff0d1-9375-5685-968a-48ce8b50e3e0"ULID vs UUIDv7 — 하나 골라서 끝내자
UUIDv7가 표준화되기 전에도 커뮤니티는 시간 정렬되는 ID 포맷을 여러 개 만들어왔습니다. 가장 유명한 게 2016년에 나온 ULID(Universally Unique Lexicographically Sortable Identifier)입니다. ULID도 128비트입니다 — 48비트 ms 타임스탬프 + 80비트 랜덤 — 다만 hex 하이픈이 아니라 Crockford Base32로 인코딩합니다. 시각적 형식은 01HX1Y9ZQM7TWBKE6JR5VN8YGB 같은 26자 문자열.
기능적으로 ULID와 UUIDv7은 매우 비슷합니다. 둘 다 시간 정렬되고, ms 타임스탬프 prefix가 있고, 랜덤 비트가 충분하고, 텍스트로 저장하면 lexicographic 정렬이 됩니다. 차이는 미학과 생태계 —
인코딩: ULID는 Crockford Base32(26자). UUIDv7은 hex + 하이픈(36자). ULID 문자열이 더 짧고 헷갈리는 문자(I, L, O, U)를 일부러 뺍니다. URL과 사람 가독성은 ULID가 우세.
DB 지원: UUIDv7은 UUID이므로 uuid 타입을 가진 모든 DB가 16바이트 binary로 효율 저장합니다. ULID는 네이티브 타입이 없어 CHAR(26)이나 BINARY(16)으로 저장하고 경계에서 변환해야 합니다.
툴링: UUID는 표준어입니다. 모든 언어, 모든 ORM, 모든 DB에 수십 년치 UUID 도구가 있습니다. ULID는 라이브러리는 좋지만 도구 통합이 들쑥날쑥합니다.
스펙 성숙도: UUIDv7은 RFC 9562(2024년 5월, IETF 표준). ULID는 GitHub의 커뮤니티 스펙.
2026년 기본 선택은 UUIDv7입니다 — URL 미학상 ULID를 꼭 써야 할 이유가 없는 한. UUIDv7은 진짜 RFC라는 제도적 모멘텀, 점차 네이티브 DB 지원(Postgres 18+, MySQL 9+, 주요 드라이버 전부), 그리고 "UUID 그냥 써"라는 모든 코드 리뷰어와 DBA가 이미 이해하는 언어를 갖고 있습니다.
최종 사용자 노출용으로 URL-friendly한 짧은 ID(YouTube /watch?v=, Stripe cus_)가 필요하다면 UUIDv7도 ULID도 이상적이지 않습니다 — 그건 NanoID 스타일의 21자 순수 랜덤 alphanumeric 토큰이 답입니다. 다른 도구. 정렬 가능한 ID는 내부 PK로, NanoID는 외부 노출용으로 — 역할 분리하세요.
// Side-by-side
UUIDv7: 018fb1c2-7abc-7d3e-8f4a-1b2c3d4e5f60 (36 chars, hex)
ULID: 01HX1Y9ZQM7TWBKE6JR5VN8YGB (26 chars, base32)
NanoID: V1StGXR8_Z5jdHi6B-myT (21 chars, alphanumeric)
// Both UUIDv7 and ULID sort by time when stored as strings:
SELECT id FROM orders ORDER BY id; -- chronological without an extra index!
// UUIDv7 Postgres setup
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- for older versions
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- v4
-- or in PG18+: DEFAULT uuidv7()
user_id uuid REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
// Generating ULID
import { ulid } from 'ulid';
ulid(); // "01HX1Y9ZQM7TWBKE6JR5VN8YGB"
ulid(1714560000000); // ULID for a specific timestamp