Files
llm-wiki/vault/30-knowledge/projects/ca-tmpl/resource-identifier-format.md
T

17 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed
title source_type status confidence tags related_projects last_reviewed
ca-tmpl - Resource Identifier (ULID) 결정 project verified high
ca-skeleton
resource-identifier
ulid
actually-implemented
ca-skeleton
ca-tmpl
2026-07-02

ca-tmpl - Resource Identifier (ULID) 결정

Layer: wiki/projects/ — 내 프로젝트 사실. 일반 개념(ULID vs UUIDv7 vs UUIDv4 vs Snowflake tradeoff)은 wiki/concepts/resource-identifier-format 참조.

프로젝트 컨텍스트

  • 프로젝트: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
  • 목표: resource ID 형식을 ULID (26-char Crockford base32, time-ordered) 로 못박고, ID 가 URL / log / DB primary key / cache key / idempotency / multi-tenancy / privacy 에 미치는 계약을 한 곳에서 결정. ID 형식은 한 번 노출되면 되돌리기 어렵다 (/v1/worklogs/<id> 가 client SDK + log + DB schema + cache key + FK 에 박힘) 는 인식에서 skeleton default 를 future-safe 한 선택으로 고정하는 것이 동기.
  • 결정 SSOT: raw/branch-notes/feature-resource-identifier-contract (D1~D19 + Decision Evidence Map). 본 문서는 그 중 실제 코드로 구현된 사실만 추출한다.
  • 진행 단계: 코드 구현 + 로컬 검증 완료. feature-resource-identifier-contract 브랜치에서 domain VO + port, ULID adapter, persistence mapping, web serializer, ArchUnit rule, 단위 테스트까지 작성되어 코드 베이스에 존재한다. 운영 배포 / 실 DB 통합 테스트 / 측정값은 없다.
  • 이 브랜치가 신설한 모듈: adapter-identifier (비-IO 인프라 능력 어댑터). feature-skeleton-package-blueprint-contract 가 9번째 모듈로 OUT_OF_BRANCH_SCOPE 표시했던 영역이 본 브랜치의 산출물이다.

Ground-truth 대조 (2026-06-04, ca-tmpl @c36b764 "ULID 리소스 식별자 계약 구현 및 adapter-identifier 모듈 생성")

/home/donghyeon/workspace/ca-tmpl 코드를 직접 읽어 검증한 사실 (현재 checkout HEAD = db61075, 본 브랜치 구현 커밋 c36b764 는 history 에 존재하며 식별자 코드는 HEAD 에 그대로 잔존):

  • 패키지 root 는 dev.caskeleton.*.
  • 신규 모듈 adapter-identifier 실재 — src/adapter-identifier/ (Gradle settings.gradle:13 include 'adapter-identifier'). domain-core 에만 의존하고 ulid-creator:5.2.3 를 implementation 으로 선언.
  • domain port + marker (ResourceId, IdFactory) 는 src/domain-core/.../domain/identifier/ 에 실재.
  • sample 도메인 VO + port + adapter (WorkLogId, WorkLogIdFactory, UlidWorkLogIdFactory) 는 sample-portfolio 에 실재.
  • ArchUnit rule 4개 (no_long_id_pk / no_uuid_random_in_controller / no_math_random_for_id / no_varchar_255_for_id_column) + identifier_adapter_does_not_depend_on_other_adapters_or_bootstrapsrc/app-bootstrap/.../architecture/CleanArchitectureTest.java 에 실재. 5번째 후보 no_find_by_id_without_tenant 는 코드에 없음 (브랜치 결정대로 feature-tenant-context-policy 로 이관).
  • ./gradlew :adapter-identifier:test :sample-portfolio:test :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*ArchitectureViolationFixtureTest' --tests '*WorkLogId*' --tests '*UlidCodec*' --tests '*UlidWorkLogIdFactory*' --tests '*WorkLogIdSerializer*' → BUILD SUCCESSFUL (2026-06-04 재실행, src/ working dir 기준).

실제 구현 내용 (actually-implemented)

ca-tmpl 코드에서 직접 확인한 산출물:

domain-core (재사용 가능 port + marker, dev.caskeleton.domain.identifier.*)

  • ResourceId.javaResourceId<SELF extends ResourceId<SELF>> marker interface. String value() (canonical 26-char uppercase Crockford base32 ULID) 1 메서드. 의도적으로 non-sealedpermits WorkLogId 를 쓰면 domain-coresample-portfolio 를 import 하게 되어 모듈 의존 규칙 위반. closed-set 보장은 no_long_id_pk ArchUnit rule (빌드타임) 로 대체 (Javadoc 에 사유 명시).
  • IdFactory.javaIdFactory<T extends ResourceId<?>> domain port. T newId() 1 메서드. ID minting 책임 은 도메인 port 에, 실제 생성 행위 는 infrastructure adapter 에 둔다 (D4/D5).

sample-portfolio domain (dev.caskeleton.sample.portfolio.domain.worklog.*)

  • WorkLogId.javarecord WorkLogId(String value) implements ResourceId<WorkLogId>. compact constructor 에서 ^[0-9A-HJKMNP-TV-Z]{26}$ regex 로 검증 (I/L/O/U 제외 Crockford base32). 도메인 안에 ULID 라이브러리 의존 없음 (canonical form 검증만).
  • WorkLogIdFactory.javainterface WorkLogIdFactory extends IdFactory<WorkLogId> (type-specific port specialization).
  • WorkLog.javacreate(WorkLogId id, ...) / rehydrate(WorkLogId id, ...). 도메인이 자기 ID 를 UUID.randomUUID() 로 self-mint 하지 않음 (id 는 factory 가 만들어 use case 가 주입, D4/D5).

adapter-identifier (신규 모듈, dev.caskeleton.adapter.identifier.*)

  • UlidCodec.java — production-level, 도메인 무관 ULID 변환 유틸 (final, private ctor). normalize(String) (D3: case-insensitive 입력 → canonical uppercase 26-char, Ulid.from(in.toUpperCase(Locale.ROOT)).toString()), toUuid(String), fromUuid(UUID) (D10: ULID ↔ 128-bit UUID).
  • package-info.java — 이 모듈이 non-IO 인프라 능력 어댑터 임을 문서화. adapter-outbound ("external HTTP/messaging/cache/notifications") 와 구분되는 이유 = ULID 라이브러리 래퍼는 외부 시스템 통합점이 아니라 인프라 능력이라는 것.
  • build.gradledomain-core + ulid-creator:5.2.3 만 의존.

sample-portfolio adapter (ULID 생성/직렬화/영속화)

  • adapter/identifier/UlidWorkLogIdFactory.java@Component implements WorkLogIdFactory. WorkLogId.of(UlidCreator.getMonotonicUlid().toString()). monotonic factory (동일 ms 내 단조 증가, ULID-C5) + 내부 SecureRandom (D9). 주석에 "이 sample 에서 UlidCreator 직접 호출 허용은 여기뿐" 명시.
  • adapter/persistence/entity/WorkLogEntity.java@Id @Column(name="id", columnDefinition="uuid", nullable=false, updatable=false) @JdbcTypeCode(SqlTypes.UUID) private UUID id. PostgreSQL 16 native uuid (16-byte binary), varchar(26/36) 아님 (D10). tenant 컬럼은 주석으로만 (deferred to feature-tenant-context-policy).
  • adapter/persistence/mapper/WorkLogPersistenceMapper.javaUlid.from(id.value()).toUuid() / Ulid.from(uuid).toString() 로 ULID↔UUID 변환. persistence 가 adapter-outbound(및 UlidCodec) 에 의존하지 못하는 boundary rule 때문에 Ulid 를 직접 사용 (주석 명시).
  • adapter/web/json/WorkLogIdSerializer.java@JsonComponent extends JsonSerializer<WorkLogId>. record 기본 {"value":"..."} 대신 bare ULID 문자열로 직렬화 (D6 NO typed prefix, §5).

app-bootstrap ArchUnit fitness functions (architecture/CleanArchitectureTest.java, D17 결정 SSOT = 본 브랜치):

  • no_long_id_pk..domain.. 패키지의 id 필드는 ResourceId 구현체여야 함 (Long/int 금지). JPA entity (..adapter.persistence..) 의 @Id UUID id 는 D10 정합으로 검사 대상 제외.
  • no_uuid_random_in_controller..adapter.web..controller.. + ..application..UUID.randomUUID() / com.github.f4b6a3.ulid.UlidCreator 직접 호출 금지 (factory 주입 강제). web filter 의 trace-id 생성은 의도적으로 scope 밖 (D18).
  • no_math_random_for_iddev.caskeleton.. 전역에서 Math.random() 금지 (CSPRNG 아님, D9).
  • no_varchar_255_for_id_column@Column 매핑된 id 필드는 명시적 columnDefinition(예: "uuid") 또는 비-default length 의무. haveExplicitColumnLength() custom ArchCondition 으로 검사 (columnDefinition 비어있지 않거나 length != 255).
  • identifier_adapter_does_not_depend_on_other_adapters_or_bootstrapadapter-identifier 가 sibling adapter / persistence / bootstrap 에 손대지 못하도록 격리 (§4 taxonomy).

로컬/dev 검증 (locally-verified)

  • 단위 테스트 PASS (2026-06-04 재실행, BUILD SUCCESSFUL):
    • WorkLogIdTest — regex 검증 (valid / invalid / I·L·O·U 포함 거부).
    • UlidCodecTestnormalize/toUuid/fromUuid round-trip + case-insensitive 입력.
    • UlidWorkLogIdFactoryTest — monotonic 생성, 형식 적합.
    • WorkLogIdSerializerTest — bare ULID 문자열 직렬화.
    • WorkLogPersistenceMapperTest, WorkLogRepositoryAdapterTest, WorkLogControllerWireTest — ULID↔UUID 매핑 + D3 정규화 wire 경로.
  • ArchUnit fitness function PASS: CleanArchitectureTest (위 5개 rule) + ArchitectureViolationFixtureTest (의도된 위반 fixture 를 실제로 잡아냄).
  • 검증 범위는 JVM 단위 테스트 + 정적 분석까지. 실 PostgreSQL 16 connection 으로 uuid 컬럼 insert/index 동작을 검증한 통합 테스트는 없음 (아래 planned).

운영 검증 (prod-verified)

없음. 운영 환경에 배포된 적이 없다. 측정값 / 인시던트 / 릴리즈 노트 / 벤치마크 어느 것도 없다.

문서/계획만 존재 (documented-only / planned)

다음은 설계/문서/위임 상태이며 면접에서 "구현했다 / 검증했다"고 말하면 안 된다.

  • CUID2 override (D7): privacy-sensitive 도메인용 timestamp-leak-free 대안. 코드에 없음 (documented-only).
  • constant-time 비교 미적용 (D9): 공개 resource id 는 표준 record equals 사용. constant-time 비교는 비밀값 영역이라 의도적으로 적용 안 함 (feature-security-operational-baseline SSOT).
  • multi-tenancy ID 정합 (D13): ID 자체에 tenant 인코딩 거부만 결정. TenantId VO / tenant 테이블 / composite index / findByIdAndTenant / tenant-scoped ArchUnit rule (no_find_by_id_without_tenant) 은 코드에 없음feature-tenant-context-policy (예정) 위임. WorkLogEntity 의 tenant 컬럼은 주석으로만 존재 (documented-only).
  • Idempotency-Key 처리 (D14): resource ID(ULID) 와 idempotency key(UUID v4 client-generated) 의 형식 분리만 명시. TTL 저장소 / fingerprint 비교 / 422 응답은 feature-rate-limit-idempotency-contract 위임 (planned).
  • log scrubber UlidLogScrubber (D8/§7): user-linked ID redaction 코드 미작성. feature-log-management-contract 위임 (documented-only).
  • PostgreSQL 16 uuid index locality 벤치마크 (D10): ULID time-ordered insert 의 BTREE page split 완화 정량 측정 없음 (planned, UNSUPPORTED_IMPL_DECISION).
  • dual column (internal BIGINT + external ULID) override (D11): skeleton 은 external-only. dual 은 prod-grade 도메인 권고 수준 (documented-only).
  • OpenAPI 3.1 pattern schema (§5): 브랜치 노트의 reference fragment. 실제 generated OpenAPI 문서로의 반영은 본 문서 추출 범위에서 코드로 확인하지 않음 (documented-only).

면접에서 말할 수 있는 범위

자신 있게 답할 수 있는 질문

  • 왜 skeleton default resource ID 로 ULID 를 골랐는가 — UUID v4(DB B-tree 단편화), Snowflake(worker_id 외부 조율), sequential(enumeration) 거부 + UUID v7 은 Java 21 java.util.UUID native 미지원이라 3rd-party 의존이면 ULID 가 URL UX(26 vs 36자) + 라이브러리 성숙도 우위. (실제 WorkLogId record + UlidWorkLogIdFactory 로 구현.)
  • ID 생성 책임을 어느 계층에 뒀는가 — domain port (IdFactory/WorkLogIdFactory) 가 책임을 소유하고, infrastructure adapter (UlidWorkLogIdFactory) 가 실제 생성, application use case 가 주입·orchestration. 도메인이 UUID.randomUUID() 로 self-mint 하지 않도록 ArchUnit 으로 강제.
  • ULID 를 DB 에 어떻게 저장했는가 — PostgreSQL 16 native uuid 타입(16-byte binary), @JdbcTypeCode(SqlTypes.UUID) + columnDefinition="uuid", Ulid.from(...).toUuid() 변환. varchar(26/36) 를 거부한 이유.
  • ArchUnit 4개 rule (no_long_id_pk / no_uuid_random_in_controller / no_math_random_for_id / no_varchar_255_for_id_column) 로 어떤 anti-pattern 을 빌드타임에 차단했는가, 위반 fixture 로 rule 동작을 보증한 방법.
  • adapter-identifier 모듈을 왜 신설했는가 — ULID 라이브러리 래퍼는 외부 시스템 통합(adapter-outbound)이 아니라 non-IO 인프라 능력이라 의미가 다름. 모듈 격리도 ArchUnit 으로 강제.
  • ResourceId 를 왜 sealed 가 아닌 non-sealed 로 뒀는가 — permits WorkLogIddomain-coresample-portfolio 역의존을 만들기 때문. closed-set 보장은 no_long_id_pk 로 대체.
  • Crockford base32 가 I/L/O/U 를 제외하는 이유 + 그래서 ULID 의 URL/case 정책 (canonical uppercase 출력 + case-insensitive 입력 정규화).

적당히 답할 수 있는 질문

  • ULID vs UUID v7 vs Snowflake 의 일반적 trade-off (정렬성, timestamp leak, 길이, 조율 부담). (개념 수준 — wiki/concepts/resource-identifier-format.)
  • time-ordered ID 가 B-tree index locality 에 유리한 원리 (Percona MySQL 벤치마크는 parallel evidence 로만 인용 — PostgreSQL HEAP/MVCC 에 직접 적용 불가).
  • timestamp leak 가 user-facing ID 에서 실질 문제인 이유 + CUID2 같은 완화 옵션.

답하면 안 되는 질문 (모른다고 해야 함)

  • "PostgreSQL 에서 ULID time-ordered insert 가 random UUID 대비 page split 을 줄이는 걸 측정했는가?" → 측정 안 함. 벤치마크 없음.
  • "실 DB 로 uuid 컬럼 insert/조회 통합 테스트를 했는가?" → 안 함. JVM 단위 테스트 + 정적 분석까지.
  • "운영에서 인시던트나 성능 사례가 있었는가?" → 운영 배포 없음.
  • "multi-tenant 격리(WHERE tenant_id = X AND id = Y)를 구현했는가?" → 안 함. ID 에 tenant 인코딩 거부만 결정, 모델은 feature-tenant-context-policy 위임.
  • "Idempotency-Key 처리를 구현했는가?" → 형식 분리만 명시. 처리는 feature-rate-limit-idempotency-contract 위임.

과장 금지 지점

  • "운영에서 검증했다 / prod 에서 돌고 있다" → 금지. 로컬 단위 테스트 + 정적 분석까지가 검증 범위.
  • "ULID 가 PostgreSQL index 성능을 개선하는 걸 측정했다" → 금지. Percona 벤치마크는 MySQL InnoDB 기준 parallel evidence 일 뿐, PostgreSQL 측정값 없음.
  • "multi-tenancy 를 구현했다" → 금지. ID 형식이 tenant 와 충돌하지 않도록 보장만 했고, tenant 모델은 미구현.
  • "ULID 가 무조건 UUID 보다 우월하다" → 금지. timestamp leak(privacy), 비표준(IETF 아님), 라이브러리 의존이라는 trade-off 존재. UUID v7 native 가 되는 stack 이면 결정이 달라질 수 있음.
  • "typed prefix(tk_)를 안 쓴 게 정답이다" → 단정 금지. Stripe 는 prefix 를 쓴다 — skeleton 의 bare ULID 는 lock-in 회피를 택한 하나의 선택.

Blog-topic ingest: resource identifier 묶음 (2026-07-02)

아래 raw seed들은 resource identifier canonical에 연결했다.

관련 개념

Sources

Cluster / 묶음