996 lines
102 KiB
Markdown
996 lines
102 KiB
Markdown
---
|
|
title: branch / feature-resource-identifier-contract
|
|
source_type: branch-note
|
|
status: verified
|
|
branch: feature-resource-identifier-contract
|
|
parent_branch:
|
|
related_projects: [ca-skeleton]
|
|
tags: [branch, ca-skeleton, identifier, uuid, ulid, security]
|
|
created: 2026-05-31
|
|
last_reviewed: 2026-06-04
|
|
target_merge:
|
|
status_label: merged
|
|
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-046
|
|
kind: project-work-item
|
|
project: ca-skeleton-operational-contract
|
|
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-046
|
|
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-RANDOM-001@1]
|
|
refines: []
|
|
overrides: []
|
|
depends_on: []
|
|
contract_packet: 1
|
|
contract_packet_sha256: 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번째 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.
|
|
|
|
<!-- section-id: branch-parent -->
|
|
## 부모 (필수)
|
|
|
|
> 이 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 `WorkLogId` fixture 와 정합.
|
|
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] — `Idempotency-Key` HTTP 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 id` PK / controller 에서 `UUID.randomUUID()` / `Math.random()` 사용) 차단 정책 정합.
|
|
|
|
<!-- GENERATED: branch-contract:start -->
|
|
<!-- section-id: branch-contract-packet -->
|
|
## 브랜치 계약 패킷
|
|
|
|
- **생성 시 프로젝트 개정**: `1`
|
|
- **패킷 스키마**: `contract_packet: 1`
|
|
- **완료 조건**: ULID format·PostgreSQL uuid persistence·SecureRandom test가 통과한다
|
|
|
|
<!-- section-id: inherited-project-decisions -->
|
|
### 상속한 프로젝트 결정
|
|
|
|
| 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]] |
|
|
|
|
<!-- section-id: branch-local-decisions -->
|
|
### 브랜치 지역 결정
|
|
|
|
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
|
|
|
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
|
|---|---|---|---|---|
|
|
|
|
<!-- section-id: declared-overrides -->
|
|
### 선언한 예외
|
|
|
|
| Override ID | Overrides | Reason | Approval | Status |
|
|
|---|---|---|---|---|
|
|
<!-- GENERATED: branch-contract:end -->
|
|
|
|
<!-- section-id: branch-goal -->
|
|
## 목표
|
|
|
|
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.
|
|
- `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개:
|
|
|
|
```text
|
|
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:
|
|
|
|
<!-- section-id: branch-scope -->
|
|
## 범위
|
|
|
|
### 포함 범위
|
|
|
|
- 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
|
|
|
|
- [X] **(P0)** D1: resource ID default 형식 = **ULID** (sequential / UUID v4 / Snowflake 거부, UUIDv7 trade-off 명시) — 등급: `documented-only`
|
|
- [X] D2: ID charset / encoding / length = **Crockford base32 26-char (ULID 고정)** — 등급: `documented-only`
|
|
- [X] D3: URL-safe charset = RFC 3986 `unreserved` 진부분집합 + canonical uppercase + case-insensitive 입력 수용 — 등급: `documented-only`
|
|
|
|
### P1 — Architecture & responsibility
|
|
|
|
- [X] D4: ID 생성 책임 = **server-assigned** (resource ID) + **client-generated** (Idempotency-Key only) — 등급: `documented-only`
|
|
- [X] D5: ID generation architecture layer = **Domain entity factory** (`WorkLogId.newId()`) — 등급: `documented-only`
|
|
- [X] D6: prefix 정책 = **NO typed prefix** (bare ULID, type 식별은 URL collection name) — 등급: `documented-only`
|
|
|
|
### P1 — Privacy & security
|
|
|
|
- [X] D7: timestamp leak 완화 = **ACCEPT default** + CUID2 override 허용 (privacy-sensitive 도메인) — 등급: `documented-only`
|
|
- [X] D8: PII / GDPR 분류 = bare ULID = non-PII, user-linked ID = PII (log scrubber regex 적용 대상은 user-linked 만) — 등급: `documented-only`
|
|
- [X] D9: enumeration 방어 = `SecureRandom` 의무. constant-time 비교 **미적용** (공개 resource id 는 표준 `equals`. 비밀값 비교는 `feature-security-operational-baseline` 위임) — 등급: `documented-only`
|
|
|
|
### P1 — DB & persistence
|
|
|
|
- [X] D10: DB primary key = **PostgreSQL 16 `uuid` native** (project §34 단일 DB) — varchar / BIGINT / MySQL `BINARY(16)` 거부 — 등급: `documented-only`
|
|
- [X] D11: Public ID vs Internal Sequence = **external-only** (ULID = public ID = DB PK 동일) — 등급: `documented-only`
|
|
- [X] D12: Cache key 전략 = ULID (public ID 동일), Redis format `<resource-type>:<ulid>` — 등급: `documented-only`
|
|
|
|
### P2 — Operational & ergonomic
|
|
|
|
- [X] D13: multi-tenancy = **ID 내 tenant 인코딩 거부 (형식적 위치만)**. tenant 모델 + persistence + auth 해석 = `feature-tenant-context-policy` (예정) 위임 — 등급: `documented-only`
|
|
- [X] D14: `Idempotency-Key` (UUID v4, client-generated, 24h TTL) vs Resource ID (ULID, server-assigned, persistent) — 별개 형식 명시. Fingerprint mismatch = HTTP 422 — 등급: `documented-only`
|
|
- [X] D15: ID 재사용 정책 = **NEVER reuse** (soft-delete + hard-delete 모두) — 등급: `documented-only`
|
|
|
|
### P2 — Tooling & enforcement
|
|
|
|
- [X] 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`
|
|
- [X] 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-contract` suite 가 코드 호스팅, 본 branch 가 결정 SSOT. `no_find_by_id_without_tenant` 는 `feature-tenant-context-policy` 이관 — 등급: `documented-only`
|
|
- [X] 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
|
|
|
|
- [X] 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`/`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 `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 의 `WorkLogCommandService` 가 `WorkLogIdFactory` 를 주입받을지 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.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-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-C1~C6 (spec full), CROCKFORD-C1~C4 (Crockford base32 alphabet + 디코딩 정규화), NANOID-C1~C5 (NanoID 대안 비교), CUID2-C1~C5 (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`/`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`)
|
|
|
|
```java
|
|
// 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-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` 라이브러리)
|
|
|
|
```java
|
|
// 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 만 정의.
|
|
|
|
```java
|
|
// 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 무관 단순 형식):
|
|
```sql
|
|
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 입력)
|
|
|
|
```java
|
|
// 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)
|
|
|
|
```java
|
|
// 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):
|
|
|
|
```yaml
|
|
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).
|
|
|
|
```java
|
|
// 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)
|
|
|
|
```java
|
|
// 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
|
|
|
|
```java
|
|
// 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가 기존 `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` — `id` `UUID`→`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 정규화 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-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에서 파생된 자료)
|
|
|
|
<!-- GENERATED: sources:start -->
|
|
- [[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]]
|
|
<!-- GENERATED: sources:end -->
|
|
|
|
<!-- GENERATED: interviews:start -->
|
|
- [[raw/interviews/clean-architecture-identifier-generation]]
|
|
<!-- GENERATED: interviews:end -->
|
|
|
|
<!-- GENERATED: errors:start -->
|
|
- [[raw/errors/archunit-no-uuid-random-trace-id-false-positive-2026-06-01]]
|
|
- [[raw/errors/ulid-fixture-crockford-u-self-inconsistency-2026-06-01]]
|
|
<!-- GENERATED: errors:end -->
|
|
|
|
<!-- GENERATED: blog-topics:start -->
|
|
- [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]]
|
|
- [[raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01]]
|
|
<!-- GENERATED: blog-topics:end -->
|
|
|
|
<!-- GENERATED: branches:start -->
|
|
- [[raw/branch-notes/chore-ulid-to-uuidv7]]
|
|
<!-- GENERATED: branches:end -->
|
|
|
|
### 근거 자료
|
|
|
|
- [[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-Key` 는 *client-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-C8~C12)
|
|
- [[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):
|