157 lines
17 KiB
Markdown
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 -->
|