결정적 ID와 정렬되는 ID
UUID v5는 같은 입력에서 항상 같은 값이 나오고 ULID는 문자열 정렬이 시간순과 일치합니다. 둘이 각각 어떤 문제를 푸는지 정리했습니다.
무작위 ID가 필요할 때는 UUID v4를 쓰면 됩니다. 그런데 ID에 무작위 말고 다른 성질이 필요할 때가 있습니다. 같은 입력에서 같은 값이 나와야 하거나, 정렬했을 때 만든 순서대로 놓여야 하는 경우입니다.
UUID v5 — 같은 입력이면 같은 값
네임스페이스와 이름을 SHA-1로 해싱해 만듭니다. 무작위가 전혀 들어가지 않습니다.
uuid5(NAMESPACE_URL, 'https://onuel.dev/blog/')
4f384e56-5887-5878-8c15-5f03559f43d4
4f384e56-5887-5878-8c15-5f03559f43d4
4f384e56-5887-5878-8c15-5f03559f43d4
몇 번을 실행하든, 어느 기기에서 실행하든 같습니다.
네임스페이스가 바뀌면 값도 완전히 달라집니다. 같은 이름이라도 네임스페이스로 영역이 갈립니다.
NAMESPACE_URL 4f384e56-5887-5878-8c15-5f03559f43d4
NAMESPACE_DNS ba49b706-4df4-5aea-abc4-0982b6027ab4
NAMESPACE_OID 68a3b33d-e88b-5dee-8475-16906fa6fc02
NAMESPACE_X500 43d71519-bde8-5842-bc67-6d78c64ad4ba
네임스페이스는 직접 만들어도 됩니다. UUID 하나면 되므로 보통 도메인으로 하나 만들어 두고 그 아래에서 씁니다.
ns = uuid5(NAMESPACE_DNS, 'onuel.dev')
= 335ceb18-3ee0-54ca-980c-e09036ea6c18
uuid5(ns, 'order:1001') = 274f2c48-e346-59a7-bbff-f337e14f0e11
uuid5(ns, 'order:1002') = 987d5f83-b8fb-5edc-81dc-f64c47f36269
uuid5(ns, 'order:1001') = 274f2c48-e346-59a7-bbff-f337e14f0e11
어디에 쓰나
- 외부 시스템의 키를 내부 UUID로 옮길 때 — 매핑 테이블 없이 양쪽에서 같은 값을 계산할 수 있습니다
- 멱등 처리 — 같은 요청이 두 번 와도 같은 ID가 나오므로 유니크 제약이 중복을 막습니다
- 분산 환경에서 조율 없이 같은 ID 만들기 — 서로 통신하지 않는 두 서비스가 같은 입력에서 같은 값을 냅니다
쓰면 안 되는 곳
입력을 아는 사람은 누구나 같은 값을 만들 수 있습니다.
uuid5(NAMESPACE_DNS, 'user@example.com')
= b21b5663-c40d-51b7-8d60-c015e3de48e9
이메일로 사용자 ID를 만들면, 그 ID를 본 사람이 이메일을 대입해 맞춰 볼 수 있습니다. 반대로 이메일 목록이 있으면 어느 ID가 누구인지 전부 계산됩니다. 추측 불가를 기대하는 자리에는 쓰지 않습니다.
v3와의 차이
v3는 MD5, v5는 SHA-1을 씁니다. 그 외에는 같습니다. 새로 쓴다면 v5입니다.
두 경우 모두 해시 다이제스트에서 6비트만 덮어씁니다.
SHA-1 다이제스트 앞 16바이트 : 4f384e5658875878cc155f03559f43d4
UUID v5 : 4f384e56588758788c155f03559f43d4
^^ 여기만 바뀐다
버전 4비트와 변형 2비트입니다. 나머지는 다이제스트 그대로입니다.
ULID — 문자열 정렬이 시간순
128비트를 Crockford Base32 26글자로 적습니다. 앞 10글자가 48비트 밀리초 타임스탬프, 뒤 16글자가 80비트 난수입니다.
01M08ZB5ZVXJ93CJ6RYDM9T9XM
└────────┘└──────────────┘
타임스탬프 난수
시간 순서로 만든 값들은 사전순으로 정렬해도 순서가 같습니다.
01K2XZ8CR0B06P7B3B5D8KYAET
01K2XZ8DQ855MQCXBM544EG3QM
01K2XZ8EPGQVS2BE71DF8TRED4
01K2XZ8FNRN42BQJWCTMPNTJ6M
01K2XZ8GN00J9BJRT850JAAKFT
사전순 정렬 = 생성 순서 : true
앞부분이 타임스탬프이고 Base32 알파벳이 사전순과 값 순서가 일치하도록 배열돼 있어서 그렇습니다.
같은 밀리초 안에서는 보장되지 않는다
타임스탬프가 같으면 뒤의 80비트 난수가 순서를 정합니다. 그건 무작위입니다.
01K2XZ8CR0B3FZ2NQAGF6VH15A
01K2XZ8CR0VWDASZ2XR6KJHE2Z
01K2XZ8CR06H19MR5ZRYNE9124
정렬 = 생성 순서 : false
같은 밀리초 안에서도 순서가 필요하면 단조(monotonic) 생성기를 씁니다. 밀리초가 같으면 앞 값의 난수 부분에 1을 더합니다.
01K2XZ8CR0RG3K2BJNJ3CXCBS4
01K2XZ8CR0RG3K2BJNJ3CXCBS5
01K2XZ8CR0RG3K2BJNJ3CXCBS6
정렬 = 생성 순서 : true
단, 이건 같은 프로세스 안에서만 보장됩니다. 서버가 여러 대면 밀리초 단위 순서는 여전히 갈립니다.
Base32 알파벳
Crockford Base32는 0-9와 A-Z에서 I, L, O, U를 뺀 32글자입니다. I와 1, O와 0을 헷갈리지 않고, U는 뜻하지 않은 단어가 만들어지는 것을 피하려고 뺐습니다. 대소문자 구분이 없어 전화로 불러 주거나 손으로 적기에 무난합니다.
UUID v7과 비교
UUID v7도 앞부분이 밀리초 타임스탬프라 정렬됩니다. 차이는 표기와 생태계입니다.
| ULID | UUID v7 | |
|---|---|---|
| 비트 수 | 128 | 128 |
| 문자열 길이 | 26 | 36 (하이픈 포함) |
| 알파벳 | Crockford Base32 | 16진수 + 하이픈 |
| 표준 | 커뮤니티 명세 | RFC 9562 |
| DB 지원 | 문자열/바이너리로 저장 | UUID 타입 그대로 |
데이터베이스에 UUID 컬럼 타입이 있고 그걸 쓸 것이라면 v7이 자연스럽습니다. URL이나 화면에 노출되는 ID라면 26자가 36자보다 낫습니다.
정렬되는 ID를 쓰는 이유
무작위 ID를 기본 키로 쓰면 삽입 위치가 인덱스 전체에 흩어집니다. B-트리 페이지가 여기저기 쪼개지고 캐시 적중률이 떨어집니다. 타임스탬프가 앞에 있으면 새 행이 인덱스 끝에 붙습니다.
대신 값에서 생성 시각이 드러납니다. 밀리초 단위로 노출되므로, 그것이 문제가 되는 자리(예: 가입 시각을 숨겨야 하는 경우)에는 v4를 씁니다.
확인 순서
- 필요한 성질을 먼저 정합니다 — 추측 불가(v4), 재현 가능(v5), 정렬 가능(v7/ULID)
- v5를 쓴다면 입력이 공개돼도 되는 값인지 봅니다
- ULID를 쓴다면 단조 생성기가 필요한지 봅니다. 같은 밀리초에 여러 개를 만드는 코드인지가 기준입니다
- 데이터베이스 컬럼 타입을 확인합니다. ULID를
CHAR(26)으로 둘지 16바이트 바이너리로 둘지에 따라 인덱스 크기가 달라집니다 - ID를 로그나 URL에 노출한다면 시각이 드러나도 되는지 확인합니다
tools.onuel.dev의 UUID v5에 네임스페이스와 이름을 넣으면 같은 입력에서 항상 같은 값이 나오는 것을 확인할 수 있고, ULID 생성기에서 여러 개를 만들어 보면 앞자리가 공유되는 모습이 보입니다. 비교 대상으로는 UUID v4와 UUID v7이 있습니다.
- #UUID
- #ULID
- #식별자