Files
llm-wiki/wiki/projects/ca-tmpl/resource-identifier-format.md
T

157 lines
17 KiB
Markdown

---
title: ca-tmpl - Resource Identifier (ULID) 결정
source_type: project
status: verified
confidence: high
tags: [ca-skeleton, resource-identifier, ulid, actually-implemented]
related_projects: [ca-skeleton, ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Resource Identifier (ULID) 결정
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념(ULID vs UUIDv7 vs UUIDv4 vs Snowflake tradeoff)은 [[wiki/concepts/resource-identifier-format]] 참조.
## 프로젝트 컨텍스트
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
- **목표**: resource ID 형식을 **ULID** (26-char Crockford base32, time-ordered) 로 못박고, ID 가 URL / log / DB primary key / cache key / idempotency / multi-tenancy / privacy 에 미치는 계약을 한 곳에서 결정. ID 형식은 *한 번 노출되면 되돌리기 어렵다* (`/v1/worklogs/<id>` 가 client SDK + log + DB schema + cache key + FK 에 박힘) 는 인식에서 skeleton default 를 future-safe 한 선택으로 고정하는 것이 동기.
- **결정 SSOT**: [[raw/branch-notes/feature-resource-identifier-contract]] (D1~D19 + Decision Evidence Map). 본 문서는 그 중 *실제 코드로 구현된* 사실만 추출한다.
- **진행 단계**: **코드 구현 + 로컬 검증 완료.** `feature-resource-identifier-contract` 브랜치에서 domain VO + port, ULID adapter, persistence mapping, web serializer, ArchUnit rule, 단위 테스트까지 작성되어 코드 베이스에 존재한다. 운영 배포 / 실 DB 통합 테스트 / 측정값은 없다.
- **이 브랜치가 신설한 모듈**: `adapter-identifier` (비-IO 인프라 능력 어댑터). `feature-skeleton-package-blueprint-contract` 가 9번째 모듈로 OUT_OF_BRANCH_SCOPE 표시했던 영역이 본 브랜치의 산출물이다.
## Ground-truth 대조 (2026-06-04, ca-tmpl @c36b764 "ULID 리소스 식별자 계약 구현 및 adapter-identifier 모듈 생성")
`/home/donghyeon/workspace/ca-tmpl` 코드를 직접 읽어 검증한 사실 (현재 checkout HEAD = `db61075`, 본 브랜치 구현 커밋 `c36b764` 는 history 에 존재하며 식별자 코드는 HEAD 에 그대로 잔존):
- 패키지 root 는 `dev.caskeleton.*`.
- **신규 모듈 `adapter-identifier`** 실재 — `src/adapter-identifier/` (Gradle `settings.gradle:13 include 'adapter-identifier'`). `domain-core` 에만 의존하고 `ulid-creator:5.2.3` 를 implementation 으로 선언.
- domain port + marker (`ResourceId`, `IdFactory`) 는 `src/domain-core/.../domain/identifier/` 에 실재.
- sample 도메인 VO + port + adapter (`WorkLogId`, `WorkLogIdFactory`, `UlidWorkLogIdFactory`) 는 `sample-portfolio` 에 실재.
- ArchUnit rule 4개 (`no_long_id_pk` / `no_uuid_random_in_controller` / `no_math_random_for_id` / `no_varchar_255_for_id_column`) + `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap``src/app-bootstrap/.../architecture/CleanArchitectureTest.java` 에 실재. 5번째 후보 `no_find_by_id_without_tenant` 는 코드에 **없음** (브랜치 결정대로 `feature-tenant-context-policy` 로 이관).
- `./gradlew :adapter-identifier:test :sample-portfolio:test :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*ArchitectureViolationFixtureTest' --tests '*WorkLogId*' --tests '*UlidCodec*' --tests '*UlidWorkLogIdFactory*' --tests '*WorkLogIdSerializer*'` → BUILD SUCCESSFUL (2026-06-04 재실행, `src/` working dir 기준).
## 실제 구현 내용 (`actually-implemented`)
ca-tmpl 코드에서 직접 확인한 산출물:
**domain-core (재사용 가능 port + marker, `dev.caskeleton.domain.identifier.*`)**
- `ResourceId.java``ResourceId<SELF extends ResourceId<SELF>>` marker interface. `String value()` (canonical 26-char uppercase Crockford base32 ULID) 1 메서드. **의도적으로 `non-sealed`**`permits WorkLogId` 를 쓰면 `domain-core``sample-portfolio` 를 import 하게 되어 모듈 의존 규칙 위반. closed-set 보장은 `no_long_id_pk` ArchUnit rule (빌드타임) 로 대체 (Javadoc 에 사유 명시).
- `IdFactory.java``IdFactory<T extends ResourceId<?>>` domain port. `T newId()` 1 메서드. ID minting *책임* 은 도메인 port 에, 실제 *생성 행위* 는 infrastructure adapter 에 둔다 (D4/D5).
**sample-portfolio domain (`dev.caskeleton.sample.portfolio.domain.worklog.*`)**
- `WorkLogId.java``record WorkLogId(String value) implements ResourceId<WorkLogId>`. compact constructor 에서 `^[0-9A-HJKMNP-TV-Z]{26}$` regex 로 검증 (I/L/O/U 제외 Crockford base32). 도메인 안에 ULID 라이브러리 의존 없음 (canonical form 검증만).
- `WorkLogIdFactory.java``interface WorkLogIdFactory extends IdFactory<WorkLogId>` (type-specific port specialization).
- `WorkLog.java``create(WorkLogId id, ...)` / `rehydrate(WorkLogId id, ...)`. 도메인이 자기 ID 를 `UUID.randomUUID()` 로 self-mint 하지 않음 (id 는 factory 가 만들어 use case 가 주입, D4/D5).
**adapter-identifier (신규 모듈, `dev.caskeleton.adapter.identifier.*`)**
- `UlidCodec.java` — production-level, 도메인 무관 ULID 변환 유틸 (final, private ctor). `normalize(String)` (D3: case-insensitive 입력 → canonical uppercase 26-char, `Ulid.from(in.toUpperCase(Locale.ROOT)).toString()`), `toUuid(String)`, `fromUuid(UUID)` (D10: ULID ↔ 128-bit UUID).
- `package-info.java` — 이 모듈이 *non-IO 인프라 능력 어댑터* 임을 문서화. `adapter-outbound` ("external HTTP/messaging/cache/notifications") 와 구분되는 이유 = ULID 라이브러리 래퍼는 외부 시스템 통합점이 아니라 인프라 능력이라는 것.
- `build.gradle``domain-core` + `ulid-creator:5.2.3` 만 의존.
**sample-portfolio adapter (ULID 생성/직렬화/영속화)**
- `adapter/identifier/UlidWorkLogIdFactory.java``@Component implements WorkLogIdFactory`. `WorkLogId.of(UlidCreator.getMonotonicUlid().toString())`. monotonic factory (동일 ms 내 단조 증가, ULID-C5) + 내부 `SecureRandom` (D9). 주석에 "이 sample 에서 `UlidCreator` 직접 호출 허용은 여기뿐" 명시.
- `adapter/persistence/entity/WorkLogEntity.java``@Id @Column(name="id", columnDefinition="uuid", nullable=false, updatable=false) @JdbcTypeCode(SqlTypes.UUID) private UUID id`. PostgreSQL 16 native `uuid` (16-byte binary), `varchar(26/36)` 아님 (D10). tenant 컬럼은 주석으로만 (deferred to `feature-tenant-context-policy`).
- `adapter/persistence/mapper/WorkLogPersistenceMapper.java``Ulid.from(id.value()).toUuid()` / `Ulid.from(uuid).toString()` 로 ULID↔UUID 변환. persistence 가 `adapter-outbound`(및 `UlidCodec`) 에 의존하지 못하는 boundary rule 때문에 `Ulid` 를 직접 사용 (주석 명시).
- `adapter/web/json/WorkLogIdSerializer.java``@JsonComponent extends JsonSerializer<WorkLogId>`. record 기본 `{"value":"..."}` 대신 bare ULID 문자열로 직렬화 (D6 NO typed prefix, §5).
**app-bootstrap ArchUnit fitness functions** (`architecture/CleanArchitectureTest.java`, D17 결정 SSOT = 본 브랜치):
- `no_long_id_pk``..domain..` 패키지의 `id` 필드는 `ResourceId` 구현체여야 함 (`Long`/`int` 금지). JPA entity (`..adapter.persistence..`) 의 `@Id UUID id` 는 D10 정합으로 검사 대상 제외.
- `no_uuid_random_in_controller``..adapter.web..controller..` + `..application..``UUID.randomUUID()` / `com.github.f4b6a3.ulid.UlidCreator` 직접 호출 금지 (factory 주입 강제). web filter 의 trace-id 생성은 의도적으로 scope 밖 (D18).
- `no_math_random_for_id``dev.caskeleton..` 전역에서 `Math.random()` 금지 (CSPRNG 아님, D9).
- `no_varchar_255_for_id_column``@Column` 매핑된 `id` 필드는 명시적 `columnDefinition`(예: `"uuid"`) 또는 비-default length 의무. `haveExplicitColumnLength()` custom `ArchCondition` 으로 검사 (`columnDefinition` 비어있지 않거나 `length != 255`).
- `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap``adapter-identifier` 가 sibling adapter / persistence / bootstrap 에 손대지 못하도록 격리 (§4 taxonomy).
## 로컬/dev 검증 (`locally-verified`)
- 단위 테스트 PASS (2026-06-04 재실행, BUILD SUCCESSFUL):
- `WorkLogIdTest` — regex 검증 (valid / invalid / I·L·O·U 포함 거부).
- `UlidCodecTest``normalize`/`toUuid`/`fromUuid` round-trip + case-insensitive 입력.
- `UlidWorkLogIdFactoryTest` — monotonic 생성, 형식 적합.
- `WorkLogIdSerializerTest` — bare ULID 문자열 직렬화.
- `WorkLogPersistenceMapperTest`, `WorkLogRepositoryAdapterTest`, `WorkLogControllerWireTest` — ULID↔UUID 매핑 + D3 정규화 wire 경로.
- ArchUnit fitness function PASS: `CleanArchitectureTest` (위 5개 rule) + `ArchitectureViolationFixtureTest` (의도된 위반 fixture 를 실제로 잡아냄).
- 검증 범위는 **JVM 단위 테스트 + 정적 분석까지**. 실 PostgreSQL 16 connection 으로 `uuid` 컬럼 insert/index 동작을 검증한 통합 테스트는 **없음** (아래 planned).
## 운영 검증 (`prod-verified`)
**없음.** 운영 환경에 배포된 적이 없다. 측정값 / 인시던트 / 릴리즈 노트 / 벤치마크 어느 것도 없다.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음은 설계/문서/위임 상태이며 **면접에서 "구현했다 / 검증했다"고 말하면 안 된다**.
- **CUID2 override (D7)**: privacy-sensitive 도메인용 timestamp-leak-free 대안. 코드에 없음 (`documented-only`).
- **constant-time 비교 미적용 (D9)**: 공개 resource id 는 표준 record `equals` 사용. constant-time 비교는 *비밀값* 영역이라 의도적으로 적용 안 함 (`feature-security-operational-baseline` SSOT).
- **multi-tenancy ID 정합 (D13)**: ID 자체에 tenant 인코딩 거부만 결정. `TenantId` VO / `tenant` 테이블 / composite index / `findByIdAndTenant` / tenant-scoped ArchUnit rule (`no_find_by_id_without_tenant`) 은 코드에 **없음**`feature-tenant-context-policy` (예정) 위임. `WorkLogEntity` 의 tenant 컬럼은 주석으로만 존재 (`documented-only`).
- **Idempotency-Key 처리 (D14)**: resource ID(ULID) 와 idempotency key(UUID v4 client-generated) 의 *형식 분리만* 명시. TTL 저장소 / fingerprint 비교 / 422 응답은 `feature-rate-limit-idempotency-contract` 위임 (`planned`).
- **log scrubber `UlidLogScrubber` (D8/§7)**: user-linked ID redaction 코드 미작성. `feature-log-management-contract` 위임 (`documented-only`).
- **PostgreSQL 16 `uuid` index locality 벤치마크 (D10)**: ULID time-ordered insert 의 BTREE page split 완화 정량 측정 없음 (`planned`, UNSUPPORTED_IMPL_DECISION).
- **dual column (internal BIGINT + external ULID) override (D11)**: skeleton 은 external-only. dual 은 prod-grade 도메인 권고 수준 (`documented-only`).
- **OpenAPI 3.1 `pattern` schema (§5)**: 브랜치 노트의 reference fragment. 실제 generated OpenAPI 문서로의 반영은 본 문서 추출 범위에서 코드로 확인하지 않음 (`documented-only`).
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- 왜 skeleton default resource ID 로 **ULID** 를 골랐는가 — UUID v4(DB B-tree 단편화), Snowflake(worker_id 외부 조율), sequential(enumeration) 거부 + UUID v7 은 Java 21 `java.util.UUID` native 미지원이라 3rd-party 의존이면 ULID 가 URL UX(26 vs 36자) + 라이브러리 성숙도 우위. (실제 `WorkLogId` record + `UlidWorkLogIdFactory` 로 구현.)
- ID 생성 책임을 어느 계층에 뒀는가 — domain port (`IdFactory`/`WorkLogIdFactory`) 가 책임을 소유하고, infrastructure adapter (`UlidWorkLogIdFactory`) 가 실제 생성, application use case 가 주입·orchestration. 도메인이 `UUID.randomUUID()` 로 self-mint 하지 않도록 ArchUnit 으로 강제.
- ULID 를 DB 에 어떻게 저장했는가 — PostgreSQL 16 native `uuid` 타입(16-byte binary), `@JdbcTypeCode(SqlTypes.UUID)` + `columnDefinition="uuid"`, `Ulid.from(...).toUuid()` 변환. `varchar(26/36)` 를 거부한 이유.
- ArchUnit 4개 rule (`no_long_id_pk` / `no_uuid_random_in_controller` / `no_math_random_for_id` / `no_varchar_255_for_id_column`) 로 어떤 anti-pattern 을 빌드타임에 차단했는가, 위반 fixture 로 rule 동작을 보증한 방법.
- `adapter-identifier` 모듈을 왜 신설했는가 — ULID 라이브러리 래퍼는 외부 시스템 통합(`adapter-outbound`)이 아니라 *non-IO 인프라 능력*이라 의미가 다름. 모듈 격리도 ArchUnit 으로 강제.
- `ResourceId` 를 왜 `sealed` 가 아닌 `non-sealed` 로 뒀는가 — `permits WorkLogId``domain-core``sample-portfolio` 역의존을 만들기 때문. closed-set 보장은 `no_long_id_pk` 로 대체.
- Crockford base32 가 I/L/O/U 를 제외하는 이유 + 그래서 ULID 의 URL/case 정책 (canonical uppercase 출력 + case-insensitive 입력 정규화).
### 적당히 답할 수 있는 질문
- ULID vs UUID v7 vs Snowflake 의 일반적 trade-off (정렬성, timestamp leak, 길이, 조율 부담). (개념 수준 — [[wiki/concepts/resource-identifier-format]].)
- time-ordered ID 가 B-tree index locality 에 유리한 *원리* (Percona MySQL 벤치마크는 parallel evidence 로만 인용 — PostgreSQL HEAP/MVCC 에 직접 적용 불가).
- timestamp leak 가 *user-facing* ID 에서 실질 문제인 이유 + CUID2 같은 완화 옵션.
### 답하면 안 되는 질문 (모른다고 해야 함)
- "PostgreSQL 에서 ULID time-ordered insert 가 random UUID 대비 page split 을 줄이는 걸 측정했는가?" → **측정 안 함. 벤치마크 없음.**
- "실 DB 로 `uuid` 컬럼 insert/조회 통합 테스트를 했는가?" → **안 함. JVM 단위 테스트 + 정적 분석까지.**
- "운영에서 인시던트나 성능 사례가 있었는가?" → **운영 배포 없음.**
- "multi-tenant 격리(`WHERE tenant_id = X AND id = Y`)를 구현했는가?" → **안 함. ID 에 tenant 인코딩 거부만 결정, 모델은 `feature-tenant-context-policy` 위임.**
- "Idempotency-Key 처리를 구현했는가?" → **형식 분리만 명시. 처리는 `feature-rate-limit-idempotency-contract` 위임.**
## 과장 금지 지점
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 로컬 단위 테스트 + 정적 분석까지가 검증 범위.
- **"ULID 가 PostgreSQL index 성능을 개선하는 걸 측정했다" → 금지.** Percona 벤치마크는 MySQL InnoDB 기준 *parallel evidence* 일 뿐, PostgreSQL 측정값 없음.
- **"multi-tenancy 를 구현했다" → 금지.** ID 형식이 tenant 와 충돌하지 않도록 보장만 했고, tenant 모델은 미구현.
- **"ULID 가 무조건 UUID 보다 우월하다" → 금지.** timestamp leak(privacy), 비표준(IETF 아님), 라이브러리 의존이라는 trade-off 존재. UUID v7 native 가 되는 stack 이면 결정이 달라질 수 있음.
- **"typed prefix(`tk_`)를 안 쓴 게 정답이다" → 단정 금지.** Stripe 는 prefix 를 쓴다 — skeleton 의 bare ULID 는 lock-in 회피를 택한 *하나의* 선택.
### Blog-topic ingest: resource identifier 묶음 (2026-07-02)
아래 raw seed들은 resource identifier canonical에 연결했다.
- [[raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01]]: ULID의 Crockford base32 charset과 예시 값 검증을 다룬다. **주의**: "대충 26자 영숫자"가 아니라 동일 parser로 fixture/example을 교차검증해야 한다.
- [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]]: resource id, trace id, session id, idempotency key, api key처럼 ID 종류별 생성 주체·형식·수명이 다르므로 ArchUnit governance rule도 ID kind별로 scope해야 한다는 글감이다. **주의**: 모든 `UUID.randomUUID()` 금지가 항상 옳다고 쓰지 않는다.
## 관련 개념
- [[wiki/concepts/resource-identifier-format]] — ULID vs UUIDv7 vs UUIDv4 vs Snowflake 일반 trade-off, sortability, timestamp leakage, Crockford base32.
## Sources
- [[raw/branch-notes/feature-resource-identifier-contract]] — D1~D19 + Decision Evidence Map + 구현 결과(2026-06-01). 본 문서의 결정 SSOT.
- [[raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01]] — ULID/Crockford base32 예시 검증 블로그 글감 raw seed
- [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]] — identifier governance scope 블로그 글감 raw seed
- [[raw/project-notes/ca-skeleton-operational-contract]] — §17 Sample Domain Fixture (`WorkLogId`), §22 Sample-portfolio Contract Matrix, §34 Stack Commitment (Java 21 / Spring Boot 3.5.14 / PostgreSQL 16 / archunit-junit5 1.3.0).
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — `adapter-identifier` 를 9번째 모듈로 OUT_OF_BRANCH_SCOPE 표시 (본 브랜치가 그 모듈을 신설).
- ca-tmpl @c36b764 코드 (ground-truth): `src/domain-core/.../domain/identifier/{ResourceId,IdFactory}.java`, `src/adapter-identifier/.../adapter/identifier/{UlidCodec,package-info}.java`, `src/sample-portfolio/.../domain/worklog/{WorkLogId,WorkLogIdFactory}.java`, `.../adapter/identifier/UlidWorkLogIdFactory.java`, `.../adapter/persistence/entity/WorkLogEntity.java`, `.../adapter/persistence/mapper/WorkLogPersistenceMapper.java`, `.../adapter/web/json/WorkLogIdSerializer.java`, `src/app-bootstrap/.../architecture/CleanArchitectureTest.java`.
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-resource-identifier-format-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->