ULID와 UUIDv7: 기본키 선택을 가르는 표준·인코딩·DB 호환성 차이
새 프로젝트를 시작할 때마다 기본키(Primary Key)를 어떻게 설계할지 한 번쯤 고민하게 됩니다. AUTO_INCREMENT는 분산 환경에서 금방 한계가 드러나고, UUIDv4는 완전 무작위라 B-tree 인덱스를 뒤죽박죽으로 만듭니다. B-tree 인덱스는 값이 순서대로 삽입될 때 가장 효율적인데, 완전 무작위 값이 들어오면 페이지 분할(page split)이 빈번해져 쓰기 성능이 눈에 띄게 저하됩니다. 그 대안으로 "시간 순 정렬이 되는 ID"를 찾다 보면 어김없이 ULID와 UUIDv7이 함께 등장하죠.
저도 처음엔 "둘 다 타임스탬프 붙은 UUID 아냐?"라고 가볍게 생각했다가, 막상 테이블 스키마를 설계하면서 둘의 차이가 생각보다 크다는 걸 뼈저리게 느꼈습니다. 이 글을 읽고 나면 표준 여부, 인코딩 방식, DB 호환성 세 가지 축에서 두 포맷의 차이가 명확하게 정리되고, 자신의 프로젝트 상황에 맞는 선택을 바로 내릴 수 있을 겁니다.
RFC 표준이 필요하거나 기존 UUID 인프라를 그대로 이어 쓰고 싶다면 UUIDv7, URL 가독성과 비표준 수용이 가능한 신규 서비스라면 ULID가 맞는 선택입니다. 아래에서 그 근거를 하나씩 살펴보겠습니다.
핵심 개념
ULID — 가독성을 위해 태어난 커뮤니티 스펙
ULID(Universally Unique Lexicographically Sortable Identifier)는 2016년 Alizain Feerasta가 제안한 포맷입니다. 48비트 Unix 밀리초 타임스탬프와 80비트 랜덤값을 Crockford Base32로 인코딩해 26자의 대소문자 구분 없는 문자열로 표현합니다.
01ARZ3NDEKTSV4RRFFQ69G5FAV
└──────────┘└────────────────┘
타임스탬프(10자) 랜덤(16자)80비트는 2^80, 즉 약 1조의 1조 배에 달하는 경우의 수입니다. 초당 100만 개를 생성해도 충돌이 발생하려면 사실상 천문학적 시간이 걸리는 수준이라, 분산 환경에서 중앙 조정 없이 안심하고 쓸 수 있습니다.
Crockford Base32에서는 O, I, L, U 같이 숫자나 다른 문자와 헷갈리기 쉬운 문자를 아예 제거했습니다. 그래서 사람이 직접 눈으로 보거나 타이핑할 때 오타가 줄어들고, 결과 문자열이 URL-safe해서 /posts/01ARZ3NDEKTSV4RRFFQ69G5FAV 같은 경로에 그대로 쓸 수 있습니다.
Crockford Base32: 0~9와 알파벳 22자(O, I, L, U 등 혼동 유발 문자 제외)로 이루어진 32진수 인코딩 방식. 표준 Base32보다 사람 친화적이며, 대소문자를 구분하지 않습니다.
UUIDv7 — 2024년 IETF가 공식 발행한 RFC 표준
UUIDv7은 2024년 5월 RFC 9562로 공식 발행된 IETF 표준입니다. 구조는 128비트로, 48비트 타임스탬프 + 4비트 버전 + 12비트 rand_a + 2비트 변형(variant) + 최대 62비트 rand_b로 이루어집니다. 외형은 우리가 늘 보던 UUID 하이픈 형식 그대로입니다.
018f7c83-6afe-7e3d-b1a2-4c5d6e7f8a9b
└─────────────┘
타임스탬프 48비트 (앞부분에 정렬)RFC 9562: IETF(인터넷 기술 표준화 기구)가 2024년 5월 공식 발행한 UUID 표준 문서. UUIDv1~v5를 정의한 RFC 4122를 대체하며, v6·v7·v8을 새롭게 정의합니다.
UUIDv7의 가장 큰 장점은 기존 UUID 컬럼과 완전 호환된다는 점입니다. 스키마를 하나도 건드리지 않고 생성 로직만 UUIDv4에서 UUIDv7으로 바꿀 수 있어서, 레거시가 있는 서비스에서 특히 매력적입니다.
한 가지 알아두면 좋은 점은 rand_a 12비트를 카운터로 활용하면 실질적인 무작위 비트가 줄어든다는 것입니다. RFC 9562는 rand_a 활용 방식을 구현에 위임하고 있어서, 라이브러리에 따라 실질적인 랜덤 비트는 최대 74비트 수준이 됩니다.
두 포맷을 나란히 놓으면
두 포맷 모두 타임스탬프를 앞에 배치해 시간 순 정렬이 가능하고, 분산 환경에서 중앙 조정 없이 독립 생성이 됩니다. UUIDv4의 완전 무작위성이 B-tree 인덱스를 단편화시키던 문제를 둘 다 해결하도록 설계된 거죠. 실제 차이는 "어떻게 포장하느냐"에서 갈립니다.
| 항목 | ULID | UUIDv7 |
|---|---|---|
| 표준 여부 | 커뮤니티 스펙 (비표준) | IETF RFC 9562 공식 표준 |
| 인코딩 | Crockford Base32 (26자) | 하이픈 UUID 텍스트 (36자) |
| 바이너리 크기 | 16B | 16B |
| 랜덤 비트 | 80비트 | 최대 74비트 (구성에 따라 변동) |
| DB 네이티브 지원 | 없음 | PostgreSQL 18, MariaDB 11.7 |
| 기존 UUID 컬럼 호환 | 불가 | 완전 호환 |
실전 적용
PostgreSQL 신규 서비스에서 UUIDv7 적용하기
PostgreSQL 18은 uuidv7() 내장 함수를 추가했습니다. 현재(2026년 5월 기준) 출시 상태와 정확한 버전을 SELECT version();으로 직접 확인하고 사용하시면 좋습니다. 외부 라이브러리 없이 DB 레이어에서 바로 생성하고, 기존 uuid 컬럼 타입을 그대로 쓸 수 있습니다.
-- PostgreSQL 18 이상
CREATE TABLE posts (
id UUID DEFAULT uuidv7() PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 삽입 예시
INSERT INTO posts (title, body) VALUES ('첫 번째 글', '내용...');
-- 시간 순 정렬 (인덱스 효율적)
SELECT * FROM posts ORDER BY id;
-- ID에서 타임스탬프 추출 (PostgreSQL 18 신기능)
SELECT uuid_extract_timestamp(id) FROM posts LIMIT 5;DB에서 생성하지 않고 앱에서 직접 만들고 싶다면 이렇게 활용할 수 있습니다.
// TypeScript — uuid 패키지 v10+
import { v7 as uuidv7 } from 'uuid';
// TypeORM 엔티티 — 인스턴스 필드 초기화 대신 @BeforeInsert로 설정해야
// TypeORM이 값을 null로 덮어쓰는 문제를 피할 수 있습니다
@Entity()
export class Post {
@PrimaryColumn('uuid')
id: string;
@BeforeInsert()
generateId() {
if (!this.id) {
this.id = uuidv7();
}
}
@Column()
title: string;
}# Python — pip install uuid7
from uuid7 import uuid7
post_id = uuid7()
print(str(post_id))
# '018f7c83-6afe-7e3d-b1a2-4c5d6e7f8a9b'| 핵심 설명 | 내용 |
|---|---|
DEFAULT uuidv7() |
DB 레벨 생성 — 앱 코드 의존 없이 항상 정렬 보장 |
uuid_extract_timestamp() |
별도 created_at 컬럼 없이도 생성 시각 추출 가능 |
@BeforeInsert() 훅 |
인스턴스 필드 직접 초기화보다 이 방식을 쓰는 것이 TypeORM과 충돌이 없음 |
URL에 ID를 노출하는 서비스에서 ULID 활용하기
API 경로나 공유 URL에 ID를 직접 담는 경우, ULID의 26자 문자열은 그대로 활용할 수 있습니다. 018f7c83-6afe-7e3d-b1a2-4c5d6e7f8a9b 같은 UUID를 URL에 넣으면 길이도 길고 하이픈이 시각적으로 복잡해 보이는데, ULID는 하이픈도 없고 10자 가량 짧습니다. 참고로 하이픈 자체는 RFC 3986 기준 비예약 문자(unreserved character)라 퍼센트 인코딩이 필요하지 않습니다. ULID의 URL 장점은 인코딩 여부가 아니라 26자의 짧은 길이와 대소문자 무감각성에 있습니다.
// TypeScript — ulid 패키지
import { ulid } from 'ulid';
const postId = ulid();
// '01ARZ3NDEKTSV4RRFFQ69G5FAV'
// Express.js 라우터 예시
app.get('/posts/:id', async (req, res) => {
// URL: /posts/01ARZ3NDEKTSV4RRFFQ69G5FAV
const post = await db.posts.findOne({ id: req.params.id });
res.json(post);
});// Go — oklog/ulid/v2 (crypto/rand 기반 엔트로피)
import (
"crypto/rand"
"github.com/oklog/ulid/v2"
"time"
)
func generateID() string {
t := time.Now()
// math/rand + 나노초 시드 대신 crypto/rand를 써야
// 동시 요청이 몰릴 때 시드 충돌 위험을 없앨 수 있습니다
entropy := ulid.Monotonic(rand.Reader, 0)
return ulid.MustNew(ulid.Timestamp(t), entropy).String()
// "01ARZ3NDEKTSV4RRFFQ69G5FAV"
}PostgreSQL에서 ULID를 저장할 때는 컬럼 타입 선택이 중요합니다. uuid 타입 컬럼에 ULID를 넣으면 DB가 거부합니다.
-- 옵션 1: TEXT 컬럼 (단순하지만 바이너리보다 10B 더 사용)
CREATE TABLE posts (
id TEXT PRIMARY KEY CHECK (length(id) = 26),
title TEXT NOT NULL
);
-- 옵션 2: BYTEA 컬럼 (16B, 바이너리 변환 필요)
CREATE TABLE posts (
id BYTEA PRIMARY KEY,
title TEXT NOT NULL
);| 핵심 설명 | 내용 |
|---|---|
crypto/rand 엔트로피 |
암호학적으로 안전한 엔트로피 소스 — 동시 요청이 몰려도 충돌 위험 없음 |
ulid.Monotonic() |
같은 밀리초 내에서도 단순 증가(monotonic) 보장 |
TEXT vs BYTEA |
가독성 우선이면 TEXT, 저장 효율 우선이면 BYTEA 16B |
레거시 UUIDv4에서 UUIDv7으로 마이그레이션하기
기존 서비스가 UUIDv4를 쓰고 있고 인덱스 성능 문제가 생겨서 시간 순 ID로 전환해야 하는 상황, 실무에서 꽤 자주 맞닥뜨리는 케이스입니다. 이 경우 ULID로 가면 스키마 전체를 손봐야 합니다. 기존 uuid 컬럼을 TEXT 또는 BYTEA로 바꿔야 하고, 외래 키, 인덱스, ORM 설정까지 연쇄적으로 변경이 필요합니다. UUIDv7은 포맷이 동일해서 생성 로직만 교체하면 됩니다.
-- 기존 테이블 (변경 불필요)
CREATE TABLE users (
id UUID PRIMARY KEY, -- 그대로 유지
email TEXT UNIQUE NOT NULL
);
-- 마이그레이션 전: gen_random_uuid() (UUIDv4)
INSERT INTO users (id, email) VALUES (gen_random_uuid(), 'user@example.com');
-- 마이그레이션 후: uuidv7() (PostgreSQL 18)
ALTER TABLE users ALTER COLUMN id SET DEFAULT uuidv7();
-- 스키마 구조 변경 없이 기본값만 교체// Java Spring Boot — uuid-creator 라이브러리
// @GenericGenerator의 strategy에는 IdentifierGenerator 구현체가 필요합니다.
// uuid-creator는 Hibernate 어댑터를 별도 제공하므로,
// @PrePersist로 직접 생성하는 방식이 더 안전하고 이식성도 높습니다.
import com.github.f4b6a3.uuid.UuidCreator;
import java.util.UUID;
@Entity
public class User {
@Id
@Column(columnDefinition = "uuid")
private UUID id;
@PrePersist
protected void onCreate() {
if (this.id == null) {
this.id = UuidCreator.getTimeOrderedEpoch(); // UUIDv7
}
}
}| 핵심 설명 | 내용 |
|---|---|
ALTER COLUMN id SET DEFAULT |
스키마 구조 변경 없이 기본값만 교체 — 기존 데이터 무변경 |
UUID 타입 유지 |
외래 키·인덱스·ORM 매핑 모두 그대로 재사용 가능 |
@PrePersist |
Hibernate 생명주기 콜백으로 ID 생성 — 라이브러리 어댑터 없이 직접 제어 가능 |
장단점 분석
장점
두 포맷 모두 시간 순 정렬로 B-tree 인덱스 효율을 크게 개선한다는 점은 같지만, 각자 강점이 있는 영역이 다릅니다.
UUIDv7의 가장 큰 강점은 에코시스템 지원입니다. IETF 공식 표준인 덕분에 PostgreSQL 18과 MariaDB 11.7에 네이티브 함수가 들어갔고, .NET 9는 Guid.CreateVersion7()을 표준 라이브러리에 포함했습니다. Python도 3.14 버전에서 표준 uuid 모듈이 UUIDv7을 지원할 예정이고요. 기존 UUID 인프라를 그대로 이어 쓸 수 있다는 점은 레거시가 있는 조직에서 특히 결정적인 이점입니다.
ULID는 사람이 다루기 편하게 만들어진 포맷이라는 게 핵심 강점입니다. 26자의 짧고 깔끔한 문자열은 URL에 그대로 들어가도 어색하지 않고, 혼동 문자가 없어서 눈으로 읽거나 직접 타이핑할 때 오타가 줄어듭니다. 로그를 보다가 ID를 복사해야 하는 상황을 생각해보면, 36자 하이픈 문자열보다 26자 깔끔한 문자열이 훨씬 낫죠.
| 항목 | ULID | UUIDv7 |
|---|---|---|
| 표준 여부 | 커뮤니티 스펙 | IETF RFC 9562 공식 표준 |
| DB 네이티브 지원 | 없음 | PostgreSQL 18, MariaDB 11.7 |
| 기존 UUID 컬럼 호환 | 불가 | 완전 호환 |
| URL 가독성 | 높음 (26자, 하이픈 없음) | 보통 (36자, 하이픈 포함) |
| 랜덤 비트 | 80비트 | 최대 74비트 |
| 언어 생태계 | JS/Go/Rust/Python 성숙 | 빠르게 확장 중 |
단점 및 주의사항
사용하기 전에 반드시 짚고 넘어가야 할 부분이 있습니다. 실제 운영에서 이 부분을 간과하면 나중에 꽤 곤란한 상황이 생길 수 있습니다.
⚠️ 보안 주의: ULID와 UUIDv7 모두 ID에 생성 시각이 포함됩니다. API 키, 세션 토큰, CSRF 방지 토큰처럼 예측 불가능성이 중요한 곳에는 이 두 포맷을 사용하지 않는 것이 좋습니다. 이런 용도에는 여전히 UUIDv4나
crypto.randomBytes()가 적합합니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 타임스탬프 노출 (공통) | ID에서 생성 시각 추출 가능 | 보안 민감 토큰에는 UUIDv4 유지 |
| ULID — 비표준 | 커뮤니티 스펙이라 장기 지원 불확실 | 외부 라이브러리 의존 감수, 필요 시 UUIDv7 전환 계획 마련 |
| ULID — DB 호환 문제 | PostgreSQL uuid 타입에 저장 불가 |
신규 스키마에서 TEXT 또는 BYTEA 컬럼 명시적 선택 |
| ULID — 인코딩 오버헤드 | Base32 인코딩 비용으로 네이티브 UUID 생성 대비 속도 차이 발생 | 초당 수백만 생성 시나리오에서는 직접 벤치마크 후 결정 권장 |
| UUIDv7 — Java 미지원 | JDK 표준 라이브러리 미지원 (JDK-8357251 논의 중) | uuid-creator 등 서드파티 라이브러리 사용 |
| MySQL 미지원 | MySQL은 아직 앱 레이어 생성에 의존 (MariaDB 11.7은 네이티브 지원) | MySQL 환경에서는 앱 레이어 라이브러리로 대체 |
실무에서 가장 흔한 실수
- 보안 토큰에 ULID/UUIDv7 사용: 두 포맷 모두 타임스탬프가 내포되어 있습니다. API 키, 세션 토큰처럼 예측 불가능성이 중요한 곳에는 UUIDv4나
crypto.randomBytes()가 적합합니다. - ULID를 UUID 컬럼에 저장 시도:
uuid타입 컬럼에 ULID 문자열을 넣으면 DB가 거부합니다. ORM이 자동으로 UUID 컬럼을 생성하는 경우, 컬럼 타입을TEXT또는BYTEA로 명시적으로 오버라이드하는 것이 필요합니다. - 같은 밀리초 내 정렬 보장 착각: 두 포맷 모두 밀리초 단위 정렬입니다. 같은 밀리초 안에서 삽입 순서대로 정렬하려면 ULID의
Monotonic옵션이나 UUIDv7의rand_a카운터 방식을 명시적으로 활성화하는 것이 좋습니다.
마치며
RFC 표준이 필요하거나 레거시 UUID 인프라가 있다면 UUIDv7, URL 가독성과 비표준 수용이 가능한 신규 서비스라면 ULID가 맞습니다. 직접 파일럿을 적용해보니, 기존 UUIDv4 기반 이벤트 테이블에서 UUIDv7으로 생성 로직만 바꿨을 때 EXPLAIN ANALYZE 결과에서 인덱스 스캔 방식이 명확히 달라지는 걸 확인할 수 있었습니다. 스키마는 한 줄도 건드리지 않고요.
지금 바로 시작해볼 수 있는 3단계:
- 기존 코드베이스 확인:
grep -r "gen_random_uuid\|uuid4\|UUID.randomUUID" .로 현재 UUIDv4를 쓰는 위치를 파악할 수 있습니다. 기존uuid컬럼이 있다면 UUIDv7 마이그레이션이 훨씬 수월합니다. - DB 버전 확인: PostgreSQL이라면
SELECT version();, MariaDB라면SELECT VERSION();으로 버전을 확인할 수 있습니다. PostgreSQL 18 이상이면uuidv7()내장 함수를 바로 쓸 수 있고, MariaDB 11.7 이상이면 네이티브 UUIDv7 함수를 지원합니다. 그 아래 버전이라면 앱 레이어 라이브러리(uuidv10+,uuid7패키지 등)를 검토해볼 수 있습니다. - 소규모 테이블로 파일럿 적용: 사용자 테이블 전체보다 부담이 적은 로그·이벤트 테이블에 먼저 적용해보시면 좋습니다.
EXPLAIN ANALYZE SELECT * FROM events ORDER BY id LIMIT 100;으로 인덱스 스캔 방식이 바뀌는 것을 눈으로 확인할 수 있습니다.
참고 자료
- PostgreSQL 18에 UUIDv7이 도입됩니다 — jypark.pe.kr (한국어)
- RFC 9562 — Universally Unique IDentifiers (UUIDs)
- ULID Spec (공식 GitHub)
- UUIDv7 Comes to PostgreSQL 18 — The Nile
- PostgreSQL 18 UUIDv7 Support — Neon
- Exploring PostgreSQL 18's new UUIDv7 support — Aiven
- Goodbye Random Inserts: UUIDv7 vs ULID vs UUIDv4 — Medium (Ahmed K Emara)
- UUID v4 vs v7 vs ULID: Choosing the Right ID Format in 2026 — codetools.run
- Comprehensive comparison of UUID v4, v7, and ULID — iXam
- A Comparative Analysis of Identifier Schemes: UUIDv4, UUIDv7, and ULID — arXiv
- Stop using UUID v4 — UUIDv7 is the 2026 default — DEV Community
- An Introduction to ULIDs — ByteAether