161 lines
10 KiB
Markdown
161 lines
10 KiB
Markdown
---
|
|
title: Resource Identifier를 UUID 대신 ULID로 고정한 이유
|
|
source_type: blog
|
|
status: verified
|
|
confidence: high
|
|
tags: [blog, ca-tmpl, resource-identifier, ulid]
|
|
related_projects: [ca-tmpl]
|
|
last_reviewed: 2026-07-03
|
|
canonical_sources:
|
|
- wiki/projects/ca-tmpl/resource-identifier-format
|
|
audience: backend-engineer
|
|
target_publish:
|
|
status_label: 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
|
|
|
|
1. identifier를 나중에 정하면 생기는 문제.
|
|
2. ULID를 API 표기 규칙으로 선택한 이유와 trade-off.
|
|
3. domain value object, generation port, adapter module의 역할 분리.
|
|
4. persistence와 wire format의 분리.
|
|
5. ArchUnit rule로 id governance를 고정한 범위.
|
|
6. 운영 규모에서의 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 (있다면)
|
|
|
|
```java
|
|
// 출처: [[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();
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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();
|
|
}
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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;
|
|
```
|
|
|
|
```java
|
|
// 출처: [[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-identifier` module과 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 `uuid` column을 쓰는가?
|
|
- 어떤 ArchUnit rule이 id 생성 위치와 id column mapping을 막는가?
|
|
- 다음 글로 넘길 부분:
|
|
- sharding/partitioning 환경의 id 전략.
|
|
- PostgreSQL index locality benchmark.
|
|
- multi-tenant id scoping과 tenant-aware repository rule.
|
|
|
|
## 게시 체크리스트 / Publish checklist
|
|
|
|
- [x] 모든 사실 주장에 canonical 링크 있음
|
|
- [x] 사실 vs 의견 분리 명시됨
|
|
- [x] 금지 마케팅 표현 없음
|
|
- [x] 코드 예제 출처 명시
|
|
- [x] 타깃 독자 가정과 톤 일치
|
|
- [x] `/lint` 통과
|
|
- [ ] 게시 URL 기록 (게시 후):
|
|
|
|
## Related / 관련
|
|
|
|
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
|
|
- 후속 글 후보: [[wiki/blog/ca-tmpl-multi-tenancy-isolation-patterns-2026-07-02]]
|