ULID 알파벳에 I, L, O, U가 없는 이유
ULID(Universally Unique Lexicographically Sortable Identifier)를 실제로 생성해 보면 문자열에 I, L, O, U 네 글자가 절대 등장하지 않는다는 걸 눈치챌 수 있습니다. 우연이 아니라 Crockford's Base32라는 인코딩 방식이 애초에 알파벳에서 이 네 글자를 빼놓고 설계되었기 때문입니다. 왜 하필 이 네 글자만 빠졌는지, 그리고 이게 왜 "타임스탬프가 서기 10889년까지 표현 가능하다"는 이야기로까지 이어지는지 구조를 하나씩 따라가 보겠습니다.
1. Base32란: 5비트를 문자 하나로
2진수를 사람이 다루기 쉬운 문자열로 바꾸는 인코딩 중 하나가 Base32입니다. 32는 2의 5제곱이므로, 비트열을 5비트씩 잘라 각 조각을 32개 문자 중 하나에 대응시킵니다. 32개의 서로 다른 문자가 필요하다는 뜻이고, 보통 숫자 0~9(10개)와 영문 대문자 26개 중 22개를 뽑아 채웁니다. 여기서 "어떤 22개를 고르느냐"가 인코딩마다 달라지는 지점입니다. 표준 Base32(RFC 4648)는 A~Z 26자를 그대로 쓰고 숫자는 패딩용으로만 일부 사용하는 반면, ULID가 채택한 Crockford's Base32는 처음부터 "사람이 손으로 옮겨 적어도 헷갈리지 않을 것"을 설계 목표로 알파벳을 새로 짰습니다.
2. 실제 도구 코드로 확인: 표준 Crockford 알파벳
ULID 생성기의 인코딩 테이블을 그대로 열어 보면 다음과 같이 정의되어 있습니다.
0123456789ABCDEFGHJKMNPQRSTVWXYZ
총 32글자입니다. 알파벳 A부터 Z까지 26자 중에서 I, L, O, U 네 글자가 빠져 있고, 그 자리를 숫자 0~9가 채워 정확히 32개를 맞춘 것을 확인할 수 있습니다. 이건 Douglas Crockford가 2000년대 초 JSON을 정의한 것과 비슷한 실용주의로, "사람이 실제로 손글씨로 옮겨 적거나 화면에서 읽어야 하는 코드"를 위해 1988년 이전부터 여러 시스템(전화 코드, 우편 코드 등)에서 반복적으로 검증된 관례를 인코딩 표준으로 정리한 것입니다.
3. I, L, O를 뺀 이유: 손으로 옮겨 적을 때의 함정
세 글자를 뺀 이유는 각각 뚜렷한 숫자와 헷갈리기 때문입니다. 대문자 I와 소문자 l은 폰트에 따라 숫자 1과 거의 구분이 안 되고, 대문자 O는 숫자 0과 모양이 동일한 폰트가 많습니다. 사람이 로그에 찍힌 ID를 손으로 옮겨 적거나, 전화로 불러주거나, 캡처된 스크린샷을 보고 다시 타이핑할 때 이 세 글자는 실수의 주범입니다. Crockford Base32는 아예 알파벳에서 이 세 글자를 빼버려서 "1처럼 보이는 문자가 여러 개 존재하는" 혼동 자체를 원천 차단합니다. 대신 디코딩 시에는 관대하게 처리하는데, ULID 표준 스펙에서는 I·L을 1로, O를 0으로 자동 치환해서 읽어주는 게 일반적입니다(다만 이 사이트의 디코더는 정확히 표준 알파벳만 유효 문자로 인정합니다).
4. U까지 뺀 이유: 손글씨가 아니라 의미론적 방지
앞의 세 글자와 달리 U는 다른 글자와 시각적으로 헷갈리지 않습니다. U가 빠진 이유는 순전히 의도치 않게 욕설이나 비속어 형태의 문자열이 생성되는 것을 막기 위해서입니다. Base32처럼 무작위 문자 조합이 대량으로 생성되는 상황에서 U가 포함되면 영어권에서 불쾌한 단어 패턴이 등장할 확률이 올라가는데, 알파벳에서 U 자체를 빼면 그런 조합이 애초에 나올 수 없습니다. I·L·O가 "가독성" 문제라면 U는 "우연히 나쁜 의미가 만들어지는 것을 막는" 순전히 사회적인 이유로 빠진 유일한 글자입니다.
5. 타임스탬프 파트: 왜 서기 10889년까지인가
ULID 26자는 앞 10자(타임스탬프)와 뒤 16자(랜덤)로 나뉩니다. 실제 생성 코드를 보면 encodeBase32(ts, 10)로 밀리초 단위 유닉스 타임스탬프를 10글자로 인코딩합니다. 10글자 × 5비트 = 50비트 공간이지만 ULID 스펙이 실제로 사용하는 건 48비트뿐이라, 앞자리 2비트는 항상 0으로 남는 여유 공간입니다. 48비트로 표현 가능한 최대 밀리초 값은 2⁴⁸-1이고, 이를 연도로 환산하면 다음과 같습니다.
| 항목 | 값 |
|---|---|
| 타임스탬프 비트 수 | 48비트 |
| 표현 가능한 최대 밀리초 | 2⁴⁸ − 1 = 281,474,976,710,655ms |
| 연 단위 환산 (÷1000÷60÷60÷24÷365.25) | 약 8,919년 |
| 유닉스 에폭(1970년) 기준 최대 표현 연도 | 서기 약 10889년 |
| 랜덤 파트 비트 수 / 문자 수 | 80비트(10바이트) / 16자 (40비트씩 2묶음 → 8자씩) |
즉 48비트를 10글자(50비트 공간)에 인코딩하면서 2비트를 여유로 남긴 설계 덕분에, 실용적으로는 사실상 무한에 가까운 미래 시점까지 밀리초 정밀도로 정렬 가능한 ID를 만들 수 있는 셈입니다. 참고로 랜덤 파트는 crypto.getRandomValues()로 얻은 80비트 진짜 난수를 그대로 반영하므로, 같은 밀리초 안에서 충돌할 확률은 2⁸⁰분의 1 수준으로 무시할 만합니다.
6. 그래서 언제 ULID를, 언제 UUID를 쓰나
정렬 가능성이 필요 없고 단순히 전역적으로 고유하기만 하면 되는 경우엔 익숙한 UUID 생성기로 충분합니다. 반면 데이터베이스 기본키처럼 삽입 순서와 문자열 정렬 순서가 일치해야 인덱스 단편화가 줄어드는 상황이라면 ULID가 유리합니다. 랜덤 문자열 ID를 여러 개 한꺼번에 만들어야 한다면 NanoID 생성기처럼 커스텀 알파벳·길이를 지정할 수 있는 도구도 고려할 만합니다.
자주 묻는 질문
Q. 표준 Base32(RFC 4648)와 Crockford Base32를 같이 쓰면 안 되나요?
알파벳 매핑 자체가 다르기 때문에 디코더가 서로 호환되지 않습니다. RFC 4648 Base32로 인코딩된 문자열을 Crockford 디코더에 넣으면 다른 값이 나오거나 에러가 납니다. 반드시 인코딩한 쪽과 같은 방식으로 디코딩해야 합니다.
Q. 소문자로 된 ULID도 유효한가요?
Crockford Base32는 대소문자를 구분하지 않고 디코딩하도록 설계되어 있어, 소문자로 입력해도 대문자로 정규화해 해석하는 구현이 일반적입니다. 다만 생성 시에는 대문자로 출력하는 것이 표준 관례입니다.
Q. ULID의 타임스탬프 부분만 보고 생성 시각을 알 수 있나요?
네. 앞 10자를 Base32 디코딩하면 밀리초 단위 유닉스 타임스탬프가 그대로 복원됩니다. 이 때문에 ULID는 UUID와 달리 문자열만 보고도 대략적인 생성 시점을 역산할 수 있다는 점을 보안 관점에서 유의해야 합니다.
Q. I, L, O, U가 빠졌다고 문자 공간이 부족해지지 않나요?
32진법 자체는 26자가 아니라 정확히 32개의 서로 다른 기호만 있으면 되므로, 숫자 10개와 영문 22개를 조합해도 여전히 32개를 채울 수 있습니다. 오히려 헷갈리는 문자를 뺀 덕분에 실사용 신뢰도가 올라간다는 게 이 설계의 핵심입니다.