Files
llm-wiki/wiki/concepts/resource-identifier-format.md

136 lines
13 KiB
Markdown

---
title: Resource Identifier Format (ULID vs UUIDv7 vs UUIDv4 vs Snowflake)
source_type: llm-generated
status: draft
confidence: medium
tags: [resource-identifier, ulid, uuid, backend]
related_projects: [ca-skeleton]
last_reviewed: 2026-06-04
---
# Resource Identifier Format (ULID vs UUIDv7 vs UUIDv4 vs Snowflake)
> Layer: `wiki/concepts/` — 일반 개념. 특정 프로젝트(ca-tmpl)의 적용 사실은 [[wiki/projects/ca-tmpl/resource-identifier-format]] 로 분리.
## Summary
Resource identifier format 결정은 API resource 를 가리키는 public ID 의 *형식*(random vs time-ordered, charset, 길이, prefix)을 고르는 일이다. 후보는 크게 random 계열(UUID v4, NanoID)과 time-ordered 계열(UUID v7, ULID, KSUID, Snowflake, TSID)로 갈린다. 핵심 trade-off 축은 **(1) 정렬성/DB index locality, (2) timestamp leak(privacy), (3) URL 길이/charset, (4) 조율 부담, (5) 표준 여부**다. ID 는 URL·log·DB PK·cache key·FK 에 한 번 박히면 변경이 breaking 이므로, 형식 선택은 되돌리기 어려운 결정이다.
## Standard (공식 정의)
### UUID (RFC 9562, 2024)
IETF RFC 9562 는 UUID 의 128-bit 구조와 버전을 정의한다. v4 는 순수 random, v7 은 48-bit Unix millisecond timestamp 를 앞에 두는 **time-ordered** 변형이며, 같은 timestamp 내 단조성을 위한 monotonicity 메커니즘을 규정한다. RFC 는 새 ID 가 필요할 때 time-ordered 변형(v6/v7)을 SHOULD 로 권고한다. §8 은 timestamp 노출의 attack surface 를 "very small" 로 기술한다.
출처: [[raw/official-docs/rfc9562-uuid]] (RFC9562-C1~C5).
### ULID (공식 spec)
ULID 는 128-bit 를 **26-char Crockford base32** 로 인코딩한 형식이다. 앞 48-bit 가 millisecond timestamp(정렬 가능), 뒤 80-bit 가 random. `getMonotonicUlid()` 류의 monotonic factory 는 동일 ms 내 단조 증가를 보장한다. 128-bit 이므로 UUID 와 binary 호환(상호 변환 가능)이다.
출처: [[raw/official-docs/ulid-spec.md]] (ULID-C1~C6).
### Crockford base32 / RFC 3986
- **Crockford base32**: 32-char alphabet 에서 사람이 혼동하는 **I / L / O / U 를 제외**한다. 디코딩 시 `I`/`L``1`, `O``0` 으로 정규화하고 대소문자를 구분하지 않는다(case-insensitive). 출처: [[raw/official-docs/crockford-base32-spec.md]] (CROCKFORD-C1~C4).
- **RFC 3986 (URI generic syntax)**: `unreserved` charset 은 `ALPHA / DIGIT / "-" / "." / "_" / "~"`. path component 는 case-sensitive 로 취급되며 §6.2.2.1 의 case normalization 규칙은 scheme/host 에만 적용된다. ULID 의 `0-9A-Z``unreserved` 의 진부분집합이라 percent-encoding 없이 URL path 에 안전하다. 출처: [[raw/official-docs/rfc3986-uri-generic-syntax]] (RFC3986-C1/C3/C4).
### 식별자 관례 (벤더 표준 — best practice 아님)
- **Google AIP-148**: `name`(server-assigned), `uid`(system-assigned opaque, non-PII), `display_name`(mutable), `parent`(계층 resource name) 표준 필드. 출처: [[raw/official-docs/google-aip-148-standard-fields]] (AIP148-C1~C5).
- **Stripe**: typed prefix opaque ID(`ch_`, `cus_`, `pi_`). 단 Stripe 스스로 prefix 변경을 *backward-compatible* 로 분류 → prefix 영구 불변 보장이 아니므로 prefix 의존 코드는 lock-in 위험. Idempotency-Key 는 client-generated 로 resource ID 와 별개. 출처: [[raw/official-docs/stripe-resource-id-convention]] (STRIPE-C1~C5).
> AIP-148·Stripe 는 `official-vendor-doc`/벤더 관례다. RFC 9562·RFC 3986·ULID spec 같은 `official-standard` 와 달리 "공식 best practice" 로 일반화하면 안 된다.
## 한계 / 주의점
후보별 trade-off:
| 형식 | 정렬성(DB index) | timestamp leak | URL 길이 | 조율 부담 | 표준 |
| --- | --- | --- | --- | --- | --- |
| Sequential integer | 최상 | 없음(but enumeration/count leak) | 짧음 | 없음 | — |
| UUID v4 | 나쁨(random → B-tree 단편화) | 없음 | 36자(dashed) | 없음 | RFC 9562 |
| UUID v7 | 좋음(time-ordered) | **48-bit ms 노출** | 36자 | 없음 | RFC 9562 |
| ULID | 좋음(time-ordered) | **48-bit ms 노출** | 26자 | 없음 | ULID spec(비-IETF) |
| NanoID | 나쁨(random) | 없음 | 21자(default) | 없음 | 라이브러리 |
| KSUID | 좋음 | 초 단위 노출 | 27자(base62) | 없음 | 라이브러리 |
| Snowflake | 좋음(k-sorted) | ms 노출 + machine ID | ~19자(64-bit) | **worker/datacenter id 조율** | 라이브러리 |
| TSID | 좋음 | ms 노출 | BIGINT fit | 일부 | 라이브러리 |
| CUID2 | 없음(보안 우선) | **없음(저자 주장)** | 24자(base36) | 없음 | 라이브러리 |
주요 함정:
- **Sequential ID**: enumeration attack + count leak + tenant 격리 위반. public ID 로 부적합.
- **random UUID v4 의 DB 비용**: time-ordered 가 아니라 B-tree index 에 random insert → page split + WAL/디스크 증가. Percona 의 MySQL InnoDB 25M-row 벤치마크에서 random UUID PK 가 ordered UUID 대비 +50% 디스크, ordered UUID ≈ BIGINT 성능. 단 이는 MySQL InnoDB clustered index 기준 — PostgreSQL HEAP/MVCC 등 다른 엔진에는 *parallel evidence* 로만 적용된다. 출처: [[raw/company-tech-blogs/percona-uuid-storage-mysql]] (PERCONA-UUID-C2~C5).
- **timestamp leak**: UUID v7 / ULID 는 48-bit ms timestamp 가 평문 노출 → 작성 시각·가입 순서·활동 패턴 추론 가능. *user-facing* ID 에서 실질 문제. 완화책은 수용 / random scramble / CUID2 채택. CUID2 의 timestamp 비노출은 *저자 주장*이며 독립 감사로 확인된 것은 아니다. 출처: [[raw/official-docs/cuid2-spec.md]] (CUID2-C1).
- **Snowflake 의 조율 부담**: worker_id / datacenter_id 를 노드마다 사전 할당해야 함 → 단일 generator 환경에는 과한 운영 부담. 출처: [[raw/company-tech-blogs/snowflake-twitter-id]] (SNOWFLAKE-C1~C5).
- **case-insensitive charset 의 함정**: Crockford base32(ULID)는 입력이 case-insensitive 라 서버가 URL boundary 에서 canonical uppercase 로 normalize 하지 않으면 cache key miss 가 발생한다.
- **typed prefix lock-in**: Stripe 자신이 prefix 변경을 backward-compatible 로 본다 → prefix 를 파싱·의존하는 코드는 깨질 수 있다.
- **public ID vs internal sequence**: external-only(ULID 하나가 public ID = PK, Stripe)는 단순하지만, dual column(internal BIGINT + external ULID, Shopify/Linear/PlanetScale)은 audit/JOIN 성능을 회수한다. 후자는 cache key/FK 를 어느 쪽으로 둘지 추가 결정을 부른다. 출처: [[raw/company-tech-blogs/planetscale-nanoid-api]] (PLANETSCALE-NANOID-C4).
## Project Application
- ca-tmpl(Clean Architecture skeleton)에서의 실제 ULID 채택 + `adapter-identifier` 모듈 구현 사실은 [[wiki/projects/ca-tmpl/resource-identifier-format]] 참조. (본 개념 문서는 일반론만 다룬다.)
## Claim-backed Knowledge
> 인용된 raw source 의 claim 만. 출처 없는 일반화 금지.
| Knowledge Point | Supporting Claims | Confidence | Notes |
| --- | --- | --- | --- |
| RFC 9562 가 UUID v7 = time-ordered(48-bit Unix ms) 를 정의하고 새 ID 에 time-ordered 를 SHOULD 권고 | [[raw/official-docs/rfc9562-uuid]] RFC9562-C1/C3 | high | `official-standard` |
| RFC 9562 §8 이 timestamp 노출 attack surface 를 "very small" 로 기술 | [[raw/official-docs/rfc9562-uuid]] RFC9562-C5 | high | `official-standard` |
| ULID = 26-char Crockford base32, 48-bit ms timestamp + 80-bit random, monotonic 정렬 | [[raw/official-docs/ulid-spec.md]] ULID-C1~C5 | high | `official-reference`(비-IETF spec) |
| Crockford base32 가 I/L/O/U 제외 + 디코딩 시 정규화(case-insensitive) | [[raw/official-docs/crockford-base32-spec.md]] CROCKFORD-C1~C3 | high | `official-reference` |
| RFC 3986 `unreserved` = `ALPHA / DIGIT / "-" / "." / "_" / "~"`, path case-sensitive | [[raw/official-docs/rfc3986-uri-generic-syntax]] RFC3986-C1/C3 | high | `official-standard` |
| Google AIP-148 의 uid = system-assigned opaque(non-PII), display_name 과 분리 | [[raw/official-docs/google-aip-148-standard-fields]] AIP148-C2/C3 | medium | `official-vendor-doc` (벤더 관례, 공식 표준 아님) |
| Stripe 가 typed prefix 변경을 backward-compatible 로 분류(영구 불변 보장 아님) | [[raw/official-docs/stripe-resource-id-convention]] STRIPE-C2 | medium | `official-vendor-doc` |
| Percona: MySQL InnoDB 에서 random UUID PK 가 ordered UUID 대비 +50% 디스크, ordered UUID ≈ BIGINT (25M-row) | [[raw/company-tech-blogs/percona-uuid-storage-mysql]] PERCONA-UUID-C2/C5 | medium | `company-case-study` (MySQL 5.x, 타 엔진엔 parallel evidence) |
| CUID2 가 timestamp leak 없음 | [[raw/official-docs/cuid2-spec.md]] CUID2-C1 | low | `official-reference` (저자 주장, 독립 감사 미확인) |
| Snowflake 가 worker/datacenter id 사전 조율을 요구 | [[raw/company-tech-blogs/snowflake-twitter-id]] SNOWFLAKE-C1 | medium | `company-case-study` |
| NanoID 21자 default + URL-safe alphabet `A-Za-z0-9_-` + crypto-strong random | [[raw/official-docs/nanoid-spec]] NANOID-C1/C2/C4 | high | `official-reference` |
| Brandur(전 Stripe): Idempotency-Key 는 client-generated, ~24h TTL, request fingerprint 비교 | [[raw/company-tech-blogs/brandur-stripe-idempotency-keys]] BRANDUR-IDEMP-C8~C12 | medium | `engineering-blog` |
## 내가 설명할 수 있어야 하는 것
- time-ordered ID(UUID v7 / ULID)가 random UUID v4 대비 DB index locality 에 유리한 *원리*(B-tree 에 정렬된 키가 append 우세).
- timestamp leak 가 왜 *user-facing* ID 에서만 실질 문제인지, 완화책(수용 / scramble / CUID2)의 trade-off.
- Crockford base32 가 I/L/O/U 를 제외하는 이유 + 그래서 생기는 canonical uppercase 출력 + case-insensitive 입력 정규화 의무.
- public ID vs internal sequence(external-only vs dual column)의 trade-off.
- Idempotency-Key(client-generated, ephemeral) 와 resource ID(server-assigned, persistent)가 왜 별개 형식인지.
- "Netflix/Stripe 가 X 를 쓰니까 공식이다" 가 아니라, RFC(official-standard) 와 벤더 관례(vendor-doc)·사례(case-study)를 구분해 말하는 것.
## Interview Questions
- ULID 와 UUID v7 은 둘 다 time-ordered 인데 왜 ULID 를 고를 수 있는가? (URL 길이 26 vs 36, Crockford base32 의 human-friendliness, Java 21 `java.util.UUID` 의 v7 native 미지원.)
- random UUID v4 를 DB PK 로 쓰면 어떤 비용이 있는가? 어느 엔진 기준 벤치마크인가?
- ULID/UUID v7 의 timestamp leak 가 실제로 어떤 정보를 노출하는가? 언제 문제이고 어떻게 완화하나?
- typed prefix(`tk_`)를 쓰는 것의 장단점은? Stripe 가 prefix 변경을 어떻게 분류하는가?
- public ID 와 internal sequence 를 분리(dual column)하는 동기와 비용은?
## Do Not Overclaim
- **"ULID 가 UUID 보다 항상 우월하다" → 금지.** timestamp leak(privacy), 비-IETF 표준, 라이브러리 의존이라는 trade-off 존재.
- **"random UUID 는 PostgreSQL 에서도 느리다" → 단정 금지.** 인용 벤치마크는 MySQL InnoDB clustered index 기준 — 다른 엔진에는 parallel evidence 일 뿐.
- **"CUID2 는 timestamp 가 절대 안 샌다" → 단정 금지.** spec 저자 주장이며 독립 감사로 확인된 것은 아니다.
- **"Google AIP / Stripe 관례 = 업계 공식 표준" → 금지.** 벤더 관례·사례이지 RFC 같은 official-standard 가 아니다.
- **"sequential ID 는 무조건 나쁘다" → 맥락 의존.** internal-only(외부 비노출) 라면 합리적일 수 있고, dual column 의 internal PK 가 그 예다.
## Sources
- [[raw/official-docs/rfc9562-uuid]] — IETF RFC 9562 (UUID v4/v6/v7/v8, monotonicity, §8 attack surface).
- [[raw/official-docs/ulid-spec.md]] — ULID 공식 spec (26-char Crockford base32, monotonic).
- [[raw/official-docs/crockford-base32-spec.md]] — Crockford base32 (I/L/O/U 제외, case-insensitive 디코딩).
- [[raw/official-docs/rfc3986-uri-generic-syntax]] — URI generic syntax (`unreserved` charset, case normalization).
- [[raw/official-docs/cuid2-spec.md]] — CUID2 (timestamp-leak-free 저자 주장).
- [[raw/official-docs/nanoid-spec]] — NanoID (21자 URL-safe, crypto random).
- [[raw/official-docs/google-aip-148-standard-fields]] — Google AIP-148 standard fields.
- [[raw/official-docs/stripe-resource-id-convention]] — Stripe typed prefix opaque ID 관례.
- [[raw/company-tech-blogs/percona-uuid-storage-mysql]] — Percona MySQL InnoDB UUID PK 벤치마크.
- [[raw/company-tech-blogs/snowflake-twitter-id]] — Twitter Snowflake (조율 부담).
- [[raw/company-tech-blogs/planetscale-nanoid-api]] — PlanetScale NanoID + dual column 사례.
- [[raw/company-tech-blogs/brandur-stripe-idempotency-keys]] — Brandur: Idempotency-Key vs resource ID.
- [[raw/company-tech-blogs/segment-ksuid]] — Segment KSUID (base62, 초 단위 timestamp).
- [[raw/company-tech-blogs/github-graphql-global-node-id]] — GitHub global node ID (base64 type-encoded).
- [[raw/company-tech-blogs/aws-iam-arn-format]] — AWS ARN 계층 prefix.