ULID란? 정렬 가능한 고유 식별자 완벽 가이드
무작위 UUIDv4를 주 키로 삽입할 때마다 그 값은 데이터베이스 인덱스의 예측할 수 없는 위치에 자리 잡습니다. 이 작업을 수백만 번 반복하면 인덱스는 단편화되고, 캐시는 어지러워지며, 쓰기 속도는 느려집니다. ULID는 UUID에서 마음에 들었던 점을 포기하지 않으면서 이 문제를 해결합니다. 여전히 중앙 조정자 없이 어디서든 하나를 발급할 수 있지만, 값이 흩어지는 대신 시간 순서대로 자리 잡습니다.
그렇다면 26자리 문자열은 어떻게 스스로 시간 순으로 정렬될까요? ULID를 선택하기 전에 이 점을 짚어 둘 만합니다.
ULID(Universally Unique Lexicographically Sortable Identifier)는 26자리 Crockford Base32 문자로 표기되는 128비트 식별자입니다. 앞 10자리는 밀리초 단위 타임스탬프를, 뒤 16자리는 무작위 비트를 인코딩합니다. 따라서 나중에 생성된 ULID는 일반 문자열로 비교했을 때 항상 먼저 생성된 것보다 뒤에 정렬됩니다. 오프라인에서 생성할 수 있는 정렬 가능한 고유 식별자입니다.
이 가이드는 그 구조를 하나씩 뜯어봅니다. 문자 단위로 디코딩한 해부, 정말로 정렬된다는 증명, 데이터베이스 이점을 뒷받침하는 B-tree 계산, 그리고 내장된 타임스탬프가 무엇을 노출하는지에 대한 솔직한 검토까지 다룹니다. 글을 읽으면서 ULID 생성기로 실제 값을 따라가 볼 수 있습니다. 하나 생성해서 디코딩하고 UUID로 변환해 보세요.
ULID란 무엇인가요?
ULID(Universally Unique Lexicographically Sortable Identifier)는 UUID보다 더 정렬하기 좋고 더 간결한 대안으로 설계된 128비트 식별자입니다. 26자리 Crockford Base32 문자로 표기됩니다. 앞 10자리는 Unix 에폭 이후 경과한 밀리초를 담은 48비트 타임스탬프를, 나머지 16자리는 80비트의 무작위 값을 담습니다. 시간이 앞에 오기 때문에 문자열은 시간 순으로 정렬됩니다.
이 형식은 마지막 성질, 즉 시간 순 정렬을 위해 나왔습니다. UUIDv4는 완전히 무작위라서 고유성 측면에서는 훌륭하지만, 1초 차이로 생성된 두 ID 사이에는 아무런 관계가 없습니다. ULID는 조정이 필요 없는, 어디서든 생성하는 방식을 유지하면서 그 위에 시간 순서를 더합니다. 그래서 ULID로 이루어진 열은 별도의 처리 없이도 자연스럽게 생성 시각 순으로 정렬됩니다.
다음은 형식을 한눈에 보는 표입니다.
| 속성 | 값 |
|---|---|
| 비트 | 128 |
| 인코딩 | 26자리 Crockford Base32 문자 |
| 구성 | 48비트 타임스탬프 + 80비트 무작위 값 |
이 글의 나머지 부분에서는 각 조각이 어떻게 동작하는지 채워 나갑니다. 인코딩과 정렬 가능성은 각각 별도의 절을 둘 만하므로 Base32와 정렬 증명은 곧 다루기로 하고, 먼저 구성부터 살펴봅니다.
ULID의 해부: 48비트의 시간 + 80비트의 무작위 값
ULID의 26자리 문자는 두 절반으로 깔끔하게 나뉩니다. 앞 10자리는 타임스탬프이고, 뒤 16자리는 무작위 부분입니다. 표준 예시를 펼쳐 놓으면 그 경계가 분명하게 드러납니다.
01ARYZ6S41 TSV4RRFFQ69G5FAV
└────────┘ └──────────────┘
10 chars 16 chars
48-bit ms 80-bit random
timestamp
구성 요소는 둘, 역할도 둘입니다. 하나는 언제를 기록하고, 다른 하나는 고유성을 보장합니다. 각각을 디코딩해 봅시다.
48비트 타임스탬프 (앞 10자리)
앞 10자리는 48비트 정수를 인코딩합니다. ULID가 생성된 순간의 Unix 에폭 이후 경과한 밀리초 값입니다. 명세에 그대로 실린 표준 예시를 가져와 봅니다.
01ARYZ6S41 -> 1469918176385 ms -> 2016-07-30T22:36:16.385Z
이것은 실제로 되돌릴 수 있는 디코딩입니다. 01ARYZ6S41TSV4RRFFQ69G5FAV를 디코더에 붙여넣으면 정확히 2016-07-30T22:36:16.385Z가 그대로 돌아옵니다. 시간 구성 요소는 해시가 아니라 평범한 데이터이므로 읽어 내는 데 아무런 비용이 들지 않습니다.
사람들이 자주 걸려 넘어지는 작은 세부 사항이 하나 있습니다. ULID의 첫 번째 문자는 항상 0과 7 사이입니다. Crockford 문자 하나는 5비트를 담는데, 48비트는 5의 배수가 아닙니다. 타임스탬프는 10자리가 담을 수 있는 50비트 중 하위 48비트를 차지하고, 첫 번째 문자의 상위 2비트는 영구히 0으로 남습니다. 이 두 개의 0비트가 그 문자의 값을 최대 7로 제한합니다. ULID가 8 이상으로 시작하는 것을 본다면 그것은 잘못된 값입니다.
80비트의 무작위 값 (뒤 16자리)
나머지 16자리는 80비트의 무작위 값을 담으며, 고유성이 나오는 곳이 바로 이 절반입니다. 이 비트는 암호학적으로 안전한 출처에서 와야 합니다. 브라우저에서는 Math.random이 아니라 crypto.getRandomValues입니다. 그 차이가 중요합니다. Math.random은 공격자가 값을 추측하거나 충돌시킬 수 있을 만큼 예측 가능한 반면, CSPRNG는 그렇지 않습니다.
80비트는 얼마나 넓은 공간일까요? 대략 1.2 × 10²⁴ 가지의 값을 표현할 수 있으며, 게다가 이것은 밀리초당 수치입니다. 단 1밀리초 안에 수백만 개의 ULID를 발급하더라도 두 값이 같은 80비트를 뽑을 확률은 무시할 만큼 작게 유지됩니다. 타임스탬프와 달리 이 절반에는 디코딩할 수 있는 의미가 전혀 없습니다. 모든 ULID를 서로 다르게 만드는 것만이 유일한 목적인 잡음일 뿐입니다.
Crockford의 Base32: ULID가 I, L, O, U를 빼는 이유
ULID는 Crockford의 Base32로 인코딩됩니다. 숫자 0–9와 네 글자를 뺀 알파벳 A–Z로 이루어진 32개 기호의 집합입니다.
0123456789ABCDEFGHJKMNPQRSTVWXYZ
빠진 글자는 I, L, O, U입니다. 세 글자는 숫자와 닮아 보여서 제외됩니다. I와 L은 1을, O는 0을 닮았습니다. 그래서 화면에서 ULID를 읽는 사람이 글자를 숫자로 헷갈릴 일이 없습니다. 그 이면에는 너그러운 입력 처리가 있습니다. 규격을 따르는 디코더는 I와 L을 1로, O를 0으로 되돌려 매핑하고, 문자열 전체를 대소문자 구분 없이 처리합니다. U는 따로 제외되는데, 모욕적인 단어가 우연히 만들어지는 것을 피하기 위해서입니다.
비트 계산이 또 다른 이유입니다. Base32 문자 하나는 5비트를 인코딩하는 반면, 16진수 문자 하나는 4비트만 인코딩합니다. 문자당 5비트로 128비트를 채우면 26자가 필요하고, UUID가 하는 방식대로 같은 128비트를 문자당 4비트로 채우면 32자에 하이픈 네 개를 더해 36자가 필요합니다. 그래서 ULID는 UUID보다 의미 있게 짧고, 하이픈이 없어서 이스케이프 없이도 URL이나 파일 이름, 헤더에 그대로 넣을 수 있습니다.
Crockford의 Base32는 32개 기호(0–9와 I, L, O, U를 뺀 A–Z)로 이루어진, 문자당 5비트를 인코딩하는 알파벳입니다. ULID는 이를 사용해 128비트를 대소문자 구분 없는 URL 안전 문자 26자로 압축합니다. 그리고 결정적으로, 이 알파벳은 오름차순으로 배열되어 있어서 인코딩된 문자열이 원시 비트와 동일한 순서로 정렬되도록 해 줍니다.
ULID가 시간 순으로 정렬되는 이유
많은 글이 ULID가 시간 순으로 정렬된다고 말합니다. 하지만 왜 그런지를 보여 주는 글은 드뭅니다. 그 논거는 이미 갖춘 두 가지 사실에 기반합니다. 타임스탬프가 값에서 가장 중요한 부분이라는 것, 그리고 Crockford의 알파벳이 오름차순으로 배열되어 있다는 것입니다.
이 둘을 합치면 다음과 같은 등가 관계의 연쇄를 얻습니다.
string compare == 128-bit integer compare == creation-time compare
왼쪽에서 오른쪽으로 읽어 보세요. 두 ULID를 문자 단위로 비교하는 것(문자열 정렬이 동작하는 방식)은 그 바탕이 되는 128비트 정수를 비교하는 것과 같은 결과를 냅니다. 알파벳이 순서를 보존하기 때문에, 즉 “더 높은” 문자는 항상 더 큰 값을 뜻하기 때문입니다. 128비트 정수를 비교하는 것은 생성 시각을 비교하는 것과 같은 결과를 냅니다. 타임스탬프가 가장 중요한 비트에 자리 잡고 있어 비교를 지배하기 때문이며, 무작위 꼬리 부분은 같은 밀리초 안에서만 동률을 가릅니다. 문자열 순서, 비트 순서, 시간 순서는 모두 같은 순서입니다.
간단한 시연을 보겠습니다. 1밀리초 간격으로 발급된 두 ULID입니다.
01ARYZ6S41... (created at T)
01ARYZ6S42... (created at T + 1 ms)
열 번째 문자가 1에서 2로 한 칸 올라가고, 평범한 텍스트 정렬은 두 번째 값을 첫 번째 뒤에 놓습니다. 타임스탬프 열도, 특수 비교기도 필요 없습니다. 다음 절에서 자세히 설명할 실질적인 이득은 한 줄로 요약됩니다. ORDER BY id가 추가 인덱스 없이 행을 시간 순으로 반환합니다.
데이터베이스 주 키로서의 ULID: B-tree 지역성
ULID가 실제로 값어치를 하는 곳이 바로 데이터베이스입니다. 대부분의 관계형 데이터베이스는 주 키 인덱스를 B-tree로 저장하며, 새 키가 그 트리의 어디에 자리 잡는지가 삽입 비용을 결정합니다.
무작위 UUIDv4는 삽입할 때마다 예측할 수 없는 어딘가에 자리 잡습니다.
UUIDv4: 새 키마다 무작위 리프 페이지를 목표로 삼습니다. 그 페이지는 대개 가득 차 있어서 엔진이 페이지를 분할하고, 행의 절반을 다른 곳으로 복사하며, 트리 곳곳의 페이지를 더럽힙니다. 수백만 행에 걸치면 이는 인덱스를 단편화하고, 버퍼 캐시에서 유용한 페이지를 밀어내며, 삽입 처리량을 끌어내립니다. (구체적인 인덱스 페이지 분할 수치는, 쓰기가 많은 테이블에서 보통 2~10배 차이가 나는데, 비교 가이드를 참고하세요.)
시간 접두사가 붙은 ULID는 매번 끝에 자리 잡습니다.
ULID: 상위 비트가 타임스탬프이므로 새 키마다 직전 키보다 크고, 따라서 인덱스의 오른쪽 끝 또는 그 부근에 덧붙습니다. 삽입은 순차적으로 유지되고, 페이지 분할은 거의 사라지며, 인덱스는 간결하게 유지되고, 시간 구간에 대한 범위 스캔은 연속된 페이지를 읽어 들입니다.
자동 증가 정수의 삽입 지역성을 갖추면서도 UUID의 조정 없는 생성을 그대로 얻습니다. 게다가 추측 가능한 순차 카운터를 노출하지도 않습니다. 무작위 꼬리 부분이 여전히 다음 값을 정확히 가려 주기 때문입니다.
저장 팁: 128비트를 26자리 텍스트 필드가 아니라 16바이트의 이진 바이트로 저장하세요. PostgreSQL의 uuid 열, MySQL의 BINARY(16)입니다. 26자리 텍스트로 저장하면 공간을 낭비하고 인덱스를 부풀립니다. Base32 문자열로의 인코딩은 사람이나 URL이 보는 가장자리에서만 하세요. 두 형태가 같은 128비트이므로, 생성기의 변환 탭이 바로 이런 용도로 ULID를 UUID로 변환해 줍니다.
단조 ULID: 같은 밀리초 안에서의 엄격한 순서
정렬 가능성 증명에는 솔직히 한 가지 빈틈이 있습니다. 단일 밀리초 안에서는 일반 ULID가 엄격하게 순서대로 정렬되지 않습니다. 같은 10자리 시간 접두사를 공유하지만, 80비트 무작위 꼬리는 독립적으로 뽑히므로 같은 밀리초의 두 ULID 중 어느 쪽이 먼저 정렬될지는 사실상 동전 던지기입니다. 대부분의 용도에서는 이것으로 충분합니다. 하지만 밀리초보다 짧은 간격에서도 엄격한 순서가 필요할 때는 충분하지 않습니다.
단조 생성이 그 빈틈을 메웁니다. 규칙은 간단합니다. 주어진 밀리초의 첫 번째 ULID는 평소처럼 새로운 무작위 값을 받고, 같은 밀리초 안의 이후 모든 ULID는 직전의 80비트 무작위 값을 가져와 1만큼 증가시켜 만듭니다(빅 엔디언 정수로 취급하며, 필요에 따라 상위 비트로 올림이 전파됩니다). 따라서 각 값은 직전 값보다 엄격하게 큽니다.
1밀리초 안에서 생성된 묶음을 보면 이를 확인할 수 있습니다. 마지막 문자만 움직입니다.
01KVT0F720ZK9N4T2QX7VR8WMC
01KVT0F720ZK9N4T2QX7VR8WMD
01KVT0F720ZK9N4T2QX7VR8WME
…WMC < …WMD < …WME가 보장됩니다. 이는 행이 밀리초 시계가 째깍이는 것보다 빠르게 생성될 수 있는 상황에서 중요합니다. 처리량이 높은 삽입, 이벤트 로그, 빡빡한 루프 안의 메시지 ID 같은 경우입니다. 시계가 다음 밀리초로 넘어가면 생성은 새로운 무작위 값으로 되돌아가고 이 주기가 반복됩니다.
ULID 대 UUID: 언제 무엇을 쓸까
대부분의 사람들이 실제로 들고 오는 질문은 ULID 대 UUID입니다. 현실적으로 ULID와 견주게 될 두 UUID 버전을 골라 비교해 봅니다. (Snowflake와 NanoID를 포함한 다섯 방향 결정 행렬 전체는 ULID, UUID, Snowflake 전체 비교를 참고하세요.)
| 속성 | ULID | UUIDv4 | UUIDv7 |
|---|---|---|---|
| 길이 | 26자 | 36자 | 36자 |
| 인코딩 | Crockford Base32 | 하이픈 16진수 | 하이픈 16진수 |
| 시간 순 정렬? | 예 | 아니요 | 예 |
| 타임스탬프 내장? | 예 (48비트 ms) | 아니요 | 예 (48비트 ms) |
| 표준화? | 커뮤니티 명세 | RFC 9562 | RFC 9562 |
| 적합한 용도 | 짧고 정렬 가능한 ID | 불투명한 무작위 ID | UUID 형식의 정렬 가능한 ID |
말로 풀어 보면, 가장 짧고 URL 안전하며 정렬 가능한 문자열을 원할 때는 ULID를 택하세요. 시간이 내장되지 않은 불투명하고 완전히 무작위인 식별자를 원할 때는 UUIDv4를 택하세요. 예를 들어 언제 생성되었는지 드러내고 싶지 않은 공개 토큰이 그렇습니다. 시간 순서가 필요하지만 표준 UUID 형식 안에 머물러야 하고, 버전과 변형 비트가 고정된 위치에 있으며 그대로 넣을 수 있는 네이티브 uuid 열이 필요할 때는 UUIDv7을 택하세요.
셋 다 128비트이므로 ULID ↔ UUID 변환은 어느 방향이든 손실이 없습니다. ULID와 ulid vs uuid v7 사이의 관계는 보기보다 가깝습니다. UUIDv7은 본질적으로 ULID가 개척한 바로 그 시간 접두사 아이디어를 IETF가 표준화한 형태입니다. UUID 자체가 처음이라면, 기본기부터 먼저 익히고 나서 이 비교로 돌아오세요.
프라이버시 절충: ULID는 생성 시각을 노출합니다
내장된 타임스탬프는 누가 ID를 읽느냐에 따라 기능이기도 하고 누출이기도 합니다. ULID를 가진 사람이라면 누구나 한 번에 타임스탬프를 디코딩해서 레코드가 생성된 정확한 밀리초를 알아낼 수 있습니다. 데이터베이스 접근 권한이 전혀 필요 없습니다.
자체 시스템 내부에서는 이것이 순전한 장점입니다. 즉각적인 감사, 공짜 정렬, 손쉬운 디버깅을 누립니다. 하지만 공개되는 식별자에서는 실질적인 정보 공개가 됩니다. 생성 시각 자체가 사업적으로 민감할 수 있고, 시간을 두고 표본으로 수집한 몇 개의 ULID는 생성 속도를 노출합니다. 초당 주문, 계정, 메시지를 몇 개 발급하는지 말입니다. 경쟁사나 스크레이퍼가 추정하고 싶어 하는 종류의 정보입니다.
공정하게 말하면, 이것은 UUIDv1보다 좁은 누출입니다. UUIDv1은 역사적으로 생성 머신의 MAC 주소를 내장했지만, ULID는 시간만 노출할 뿐 하드웨어 식별 정보는 결코 노출하지 않습니다. 그래도 따져 봐야 합니다. 간단한 완화책은 이렇습니다. ULID는 내부용으로 두고, 순서가 중요하지 않은 공개용 ID에는 완전히 무작위인 UUIDv4를 내주세요.
ULID를 다룰 때 흔한 함정
ULID 문제는 대부분 형식 자체의 버그가 아니라, 피할 수 있는 몇 가지 엔지니어링 결정입니다. 자주 반복되는 것들은 다음과 같습니다.
- 같은 밀리초의 일반 ULID가 순서대로 정렬된다고 가정하기. 이들은 시간 접두사를 공유하지만 무작위 꼬리가 독립적이므로 순서가 정의되지 않습니다. 해결: 밀리초보다 짧은 간격에서 엄격한 순서가 필요하면 단조 모드를 사용하세요.
- ULID를 26자리 텍스트로 저장하기. 공간을 낭비하고 인덱스를 부풀립니다. 해결: 128비트를 16바이트(
uuid/BINARY(16))로 저장하고 Base32로의 인코딩은 가장자리에서만 하세요. - ULID→UUID 변환이 v4나 v7로 보고되리라 기대하기. 변환은 같은 비트를 다시 인코딩할 뿐 UUID 버전과 변형 필드를 설정하지 않으므로, 그것을 검사하는 라이브러리는 태그된 버전을 보지 못합니다. 해결: 결과를 불투명한 128비트 값으로 취급하거나, 태그가 필요하면 진짜 UUIDv7을 생성하세요.
- 무작위 값을
Math.random으로 채우기. 예측 가능하고 충돌할 수 있습니다. 해결: 항상crypto.getRandomValues같은 CSPRNG를 사용하세요. - 타임스탬프 누출을 따져 보지 않고 ULID를 공개적으로 노출하기. 위의 프라이버시 절을 참고하세요. 해결: 내부에는 ULID를, 공개 ID에는 무작위 UUIDv4를 쓰세요.
- ULID에
I,L,O,U를 손으로 입력하기. 이 글자들은 알파벳에 없으며, 다시 입력하는 것은 오류를 부릅니다. 해결: ULID는 다시 타이핑하지 말고 복사하세요.
자주 묻는 질문
ULID는 UUID처럼 공식 표준인가요?
아닙니다. ULID는 IETF RFC가 아니라 GitHub에 공개된 커뮤니티 명세입니다. 널리 구현되어 있고 안정적이지만, 뒤를 받쳐 주는 표준 기구는 없습니다. 표준화된 시간 순 식별자가 필요하다면, UUIDv7(RFC 9562)이 공식 UUID 형식 안에서 같은 아이디어를 적용합니다.
ULID는 몇 글자이고, 왜 UUID보다 짧은가요?
26자로, UUID의 36자와 대비됩니다. ULID는 문자당 5비트를 채우는 Crockford Base32를 사용합니다. UUID의 16진수는 4비트만 채우고 하이픈 네 개를 더합니다. 따라서 같은 128비트라도 Base32에서는 더 적은 글자가 필요하며, 그중 어느 것도 URL 이스케이프가 필요 없습니다.
두 ULID가 충돌할 수 있나요?
사실상 절대 그렇지 않습니다. 1밀리초 안에서 ULID는 80비트의 무작위 값을 가지는데, 약 1.2 × 10²⁴ 가지의 경우의 수입니다. 그래서 밀리초당 수백만 개를 생성하더라도 충돌 확률은 무시할 만큼 작게 유지됩니다. 한 가지 요건은 암호학적으로 안전한 RNG가 무작위 값을 채워야 한다는 것입니다. Math.random은 그 보장을 무효로 만듭니다.
ULID를 PostgreSQL이나 MySQL에 저장할 수 있나요?
예. ULID는 128비트이므로 UUID 형태로 변환해 uuid 열(PostgreSQL)이나 BINARY(16)(MySQL)에 저장하고, Base32 문자열은 가장자리에서만 렌더링하세요. 네이티브 ULID 열 타입은 없지만, UUID 표현은 똑같이 16바이트면 충분하고 인덱스를 간결하게 유지합니다.
ULID는 대소문자를 구분하나요?
표준 형태는 대문자이지만, Crockford Base32는 입력에서 대소문자를 구분하지 않습니다. 디코더는 소문자도 똑같이 읽고, I/L을 1로, O를 0으로 매핑합니다. 동등성 검사와 인덱스에서 예상치 못한 일을 피하려면, 저장하거나 비교하기 전에 한 가지 대소문자로 정규화하세요.
48비트 타임스탬프가 언젠가 바닥나나요?
아주 오랫동안 그렇지 않습니다. 48비트의 밀리초는 카운터가 넘치기까지 서기 10889년에 도달하므로, 타임스탬프 구성 요소는 실제 어떤 애플리케이션에서도 사실상 미래에 대비된 셈입니다. 이 형식이 자리를 다 쓰기 훨씬 전에 여러분은 시스템도, 언어도, 데이터베이스도 교체하게 될 것입니다.
서버 없이 브라우저나 모바일에서 ULID를 생성할 수 있나요?
예, 그것이 핵심 이점입니다. ULID는 중앙 조정자가 필요 없으므로 어떤 노드, 엣지 워커, 브라우저, 기기든 자신의 시계와 안전한 RNG만으로 하나를 발급할 수 있습니다. 서로 다른 머신에서 생성된 값도 나중에 시간 순으로 함께 정렬됩니다. 타임스탬프가 ID 자체 안에 들어 있기 때문입니다.
결론
ULID는 구체적이고 실제적인 문제, 즉 무작위 키가 인덱스를 단편화하는 문제를 분산 생성을 포기하지 않으면서 해결합니다. 그 동작 원리는 기억해 둘 만합니다.
- ULID는 48비트 밀리초 타임스탬프 + 80비트의 무작위 값으로, 26자리 Crockford Base32 문자로 인코딩됩니다.
- 타임스탬프가 가장 중요한 구성 요소이고 알파벳이 순서를 보존하기 때문에 시간 순으로 정렬됩니다. 문자열 순서가 곧 시간 순서입니다.
- 그 순서 덕분에 B-tree는 무작위 UUIDv4에 없는 삽입 지역성을 얻어, 쓰기를 빠르게 유지하고 인덱스를 간결하게 유지합니다.
- 같은 밀리초에 발급한 ID에 엄격한 순서가 필요하면 단조 모드를 사용하세요.
- 공개되는 식별자로 ULID를 노출하기 전에 타임스탬프 누출을 따져 보세요.
- 표준 UUID 형식 안에 머물러야 한다면 대신 UUIDv7을 택하세요.
이를 실제로 적용할 준비가 되었다면, ULID 생성기를 열어 브라우저 안에서만 ULID를 생성하고, 디코딩하고, 변환해 보세요. 서버도, 업로드도, 저장되는 것도 없습니다.