17 KiB
title, source_type, status, confidence, tags, related_projects, last_reviewed
| title | source_type | status | confidence | tags | related_projects | last_reviewed | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ca-tmpl - Resource Identifier (ULID) 결정 | project | verified | high |
|
|
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/(Gradlesettings.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_pkArchUnit 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 nativeuuid(16-byte binary),varchar(26/36)아님 (D10). tenant 컬럼은 주석으로만 (deferred tofeature-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()customArchCondition으로 검사 (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/fromUuidround-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-baselineSSOT). - multi-tenancy ID 정합 (D13): ID 자체에 tenant 인코딩 거부만 결정.
TenantIdVO /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
uuidindex 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
patternschema (§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.UUIDnative 미지원이라 3rd-party 의존이면 ULID 가 URL UX(26 vs 36자) + 라이브러리 성숙도 우위. (실제WorkLogIdrecord +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.