Files
llm-wiki/raw/branch-notes/feature-resource-identifier-contract.md

102 KiB

title, source_type, status, branch, parent_branch, related_projects, tags, created, last_reviewed, target_merge, status_label, id, kind, project, work_item, inherits, refines, overrides, depends_on, contract_packet, contract_packet_sha256
title source_type status branch parent_branch related_projects tags created last_reviewed target_merge status_label id kind project work_item inherits refines overrides depends_on contract_packet contract_packet_sha256
branch / feature-resource-identifier-contract branch-note verified feature-resource-identifier-contract
ca-skeleton
branch
ca-skeleton
identifier
uuid
ulid
security
2026-05-31 2026-06-04 merged BR-CA-SKELETON-OPERATIONAL-CONTRACT-046 project-work-item ca-skeleton-operational-contract WI-CA-SKELETON-OPERATIONAL-CONTRACT-046
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-RANDOM-001@1
1 e59f870a8ac62330ab217e132d73bec75903302157eef8df5a5f51189800ce7d

branch: feature-resource-identifier-contract

Layer: raw/branch-notes/ — resource ID 형식 결정 + ID 가 URL / log / idempotency / DB primary key / cache / multi-tenancy / privacy 에 미치는 계약을 정의합니다. 완료 후 /ingestwiki/projects/ 에만 추출합니다. status_label: in-progress | review | merged | abandoned

Ground-truth 대조 (2026-06-04, ca-tmpl @c36b764): /home/donghyeon/workspace/ca-tmpl 코드 직접 확인 — domain port (ResourceId/IdFactory @ domain-core), sample VO+adapter (WorkLogId/WorkLogIdFactory/UlidWorkLogIdFactory), 신규 모듈 adapter-identifier (UlidCodec), persistence (@JdbcTypeCode(SqlTypes.UUID) + columnDefinition="uuid"), WorkLogIdSerializer, ArchUnit 4개 rule + identifier 모듈 격리 rule 모두 실재. 5번째 rule no_find_by_id_without_tenant 는 결정대로 미구현(tenant 위임). ./gradlew :adapter-identifier:test :sample-portfolio:test :app-bootstrap:test --tests '*CleanArchitectureTest' … BUILD SUCCESSFUL. status: verified. wiki 추출: wiki/projects/ca-tmpl/resource-identifier-format (project, actually-implemented+locally-verified) + wiki/concepts/resource-identifier-format (general). Claim ID / Decision Evidence Map / UNSUPPORTED_DECISION: handled per branch-note Decision Evidence Map.

부모 (필수)

이 branch 가 어느 작업 묶음에 속하는지. 본 branch 는 project-note 의 직접 자식 (parent_branch: 비어 있음).

ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 §17 Sample Domain Fixture (sample-portfolio 의 WorkLogId) + §22 Sample-portfolio Contract Matrix + §6 Operational Error Category (resource id 의 log redaction) + §25 SSOT Owner Map (identifier 영역 owner) 의 운영 계약 중 resource identifier 영역을 정제한다.

형제 branch (cross-cite 후보)

브랜치 계약 패킷

  • 생성 시 프로젝트 개정: 1
  • 패킷 스키마: contract_packet: 1
  • 완료 조건: ULID format·PostgreSQL uuid persistence·SecureRandom test가 통과한다

상속한 프로젝트 결정

Decision Ref Project Summary Branch Application Source
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1 database는 PostgreSQL 16 단일 stack이다 Work Item 완료 조건에 적용 raw/project-notes/ca-skeleton-operational-contract
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-RANDOM-001@1 ULID·idempotency key·token 생성의 random source는 SecureRandom이다 Work Item 완료 조건에 적용 raw/project-notes/ca-skeleton-operational-contract

브랜치 지역 결정

기존 branch-local 결정은 아래 ## Decision Evidence Map / 결정-근거 매핑의 D-row가 소유하며 이 packet에서 복제하지 않는다.

Decision ID Decision Relation Supporting Claims Status

선언한 예외

Override ID Overrides Reason Approval Status

목표

resource ID 형식 결정은 한 번 노출되면 되돌리기 어렵습니다/v1/worklogs/<id> 가 client SDK + log + DB schema + cache key + FK 에 박힌 후 변경은 breaking. 본 branch 는 14개 영역의 cascade failure 를 default 결정으로 차단:

1. Format 후보군 (P0 결정 — D1)

후보: UUID v4 / UUID v7 / ULID / NanoID / KSUID / TSID / CUID2 / opaque prefix string (Stripe-style) / sequential / Snowflake.

  • Sequential integer: enumeration / count leak / tenant 격리 위반 → 거부.
  • UUID v4 (random 128bit): DB B-tree fragmentation + URL 36자 + 시간 정보 부재.
  • UUID v7 (RFC 9562, time-ordered): v4 약점 일부 해소, 단 48bit timestamp 평문 노출 + Java 21 native 미지원.
  • ULID (26자 base32, time-ordered): UUIDv7 보다 짧음 + case-insensitive base32 + 라이브러리 성숙.
  • NanoID (21자 URL-safe): configurable, modern startup default, time-ordered 아님 (UUIDv4 와 동일한 DB 약점).
  • KSUID (Segment, 27자 base62): 158bit time-ordered, base62 case-sensitive.
  • TSID (64bit): BIGINT fit, DB PK 8바이트 (UUID 16바이트의 반).
  • CUID2 (security-focused): timestamp leak 없음 — UUIDv7/ULID 의 privacy 약점 보완.
  • opaque prefix string (tk_...): Stripe convention, type identification + brand, 표준 없음.
  • Snowflake (Twitter): datacenter_id + worker_id coordination 부담 → 단일 generator skeleton 부적합 (명시적 거부).

2. Timestamp leak / Privacy (D7)

UUID v7 / ULID 는 48bit millisecond timestamp 평문 노출 — 시나리오:

  • 사용자 게시물 ID → 작성 시각 추론 → 활동 패턴 / 시간대 노출.
  • 가입 순서 추론 → "early adopter" 마케팅 타깃화 가능.
  • Tenant 첫 트랜잭션 ID → tenant 가입 일자 leak.

완화책 (결정 사항): (a) 수용 (b) random suffix scramble (Stripe-style) (c) CUID2 채택.

3. HTTP 표준 정합 (RFC 3986 — D3)

  • path 는 case-sensitive normalization 권고 → base32 (case-insensitive) ID 의 normalize 의무.
  • Allowed charset = unreserved (ALPHA / DIGIT / "-" / "." / "_" / "~") → base64 standard charset (+/=) 는 URL-safe 아님.
  • 하이픈 더블클릭 selection 문제 (UUID dashed 36자) — 디버깅 UX.
  • AWS ALB path pattern 128자 한계 / CloudFront cache key 1024자 / reverse proxy log truncate 한계.

4. DB Primary Key 성능 (PostgreSQL 16, project §34 — D10)

  • PostgreSQL 16 BTREE: UUID v4 random insert 시 page split + WAL traffic 증가. ULID time-ordered insert 는 page append 우세 → page split 완화.
  • VACUUM 비용: random UUID PK 는 page hot-spot 분산되어 vacuum 부하 분산. ULID time-ordered 는 최근 page 만 hot.
  • HEAP + MVCC: PostgreSQL 은 MySQL InnoDB 의 clustered index 와 architecture 다름 — secondary index PK 복사 비용 없음 (대신 visibility check 비용).
  • 컬럼 타입: PostgreSQL uuid native (16-byte binary) 단일 선택. varchar(26/36) / BIGINT 거부.

5. 라이브러리 매트릭스 (project §34 Stack Commitment — D16)

  • Java 21 java.util.UUID — v7 native 미지원 → ULID 채택으로 영향 없음.
  • Spring Boot 3.5.14 — @GeneratedValue(strategy=UUID) 사용 안 함 (D5 도메인 factory 가 WorkLogId.newId() 제공).
  • Hibernate 6.5.x (Spring Boot transitive) — @JdbcTypeCode(SqlTypes.UUID) + PostgreSQL JDBC driver 의 uuid native binding.
  • Jackson 2.18.x (Spring Boot transitive) — ULID 는 custom JsonSerializer<WorkLogId> 사용 (UUID dashed 기본 직렬화 우회).
  • OpenAPI 3.1 — type: string + pattern: "^[0-9A-HJKMNP-TV-Z]{26}$" (ULID 비-IETF 이므로 format: uuid 사용 안 함).
  • java.security.SecureRandom 사용 의무 — Math.random() 은 enumeration 가능 (D17 ArchUnit rule 로 차단).
  • archunit-junit5 1.3.0 — D17 5개 rule 의 test runner.
  • Gradle (Groovy DSL, build.gradle + settings.gradle) — Spring Boot 3.5.14 multi-module + apply false 패턴. version catalog (gradle/libs.versions.toml) 도입은 option (현재 user config 는 version '0.0.1-SNAPSHOT' inline).

6. GDPR 분류 (D8)

  • GDPR Article 4(1): "identifier linked to natural person" = PII.
  • User UUID 는 PII (indirect identifier). Resource UUID 는 context 의존 (예: 의료 record ID 는 PII).
  • CCPA "unique personal identifier" 정의 동일.
  • Log scrubber regex 로 UUID format 자동 감지 가능 여부.

7. Multi-tenancy 격리 (D13)

  • ID 에 tenant prefix 포함 vs 별도 path segment (/v1/tenants/{tenantId}/worklogs/{worklogId}) 결정.
  • Tenant scope cross-check 의무 — lookup 시 WHERE tenant_id = X AND id = Y (id 단독 lookup 으로 cross-tenant 가능).
  • Sharding hint encode 거부 (단일 generator skeleton 가정).

8. Idempotency-Key vs Resource ID 구분 (D14)

  • Idempotency-Key HTTP header (RFC draft) — client-generated UUID, 24h TTL.
  • WorkLogIdserver-assigned, persistent.
  • 둘은 별개 — 형식이 다를 수 있음 (UUID v4 idempotency key + ULID resource id 의 조합 허용).

9. Public ID vs Internal Sequence 분리 (D11)

  • External-only (Stripe): public UUID 만, internal sequence 없음. 코드 단순 + cache key 일관.
  • Dual (Shopify / Linear): internal BIGINT PK + external UUID (column 2개). audit log / internal admin 회수.
  • Dual 선택 시 cache key / FK / JOIN 어느쪽으로 갈지 추가 결정 (D12 cache key 전략).

10. Sample-portfolio WorkLogId concrete fixture (D19)

opaque string placeholder 가 아닌 실제 valid 값 1개:

WorkLogId = "01ARZ3NDEKTSV4RRFFQ69G5FAV"  (ULID 26자 example)

baseline branch + 기타 형제 branch 가 본 fixture 를 reference. D1 형식 결정 직후 채움.

11. 영구 폐기 (D15)

  • Default: never reuse (audit trail 정합).
  • Soft-deleted resource GET 동작: 404 vs 410 Gone (baseline branch HTTP semantic 정합).
  • ID re-creation 시 timestamp 가 과거인 ULID/UUIDv7 → monotonicity 위반 위험.

12. Anti-pattern ArchUnit 차단 (D17)

skeleton educational 가치 측면, ArchUnit 으로 차단:

  • Long id (auto-increment sequential) PK 사용 금지.
  • Controller / Service 에서 직접 UUID.randomUUID() 호출 금지 — factory 강제.
  • Math.random() 기반 ID 생성 금지.
  • ID column 이 varchar(255) 의 정확한 길이 미명시 금지.

13. ID Generation Architecture Layer (D5)

clean architecture 정합:

  • Domain layer (entity factory) — DDD 정통, ID 가 도메인 식별성의 일부.
  • Application layer (use case) — ID 생성을 use case 에서.
  • Infrastructure layer (DB sequence / Hibernate generator) — 데이터 영속화 부산물.

ca-skeleton 의 선택 — 결정 사항.

14. Out-of-scope 명시적 거부 (D18)

본 branch 결정 범위 밖 이나 명시 필요:

  • API key / OAuth client_id — 별도 token format (opaque, prefix-typed). feature-security-operational-baseline 책임.
  • Webhook event_idfeature-webhook-outbound-contract 책임.
  • Trace ID / Span ID — W3C trace context. feature-distributed-tracing-contract 책임.
  • Session ID — security branch (ephemeral, regenerate on auth).

본 branch 의 결정: 위 14항 각각에 대한 default 박기 + sample-portfolio WorkLogId 가 default 의 reference fixture.

  • 이슈:
  • PR:

범위

포함 범위

  • resource ID 형식 default 결정 — UUID v4 / v7 / ULID / NanoID / KSUID / TSID / CUID2 / opaque prefix string / Snowflake 중 선택 (sequential 거부)
  • ID 의 charset / length / encoding 정책 (Crockford base32 vs RFC 4648 base32 vs base62 vs base58 vs hex)
  • ID 의 URL-safe 보장 (RFC 3986 unreserved charset)
  • ID 의 case sensitivity 정책 (case-sensitive normalize vs case-insensitive comparison)
  • ID 생성 책임 — server-generated default vs client-generated 허용 여부
  • ID generation architecture layer — domain entity factory vs application use case vs infrastructure
  • ID 의 timestamp leak 완화 정책 (수용 / scramble / CUID2 채택)
  • ID 의 prefix 정책 (Stripe-style typed tk_ / usr_ vs Google-style flat) — 채택 시 type identification 가능
  • ID 의 DB primary key 정책 (PostgreSQL 16 uuid native — project §34 단일 DB)
  • ID 의 cache key 정책 (external public ID 사용 vs internal sequence 사용 — Dual 선택 시)
  • ID 의 log redaction / PII 분류 (GDPR Article 4(1) 기준, user vs resource ID 구분)
  • ID 의 idempotency key 와의 구분 (Idempotency-Key HTTP header 와 resource ID 형식 분리)
  • ID 의 sequence 추측 방지 (SecureRandom 의무, enumeration 방어). timing attack 방어 (constant-time 비교) 는 비밀값 영역 — feature-security-operational-baseline 위임
  • ID 의 재사용 정책 (soft-delete 후 영구 폐기)
  • ID 와 multi-tenancy 정합 (tenant prefix vs path segment, scope cross-check 의무)
  • Public ID vs Internal Sequence 분리 정책 (external-only vs dual column)
  • Library 호환성 매트릭스 (Java UUID class / Spring @GeneratedValue / Hibernate @JdbcTypeCode / Jackson / OpenAPI 3.1)
  • ArchUnit rule SSOT — anti-pattern 차단 (no_long_id_pk, no_uuid_random_in_controller, no_math_random_for_id, no_varchar_255_for_id_column)
  • sample-portfolio WorkLogId reference fixture concrete value

제외 범위

  • 사용자 / tenant 자체의 ID 형식 (feature-security-operational-baseline 책임)
  • 외부 system 의 ID 매핑 (예: payment provider charge ID — 도메인별 결정)
  • API key / OAuth client_id format (feature-security-operational-baseline 책임)
  • Webhook event_id format (feature-webhook-outbound-contract 책임)
  • Trace ID / Span ID format (feature-distributed-tracing-contract 책임 — W3C trace context)
  • Session ID format (security branch 책임 — ephemeral, regenerate on auth)
  • 기존 sequential ID 시스템에서 본 default 로 migration 정책 (project-level migration plan)
  • 사람-친화 prefix sequence (Linear TEAM-123 같은) — skeleton 범위 밖, 도메인 결정

근거 (필수, 최소 1개+)

본 branch 의 결정 근거. 본 scaffolding 단계에서는 후보 raw 만 listed. raw 미보관 항목은 Phase B 에서 wiki-source-summarizer 로 fetch.

Official docs

Source 후보 정당화할 결정 영역 상태
raw/official-docs/rfc9562-uuid IETF RFC 9562 (UUID v4 random / v6 reordered / v7 time-ordered / v8 custom) — D1/D7/D10 근거 (RFC9562-C1~C5) 보관 완료
raw/official-docs/ulid-spec.md ULID 공식 spec (26자 base32 + monotonic) — D1/D2/D3/D7/D10 근거 보관 완료
raw/official-docs/nanoid-spec NanoID 21자 URL-safe + collision probability — D1/D2/D3/D9 근거 (NANOID-C1~C5) 보관 완료
raw/official-docs/cuid2-spec.md CUID2 — security-focused, no timestamp leak — D1/D7/D9 근거 (CUID2-C1~C5) 보관 완료
raw/official-docs/rfc3986-uri-generic-syntax URI generic syntax (allowed charset / case sensitivity / path component) — §2.3 unreserved charset + §6.2.2.1 case normalization 보관 완료
raw/official-docs/crockford-base32-spec Crockford base32 32자 alphabet (I/L/O/U 제외) + case-insensitive 디코딩 + 하이픈 무시 — D2/D3 근거 (CROCKFORD-C1~C5) 보관 완료
raw/official-docs/google-aip-148-standard-fields Google AIP-148 standard fields (name / uid / display_name / parent) — D5/D6/D8/D13 근거 (AIP148-C1~C5) 보관 완료
raw/official-docs/stripe-resource-id-convention Stripe typed prefix opaque ID (ch_, cus_, pi_) 관례 + Idempotency-Key vs resource ID 구분 + prefix 변경 = backward-compatible (영구 불변 보장 아님) — D1/D4/D6/D11/D14 근거 (STRIPE-C1~C5) 보관 완료

Company tech blogs (case studies)

Source 후보 정당화할 결정 영역 상태
raw/company-tech-blogs/segment-ksuid KSUID 27자 base62, 32-bit 초단위 timestamp + 128-bit random, custom epoch (2014-05-13) — D1 대안 후보 / D2 base62 vs base32 / D7 초단위 정밀도 (KSUID-C1~C5) 보관 완료
raw/company-tech-blogs/aws-iam-arn-format AWS ARN 6-field 계층 prefix (partition:service:region:account-id:resource-type:resource-id) — D6/D13 case study (AWS-ARN-C1~C5) 보관 완료
raw/company-tech-blogs/github-graphql-global-node-id base64(type:numeric_id) Relay-style global node ID — D6 type-encoded prefix / D11 public-internal duality / D13 (GITHUB-NODE-ID-C1~C5) 보관 완료
raw/company-tech-blogs/snowflake-twitter-id Snowflake 64bit ID (41+10+12 bit), k-sorted, coordination 부담 — D1 거부 근거 / D10 BIGINT fit / D13 partition 힌트 패턴 (SNOWFLAKE-C1~C5) 보관 완료
raw/company-tech-blogs/planetscale-nanoid-api PlanetScale 이 UUID 대신 NanoID 채택 +public_id (NanoID) + BigInt PK Dual 컬럼 패턴 prod 사례 — D1/D2/D10/D11 (PLANETSCALE-NANOID-C1~C5) 보관 완료
raw/company-tech-blogs/brandur-stripe-idempotency-keys Brandur Leach (전 Stripe):Idempotency-Key 가 client-generated, 24h TTL, request fingerprint 검사 — D4/D14/D11 (BRANDUR-IDEMP-C8~C12) 보관 완료
raw/company-tech-blogs/percona-uuid-storage-mysql Percona MySQL 5.x 25M-row 벤치마크 — random UUID PK 는 ordered UUID 대비 +50% 디스크 / BIGINT 대비 +30% / ordered UUID ≈ BIGINT 성능 — D10/D11 정량 근거 (PERCONA-UUID-C1~C5) 보관 완료
(예정)raw/company-tech-blogs/shopify-public-private-id.md Dual (internal BIGINT + external UUID) 사례 (PlanetScale-NANOID-C4 가 동등 사례 대체) raw 미보관
(예정)raw/company-tech-blogs/linear-app-id-format.md 사람-친화 prefix sequence (TEAM-123) 사례 — out-of-scope (D18) raw 미보관
(예정)raw/company-tech-blogs/uuid-v7-performance-benchmark.md UUID v7 특화 MySQL 8 / PostgreSQL 벤치마크 (Percona 는 v1 기준) — D10 UNSUPPORTED_IMPL_DECISION 해소 후보 raw 미보관
(예정)raw/company-tech-blogs/woowahan-id-generation.md 한국 사례 — ID 생성 전략 raw 미보관

TODO

각 항목 옆에 증거 등급 표기: actually-implemented | locally-verified | prod-verified | documented-only | planned | needs-confirmation

P0 — Core format decision

  • (P0) D1: resource ID default 형식 = ULID (sequential / UUID v4 / Snowflake 거부, UUIDv7 trade-off 명시) — 등급: documented-only
  • D2: ID charset / encoding / length = Crockford base32 26-char (ULID 고정) — 등급: documented-only
  • D3: URL-safe charset = RFC 3986 unreserved 진부분집합 + canonical uppercase + case-insensitive 입력 수용 — 등급: documented-only

P1 — Architecture & responsibility

  • D4: ID 생성 책임 = server-assigned (resource ID) + client-generated (Idempotency-Key only) — 등급: documented-only
  • D5: ID generation architecture layer = Domain entity factory (WorkLogId.newId()) — 등급: documented-only
  • D6: prefix 정책 = NO typed prefix (bare ULID, type 식별은 URL collection name) — 등급: documented-only

P1 — Privacy & security

  • D7: timestamp leak 완화 = ACCEPT default + CUID2 override 허용 (privacy-sensitive 도메인) — 등급: documented-only
  • D8: PII / GDPR 분류 = bare ULID = non-PII, user-linked ID = PII (log scrubber regex 적용 대상은 user-linked 만) — 등급: documented-only
  • D9: enumeration 방어 = SecureRandom 의무. constant-time 비교 미적용 (공개 resource id 는 표준 equals. 비밀값 비교는 feature-security-operational-baseline 위임) — 등급: documented-only

P1 — DB & persistence

  • D10: DB primary key = PostgreSQL 16 uuid native (project §34 단일 DB) — varchar / BIGINT / MySQL BINARY(16) 거부 — 등급: documented-only
  • D11: Public ID vs Internal Sequence = external-only (ULID = public ID = DB PK 동일) — 등급: documented-only
  • D12: Cache key 전략 = ULID (public ID 동일), Redis format <resource-type>:<ulid> — 등급: documented-only

P2 — Operational & ergonomic

  • D13: multi-tenancy = ID 내 tenant 인코딩 거부 (형식적 위치만). tenant 모델 + persistence + auth 해석 = feature-tenant-context-policy (예정) 위임 — 등급: documented-only
  • D14: Idempotency-Key (UUID v4, client-generated, 24h TTL) vs Resource ID (ULID, server-assigned, persistent) — 별개 형식 명시. Fingerprint mismatch = HTTP 422 — 등급: documented-only
  • D15: ID 재사용 정책 = NEVER reuse (soft-delete + hard-delete 모두) — 등급: documented-only

P2 — Tooling & enforcement

  • D16: Library 매트릭스 = ulid-creator ≥ 5.x + Spring Boot 3.5.14 (transitive Hibernate 6.5.x + Jackson 2.18.x) + OpenAPI 3.1 custom pattern + SecureRandom (Java 21) + Gradle (Groovy DSL) + archunit-junit5 1.3.0 — project §34 Stack Commitment 정합 — 등급: documented-only
  • D17: ArchUnit rules (4개) = no_long_id_pk (..domain.. 한정), no_uuid_random_in_controller, no_math_random_for_id, no_varchar_255_for_id_columnfeature-boundary-validation-mapping-contract suite 가 코드 호스팅, 본 branch 가 결정 SSOT. no_find_by_id_without_tenantfeature-tenant-context-policy 이관 — 등급: documented-only
  • D18: Out-of-scope 명시 = API key / session ID / webhook event_id / trace ID / external system ID / friendly sequence / migration policy — sibling branch SSOT cross-cite — 등급: documented-only

P2 — Reference fixture

  • D19: sample-portfolio WorkLogId fixture = 01ARZ3NDEKTSV4RRFFQ69G5FAV (26-char uppercase Crockford base32 ULID), regex ^[0-9A-HJKMNP-TV-Z]{26}$ — 등급: documented-only

진행 중 메모

  • ID 형식은 한 번 노출되면 되돌리기 어려움/v1/worklogs/<id> 가 client SDK + log + DB schema + cache key + FK 에 박힌 후 변경은 breaking. default 는 가장 미래 안전한 선택 권고.
  • D1 의 1차 후보: UUID v7 (RFC 9562, 2024 ratified, time-ordered + random). DB index 성능 + URL 36자 길이 trade-off. Java 21 native 미지원이 라이브러리 부담.
  • D1 의 2차 후보: ULID (26자 base32, time-ordered, 라이브러리 성숙). UUIDv7 보다 짧고 case-insensitive base32 — URL normalize 의무.
  • D1 의 3차 후보: NanoID (21자 URL-safe alphabet) — modern startup default, 가장 짧음. Time-ordered 아님 — DB index 성능은 UUID v4 와 동일.
  • D1 의 4차 후보: opaque prefix string Stripe-style (tk_<26 random>). type identification + brand identity 강점, 표준 없음 + project-internal generator 부담.
  • D1 의 5차 후보: CUID2 — timestamp leak 없음 (UUIDv7/ULID 의 privacy 약점 보완). user-facing ID 가 민감한 도메인 (의료/금융) 권고.
  • D7 의 trade-off: ULID / UUIDv7 의 timestamp leak 는 user-facing ID 에서만 실질 문제. Resource ID 라도 작성 시각이 민감한 도메인에서는 CUID2 또는 scramble 권고.
  • D11 의 trade-off: Stripe external-only 는 코드 단순 + cache key 일관 + idempotent. Shopify / Linear dual 은 internal sequence 의 성능 + audit log 회수. ca-skeleton minimalist 정신 = external-only 가 자연스러우나 prod-grade 에서는 dual 이 흔함.
  • D17 ArchUnit rule 은 feature-boundary-validation-mapping-contract 의 ArchUnit 패턴 (no_merge_patch_json_media_type_string 등) 과 동일 형식.

결정 사항

D1. Resource ID default 형식 = ULID

  • ca-skeleton 의 default resource ID 형식은 ULID (26-char Crockford base32, time-ordered, 48-bit ms timestamp + 80-bit random) 채택.
  • 거부된 후보: sequential integer (enumeration), UUID v4 (DB B-tree 단편화), Snowflake (worker_id 외부 조율 부담).
  • UUID v7 거부 근거 (stack commit): project §34 Stack Commitment 의 Java 21 LTS 는 java.util.UUID v7 native 미지원. 3rd-party 라이브러리 (uuid-creator) 의존이면 ULID 의 라이브러리 성숙도 + URL UX 우위 (26 vs 36자) 가 결정적. trade-off 자체 소멸.
  • Sample-portfolio fixture: WorkLogId = "01ARZ3NDEKTSV4RRFFQ69G5FAV" (D19).

encoding = Crockford base32 (26-char ULID 고정)

  • ULID 채택에 따라 Crockford base32 32-char alphabet (0123456789ABCDEFGHJKMNPQRSTVWXYZ, I/L/O/U 제외) 고정.
  • 길이: ULID spec 기준 26자 고정.
  • RFC 4648 base32 / base62 / base58 / hex 거부: ULID 표준이 Crockford base32 사용 + I/L/O/U 제외의 human-friendly 우위.

D3. URL-safe + case sensitivity = unreserved 진부분집합 + canonical uppercase + case-insensitive 입력 수용

  • ULID Crockford base32 charset (0-9A-Z, 32자) 는 RFC 3986 unreserved (RFC3986-C1) 의 진부분집합 — URL path 직접 사용 안전 (percent-encoding 불필요).
  • 캐노니컬 출력: uppercase ULID (ULID spec default).
  • 입력 수용: case-insensitive (CROCKFORD-C3: i/l1, o0 정규화).
  • 서버는 URL boundary 에서 canonical uppercase 로 normalize → DB lookup / cache lookup 의 키 일관성 보장.

D4. ID 생성 책임 = server-assigned (resource ID), client-generated (Idempotency-Key only)

  • Resource ID (WorkLogId): server-assigned. 도메인 entity factory 가 ULID 생성.
  • Idempotency-Key (HTTP header): client-generated UUID v4 (BRANDUR-IDEMP-C8/C9). 본 branch 범위 밖 — raw/branch-notes/feature-rate-limit-idempotency-contract SSOT.
  • Client 가 resource ID 를 제공하는 PUT (upsert) 패턴 거부 — 모든 생성은 POST + server-assigned.

D5. ID generation architecture layer = Domain-port + Application 주입 (DDD factory)

  • DDD 정통: ID 는 도메인 식별성의 일부 → ID 생성 책임 은 도메인 (port: WorkLogIdFactory). 그러나 ID 생성 호출 시점 은 use case 의 orchestration — Evans 의 DDD factory pattern 은 entity 자체가 자기 ID 를 minting 하라고 요구하지 않음 (factory 는 도메인 service, entity 가 아님).
  • 구현 패턴 (§1/§2 참조): domain WorkLogIdFactory interface (port) ← UlidWorkLogIdFactory (sample-portfolio adapter) 구현 ← WorkLogCommandService (application-core) 주입 → factory.newId()WorkLog.rehydrate(id, …) 로 entity 조립.
  • Infrastructure-managed (Hibernate @GeneratedValue / DB sequence) 거부: 도메인이 영속화 메커니즘에 결합 (D10 의 PostgreSQL uuid native 와도 충돌 — Hibernate generator 가 ULID 보장 안 함).
  • Domain static self-generation (WorkLog.create() 안의 UUID.randomUUID() 직접 호출) 거부: 서비스 로케이터 또는 static singleton anti-pattern + 테스트 시 generator 교체 어려움 + SecureRandom (D9) 보장 위치 모호 + D1 ULID 채택 위반. 현재 WorkLog.java:36 (ca-tmpl/.../domain/worklog/WorkLog.java) 의 UUID.randomUUID() 는 본 branch 결정 따라 마이그레이션 대상.
  • "Application layer 거부" 라는 표현 철회 — DDD 의 factory pattern 은 도메인 port + application orchestration 와 정합. 거부 대상은 application 이 ULID 라이브러리를 직접 호출 하는 것 (Liskov 위반 + D17 no_uuid_random_in_controller 의 application 확장).
  • UNSUPPORTED_IMPL_DECISION: application 의 WorkLogCommandServiceWorkLogIdFactory 를 주입받을지 vs IdFactory<WorkLogId> 의 generic interface 만 주입받을지는 구현 컨벤션 trade-off. skeleton default = type-specific port (WorkLogIdFactory) — 도메인 의도 표현이 명시적.

D6. Prefix 정책 = NO typed prefix (Google AIP-148 flat style)

  • ID 는 bare ULID (01ARZ3NDEKTSV4RRFFQ69G5FAV). Stripe-style typed prefix (tk_, usr_) 거부.
  • 거부 근거: STRIPE-C2 — Stripe 자체가 prefix 변경을 backward-compatible 로 분류. 즉 prefix 영구 불변 보장이 아니므로 의존 코드 작성 시 lock-in 위험.
  • Type identification 은 URL collection name (/v1/worklogs/{id}, /v1/users/{id}) 로 충분.
  • 도메인이 branding 위해 typed prefix 필요 시 별도 결정 — skeleton default 가 아님.

D7. Timestamp leak 완화 = ACCEPT (default), CUID2 override 허용

  • Default: ULID 48-bit ms timestamp 노출 수용. RFC9562-C5 (§8 "very small attack surface") 근거.
  • 도메인이 privacy-sensitive (의료 record / 금융 트랜잭션 등) 인 경우: CUID2 override 허용 (CUID2-C1 timestamp 비노출 보장).
  • Random suffix scramble (Stripe-style) 거부: 표준 없음 + project-internal generator 부담.
  • UNSUPPORTED_DECISION: GDPR Article 4(1) 원문 raw 미보관 — 법적 위험 수준 판단은 raw/branch-notes/feature-data-retention-privacy-contract SSOT.

GDPR 분류 = bare ULID 자체는 non-PII, user-linked ID 는 PII

  • Resource ULID (예: WorkLogId) 자체는 non-PII — AIP148-C2 (uid = opaque system-assigned identifier) 근거.
  • User-linked ID (예: UserId 또는 user 와 1:1 mapping resource) 는 GDPR Article 4(1) "indirect identifier" 로 분류 — PII 처리 의무.
  • Log scrubber regex: ^[0-9A-HJKMNP-TV-Z]{26}$ 로 ULID 감지 가능. user-linked 만 redaction (resource ID 는 audit log 필요로 그대로 유지).
  • UNSUPPORTED_DECISION: GDPR Article 4(1) 원문 raw 미보관. 최종 법적 분류는 jurisdiction-specific — raw/branch-notes/feature-data-retention-privacy-contract SSOT.

D9. Enumeration 방어 = SecureRandom 의무

  • ULID generator 는 java.security.SecureRandom 사용 의무. ulid-creator 라이브러리 기본값으로 충족.
  • Math.random() 호출 차단 — ArchUnit rule (D17 의 no_math_random_for_id).
  • 공개 resource id 의 equality check 는 표준 equals (record equals / Objects.equals) 사용. MessageDigest.isEqual() 등 constant-time 비교는 적용하지 않음. 근거: ULID resource id 는 D8 에서 non-PII 공개 식별자 (URL / audit log 평문 노출) 로 분류 — 비밀값이 아님. constant-time 비교는 토큰 / API key / session id 같은 비밀값 비교의 timing-attack 방어책이며, 공개 식별자에 일률 적용은 (a) 방어 대상이 없는 오용 + (b) record equals 의 표준 동등성 의미 훼손 → Map / Set / contains 사용에 부작용.
  • 비밀값 (token / API key / session id) 의 constant-time 비교는 raw/branch-notes/feature-security-operational-baseline SSOT — 본 branch 책임 밖.
  • 2026-06-01 spec drift 정정: 이전 본문 "ID equality check 는 MessageDigest.isEqual() 등 constant-time 사용"D8 의 공개 식별자 분류와 모순 + 코드 구현 (WorkLogId record 기본 equals) 과 불일치 → 본 결정으로 통일.

D10. DB primary key = PostgreSQL uuid native (project §34 Stack Commitment)

  • DB stack = PostgreSQL 16 (project §34). 컬럼 타입 = uuid native type + ULID-to-UUID 변환 (Ulid.toUuid()) 후 저장. ULID 128-bit 는 UUID format representable.
  • varchar(26) / varchar(36) 거부: 16-byte binary 대비 36자 문자열은 디스크·index 비효율 + ORDER BY 비교 cost.
  • BIGINT (TSID) 거부: D1 의 ULID 채택과 정합 안 함.
  • MySQL BINARY(16) 경로 out of scope (project §34 = PostgreSQL 16 단일 DB). Percona MySQL 5.x 벤치마크 (PERCONA-UUID-C2~C5) 는 parallel evidence — InnoDB clustered index 의 random vs ordered UUID 일반 원리 지지에만 사용. PostgreSQL HEAP + MVCC architecture 에 직접 적용 불가.
  • UNSUPPORTED_IMPL_DECISION: PostgreSQL 16 의 uuid column index locality 정량 벤치마크 (ULID time-ordered insert 의 BTREE page split 완화) 미보관 — 별도 raw 보강 필요 (안 한 것 1 → "PostgreSQL 16 UUID benchmark").

D11. Public ID vs Internal Sequence = external-only (ULID 가 public ID + DB PK 동일)

  • ca-skeleton skeleton default: external-only — ULID 하나가 public ID + DB PK 역할.
  • 거부된 대안: dual column (internal BIGINT + external ULID).
  • 근거: PERCONA-UUID-C5 (ordered UUID ≈ BIGINT PK 성능) — BIGINT 분리 동기 약함. ca-skeleton minimalist 정신과 정합.
  • UNSUPPORTED_IMPL_DECISION: prod-grade (billion-row + heavy JOIN) 도메인은 dual column override 권고. PlanetScale-NANOID-C4 가 dual 사례 — raw/branch-notes/feature-persistence-failure-baseline 도메인별 정책 SSOT.

D12. Cache key 전략 = ULID (public ID 와 동일)

  • D11 external-only 정합: cache key = ULID (URL path 의 ID 와 동일).
  • Redis key format: <resource-type>:<ulid> (예: worklog:01ARZ3NDEKTSV4RRFFQ69G5FAV).
  • 도메인이 dual column override 채택 시 (D11 override) cache key 가 internal sequence vs external ULID 중 별도 결정 — skeleton 범위 밖.

D13. Multi-tenancy = ID 에 tenant 인코딩 거부 (형식적 위치만 결정)

  • 본 branch 결정 범위: ID 자체 에 tenant 정보 인코딩 없음 (bare ULID, D6 와 정합). SNOWFLAKE-C1 의 machine ID 파티셔닝 패턴 거부 — distributed fan-out 전제이며 단일 generator skeleton 부적합.
  • 본 branch 결정 범위 밖 (별도 SSOT 위임):
    • Tenant 모델 (TenantId VO, tenant 테이블, FK relationship)
    • Tenant scope 의 DB 표현 (WHERE tenant_id = X AND id = Y, composite index, findByIdAndTenant repository contract)
    • Auth → tenant 해석 (URL path segment /v1/tenants/{tenantId}/… vs JWT claim)
    • ArchUnit no_find_by_id_without_tenant rule
  • 위 항목은 기존 raw/branch-notes/feature-tenant-context-policy (in-progress) SSOT 활성화 + 필요시 scope 확장 (현재 그 branch out-of-scope 는 "실제 SaaS tenant model 구현" 으로 명시 — TenantId VO / tenant 테이블 / FK 가 활성화되면 그 branch 의 out-of-scope 표 갱신 필요). 본 branch 는 ID 형식이 tenant 와 충돌하지 않도록 만 보장.
  • 이전 본문 (의무 lookup WHERE tenant_id = X AND id = Y, ArchUnit no_find_by_id_without_tenant rule) 철회 이유: 실제 ca-tmpl 코드에 tenant 도메인 모델 0건 (WorkLogRepository.java:10 ca-tmpl/.../domain/worklog/WorkLogRepository.javafindById(UUID id) 가 tenant 무관). 본 branch 가 tenant 모델 + persistence + auth 해석을 함께 결정하면 scope 폭발 + CLAUDE.md §11 의 "본 branch 결정 범위 밖 cell 작성 금지" + §15.5 R3 OUT_OF_BRANCH_SCOPE 위반.

D14. Idempotency-Key vs Resource ID 구분 (운영 SSOT cross-cite)

  • Resource ID (ULID): server-assigned, persistent, URL path 위치, 26자 Crockford base32.
  • Idempotency-Key (UUID v4 권장 by BRANDUR-IDEMP-C9): client-generated, HTTP header Idempotency-Key, 24h TTL (BRANDUR-IDEMP-C10), request fingerprint 비교 (BRANDUR-IDEMP-C12).
  • 형식 별개 허용: ULID resource id + UUID v4 idempotency key 의 조합.
  • Fingerprint mismatch 시 응답: HTTP 422 Unprocessable Entity (IETF Idempotency-Key header draft). Brandur 의 409 권고와 차이 — IETF draft 따름.
  • 운영 계약 SSOT: raw/branch-notes/feature-rate-limit-idempotency-contract — 본 branch 는 형식 분리만 명시.

D15. ID 재사용 정책 = NEVER reuse

  • Soft-delete 후 ID 재사용 금지 — 동일 ULID 가 두 개의 (시간상 다른) entity 를 가리키면 audit log replay 불가.
  • Hard-delete 후 동일 ID 의 re-create 도 금지 — ULID time-ordered 특성상 과거 timestamp 의 신규 entity 가 monotonicity 위반.
  • 410 Gone vs 404 Not Found HTTP semantic 은 raw/branch-notes/feature-api-contract-baseline D-row SSOT (본 branch 책임 밖).
  • UNSUPPORTED_DECISION: AIP-164 원문 raw 미보관 — uid 재사용 금지의 normative 근거 보강 권고.

D16. Library 호환성 매트릭스 (project §34 Stack Commitment 기준)

Stack baseline: Java 21 LTS + Spring Boot 3.5.14 + Gradle (Groovy DSL) + PostgreSQL 16 + archunit-junit5 1.3.0 (project §34 SSOT).

Layer Library Version 역할
ULID generation com.github.f4b6a3:ulid-creator ≥ 5.x UlidCreator.getMonotonicUlid() (Monotonic factory, SecureRandom 기본값)
Hibernate ORM Spring Boot 3.5.14 transitive Hibernate 6.5.x @JdbcTypeCode(SqlTypes.UUID) → PostgreSQL uuid native
Spring Boot Spring Boot 3.5.14 starter web + data-jpa + validation
Jackson Spring Boot 3.5.14 transitive Jackson 2.18.x ULID String 직렬화 (custom serializer)
OpenAPI schema OpenAPI 3.1 type: string + pattern: "^[0-9A-HJKMNP-TV-Z]{26}$" + example: "01ARZ3NDEKTSV4RRFFQ69G5FAV"
Random source java.security.SecureRandom Java 21 ulid-creator 기본값 (NANOID-C4 동등 강도 보장)
ArchUnit com.tngtech.archunit:archunit-junit5 1.3.0 D17 5개 rule 의 test runner
빌드 도구 Gradle Groovy DSL (multi-module) Spring Boot 3.5.14 +io.spring.dependency-management 1.1.6, allprojects { mavenCentral() } 패턴
  • Java 21 = java.util.UUID v7 native 미지원 — ULID 채택으로 영향 없음 (D1 정합).
  • Hibernate 6.5.x 의 @JdbcTypeCode(SqlTypes.UUID) 는 PostgreSQL JDBC driver 의 uuid 타입에 직접 mapping (별도 converter 불필요).
  • UNSUPPORTED_IMPL_DECISION: ulid-creator vs io.github.azam.ulidj 라이브러리 비교 — ulid-creator 선택 근거는 monotonic factory API + 활발한 maintenance. 비교 raw 추후 보강 권고.

D17. ArchUnit rule SSOT (4 rules, boundary suite hosted)

결정 SSOT = 본 branch. 코드 작성 위치 = raw/branch-notes/feature-boundary-validation-mapping-contract ArchUnit suite. 본 branch 의 §6 은 reference skeleton 이며 실제 컴파일/실행 대상이 아님 (R3 OUT_OF_BRANCH_SCOPE 정합).

  • no_long_id_pk: ..domain.. 패키지 한정 — 도메인 entity (POJO) 의 id 필드 타입이 Long / long / int / Integer 금지 → ResourceId 구현체 강제. JPA entity (..adapter.persistence..) 의 @Id UUID id 는 D10 (PostgreSQL uuid native) 정합으로 검사 대상 제외.
  • no_uuid_random_in_controller: controller / service / use case layer 가 UlidCreator.* / UUID.randomUUID() 직접 호출 금지 → 도메인 port (WorkLogIdFactory) 주입 강제 (D5).
  • no_math_random_for_id: ID 관련 코드에서 Math.random() 호출 전역 금지 (D9 보강).
  • no_varchar_255_for_id_column: @Column annotation 에 ID 컬럼은 정확한 columnDefinition ("uuid" for PostgreSQL native) 또는 length 명시 의무 — varchar(255) default 거부.
  • 5번째 rule no_find_by_id_without_tenant 제거 — 의문점 3 결정 따라 feature-tenant-context-policy (예정) 로 이관. tenant 모델/persistence 결정 후 해당 branch 의 ArchUnit rule 로 활성화.

D18. Out-of-scope 명시적 거부

본 branch 는 다음 ID 영역에 대한 결정을 포함하지 않음 — sibling branch SSOT cross-cite:

ID 종류 SSOT branch
API key / OAuth client_id raw/branch-notes/feature-security-operational-baseline
Session ID raw/branch-notes/feature-security-operational-baseline
Webhook event_id raw/branch-notes/feature-webhook-outbound-contract
Trace ID / Span ID (W3C trace context) feature-distributed-tracing-contract (예정 branch — 본 branch 와 별도 scaffolding 필요)
Multi-tenancy 모델 (TenantId VO + tenant 테이블 + tenant-scoped repo + auth → tenant 해석) feature-tenant-context-policy (예정 branch — 본 branch 결정 후 신규 scaffolding 필요)
External system ID 매핑 (payment provider charge ID 등) 도메인별 결정, skeleton 범위 밖
사람-친화 sequence (TEAM-123) 도메인별 결정, skeleton 범위 밖
Migration policy (기존 sequential → ULID) project-level migration plan, skeleton 범위 밖

D19. Sample-portfolio WorkLogId concrete fixture

  • WorkLogId reference value: 01ARZ3NDEKTSV4RRFFQ69G5FAV (26-char uppercase Crockford base32 ULID — ULID spec 공식 예제값)
  • 형식 검증 regex: ^[0-9A-HJKMNP-TV-Z]{26}$ (ULID Crockford base32 charset, I/L/O/U 제외)
  • Reference 사용처: raw/branch-notes/feature-api-contract-baseline URL path variable 예시 + project-note §17 Sample Domain Fixture + §22 Sample-portfolio Contract Matrix.
  • 이전 값 01HRGC7K2N4F6P8Q0R2S4T6U8V 폐기 사유 (2026-06-01 self-catch): 23번째 자리 U 가 Crockford base32 alphabet 제외 문자 (I/L/O/U) 와 충돌 → 자기 자신의 D19 regex ([0-9A-HJKMNP-TV-Z]{26}$U 제외) 통과 불가 → WorkLogId.of(...) 호출 시 IllegalArgumentException. D2 charset 결정과 D19 fixture 값의 self-inconsistency. ULID spec 공식 예제값으로 교체 = 외부 검증 가능 + I/L/O/U 부재 보장 + 면접/포트폴리오 derive 시 공식 예제 라는 정당성 추가.

결정-근거 매핑

Decision ID Decision Supporting Claims Evidence Strength Open Risk
D1 Resource ID default = ULID (26-char Crockford base32, time-ordered). 거부: sequential, UUID v4, Snowflake, UUID v7 (Java 21 native 미지원) RFC9562-C1/C3 (UUIDv7 time-ordered, SHOULD), ULID-C1C6 (spec full), CROCKFORD-C1C4 (Crockford base32 alphabet + 디코딩 정규화), NANOID-C1C5 (NanoID 대안 비교), CUID2-C1C5 (timestamp-leak-free 대안), SNOWFLAKE-C1~C5 (Snowflake 거부 근거 — coordination 부담), STRIPE-C2 (typed prefix 변경 가능성 = lock-in 경고), project §34 Stack Commitment (Java 21 = UUIDv7 native 미지원 → ULID 확정) official-standard (RFC9562·RFC3986·ULID) + project-ssot (§34) trade-off 소멸 — Java 21 stack commit 으로 UUIDv7 거부 확정
D2 charset = Crockford base32 (26-char ULID 고정). 거부: RFC 4648 base32, base62, base58, hex ULID-C1 (Crockford base32 사용), CROCKFORD-C1/C2 (32-char alphabet + I/L/O/U 제외), NANOID-C2 (URL-safe 64자 alphabet 대안 비교) official-reference URL 안전성 RFC3986-C1 unreserved subset 으로 확보 (Crockford 0-9A-Z 는 진부분집합)
D3 URL-safe = RFC 3986 unreserved 진부분집합 + canonical uppercase 출력 + case-insensitive 입력 수용 (서버 normalize) RFC3986-C1 (unreserved charset 정의), RFC3986-C3 (path case-sensitive), RFC3986-C4 (case normalization 규칙), CROCKFORD-C3 (case-insensitive 디코딩 i/l1, o0), CROCKFORD-C4 (하이픈 무시 정책) official-standard (RFC 3986) 클라이언트가 lowercase 입력 시 서버 normalize 누락하면 cache key miss 발생 — D17 ArchUnit rule 또는 boundary layer normalization 강제 필요
D4 resource ID = server-assigned, Idempotency-Key = client-generated. PUT upsert (client-provided ID) 거부 BRANDUR-IDEMP-C8 (idempotency = client generated), BRANDUR-IDEMP-C11 (HTTP header 전송 = client 구성), STRIPE-C1 (Idempotency-Key 별개 명시), AIP148-C2 (uid = system-assigned opaque),raw/branch-notes/feature-rate-limit-idempotency-contract D-row SSOT engineering-blog (BRANDUR) + official-vendor-doc (Stripe·AIP-148) resource ID 전반의 server-assigned 의무는 normative IETF 표준 없음 — AIP-148 + Stripe 관행 + DDD 정통의 결합
D5 ID generation layer = Domain port (WorkLogIdFactory) + Application 주입 (use case orchestration). 거부: ① Infrastructure-managed (Hibernate generator), ② Domain static self-generation (WorkLog.create()UUID.randomUUID() — 현재 코드 마이그레이션 대상) AIP148-C1/C2 (server/system-assigned 관례), DDD factory pattern (factory = 도메인 service, entity 가 아님) official-vendor-doc (AIP-148) + branch decision (DDD factory pattern) UNSUPPORTED_IMPL_DECISION: type-specific port (WorkLogIdFactory) vs generic IdFactory<WorkLogId> 주입은 구현 컨벤션 trade-off — skeleton default = type-specific (도메인 의도 명시)
D6 prefix 정책 = NO typed prefix (bare ULID). type identification 은 URL collection name 으로 AIP148-C1/C5 (Google flat name), STRIPE-C2 (typed prefix backward-compatible = 영구 불변 보장 아님, 의존 코드 lock-in 위험) official-vendor-doc (AIP-148·Stripe) 도메인이 branding 위해 typed prefix 필요 시 별도 결정 (skeleton 범위 밖)
D7 timestamp leak = ACCEPT default, CUID2 override 허용 (privacy-sensitive 도메인). scramble 거부 RFC9562-C5 (§8 "very small attack surface"), ULID-C2 (48-bit ms timestamp 노출 사실), CUID2-C1 (timestamp leak 없음 보장) official-standard (RFC9562·ULID) + official-reference (CUID2) UNSUPPORTED_DECISION: GDPR Article 4(1) 원문 raw 미보관 — 법적 위험 수준 판단은 raw/branch-notes/feature-data-retention-privacy-contract SSOT
D8 bare ULID = non-PII, user-linked ID = PII (GDPR indirect identifier). Log scrubber regex ^[0-9A-HJKMNP-TV-Z]{26}$ 적용 대상은 user-linked 만 AIP148-C2 (uid = opaque, non-PII), AIP148-C3 (display_name PII 와 uid 분리) official-vendor-doc (AIP-148) UNSUPPORTED_DECISION: GDPR Article 4(1) raw 미보관 — 최종 법적 분류는 jurisdiction-specific.feature-data-retention-privacy-contract SSOT
D9 SecureRandom 의무 + constant-time 비교 미적용 — 공개 resource id 는 표준 equals 사용. constant-time 은 비밀값 (token/API key/session) 영역으로 raw/branch-notes/feature-security-operational-baseline 위임 NANOID-C4 (crypto-strong random 의무), D8 (공개 식별자 분류 — non-PII, 비밀값 아님),raw/branch-notes/feature-security-operational-baseline D-row (비밀값 비교) official-reference (NANOID) + branch decision (공개 id 의 record equals 정합) NANOID-C4 는 JS 구현 기준이며 JVM ulid-creator 의 실제 SecureRandom 사용은 라이브러리 버전별 확인 전까지 needs-confirmation. 2026-06-01 spec drift 정정: 이전 "constant-time comparison" 본문은 D8 공개 식별자 분류와 모순 → 미적용으로 통일
D10 DB PK = PostgreSQL 16 uuid native (project §34 단일 DB). 거부: varchar(26/36), BIGINT, MySQL BINARY(16) (out of scope) RFC9562-C4 (monotonicity backbone), ULID-C4/C5/C6 (정렬·monotonic·binary 레이아웃), PERCONA-UUID-C2~C5 (random vs ordered UUID 일반 원리 —parallel evidence only, MySQL InnoDB 직접 적용 불가), project §34 (PostgreSQL 16 단일 DB stack) official-standard (RFC9562·ULID) + project-ssot (§34) + company-case-study (Percona = parallel only) UNSUPPORTED_IMPL_DECISION: PostgreSQL 16 uuid column index locality (ULID time-ordered insert 의 BTREE page split 완화) 정량 벤치마크 미보관 — 별도 raw 보강 필요
D11 external-only (ULID = public ID = DB PK 동일). dual column 거부 (skeleton default) PERCONA-UUID-C5 (ordered UUID ≈ BIGINT 성능 → BIGINT 분리 동기 약함), PLANETSCALE-NANOID-C4 (dual 사례 — 대안으로만 인용) company-case-study UNSUPPORTED_IMPL_DECISION: prod-grade (billion-row + heavy JOIN) 도메인은 dual override 권고 —raw/branch-notes/feature-persistence-failure-baseline 도메인별 정책 SSOT
D12 cache key = ULID (public ID 동일). Redis format <resource-type>:<ulid> D11 external-only 정합 (구조적 결정) branch decision dual column override 시 (D11) cache key 재결정 — skeleton 범위 밖
D13 multi-tenancy = ID 내 tenant 인코딩 거부 (형식적 위치만 결정). Tenant 모델 + persistence (WHERE tenant_id = X AND id = Y / composite index / findByIdAndTenant) + auth → tenant 해석 = feature-tenant-context-policy (예정 branch) SSOT 위임 AIP148-C4 (parent 필드 계층 resource name, SHOULD — 형식적 위치만 지지, tenant 모델 자체는 위임), SNOWFLAKE-C1 (machine ID 파티셔닝 거부 근거 — distributed fan-out 전제이며 skeleton 부적합) official-vendor-doc (AIP-148) + company-case-study (Snowflake 거부) tenant 모델/persistence/auth 해석은 본 branch scope 밖 — 신규 feature-tenant-context-policy scaffolding 후 cross-cite 갱신. 의문점 3 결정 따라 격하
D14 Resource ID (ULID, server-assigned, URL path) vs Idempotency-Key (UUID v4, client-generated, HTTP header, 24h TTL) —별개 형식. fingerprint mismatch → HTTP 422 BRANDUR-IDEMP-C8~C12 (분리 차원 모두), STRIPE-C1/C5 (별개 + POST 전용),raw/branch-notes/feature-rate-limit-idempotency-contract D-row engineering-blog (BRANDUR) + official-vendor-doc (Stripe) IETF Idempotency-Key draft 의 422 vs Brandur 409 불일치 — IETF draft raw 보강 권고
D15 ID 재사용 금지 (soft-delete + hard-delete 모두 NEVER reuse) — audit trail + ULID monotonicity 정합 AIP148-C2 (uid 재사용 금지 AIP-164 위임), ULID-C5 (monotonic 위반 회피) official-vendor-doc (AIP-148 간접) UNSUPPORTED_DECISION: AIP-164 원문 raw 미보관 — 재사용 금지의 normative 근거 보강 권고
D16 Library:ulid-creator ≥ 5.x + Spring Boot 3.5.14 (transitive Hibernate 6.5.x + Jackson 2.18.x) + OpenAPI 3.1 custom pattern + Java 21 SecureRandom + Gradle (Groovy DSL) + archunit-junit5 1.3.0 — project §34 Stack Commitment 정합 project §34 (Java 21, Spring Boot 3.5.14, PostgreSQL 16, Gradle, archunit-junit5 1.3.0) project-ssot (§34) + branch decision UNSUPPORTED_IMPL_DECISION:ulid-creator vs io.github.azam.ulidj 비교 raw 보강 권고 (ulid-creator 선택 근거 = monotonic factory + maintenance)
D17 4 ArchUnit rules (결정 SSOT = 본 branch, 코드 호스팅 = boundary suite):no_long_id_pk (..domain.. 한정), no_uuid_random_in_controller, no_math_random_for_id, no_varchar_255_for_id_columnraw/branch-notes/feature-boundary-validation-mapping-contract suite (archunit-junit5 1.3.0 per project §34). no_find_by_id_without_tenant (D13 보강 rule) 는 feature-tenant-context-policy 로 이관 raw/branch-notes/feature-boundary-validation-mapping-contract ArchUnit pattern (sibling SSOT, hosts code) + project §34 (archunit-junit5 1.3.0) project-ssot (§34) + branch decision 본 branch §6 = reference skeleton. 실제 코드 = boundary branch §구현 가이드.haveExplicitColumnLength() custom condition 의 1.3.0 API 호환성 검증 필요
D18 Out-of-scope: API key, session ID, webhook event_id, trace ID, external system ID, friendly sequence, migration policy — sibling branch SSOT cross-cite raw/branch-notes/feature-security-operational-baseline, raw/branch-notes/feature-webhook-outbound-contract, distributed-tracing branch (예정) branch decision distributed-tracing branch scaffolding 예정 (별도 작업)
D19 sample-portfolio WorkLogId fixture = 01ARZ3NDEKTSV4RRFFQ69G5FAV (26-char uppercase Crockford base32 ULID). Validation regex ^[0-9A-HJKMNP-TV-Z]{26}$ ULID-C1 (26자 형식), CROCKFORD-C1 (charset) official-reference baseline branch + project-note §17/§22 가 본 fixture cite — cross-branch 정합 검증 필요

구현 가이드

본 § 의 각 sub-section 은 Decision ID + Supporting Claim ID 를 reference (R1). 메커니즘 / 명명 / glob / API 모양 중 근거 없는 detailUNSUPPORTED_IMPL_DECISION 라벨 + trade-off 한 줄 (R2). 본 branch 범위 밖 영역은 남기지 않고 sibling SSOT 로 이관 (R3).

§1. Domain layer — WorkLogId value object + IdFactory<T> port

Trace: D1 (ULID), D4 (server-assigned), D5 (domain factory), D17 (no_long_id_pk)

// domain-core: dev.caskeleton.domain.identifier.IdFactory — port (production-level reusable)
package dev.caskeleton.domain.identifier;

public interface IdFactory<T extends ResourceId<?>> {
    T newId();
}

// domain-core: dev.caskeleton.domain.identifier.ResourceId — non-sealed marker
package dev.caskeleton.domain.identifier;

public interface ResourceId<SELF extends ResourceId<SELF>> {
    String value();  // 26-char uppercase Crockford base32 ULID
}

// sample-portfolio: dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId — value object
package dev.caskeleton.sample.portfolio.domain.worklog;

import dev.caskeleton.domain.identifier.ResourceId;

public record WorkLogId(String value) implements ResourceId<WorkLogId> {
    private static final java.util.regex.Pattern PATTERN =
        java.util.regex.Pattern.compile("^[0-9A-HJKMNP-TV-Z]{26}$");

    public WorkLogId {
        if (value == null || !PATTERN.matcher(value).matches()) {
            throw new IllegalArgumentException("Invalid WorkLogId format: " + value);
        }
    }

    public static WorkLogId of(String value) { return new WorkLogId(value); }
}

// sample-portfolio: dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory — port specialization
package dev.caskeleton.sample.portfolio.domain.worklog;

import dev.caskeleton.domain.identifier.IdFactory;

public interface WorkLogIdFactory extends IdFactory<WorkLogId> { }
  • 모듈 배치 (HARD 제약): ResourceId / IdFactory<T>domain-core (production-level reusable) 에, WorkLogId / WorkLogIdFactorysample-portfolio 에. domain-core 가 sample 을 보면 raw/branch-notes/feature-skeleton-package-blueprint-contract D7 (production module 이 sample-portfolio 를 import 하면 실패) 위반 → ArchUnit + Gradle dep rule 양쪽 HARD-STOP. 따라서 sealed permits WorkLogId 표현 불가non-sealed interface 채택.
  • sealed 의 enumeration 보장은 D17 no_long_id_pk 가 대체: ArchUnit rule 이 "도메인 entity 의 id 필드는 ResourceId 구현체만 허용" 으로 강화되어 컴파일타임은 아니나 빌드타임 게이트 동일.
  • 패키지 base 정합: 실제 코드는 dev.caskeleton.* (WorkLog.java:1 ca-tmpl/.../domain/worklog/WorkLog.java). 본 §의 이전 com.skeleton.* 표기는 spec drift — dev.caskeleton.* 로 통일.
  • OUT_OF_BRANCH_SCOPE: UserId, OrderId 등 다른 production 도메인의 ID value object 는 도메인 module 추가 시 동일 패턴 복제 — skeleton 은 WorkLogId 만 reference 구현. 신규 도메인이 추가될 때마다 permits 갱신 부담 없음 (non-sealed 이므로).

§2. Infrastructure layer — UlidWorkLogIdFactory adapter

Trace: D5 (도메인이 port 만 정의, infrastructure 가 구현), D9 (SecureRandom), D16 (ulid-creator 라이브러리)

// sample-portfolio: dev.caskeleton.sample.portfolio.adapter.outbound.identifier.UlidWorkLogIdFactory
package dev.caskeleton.sample.portfolio.adapter.outbound.identifier;

import com.github.f4b6a3.ulid.UlidCreator;
import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId;
import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory;
import org.springframework.stereotype.Component;

@Component
public class UlidWorkLogIdFactory implements WorkLogIdFactory {
    @Override
    public WorkLogId newId() {
        return WorkLogId.of(UlidCreator.getMonotonicUlid().toString());
    }
}
  • UlidCreator.getMonotonicUlid() → 동일 ms 내 monotonic 동작을 사용한다(ULID-C5). 내부 난수원이 D9의 SecureRandom 의무를 충족하는지는 사용 버전 source/README 확인 전까지 needs-confirmation이다.
  • 모듈 배치: 본 adapter 는 sample-portfolio 내부의 adapter/outbound/identifier/ (project-note §20 Blueprint 의 sample-portfolio 내부 adapter/outbound/ 패턴). production adapter-outbound module 에 두지 않는 이유 = WorkLogId 자체가 sample. production 도메인 추가 시 동일 패턴 복제 (각 도메인 module 이 자기 UlidXxxIdFactory 보유).
  • UNSUPPORTED_IMPL_DECISION: ulid-creator vs io.github.azam.ulidj 선택 근거 — monotonic factory API 명시성 + 활발한 maintenance. 비교 raw 보강 권고.
  • UNSUPPORTED_IMPL_DECISION: production 도메인이 N 개로 늘어날 때 재사용 가능한 generic UlidIdFactory<T extends ResourceId<T>> (production adapter-outbound) 를 도입할지 vs 도메인마다 복제할지는 신규 production 도메인 추가 시 결정. skeleton default = 도메인별 복제 (단순성).

§3. Hibernate UUID mapping (PostgreSQL 16 uuid native — project §34)

Trace: D10 (PostgreSQL uuid native), D16 (@JdbcTypeCode + Hibernate 6.5.x), D17 (no_varchar_255_for_id_column)

NOTE: 이전 버전의 본 § 가 포함한 tenant_id 컬럼 / tenant FK / composite (tenant_id, id) index 는 의문점 3 결정 따라 feature-tenant-context-policy (예정 branch) 도착 시 활성화 로 격하. 본 § 는 tenant 무관 의 ID column mapping 만 정의.

// sample-portfolio: dev.caskeleton.sample.portfolio.adapter.persistence.entity.WorkLogEntity
package dev.caskeleton.sample.portfolio.adapter.persistence.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.util.UUID;

@Entity
@Table(name = "work_log")
public class WorkLogEntity {

    @Id
    @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
    @JdbcTypeCode(SqlTypes.UUID)
    private UUID id;  // ULID-as-UUID (128-bit, Ulid.toUuid() 변환)

    // ... 도메인 필드 생략

    // NOTE (deferred to feature-tenant-context-policy):
    // @Column(name = "tenant_id", columnDefinition = "uuid", nullable = false, updatable = false)
    // @JdbcTypeCode(SqlTypes.UUID)
    // private UUID tenantId;
}
  • ULID 128-bit 는 UUID 객체로 representable — Ulid.toUuid() / Ulid.from(uuid) 양방향 변환.
  • D10 columnDefinition = "uuid" 명시 — PostgreSQL 16 의 native 16-byte UUID 타입 사용. Hibernate 6.5.x 의 @JdbcTypeCode(SqlTypes.UUID) 가 PostgreSQL JDBC driver 의 UUID binding 직접 처리.
  • D17 no_varchar_255_for_id_column 충족 — columnDefinitionvarchar 가 아닌 uuid 로 명시.
  • 패키지 배치: project-note §20 Blueprint 의 sample-portfolio 내부 adapter/persistence/entity/ 패턴 정합. 이전 버전의 com.skeleton.infrastructure.persistence 표기는 spec drift — dev.caskeleton.sample.portfolio.adapter.persistence.entity 로 통일.
  • DDL skeleton (Flyway V1__work_log.sql 예시, tenant 무관 단순 형식):
    CREATE TABLE work_log (
        id uuid PRIMARY KEY
        -- ... 도메인 컬럼
        -- NOTE (deferred to feature-tenant-context-policy):
        --   tenant_id uuid NOT NULL,
        --   CONSTRAINT fk_work_log_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id)
    );
    -- NOTE (deferred): CREATE INDEX ix_work_log_tenant_id ON work_log (tenant_id, id);
    

UUID 변환 헬퍼

Trace: D2 (Crockford base32 26-char), D3 (canonical uppercase + case-insensitive 입력)

// adapter-outbound: dev.caskeleton.adapter.outbound.identifier.UlidCodec — production utility (generic, sample-agnostic)
package dev.caskeleton.adapter.outbound.identifier;

import com.github.f4b6a3.ulid.Ulid;
import java.util.UUID;

public final class UlidCodec {
    private UlidCodec() {}

    /** D3: case-insensitive 입력 → canonical uppercase 26-char */
    public static String normalize(String input) {
        if (input == null) return null;
        return Ulid.from(input.toUpperCase()).toString();  // 검증 + 정규화
    }

    public static UUID toUuid(String ulidString) {
        return Ulid.from(ulidString).toUuid();
    }

    public static String fromUuid(UUID uuid) {
        return Ulid.from(uuid).toString();
    }
}
  • Ulid.from(String) 은 Crockford base32 디코딩 (CROCKFORD-C3) — i/l1, o0 자동 처리.
  • D3 boundary normalization: controller 의 @PathVariable 수신 직후 또는 jakarta-validation @Pattern 검증 후 normalize() 호출.

§5. Jackson serializer / OpenAPI schema

Trace: D16 (Jackson + OpenAPI 3.1)

// sample-portfolio: dev.caskeleton.sample.portfolio.adapter.web.json.WorkLogIdSerializer (sample-specific — WorkLogId 가 sample)
package dev.caskeleton.sample.portfolio.adapter.web.json;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId;
import java.io.IOException;

public class WorkLogIdSerializer extends JsonSerializer<WorkLogId> {
    @Override
    public void serialize(WorkLogId id, JsonGenerator gen, SerializerProvider sp) throws IOException {
        gen.writeString(id.value());  // 26-char uppercase
    }
}

OpenAPI 3.1 schema (yaml fragment):

components:
  schemas:
    WorkLogId:
      type: string
      description: 26-character uppercase Crockford base32 ULID
      pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'
      example: '01ARZ3NDEKTSV4RRFFQ69G5FAV'
      minLength: 26
      maxLength: 26
  • OpenAPI 3.1 format: uuid 사용 안 함 (D1 ULID 채택, UUID v4 가정의 format).
  • UNSUPPORTED_IMPL_DECISION: ULID 전용 format: ulid (비표준) 정의 vs pattern 사용 — pattern 채택 (벤더 중립).

§6. ArchUnit rule reference skeleton (4 rules)

Trace: D17 (ArchUnit) + raw/branch-notes/feature-boundary-validation-mapping-contract ArchUnit suite

본 § 의 코드는 reference skeleton — 실제 컴파일/실행 대상이 아님. 코드 작성 위치 = raw/branch-notes/feature-boundary-validation-mapping-contract §구현 가이드 (boundary suite 가 호스팅). 본 branch 는 결정 SSOT 만 보유 (D17).

// REFERENCE ONLY — actual location: feature-boundary-validation-mapping-contract ArchUnit suite
// package dev.caskeleton.archunit (예시)

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

@AnalyzeClasses(packages = "dev.caskeleton")
public class IdContractTest {

    /** D17 no_long_id_pk: ..domain.. 패키지의 POJO entity 만 검사.
     *  JPA entity (..adapter.persistence..) 의 @Id UUID id 는 D10 정합으로 검사 대상 제외. */
    @ArchTest
    static final ArchRule no_long_id_pk =
        fields().that().areDeclaredInClassesThat().resideInAPackage("..domain..")
            .and().haveNameMatching("id")
            .should().haveRawType("dev.caskeleton.domain.identifier.ResourceId")
            .orShould().haveRawType(dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId.class);

    /** D17 no_uuid_random_in_controller: controller / service / use case 가 UlidCreator / UUID.randomUUID 직접 호출 금지 */
    @ArchTest
    static final ArchRule no_uuid_random_in_controller =
        noClasses().that().resideInAnyPackage("..adapter.web..", "..application..")
            .should().callMethod(java.util.UUID.class, "randomUUID")
            .orShould().callMethodWhere(com.tngtech.archunit.core.domain.JavaCall.Predicates.target(
                target -> target.getOwner().getName().equals("com.github.f4b6a3.ulid.UlidCreator")));

    /** D9 / D17 no_math_random_for_id: Math.random() 전역 금지 */
    @ArchTest
    static final ArchRule no_math_random_for_id =
        noClasses().should().callMethod(Math.class, "random");

    /** D17 no_varchar_255_for_id_column: @Column 의 columnDefinition 또는 length 명시 의무 (id / *_id 필드) */
    @ArchTest
    static final ArchRule no_varchar_255_for_id_column =
        fields().that().areAnnotatedWith(jakarta.persistence.Column.class)
            .and().haveNameMatching(".*[iI]d$")
            .should(haveExplicitColumnLength());  // custom condition: length != default 255 OR columnDefinition != ""

    // NOTE: 5번째 rule (no_find_by_id_without_tenant) 는 의문점 3 결정 따라 제거.
    //       feature-tenant-context-policy (예정 branch) 가 tenant 모델 확정 후 그 branch SSOT 로 활성화.

    // ... haveExplicitColumnLength() custom ArchCondition 구현 생략
}
  • no_long_id_pk 적용 대상 명시: ..domain.. 패키지 한정. JPA entity (..adapter.persistence..) 의 @Id UUID id 는 D10 (PostgreSQL uuid native) 정합으로 검사 대상 제외. 본 rule 의 의도는 도메인 POJO 가 자기 식별성을 Long 으로 표현하는 anti-pattern 차단.
  • no_uuid_random_in_controller 패키지 정정: 실제 ca-tmpl 의 adapter-web module 은 ..adapter.web.. 패키지 (..web.. 단독 매칭은 너무 광범위).
  • UNSUPPORTED_IMPL_DECISION: haveExplicitColumnLength() ArchCondition 구현은 @Column.length() + @Column.columnDefinition() 반사 검사로 가능하나 ArchUnit 공식 API 에 없어 custom 작성 필요. 구현 detail 은 feature-boundary-validation-mapping-contract ArchUnit suite 에 위임.
  • OUT_OF_BRANCH_SCOPE: ArchUnit suite 의 조립 방식 (@AnalyzeClasses scope, test runner, gradle dep) 은 feature-boundary-validation-mapping-contract SSOT.

§7. Log scrubber regex (D8)

Trace: D8 (user-linked ID 만 redaction), D19 (ULID regex)

// reference: actual location TBD by feature-log-management-contract
// (production observability — likely adapter-outbound or shared-contract)
package dev.caskeleton.adapter.outbound.observability;

import java.util.regex.Pattern;

public final class UlidLogScrubber {
    private static final Pattern ULID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}");

    /** D8: user-linked ID (UserId, 또는 user 와 1:1 mapping resource ID) 만 마스킹.
     *  Resource ID (WorkLogId) 는 audit log 필요로 그대로 유지. */
    public static String scrubUserLinked(String message) {
        return ULID.matcher(message).replaceAll(match -> {
            String s = match.group();
            return s.substring(0, 6) + "**********" + s.substring(s.length() - 4);
        });
    }
}
  • UNSUPPORTED_IMPL_DECISION: regex 단일로는 "user-linked vs resource" 구분 불가 — 호출 측이 user-linked 컨텍스트 임을 알고 scrubUserLinked() 만 호출. 자동 분류는 SLF4J MDC key 분리 (user.id vs resource.id) 로 보강 필요 — raw/branch-notes/feature-log-management-contract SSOT.
  • OUT_OF_BRANCH_SCOPE: Logback / Log4j2 의 PatternLayout converter 등록은 log-management-contract SSOT.

§8. Sample-portfolio WorkLogId fixture (D19)

Trace: D19 (concrete fixture), raw/branch-notes/feature-api-contract-baseline sample-portfolio cross-cite

// sample-portfolio: src/test/java/dev/caskeleton/sample/portfolio/fixtures/SamplePortfolioFixture.java
package dev.caskeleton.sample.portfolio.fixtures;

import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId;

public final class SamplePortfolioFixture {
    /** D19: reference ULID — uppercase Crockford base32 26-char.
     *  baseline branch URL path variable 예시 + project-note §17/§22 cross-cite. */
    public static final WorkLogId WORK_LOG_ID = WorkLogId.of("01ARZ3NDEKTSV4RRFFQ69G5FAV");

    private SamplePortfolioFixture() {}
}
  • Cross-reference: raw/project-notes/ca-skeleton-operational-contract §17 Sample Domain Fixture + §22 Sample-portfolio Contract Matrix 가 본 fixture value 를 cite.
  • baseline branch URL 예시: GET /v1/worklogs/01ARZ3NDEKTSV4RRFFQ69G5FAV (D6 NO typed prefix 정합).

§9. Audit & Findings (이관 대상)

본 § 작성 중 본 branch 범위 밖 으로 식별되어 sibling branch 로 이관 권고된 항목:

항목 이관 대상 SSOT 이관 사유
ArchUnit suite 조립 (gradle dep / test runner /@AnalyzeClasses scope) raw/branch-notes/feature-boundary-validation-mapping-contract ArchUnit 운영 방식의 cross-branch SSOT
SLF4J MDC key 분리 (user.id vs resource.id) 정책 raw/branch-notes/feature-log-management-contract log redaction 자동화의 cross-branch SSOT
GDPR Article 4(1) PII 분류 법적 해석 raw/branch-notes/feature-data-retention-privacy-contract jurisdiction-specific 법적 결정 SSOT
Idempotency-Key HTTP header 처리 (TTL 저장소 / fingerprint 비교 / 422 응답) raw/branch-notes/feature-rate-limit-idempotency-contract idempotency 운영 계약 SSOT (본 branch 는형식 분리만 명시)
410 Gone vs 404 Not Found HTTP semantic (D15 ID 재사용 금지의 응답 정책) raw/branch-notes/feature-api-contract-baseline HTTP status mapping SSOT
PostgreSQL 16 uuid column index locality 벤치마크 (ULID time-ordered insert 의 BTREE page split 완화 정량) (예정)raw/company-tech-blogs/postgresql-16-uuid-index-benchmark.md D10 의 UNSUPPORTED_IMPL_DECISION 해소 (MySQL 영역 out of scope)

엣지·실패·의존

  • wire format 길이·대소문자·parser가 어긋나면 API와 snapshot consumer가 동시에 깨진다.
  • database native uuid 저장과 random-source 정책을 상속하며 controller 직접 생성을 금지한다.
  • raw/branch-notes/chore-ulid-to-uuidv7가 UUIDv7 전환을 소유하므로 ULID 기준 문구는 승인된 parent decision revision 갱신 시 함께 migration해야 한다.

검증해야 할 주장

Claim Why uncertain How to verify Status
ULID 가 48-bit millisecond timestamp 평문 노출 spec 확인 필요 ULID spec §1 timestamp 영역 verified (ULID-C2)
CUID2 가 timestamp leak 없음 (저자 주장) spec 확인 필요 CUID2 official spec verified (CUID2-C1, 저자 주장 — 독립 감사 미확인)
RFC 3986 unreserved charset 정의 = ALPHA / DIGIT / "-" / "." / "_" / "~" spec 확인 필요 RFC 3986 §2.3 verified (RFC3986-C1)
RFC 3986 path component case-sensitive spec 확인 필요 RFC 3986 §6.2.2.1 verified (RFC3986-C3/C4)
NanoID 21자 default + URL-safe alphabet A-Za-z0-9_- + crypto-strong random 라이브러리 default 확인 필요 NanoID official README verified (NANOID-C1/C2/C4)
Stripe Idempotency-Key 가 client-generated + POST 전용 정책 변경 가능 Stripe API doc verified (STRIPE-C1/C5, BRANDUR-IDEMP-C8/C11)
Brandur Stripe idempotency key 24h TTL 권고 블로그 검증 brandur.org/idempotency-keys verified (BRANDUR-IDEMP-C10)
Percona MySQL InnoDB random UUID PK = ordered UUID 대비 50% 더 큰 디스크 사용 (25M-row 벤치마크) 버전 의존 Percona blog verified (PERCONA-UUID-C2/C3/C5, MySQL 5.x 기준)
Java 21 java.util.UUID v7 native 미지원 API 변경 가능 OpenJDK source / JEP 검색 needs-confirmation (D1/D16 영향, Java 23+ 추적 필요)
Spring Boot 3.x @GeneratedValue(strategy=UUID) 가 UUID v4 기본 버전별 차이 가능 Spring Boot reference + Hibernate 6.x doc needs-confirmation (D16, ULID 사용 시 strategy 무관)
AWS ALB path pattern 128자 한계 quota 변경 가능 AWS ELB user guide needs-confirmation (D3 URL 길이 영향)
GDPR Article 4(1) "identifier linked to natural person" 정의 해석 변경 가능 EUR-Lex GDPR 원문 needs-confirmation (D7/D8 법적 분류, feature-data-retention-privacy-contract SSOT)
AIP-164 의 uid 재사용 금지 normative 근거 AIP-148 위임 google.aip.dev/164 needs-confirmation (D15 재사용 금지 직접 근거)
IETF Idempotency-Key HTTP header draft 의 422 status code 권고 draft 변경 가능 IETF datatracker needs-confirmation (D14 fingerprint mismatch 응답)
MySQL 8.0 UUID_TO_BIN(uuid, 1) swap-flag 의 UUID v7 / ULID 성능 효과 벤치마크 미보관 (예정) raw/company-tech-blogs/uuid-v7-performance-benchmark.md needs-confirmation (D10 UNSUPPORTED_IMPL_DECISION 해소)
PostgreSQL uuid native type index locality (UUID v7 / ULID 기준) 벤치마크 미보관 PostgreSQL 16 doc + 벤치마크 raw needs-confirmation (D10 PostgreSQL branch)
ulid-creator Java 라이브러리의 SecureRandom 사용 라이브러리 버전 의존 ulid-creator source / README needs-confirmation (D9 JVM 적용 범위)

마주친 문제

  • D19 fixture self-inconsistency (2026-06-01, resolved): 최초 fixture 01HRGC7K2N4F6P8Q0R2S4T6U8V의 23번째 문자 U가 Crockford base32 제외 문자(I/L/O/U)라 자기 자신의 D2 charset / D19 regex를 위반 → Ulid.from(...) / WorkLogId.of(...)IllegalArgumentException. ULID spec 공식 예제값 01ARZ3NDEKTSV4RRFFQ69G5FAV로 교체(문서+코드 일괄). 상세: raw/errors/ulid-fixture-crockford-u-self-inconsistency-2026-06-01.
  • D17 no_uuid_random_in_controller false positive (2026-06-01, resolved): §6 reference 코드의 광범위한 ..adapter.web.. selector가 기존 RequestLoggingFiltercorrelation/trace id 생성(UUID.randomUUID())을 잡음. D17 결정 텍스트("controller / service / use case") + D18(trace id 범위 밖)에 맞춰 selector를 ..adapter.web..controller.. + ..application..로 좁힘. 상세: raw/errors/archunit-no-uuid-random-trace-id-false-positive-2026-06-01.

구현 결과

등급: locally-verifiedcd src && ./gradlew check 전체 green (모든 모듈 테스트 + ArchUnit + verifyCleanArchitectureDependencies). 구현 위치: ca-tmpl working tree.

변경 파일 (ca-tmpl/src):

  • domain-core: dev/caskeleton/domain/identifier/ResourceId.java(non-sealed marker), IdFactory.java(port) — 신규.
  • sample-portfolio domain: WorkLogId.java(record + regex 검증), WorkLogIdFactory.java(port) — 신규. WorkLog.javaid UUIDWorkLogId, create(WorkLogId,...), UUID.randomUUID() 자가 생성 제거(D4/D5). WorkLogRepository.java — 포트 시그니처 WorkLogId.
  • sample-portfolio adapter.identifier: UlidWorkLogIdFactory.java(@Component, UlidCreator.getMonotonicUlid()) — 신규(§2).
  • 신규 모듈 adapter-identifier: dev/caskeleton/adapter/identifier/UlidCodec.java + package-info.java — production 유틸(§4).

§2/§4 배치 수정 (2026-06-01, user decision): spec 초안은 identifier를 adapter.outbound.identifier에 뒀으나, 이 repo의 adapter-outbound는 "external HTTP/messaging/cache/notifications"로 좁게 문서화돼 있어 ULID 라이브러리 래퍼(비-IO 인프라 능력)와 의미 불일치. → non-IO 인프라 어댑터 전용 신규 모듈 adapter-identifier 신설(adapter-web/persistence/outbound의 형제), sample은 adapter/identifier/ 서브패키지로 이동. Gradle settings + verifyCleanArchitectureDependencies 매트릭스 + ArchUnit(identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap + 형제 격리 목록에 ..adapter.identifier.. 추가) + app-bootstrap 의존 등록까지 일관 반영. adapter-outbound에서 ulid-creator 제거(repostats outbound 어댑터만 잔존).

  • sample-portfolio persistence: WorkLogEntity.java(@Id UUID+@Column(columnDefinition="uuid")+@JdbcTypeCode(SqlTypes.UUID), D10), WorkLogPersistenceMapper.java(ULID↔UUID, Ulid 직접 — persistence→adapter-outbound 의존 금지), WorkLogRepositoryAdapter.java.
  • sample-portfolio application: GetWorkLogQuery/DeleteWorkLogCommand/UpdateWorkLogCommand/WorkLogNotFoundException(WorkLogId), CreateWorkLogUseCase(WorkLogIdFactory 주입).
  • sample-portfolio web: WorkLogController.java(@PathVariable StringtoId() D3 정규화 via Ulid.from), WorkLogResponse/WorkLogSummaryResponse(WorkLogId), WorkLogIdSerializer.java(@JsonComponent, bare ULID, §5).
  • app-bootstrap test: CleanArchitectureTest.java — D17 4개 rule + haveExplicitColumnLength() custom condition(§6, boundary suite 호스팅).
  • build.gradle: sample-portfolio + adapter-identifiercom.github.f4b6a3:ulid-creator:5.2.3(D16). settings.gradle + CA 매트릭스에 adapter-identifier 등록.
  • 테스트: WorkLogIdTest/UlidCodecTest/UlidWorkLogIdFactoryTest/WorkLogIdSerializerTest/SamplePortfolioFixture(§8) 신규 + 영향받은 6개 테스트 갱신.

리뷰 체인: ca-architect-sentinel PASS · ca-spec-reviewer PASS(17/17 MET) · ca-quality-reviewer NEEDS_FIX → 4건 반영(IDS private화, monotonic 테스트 루프 강화, ArchUnit length cast 방어, D3 lowercase wire 테스트). spec-mandated 유지: UlidCodec null 반환/존재, ArchUnit 위반 fixture는 boundary-contract SSOT.

범위 밖 의도적 미구현: D13 tenant(→raw/branch-notes/feature-tenant-context-policy), D14 idempotency 처리(→raw/branch-notes/feature-rate-limit-idempotency-contract), §7 UlidLogScrubber(→raw/branch-notes/feature-log-management-contract), D7 CUID2 override, D9 constant-time 비교(현재 record 기본 equals).

묶음 (이 branch에서 파생된 자료)

근거 자료

  • raw/official-docs/rfc9562-uuid — IETF RFC 9562 (2024): UUID v7 time-ordered 48bit Unix ms timestamp 정의, UUIDv6 vs v7 SHOULD 권고, monotonicity backbone, timestamp attack surface §8 (D1/D7/D10 / RFC9562-C1~C5)
  • raw/official-docs/ulid-spec.md — ULID 공식 spec: 26자 Crockford base32, 48bit ms timestamp, monotonic 정렬, binary(16) 레이아웃 (D1/D2/D3/D7/D10)
  • raw/official-docs/cuid2-spec.md — CUID2 보안 설계: timestamp 비노출, SHA-3 해싱, Base36 24자, privacy-sensitive 도메인 후보 (D1/D7/D9)
  • raw/official-docs/crockford-base32-spec.md — Crockford base32 심볼 셋 정의 + I/L/O/U 제거 이유 + case-insensitive 디코딩 정규화 규칙 (D2/D3)
  • raw/official-docs/rfc3986-uri-generic-syntax — IETF RFC 3986: URI generic syntax normative standard. §2.3 unreserved charset (ALPHA / DIGIT / "-" / "." / "_" / "~") + §6.2.2.1 case normalization (path case-sensitive, scheme·host case-insensitive) — D2·D3 결정 최고 등급 근거
  • raw/official-docs/google-aip-148-standard-fields — Google AIP-148: name(server-assigned 관례) · uid(UUID4 system-assigned opaque) · display_name(mutable, non-unique) · parent(계층 resource name) 표준 필드 정의 (D5/D6/D8/D13 / AIP148-C1~C5)
  • raw/company-tech-blogs/aws-iam-arn-format — AWS ARN 6-field 계층 prefix case study: partition:service:region:account-id:resource-type:resource-id 구조 + / vs : separator 변형 + wildcard 제약 (D6/D13 / AWS-ARN-C1~C5)
  • raw/official-docs/nanoid-spec — NanoID 21자 URL-safe 기본 설정 (A-Za-z0-9_-), crypto 모듈 기반 SecureRandom, UUID v4 충돌 확률 동등성, customAlphabet API (D1/D2/D3/D9 / NANOID-C1~C5)
  • raw/company-tech-blogs/planetscale-nanoid-api — PlanetScale 이 UUID 대신 NanoID 를 API 식별자로 채택한 이유 + public_id (NanoID) + BigInt PK Dual 컬럼 패턴 prod 사례 (D1/D2/D10/D11 / PLANETSCALE-NANOID-C1~C5)
  • raw/company-tech-blogs/github-graphql-global-node-id — GitHub GraphQL global node ID: base64(type:numeric_id) Relay-style case study. opaque ID 취급 권고, node(id:...) direct lookup 패턴, REST ↔ GraphQL ID 공유 (D6/D11/D13 / GITHUB-NODE-ID-C1~C5)
  • raw/company-tech-blogs/segment-ksuid — Segment KSUID README: 20바이트(32-bit 초 단위 timestamp + 128-bit 랜덤), 27자 base62, custom epoch(2014-05-13), production battle-tested — D1 대안 후보 평가, D2 base62 vs base32 charset 트레이드오프, D7 초 단위 timestamp 정밀도 비교 (KSUID-C1/C2/C3)
  • raw/company-tech-blogs/brandur-stripe-idempotency-keys — Brandur Leach (전 Stripe): Idempotency-Keyclient-generated unique value (HTTP header 전송), TTL 24h 단기 correctness 보장, UUID 같은 난수 포맷 권장, 동일 key + 다른 params = client bug — D4 (ID 생성 책임) · D14 (Idempotency-Key vs Resource ID 구분) · D11 (external-only 분리 패턴 간접) 근거 (BRANDUR-IDEMP-C8C12)
  • raw/company-tech-blogs/snowflake-twitter-id — Twitter Snowflake README (2010): 64bit ID (41bit ms timestamp + 10bit machine ID + 12bit sequence), custom epoch, k-sorted 보장, 노드 간 조율 불필요 요건 — D1 Snowflake 명시적 거부 근거 (worker ID 사전 조율 부담), D10 BIGINT fit 사례, D13 datacenter partition 인코딩 대안 패턴 (SNOWFLAKE-C1~C5)
  • raw/company-tech-blogs/percona-uuid-storage-mysql — Percona (Karthik Appigatla, 2014): MySQL InnoDB clustered index 에서 random UUID PK 가 ordered UUID / BIGINT 대비 50% 더 큰 디스크 사용 + 삽입 시간 선형 증가 (25M 레코드 벤치마크). D10 (binary(16) vs varchar(36) 정량 근거) + D7 접선 (ordered UUID v1 의 timestamp 노출 부작용) (PERCONA-UUID-C1~C5)
  • raw/official-docs/stripe-resource-id-convention — Stripe 공식 API Reference: typed prefix opaque ID (ch_, cus_, pi_) 관례 + Idempotency-Key 는 client-generated 로 resource ID 와 별개 + prefix 변경이 backward-compatible 로 분류됨 (영구 불변 보장 아님) — D1/D4/D6/D11/D14 근거 (STRIPE-C1~C5)

Sub-branches (세부 작업)

  • (없음)

오류 기록 (이 branch 작업 중 발생)

면접 준비 (이 작업에서 나올 수 있는 면접 질문)

강의 (이 작업을 위해 학습한 강의)

  • (없음)

job-posting tie-ins (이 작업에서 파생된 글감)

관련 일일 노트

  • (없음 — scaffolding 단계)

완료 후 정리

  • PR 링크:
  • 리뷰 메모:
  • 머지 결과 / 배포 환경:
  • wiki 추출 대상 (verified만, wiki/projects/로만 추출):
    • actually-implemented 항목:
    • locally-verified 항목:
    • prod-verified 항목:
  • 추출하지 않을 항목 (planned / documented-only / abandoned):