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 |
|
|
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 |
|
1 | e59f870a8ac62330ab217e132d73bec75903302157eef8df5a5f51189800ce7d |
branch: feature-resource-identifier-contract
Layer:
raw/branch-notes/— resource ID 형식 결정 + ID 가 URL / log / idempotency / DB primary key / cache / multi-tenancy / privacy 에 미치는 계약을 정의합니다. 완료 후/ingest로wiki/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번째 ruleno_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:비어 있음).
- Parent project (canonical SSOT): raw/project-notes/ca-skeleton-operational-contract
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 후보)
- raw/branch-notes/feature-api-contract-baseline — URL path variable 의 ID 형식 SSOT consumer. D19 (resource URL naming) + sample-portfolio
WorkLogIdfixture 와 정합. - raw/branch-notes/feature-rate-limit-idempotency-contract —
Idempotency-KeyHTTP header (client-generated UUID, 24h TTL) 와 본 branch 의 resource ID 가 별개 임을 명시. - raw/branch-notes/feature-log-management-contract — log 에 resource ID 노출 시 PII 분류 + redaction 정책. GDPR Article 4(1) "identifier linked to natural person" 경계.
- raw/branch-notes/feature-data-retention-privacy-contract — sequential ID 의 enumeration attack + count leak + UUIDv7/ULID 의 timestamp leak 위험.
- raw/branch-notes/feature-persistence-failure-baseline — DB primary key index 성능 (UUID v4 random vs UUID v7 / ULID time-ordered vs BIGINT sequential vs TSID 64bit).
- raw/branch-notes/feature-security-operational-baseline — ID enumeration / timing attack 방어, SecureRandom 사용 의무, API key / OAuth client_id 형식 (본 branch 책임 밖).
- raw/branch-notes/feature-webhook-outbound-contract — webhook event_id 형식 분리 (resource ID 와 별개, 본 branch 책임 밖).
- raw/branch-notes/feature-boundary-validation-mapping-contract — ArchUnit 으로 anti-pattern (
Long idPK / controller 에서UUID.randomUUID()/Math.random()사용) 차단 정책 정합.
브랜치 계약 패킷
- 생성 시 프로젝트 개정:
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
uuidnative (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 의uuidnative 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-KeyHTTP header (RFC draft) — client-generated UUID, 24h TTL.WorkLogId— server-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_id —
feature-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
unreservedcharset) - 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
uuidnative — 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-KeyHTTP 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
WorkLogIdreference 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
uuidnative (project §34 단일 DB) — varchar / BIGINT / MySQLBINARY(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_column—feature-boundary-validation-mapping-contractsuite 가 코드 호스팅, 본 branch 가 결정 SSOT.no_find_by_id_without_tenant는feature-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
WorkLogIdfixture =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.UUIDv7 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 3986unreserved(RFC3986-C1) 의 진부분집합 — URL path 직접 사용 안전 (percent-encoding 불필요). - 캐노니컬 출력: uppercase ULID (ULID spec default).
- 입력 수용: case-insensitive (CROCKFORD-C3:
i/l→1,o→0정규화). - 서버는 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
WorkLogIdFactoryinterface (port) ←UlidWorkLogIdFactory(sample-portfolio adapter) 구현 ←WorkLogCommandService(application-core) 주입 →factory.newId()→WorkLog.rehydrate(id, …)로 entity 조립. - Infrastructure-managed (Hibernate
@GeneratedValue/ DB sequence) 거부: 도메인이 영속화 메커니즘에 결합 (D10 의 PostgreSQLuuidnative 와도 충돌 — Hibernate generator 가 ULID 보장 안 함). - Domain
staticself-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 의
WorkLogCommandService가WorkLogIdFactory를 주입받을지 vsIdFactory<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(recordequals/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) recordequals의 표준 동등성 의미 훼손 → 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 의 공개 식별자 분류와 모순 + 코드 구현 (WorkLogIdrecord 기본equals) 과 불일치 → 본 결정으로 통일.
D10. DB primary key = PostgreSQL uuid native (project §34 Stack Commitment)
- DB stack = PostgreSQL 16 (project §34). 컬럼 타입 =
uuidnative 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 의
uuidcolumn 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 모델 (
TenantIdVO,tenant테이블, FK relationship) - Tenant scope 의 DB 표현 (
WHERE tenant_id = X AND id = Y, composite index,findByIdAndTenantrepository contract) - Auth → tenant 해석 (URL path segment
/v1/tenants/{tenantId}/…vs JWT claim) - ArchUnit
no_find_by_id_without_tenantrule
- Tenant 모델 (
- 위 항목은 기존 raw/branch-notes/feature-tenant-context-policy (in-progress) SSOT 활성화 + 필요시 scope 확장 (현재 그 branch out-of-scope 는 "실제 SaaS tenant model 구현" 으로 명시 —
TenantIdVO /tenant테이블 / FK 가 활성화되면 그 branch 의 out-of-scope 표 갱신 필요). 본 branch 는 ID 형식이 tenant 와 충돌하지 않도록 만 보장. - 이전 본문 (의무 lookup
WHERE tenant_id = X AND id = Y, ArchUnitno_find_by_id_without_tenantrule) 철회 이유: 실제 ca-tmpl 코드에 tenant 도메인 모델 0건 (WorkLogRepository.java:10ca-tmpl/.../domain/worklog/WorkLogRepository.java의findById(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-Keyheader 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.UUIDv7 native 미지원 — ULID 채택으로 영향 없음 (D1 정합). - Hibernate 6.5.x 의
@JdbcTypeCode(SqlTypes.UUID)는 PostgreSQL JDBC driver 의uuid타입에 직접 mapping (별도 converter 불필요). - UNSUPPORTED_IMPL_DECISION:
ulid-creatorvsio.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 (PostgreSQLuuidnative) 정합으로 검사 대상 제외.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:@Columnannotation 에 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
WorkLogIdreference 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-C1 |
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/l→1, o→0), 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_column — raw/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 모양 중 근거 없는 detail 은UNSUPPORTED_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/WorkLogIdFactory는sample-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-sealedinterface 채택. - sealed 의 enumeration 보장은 D17
no_long_id_pk가 대체: ArchUnit rule 이 "도메인 entity 의 id 필드는ResourceId구현체만 허용" 으로 강화되어 컴파일타임은 아니나 빌드타임 게이트 동일. - 패키지 base 정합: 실제 코드는
dev.caskeleton.*(WorkLog.java:1ca-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/패턴). productionadapter-outboundmodule 에 두지 않는 이유 =WorkLogId자체가 sample. production 도메인 추가 시 동일 패턴 복제 (각 도메인 module 이 자기UlidXxxIdFactory보유). - UNSUPPORTED_IMPL_DECISION:
ulid-creatorvsio.github.azam.ulidj선택 근거 — monotonic factory API 명시성 + 활발한 maintenance. 비교 raw 보강 권고. - UNSUPPORTED_IMPL_DECISION: production 도메인이 N 개로 늘어날 때 재사용 가능한 generic
UlidIdFactory<T extends ResourceId<T>>(productionadapter-outbound) 를 도입할지 vs 도메인마다 복제할지는 신규 production 도메인 추가 시 결정. skeleton default = 도메인별 복제 (단순성).
§3. Hibernate UUID mapping (PostgreSQL 16 uuid native — project §34)
Trace: D10 (PostgreSQL
uuidnative), D16 (@JdbcTypeCode+ Hibernate 6.5.x), D17 (no_varchar_255_for_id_column)NOTE: 이전 버전의 본 § 가 포함한
tenant_id컬럼 /tenantFK / 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충족 —columnDefinition가varchar가 아닌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/l→1,o→0자동 처리.- 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(비표준) 정의 vspattern사용 —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 (PostgreSQLuuidnative) 정합으로 검사 대상 제외. 본 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-contractArchUnit suite 에 위임. - OUT_OF_BRANCH_SCOPE: ArchUnit suite 의 조립 방식 (
@AnalyzeClassesscope, test runner, gradle dep) 은feature-boundary-validation-mapping-contractSSOT.
§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.idvsresource.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_controllerfalse positive (2026-06-01, resolved): §6 reference 코드의 광범위한..adapter.web..selector가 기존RequestLoggingFilter의 correlation/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-verified—cd 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.java—idUUID→WorkLogId,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 String→toId()D3 정규화 viaUlid.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-identifier에com.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/company-tech-blogs/aws-iam-arn-format
- raw/company-tech-blogs/brandur-stripe-idempotency-keys
- raw/company-tech-blogs/github-graphql-global-node-id
- raw/company-tech-blogs/percona-uuid-storage-mysql
- raw/company-tech-blogs/planetscale-nanoid-api
- raw/company-tech-blogs/segment-ksuid
- raw/company-tech-blogs/snowflake-twitter-id
- raw/official-docs/crockford-base32-spec
- raw/official-docs/cuid2-spec
- raw/official-docs/google-aip-148-standard-fields
- raw/official-docs/nanoid-spec
- raw/official-docs/rfc3986-uri-generic-syntax
- raw/official-docs/rfc9562-uuid
- raw/official-docs/stripe-resource-id-convention
- raw/official-docs/ulid-spec
- raw/errors/archunit-no-uuid-random-trace-id-false-positive-2026-06-01
- raw/errors/ulid-fixture-crockford-u-self-inconsistency-2026-06-01
- raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01
- raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01
근거 자료
- 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) +BigIntPK 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-Key는 client-generated unique value (HTTP header 전송), TTL24h 단기 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 작업 중 발생)
- raw/errors/ulid-fixture-crockford-u-self-inconsistency-2026-06-01 — D19 fixture
U(Crockford 제외 문자) self-inconsistency, 공식 예제값으로 교체. - raw/errors/archunit-no-uuid-random-trace-id-false-positive-2026-06-01 —
no_uuid_random_in_controller가 trace-id 생성을 잡은 false positive, selector 정밀화.
면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- raw/interviews/clean-architecture-identifier-generation — 도메인을 인프라에 결합하지 않고 server-assigned ULID를 생성하는 계층 책임 (port + application orchestration).
강의 (이 작업을 위해 학습한 강의)
- (없음)
job-posting tie-ins (이 작업에서 파생된 글감)
- raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01 — Crockford base32가 I/L/O/U를 제외하는 이유 + 문서 예시 값 단위검증.
- raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01 — ID 종류별 거버넌스 규칙 scope 설계 + DDD factory port.
관련 일일 노트
- (없음 — scaffolding 단계)
완료 후 정리
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- wiki 추출 대상 (verified만,
wiki/projects/로만 추출):actually-implemented항목:locally-verified항목:prod-verified항목:
- 추출하지 않을 항목 (planned / documented-only / abandoned):