10 KiB
title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
| title | source_type | status | confidence | tags | related_projects | last_reviewed | canonical_sources | audience | target_publish | status_label | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Resource Identifier를 UUID 대신 ULID로 고정한 이유 | blog | verified | high |
|
|
2026-07-03 |
|
backend-engineer | ready |
Resource Identifier를 UUID 대신 ULID로 고정한 이유
Parent / 부모 (필수)
- 핵심 canonical: wiki/projects/ca-tmpl/resource-identifier-format
- 관련 개념 문서: wiki/concepts/resource-identifier-format - 일반 identifier format 비교. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
타깃 독자 / Target reader
- 독자 profile: API resource id 규칙을 skeleton 수준에서 정하려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: UUID, database primary key, API id.
- 처음 듣는다고 가정하는 것: ULID의 표기 규칙, 생성 위치, id-kind governance를 architecture rule로 고정하는 방식.
도입 / Hook
API의 id는 처음에는 단순한 문자열처럼 보입니다. 하지만 id가 어디에서 생성되는지, 어떤 형태로 외부에 노출되는지, DB에는 어떤 타입으로 저장되는지, 어떤 계층이 id를 만들 수 있는지까지 정하지 않으면 나중에 작은 균열이 생깁니다. controller에서 UUID.randomUUID()를 부르고, 다른 use case에서는 DB sequence를 쓰고, 또 다른 API는 문자열 id를 그대로 반환하는 식입니다.
ca-tmpl은 이 문제를 “UUID냐 ULID냐”의 취향 싸움으로만 보지 않았습니다. 외부 API에는 26자 대문자 Crockford base32 ULID를 노출하고, domain은 id를 value object로 다루며, 생성은 adapter port 뒤로 숨기고, persistence는 PostgreSQL uuid column으로 저장하는 계약으로 묶었습니다. 이 글은 그 결정이 왜 필요했는지, 어떤 코드로 고정됐는지, 그리고 아직 검증했다고 말하면 안 되는 부분이 무엇인지 정리합니다.
본문 outline / Body outline
- identifier를 나중에 정하면 생기는 문제.
- ULID를 API 표기 규칙으로 선택한 이유와 trade-off.
- domain value object, generation port, adapter module의 역할 분리.
- persistence와 wire format의 분리.
- ArchUnit rule로 id governance를 고정한 범위.
- 운영 규모에서의 index/locality 검증은 없음.
본문 / Body
Resource id 설계에서 먼저 정해야 하는 것은 “id 값이 무엇인가”보다 “누가 id를 만들 수 있는가”입니다. controller가 직접 UUID.randomUUID()를 호출하면 use case마다 생성 방식이 갈라질 수 있습니다. application service가 라이브러리에 직접 의존하면 domain model은 순수해 보여도 use case가 infrastructure detail을 알고 있게 됩니다. DB가 id 생성을 전담하면 API에 노출되는 id format과 persistence type이 묶입니다.
ca-tmpl은 이 지점을 domain port로 끊었습니다. domain-core에는 ResourceId marker와 IdFactory<T> port가 있고, sample domain에는 WorkLogId와 WorkLogIdFactory가 있습니다. WorkLogId는 26자 대문자 Crockford base32 ULID 문자열만 받는 value object입니다. domain은 ULID library를 직접 알지 않습니다. 실제 생성은 adapter-identifier module의 UlidWorkLogIdFactory가 맡습니다. 이렇게 하면 domain은 “id shape”만 알고, “id를 어떻게 mint하는가”는 adapter가 책임집니다.
ULID를 고른 이유는 API 표기와 정렬성의 균형입니다. ULID는 26자 문자열이라 URL path에 넣기 쉽고, 시간 성분이 앞에 있어 생성 시점 기준 정렬 가능성이 있습니다. ca-tmpl에서는 이를 외부 wire format으로 삼았습니다. 다만 이 말이 곧 “모든 DB에서 insert 성능이 검증됐다”는 뜻은 아닙니다. project canonical은 PostgreSQL index locality benchmark가 없다고 명시합니다. 이 글도 그 선을 넘지 않습니다.
재미있는 부분은 DB 저장 방식입니다. API와 domain에서는 ULID 문자열을 쓰지만, JPA entity는 UUID field를 PostgreSQL native uuid column에 저장합니다. UlidCodec이 ULID 문자열과 UUID 사이 변환을 맡고, persistence mapper가 domain WorkLogId와 entity UUID 사이를 변환합니다. 즉 외부 계약은 “대문자 ULID 문자열”이고, DB 저장 계약은 “native uuid type”입니다. 두 계약을 같은 문자열 column으로 합쳐버리지 않은 셈입니다.
wire format도 별도로 고정했습니다. Java record인 WorkLogId를 그대로 Jackson이 직렬화하면 { "value": "..." } 형태가 될 수 있습니다. ca-tmpl은 WorkLogIdSerializer를 두어 응답에서는 bare ULID string이 나가도록 했습니다. 이 결정 덕분에 API 소비자는 id field를 객체가 아니라 문자열로 다룹니다. 내부 value object와 외부 JSON shape를 분리한 것입니다.
id governance는 ArchUnit rule로도 고정되어 있습니다. domain entity의 id field는 ResourceId여야 하고, controller/application layer는 resource id 생성을 위해 UUID.randomUUID()나 UlidCreator에 직접 닿지 않아야 합니다. Math.random()도 id seed로 쓰지 못하게 막습니다. JPA @Column으로 매핑된 id field가 기본 varchar(255)로 떨어지는 것도 금지합니다. 또 adapter-identifier는 sibling adapter나 bootstrap에 의존하지 못합니다.
여기까지가 ca-tmpl이 실제로 구현하고 로컬 검증한 범위입니다. adapter-identifier module, WorkLogId, UlidCodec, persistence mapper, JSON serializer, architecture rule과 테스트가 존재합니다. 반면 CUID2 override, multi-tenancy까지 포함한 id scoping, log scrubber, PostgreSQL index benchmark는 구현됐다고 말하면 안 됩니다. 이 글의 결론은 “ULID가 어디서나 이긴다”가 아니라, “ca-tmpl은 id format을 API/Domain/Persistence/Architecture rule까지 이어지는 계약으로 만들었다”입니다.
코드 예제 / Code samples (있다면)
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: domain-core/.../ResourceId.java, ca-tmpl @f6fbd4e196b4
public interface ResourceId<SELF extends ResourceId<SELF>> {
/** The canonical 26-character uppercase Crockford base32 ULID string. */
String value();
}
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: sample-portfolio/.../WorkLogId.java, ca-tmpl @f6fbd4e196b4
public record WorkLogId(String value) implements ResourceId<WorkLogId> {
private static final Pattern PATTERN = Pattern.compile("^[0-9A-HJKMNP-TV-Z]{26}$");
public WorkLogId {
if (value == null || !PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid WorkLogId format: " + value);
}
}
}
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: adapter-identifier/.../UlidCodec.java, ca-tmpl @f6fbd4e196b4
public static String normalize(String input) {
if (input == null) {
return null;
}
return Ulid.from(input.toUpperCase(Locale.ROOT)).toString();
}
public static UUID toUuid(String ulidString) {
return Ulid.from(ulidString).toUuid();
}
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: sample-portfolio/.../WorkLogEntity.java, ca-tmpl @f6fbd4e196b4
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
@JdbcTypeCode(SqlTypes.UUID)
private UUID id;
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule NO_UUID_RANDOM_IN_CONTROLLER =
noClasses()
.that()
.resideInAnyPackage("..adapter.web..controller..", "..application..")
.should()
.callMethod(UUID.class, "randomUUID")
.orShould()
.dependOnClassesThat()
.haveFullyQualifiedName("com.github.f4b6a3.ulid.UlidCreator");
Sources / 근거 (canonical 인용 필수, derived layer 의무)
- wiki/projects/ca-tmpl/resource-identifier-format - 이 글의 1차 canonical. ULID wire format,
ResourceId,IdFactory,adapter-identifier, persistence UUID column, ArchUnit rule, local verification, 미구현 항목 경계를 따른다. - wiki/concepts/resource-identifier-format - 관련 개념 문서. UUIDv7/ULID/Snowflake/NanoID/CUID2 등 일반 비교를 위한 배경으로만 둔다.
사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는
ResourceId,IdFactory,WorkLogId,WorkLogIdFactory,UlidWorkLogIdFactory,UlidCodec,WorkLogEntity,WorkLogPersistenceMapper,WorkLogIdSerializer가 존재한다. 근거: wiki/projects/ca-tmpl/resource-identifier-format - 사실:
adapter-identifiermodule과 identifier 관련 ArchUnit rule이 존재하고, project canonical은 이를 local verification 범위로 기록한다. 근거: wiki/projects/ca-tmpl/resource-identifier-format - 사실: PostgreSQL uuid index locality benchmark, CUID2 override, multi-tenancy id scoping,
UlidLogScrubber는 구현/운영 검증으로 말하지 않는다. 근거: wiki/projects/ca-tmpl/resource-identifier-format - 의견: ca-tmpl 같은 skeleton에서는 id를 단순 primitive로 두는 것보다 value object와 generation port로 고정하는 편이 이후 boundary rule을 설명하기 쉽다.
- 알지 못하는 것: production insert/index metric, tenant별 id collision/lookup 운영 결과.
답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- ca-tmpl에서 resource id 생성이 왜 adapter port 뒤에 있는가?
- API에는 ULID string을 노출하면서 DB에는 왜 native
uuidcolumn을 쓰는가? - 어떤 ArchUnit rule이 id 생성 위치와 id column mapping을 막는가?
- 다음 글로 넘길 부분:
- sharding/partitioning 환경의 id 전략.
- PostgreSQL index locality benchmark.
- multi-tenant id scoping과 tenant-aware repository rule.
게시 체크리스트 / Publish checklist
- 모든 사실 주장에 canonical 링크 있음
- 사실 vs 의견 분리 명시됨
- 금지 마케팅 표현 없음
- 코드 예제 출처 명시
- 타깃 독자 가정과 톤 일치
/lint통과- 게시 URL 기록 (게시 후):