it-box

UUID와 ULID 차이 DB 인덱스 기준 비교

UUID v4·v7과 ULID의 구조를 비교하고, 정렬 가능성과 DB 인덱스 영향, 충돌 확률 감각, 상황별 선택 기준을 계산 가능한 예제로 정리합니다.

식별자 형식을 정하는 자리

새 테이블을 만들 때 기본 키를 정해야 합니다. 자동 증가 정수를 쓰는 방법이 가장 간단하지만, 여러 서버가 동시에 데이터를 만들거나 외부에 내부 순번을 노출하고 싶지 않을 때는 UUID나 ULID를 많이 씁니다. 두 형식은 모두 128비트 값을 문자열로 표현한 것이라 겉모습이 비슷합니다. 하지만 값이 어떻게 만들어지는지에 따라 정렬 순서와 인덱스 성능이 크게 달라집니다.

이 글은 다음 질문에 답하는 것을 목표로 합니다.

  • UUID v4, UUID v7, ULID는 각각 무엇으로 구성되는가?
  • 어떤 형식이 정렬 가능하고, 그것이 DB 인덱스에 어떤 영향을 주는가?
  • 충돌 확률은 실무에서 걱정할 수준인가?
  • 상황에 따라 무엇을 고르는가?

예제 값은 Node.js로 직접 계산한 결과입니다.

UUID v4: 무작위 122비트

UUID는 RFC 9562에 정의된 128비트 식별자입니다. 문자열은 8-4-4-4-12 형태의 16진수 36자입니다. v4는 대부분 무작위 값으로 만들어집니다. 버전 비트 4개와 변형 비트 2개를 제외하면 무작위 값은 122비트입니다.

UUID 생성기의 검증 규칙을 보면 세 번째 그룹의 첫 자리가 1~5, 네 번째 그룹의 첫 자리가 8, 9, a, b 중 하나여야 한다고 되어 있습니다. 이 도구는 v1, v3, v4, v5와 NIL 값을 생성합니다. 최대 50개까지 한 번에 만들 수 있습니다.

v4 예시는 다음과 같습니다.

8361b204-890f-44d8-a4cb-17f6bab65549

세 번째 그룹이 44d8이고, 네 번째 그룹이 a4cb입니다. 여기서 4가 버전, a가 변형 표시입니다. 나머지 자리는 무작위입니다. 따라서 v4 값만 보고 생성 시각을 알 수는 없습니다.

UUID v7: 앞부분이 시간인 UUID

RFC 9562는 시간 기반 정렬을 위한 v7도 정의합니다. v7은 앞 48비트에 Unix 밀리초 타임스탬프를 넣고, 나머지에 무작위 값을 넣습니다. 같은 밀리초에 만들어진 값끼리는 무작위 부분으로 순서가 정해집니다.

2026년 11월 10일 0시 UTC(한국 시간 오전 9시)의 밀리초 값은 1794268800000입니다. 이 값을 16진수로 바꾸면 1a1c2c12400이고, 12자리로 맞추면 01a1c2c12400입니다. v7 형식의 예시는 다음과 같습니다. 무작위 부분을 고정해서 구조만 보여 주는 예입니다.

01a1c2c12400-7abc-8def-0123456789ab
01a1c2c12401-7abc-8def-0123456789ab

1밀리초 뒤의 값은 앞부분이 한 칸만 증가합니다. 이 성질이 DB 인덱스에서 중요합니다. 다만 UUID 생성기는 v7을 제공하지 않습니다. v7이 필요하면 최신 라이브러리를 쓰거나, 사양에 맞춰 직접 구현해야 합니다. 직접 구현할 때는 무작위 부분에 암호학적으로 안전한 난수원을 써야 합니다.

ULID: 26자 Crockford Base32

ULID(Universally Unique Lexicographically Sortable Identifier)는 UUID와 같은 128비트 구조를 가지지만, 표현 방식이 다릅니다. 앞 48비트는 밀리초 타임스탬프이고, 뒤 80비트는 무작위 값입니다. 문자열은 Crockford Base32로 인코딩해서 26자입니다. 이 문자표는 혼동하기 쉬운 I, L, O, U를 뺍니다.

ULID 생성기는 원시(raw) 형식과 JSON 배열 형식을 지원하고, 최대 100개까지 만들 수 있습니다. 실제 타임스탬프 부분을 확인해 보겠습니다.

const { ulid, decodeTime, encodeTime } = require('ulid');

encodeTime(1794268800000, 10); // '01M71C2900'
decodeTime('01M71C2900YM8X20K37GYCR0QF');
// 1794268800000  →  2026-11-10T00:00:00.000Z

처음 10자가 시간을 나타냅니다. 나머지 16자가 무작위 부분입니다. 같은 밀리초에 여러 개를 만들면 앞 10자는 같고, 뒤쪽 16자가 달라집니다.

같은 밀리초 안에서 순서를 보장하려면 monotonicFactory를 써야 합니다. 이 함수로 같은 밀리초에 두 값을 만들면 마지막 자리가 한 칸 증가합니다.

01M71C2900H9WQPPYY8KEJY4RM
01M71C2900H9WQPPYY8KEJY4RN

일반 ulid() 호출은 같은 밀리초 안에서 무작위 순서로 나옵니다. 따라서 "같은 밀리초에 만들어진 값끼리도 생성 순서대로 정렬된다"는 기대를 하려면 monotonic 모드를 써야 합니다. 이 차이를 모르고 정렬 순서에 의존하면 가끔 틀린 순서가 나옵니다.

정렬 가능성은 문자열 비교로 확인된다

ULID의 가장 큰 장점은 문자열 자체가 시간 순서와 같다는 점입니다. 길이가 26자로 고정되어 있고 문자표의 순서가 ASCII 순서와 일치하므로, 문자열 비교만으로 시간 순서가 나옵니다. 앞의 예제에서 1밀리초 뒤의 값은 실제로 사전순으로 큽니다.

const a = ulid(1794268800000);
const b = ulid(1794268800001);
a < b; // true

UUID v4는 이 성질이 없습니다. 무작위 값이므로 생성 순서와 정렬 순서가 관계가 없습니다. UUID v7은 앞부분이 시간이므로 정렬이 됩니다. 단, 문자열 비교를 쓰려면 하이픈 위치와 대소문자 규칙이 모든 시스템에서 같아야 합니다. 소문자로 통일하는 규칙을 정해 두는 편이 안전합니다.

DB 인덱스에 미치는 영향

B-트리 인덱스는 키가 정렬된 순서로 저장됩니다. 새 키가 들어올 때 정렬 위치가 항상 끝이면 가장 오른쪽 페이지에만 쓰기가 몰립니다. 반면 키가 무작위이면 새 키가 인덱스 전체에 흩어져 들어갑니다.

이 차이가 실무에서 나타나는 방식은 다음과 같습니다.

  • UUID v4: 새 행이 인덱스의 여러 페이지에 흩어져 삽입됩니다. 페이지가 자주 가득 차서 분할이 일어나고, 메모리에 올라온 페이지 캐시의 재사용률이 떨어집니다. 테이블이 커질수록 이 비용이 눈에 띄기 쉽습니다.
  • UUID v7과 ULID: 새 행이 대부분 인덱스의 끝에 추가됩니다. 쓰기가 한 페이지에 모이고, 최근 데이터를 조회하는 패턴과도 잘 맞습니다.

클러스터드 인덱스를 쓰는 DBMS에서는 이 차이가 더 큽니다. 기본 키 순서대로 데이터 자체가 저장되기 때문입니다. 반대로 키 순서와 상관없이 해시 인덱스를 쓰는 경우에는 차이가 줄어듭니다.

저장 공간도 확인해야 합니다. UUID와 ULID는 모두 128비트이므로 바이너리로 저장하면 16바이트입니다. 문자열로 저장하면 UUID는 36바이트, ULID는 26바이트입니다. ULID의 문자열이 짧은 이유는 하이픈이 없고 문자표가 더 촘촘하기 때문입니다. 다만 DB 컬럼 타입을 uuid로 쓸 수 있는 경우에는 바이너리 저장이 가능하고, 이 경우 두 형식의 크기 차이는 사라집니다.

충돌 확률에 대한 감각

실무에서 UUID v4의 충돌은 거의 걱정하지 않아도 됩니다. 무작위 122비트에서 생일 문제 근사식 n² / (2 × 2^122)로 계산하면 다음과 같습니다.

생성 개수 n 충돌 확률 근사
100만 개 약 9.4 × 10⁻²⁶
10억 개 약 9.4 × 10⁻²⁰
1조 개 약 9.4 × 10⁻¹⁴

이 값은 "모든 값이 무작위로 독립적으로 만들어진다"는 가정에서의 계산입니다. 실제 위험은 충돌보다 난수원 품질과 구현 실수에서 옵니다. 예를 들어 시드가 고정된 난수 생성기를 쓰거나, 여러 서버가 같은 상태에서 난수를 만들면 충돌 확률이 이론값보다 훨씬 높아집니다.

ULID는 같은 밀리초 안에서 무작위 80비트에 의존합니다. 따라서 같은 밀리초에 많은 값을 만들면 충돌 확률은 UUID v4보다 높습니다. 같은 밀리초에 1,000개를 만든다고 가정하면 근사치는 n² / (2 × 2^80)으로 약 4 × 10⁻¹⁹입니다. 실무에서는 이 정도도 무시할 수 있지만, 초당 수십만 개를 한 서버에서 만드는 경우에는 monotonic 모드나 샤드 번호 같은 보조 장치를 검토할 만합니다.

토큰 생성기와 식별자는 다르다

토큰 생성기는 임의 길이의 문자열을 만드는 도구입니다. 이 도구는 crypto.getRandomValues()로 문자를 고르지만, 토큰이 무엇에 쓰이는지는 도구가 정하지 않습니다.

따라서 다음과 같이 구분합니다.

  • 식별자(행을 구분하는 키): UUID나 ULID를 씁니다. 추측되어도 권한이 생기지 않아야 합니다.
  • 비밀 값(세션 토큰, API 키, 비밀번호 재설정 링크): 암호학적으로 안전한 난수원을 써야 합니다. 이 도구의 결과를 그대로 쓰면 안 됩니다.

UUID v4는 난수원이 암호학적으로 안전한 구현을 쓰면 추측이 어렵습니다. 그래도 이 형식은 식별자용으로 설계되었고, 버전과 변형 비트가 고정된 구조라서 비밀 값의 길이와 엔트로피를 명확히 설명하기 어렵습니다. 세션 토큰이나 API 키는 용도에 맞게 설계된 토큰 생성 방식을 쓰는 편이 명확합니다.

식별자에 시간이 들어갈 때의 노출

UUID v7과 ULID는 앞부분에 생성 시각이 들어갑니다. 위의 decodeTime 예제처럼 누구나 값을 보면 생성 시각을 계산할 수 있습니다. 한국 서비스에서 주문번호나 게시물 ID가 이런 형식이면, 외부에서 하루 주문량이나 가입 추이를 추정할 수 있습니다. 대부분의 경우 큰 문제는 아니지만, 공개 범위를 고려해서 결정해야 합니다.

개인정보와 관련해서도 주의할 점이 있습니다. 식별자에 사용자 전화번호나 주민등록번호를 넣어 만드는 방식은 피해야 합니다. 식별자는 무작위이거나 시간 정보만 담아야 하고, 개인정보는 별도 컬럼에서 필요한 최소한만 보관합니다.

상황별 선택 기준

  • 인덱스 쓰기 성능이 중요하고 생성 순서가 의미가 있다: ULID 또는 UUID v7을 씁니다.
  • 외부 시스템과 UUID 형식을 맞춰야 한다: UUID v4나 v7을 씁니다. 라이브러리 지원 여부를 먼저 확인합니다.
  • 생성 시각을 드러내고 싶지 않다: UUID v4를 씁니다.
  • 여러 시스템이 중앙 조율 없이 만든 값을 같은 테이블에 넣는다: UUID v4, v7, ULID 모두 가능하지만, 정렬이 필요하면 v7이나 ULID를 씁니다.
  • 사람이 읽고 말로 옮길 일이 많다: 둘 다 불편합니다. 짧은 별도 코드를 만드는 것을 검토합니다.
  • 비밀 값이 필요하다: 식별자 형식을 쓰지 말고 난수원을 명확히 한 토큰 생성 방식을 씁니다.

실무 점검 목록

  • 기본 키 형식을 정할 때 정렬과 인덱스 성능을 함께 검토했는가?
  • UUID v7이나 ULID를 쓴다면 문자열 대소문자와 하이픈 규칙을 통일했는가?
  • ULID를 같은 밀리초에 대량 생성하는 경우 monotonic 모드를 쓰는가?
  • 식별자를 인증 토큰이나 비밀 값으로 쓰고 있지 않은가?
  • 식별자에 개인정보(전화번호, 주민등록번호 등)가 들어가지 않는가?
  • 외부에 노출되는 식별자에서 생성 시각이 드러나도 괜찮은지 결정했는가?
  • DB 컬럼을 바이너리 16바이트로 저장할지, 문자열로 저장할지 정했는가?
  • 테스트 데이터에서 고정된 식별자를 쓰고 있다면, 운영 코드 경로와 섞이지 않는지 확인했는가?

식별자 선택은 한 번 정하면 바꾸기 어렵습니다. 정렬이 필요한지, 시간이 드러나도 되는지, 인덱스 쓰기 패턴이 어떤지를 먼저 적어 보고 형식을 고르면 대부분의 결정은 빠르게 끝납니다.

관련 도구