38 KiB
sample-portfolio — WorkLog 포트폴리오 게시판 (예시 도메인)
이 모듈은 ca-skeleton 템플릿이 유지하는 fixture/reference 예시 도메인입니다. production 모듈
(domain-core/application-core/adapter-*/shared-contract/app-bootstrap)의
경계와 운영 계약을 "어떻게 쓰는지" 보여줍니다. 새 프로젝트는 이 모듈을 import하지 않고 자신의
도메인을 production 모듈에 추가합니다. 템플릿에서는 이 모듈을 유지하며, 다운스트림 fork는
./gradlew :app-bootstrap:sampleOffTest 통과 후에만 선택적으로 정리할 수 있습니다.
도메인: WorkLog (엔지니어링 작업 기록 게시판)
메인 화면 = GET /work-logs 목록. 각 항목은 직접 수행한 작업입니다.
| 필드 | 설명 |
|---|---|
| title | 작업 제목 |
| category | INFRASTRUCTURE / DATABASE / BACKEND / PLATFORM |
| summary / content | 요약 / 본문 |
| techStack[] | 사용 기술 |
| links[] | 관련 링크 (repo/blog) |
| period | 기간 (end=null → 진행 중) |
엔드포인트
| Method | Path | 설명 | 시연 계약 |
|---|---|---|---|
| GET | /work-logs | 목록 (메인) | Envelope wrap |
| GET | /work-logs/{id} | 상세 | 도메인 예외→404 envelope |
| POST | /work-logs | 생성 | Bean Validation @GroupSequence (B4), unknown-field 거부 (B1), mapper 실패→MAPPING_FAILED (B3) |
| PATCH | /work-logs/{id} | 부분수정 | JsonNullable 3-state (B2) |
| DELETE | /work-logs/{id} | 삭제 | 204 |
| POST | /work-logs/import | 일괄 등록 | BulkEnvelope 부분실패 (B8) |
샘플 데이터 (curl 예시)
# 1) 인프라 인증·인가 위임 (Keycloak + k3s + Vault)
curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{
"title": "Keycloak + k3s + Vault 기반 인증·인가 위임",
"category": "INFRASTRUCTURE",
"summary": "인증/인가를 Keycloak으로 위임, k3s + Vault로 시크릿·구성 관리",
"content": "...",
"techStack": ["keycloak", "k3s", "vault"],
"links": ["https://example.com/infra"],
"periodStart": "2025-01-01"
}'
# 2) DB 쿼리 튜닝
curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{
"title": "DB 쿼리 튜닝",
"category": "DATABASE",
"summary": "slow query 분석 및 인덱스/실행계획 개선",
"content": "...",
"techStack": ["postgresql"],
"links": [],
"periodStart": "2025-02-01"
}'
계층 (adapter-mirrored)
domain/worklog— WorkLog/WorkCategory/Period/RepoStats/WorkLogRepository (POJO)application/worklog— *UseCase (CommandUseCase/QueryUseCase + @UseCaseCapability)adapter/web— controller/dto/mapper/erroradapter/persistence— entity/repository/mapperadapter/outbound/repostats— B7 ACL 예시
설계 결정 참조 (코드 주석에서 이전)
여기 아래는 예전에 각 클래스의 주석/JavaDoc 에 흩어져 있던 "왜 이렇게 짰는가" 설명을 한곳에 모은 것입니다. 코드에는 "무엇을 하는지"만 짧게 남기고, 그 배경·결정·트레이드오프는 이 문서로 옮겼습니다. 코드를 읽다 "왜 이렇게 했지?"가 궁금할 때 보세요.
읽는 법:
- 모듈 규칙(허용/금지 의존, 테스트 명령)의 기준은 CLAUDE.md 입니다. 이 문서는 규칙이 아니라 결정의 근거를 모은 참조 기록입니다.
- 본문은 코드가 어떤 약속을 지키려고 존재하는지 설명합니다. 추적용 꼬리표는 코드 주석에 남기지 않고, 필요한 배경은 여기서 문장으로 풀어 설명합니다.
domain — 도메인 (순수 POJO, 프레임워크 의존 없음)
WorkLog (애그리거트 루트)
- WorkLog 애그리거트의 루트입니다. 상태를 바꾸는 길은 의도가 드러나는 메서드뿐입니다
(
rename,recategorize,startProgress,close…). 공개setXxx세터를 두지 않은 이유는, 세터를 열어두면 불변식(invariant)을 건너뛴 채로 객체를 망가뜨릴 수 있기 때문입니다. 모든 변경 경로가 불변식 검사를 거치도록 강제합니다. - 불변식을 어기면
WorkLogInvariantException을 던집니다. 이 예외는 안전한 사유(reason)만 담고, 로그 문자열이나 운영 에러 코드/HTTP 상태는 전혀 모릅니다 — 그 변환은 상위 (application/web)의 몫입니다. - id 는 도메인이 직접 만들지 않습니다.
WorkLog.create(...)는 이미 만들어진WorkLogId를 받기만 합니다. UUIDv7 생성은 유스케이스가WorkLogIdFactory포트를 통해 하고, 도메인 안에는UUID.randomUUID()나 id 생성 라이브러리가 들어오지 않습니다(서버가 id 를 정해주는 계약). version필드(낙관적 락 버전)의 역할: 새로 만든(아직 저장 안 된) 객체는null이고, 저장소가 값을 채우고 증가시킵니다. 영속 계층은 이null여부로 "INSERT 인지 UPDATE 인지"를 구분하고, 웹 경계에서는 이 값이 HTTPETag/If-Match의 출처가 됩니다. 즉 DB 가 낙관적 락 충돌로 잡아내는 그 충돌을, 웹에서는 HTTP 412 로 표현합니다.title규칙: null 이면 안 되고(NullPointerException), 공백만 있어도 안 됩니다 (TITLE_BLANK). 공백 금지는 도메인 불변식이라, 웹 경계의@NotBlank(형식 검사)와는 별개로 한 번 더 지킵니다 — 웹을 거치지 않는 호출자가 있어도 규칙이 새지 않도록.
값 객체 — WorkLogId / Period / WorkLogOwner
- WorkLogId: 36자 canonical UUID(RFC 9562 UUIDv7). 순수 값 객체라 형식(정규형)만 검증하고,
id 생성 라이브러리에는 의존하지 않습니다. 대소문자 정규화는 이 타입을 만들기 전에 웹 경계에서
끝냅니다(canonical 형태는 소문자 hex). 형식은
8-4-4-4-12hex 그룹입니다. - Period: 작업 기간.
end == null이면 "진행 중"이라는 뜻입니다. 생성자에서end < start를 막습니다(기간 역전 불가). - WorkLogOwner: 소유자 식별자. 공백/빈 값을 막고 trim 합니다.
열거형 — WorkLogStatus / WorkCategory / WorkLogSortField
- WorkLogStatus: OPEN → IN_PROGRESS → CLOSED. 전이 규칙은 WorkLog 가 강제합니다.
- WorkCategory: 포트폴리오 보드에 노출하는 작업 분류(INFRASTRUCTURE/DATABASE/BACKEND/PLATFORM).
- WorkLogSortField: 목록 정렬에 허용된 필드만 담은 닫힌 열거형입니다. 정렬 키를 ORM 에
넘기는 열린 문자열이 아니라 열거형으로 막아둔 이유는, 알 수 없는 필드를 쿼리까지 보내지 않고
웹 경계에서 400 으로 거절하기 위해서입니다(
?sort=field,direction계약을 안전하게 만드는 핵심).
WorkLogInvariantException
- WorkLog 불변식 위반을 알리는 예외입니다.
Reason(명사형 안전 enum)만 담고, 운영 에러 코드·HTTP 상태·로깅은 모릅니다. 도메인은 로거를 갖지 않고 운영 코드로 매핑하지도 않는다는 규칙 때문입니다.reason()을 로그 라인과error.category로 번역하는 일은 application/web 의 책임입니다. 사유가 거친 명사라서 민감 정보가 클라이언트로 새지 않습니다.
WorkLogReserved (도메인 이벤트)
- "WorkLog 기간이 예약됨"이라는 도메인 사실을 표현하는 이벤트입니다. 전송(transport)과
무관합니다 — 도메인 데이터(
WorkLogId/WorkCategory/LocalDate)만 담고 broker/wire/HTTP 타입을 일절 참조하지 않습니다. 이것을 발행 가능한 integration 이벤트로 바꾸는 일은 application 경계의 몫입니다(application.event.WorkLogReservedIntegrationEvent와 그 매퍼 참고).
포트 — WorkLogRepository / WorkLogIdFactory / OutboxEventIdFactory
- WorkLogRepository: WorkLog 영속 아웃바운드 포트(구현은 adapter-persistence).
findPage는 한 페이지 + 전체 개수를 함께 돌려주며,sortField/category가null이면 정렬/필터 없음을 뜻합니다. 전체 개수는 같은 필터 기준으로 세어meta.page.total이 필터된 결과를 반영하게 합니다. - WorkLogIdFactory / OutboxEventIdFactory: id 를 만들어 주는 도메인 포트. 둘을 굳이 따로 둔
이유는, 애그리거트 식별자 타입(
WorkLogId)이 이벤트 id 모양을 묶어버리지 않게 하기 위해서입니다. 실제로는 둘 다 UUIDv7 기반이지만(identifier 어댑터가 구현), 도메인은 생성 방식을 모릅니다. outbox 이벤트 id 는 outbox 봉투의eventId이자idempotencyKey로 쓰입니다(같은 값 → 이벤트 단위 중복 제거).
RepoStats
- 외부 저장소 통계를 정규화한 결과 값입니다. 아웃바운드 ACL(B7)을 통과한 뒤에만 만들어집니다.
application — 응용 (유스케이스)
Command / Query — 스스로 검증하는 입력 모델
CreateWorkLogCommand,UpdateWorkLogCommand,DeleteWorkLogCommand,GetWorkLogQuery,GetRepoStatsQuery,ListWorkLogsQuery,BatchCreateWorkLogsCommand는 생성자에서 스스로 필수값을 검증합니다("self-validating input model"). 이렇게 하면 어떤 인바운드 어댑터가 부르든(오늘은 웹, 내일은 메시징 컨슈머) 웹 경계의 jakarta validation 에 기대지 않고도 같은 계약을 보장받습니다. id 나 patch 가 null 인 것은 프로그래밍 오류이므로 생성 시점에 즉시 실패시킵니다("없음"은Patch.absent()로 표현하고 null 을 쓰지 않습니다).UpdateWorkLogCommand의 필드는Patch<T>입니다. PATCH 의 3가지 상태 — 값 없음(변경 안 함) / 명시적 null(필드 비움) / 값(교체) — 을 구분하기 위해서입니다.
CreateWorkLogUseCase — 생성 + outbox 동시 기록 (dual-write 금지)
- WorkLog 를 저장하면서
WorkLogReservedoutbox 이벤트를 같은 쓰기 트랜잭션 안에서 함께 적재하는 예시입니다. 핵심은repository.save(...)와outboxAppendPort.append(...)가 같은tx.inWrite { ... }블록 안에 있다는 점입니다. 저장이나 적재 중 하나라도 실패하면 트랜잭션 전체가 롤백됩니다 — "DB 한 번, 브로커 한 번" 식의 분리된 이중 쓰기(dual-write)를 막는 보장입니다. - 순서: ① 저장된 애그리거트로 도메인 이벤트 생성 → ② 매퍼로 integration 이벤트로 변환(경계는 application) → ③ 직접 만든 JSON 페이로드로 직렬화 → ④ 같은 트랜잭션에서 outbox 에 append.
eventId는OutboxEventIdFactory(UUIDv7) 로 만들고,idempotencyKey = eventId로 둡니다 (이벤트 단위 중복 제거).correlationId는 application-core의CorrelationIdPort로 읽습니다. inbound web adapter가 sanitized MDC 값을 포트 뒤에서 제공하므로 sample application 코드는 SLF4J/MDC를 알지 않습니다. 요청 밖에서 실행되거나 값이 blank면eventId를 그대로correlationId로 사용해 이벤트가 최소한 자신을 가리키게(self-correlation) 폴백합니다.- 쓰기 작업이라
@RequiresPermission("worklog:write")로 권한을 요구합니다(이 권한은user/admin역할 묶음에 부여).
BatchCreateWorkLogsUseCase — 원자적 일괄 생성
- 동기 일괄 생성은 전부 성공 아니면 전부 롤백입니다. 모든 항목을 하나의
tx.inWrite단위로 처리하므로 한 항목이라도(매핑/검증/영속) 실패하면 배치 전체가 되돌려지고, 엔드포인트는 부분 성공 대신 단일 4xx 를 냅니다. (BATCH_PARTIAL_FAILURE같은 부분 실패 형태는 비동기 폴링 응답용으로 예약돼 있고, 동기 배치에는 쓰지 않습니다.)
UpdateWorkLogUseCase — Patch 3-state 적용
- 각 필드의
Patch상태에 따라 다르게 동작합니다:hasValue()면 교체,isExplicitNull()이면 비움, 둘 다 아니면(없음) 변경하지 않습니다. 상태 전이는 도메인 메서드(startProgress/close)에 위임하고, OPEN 으로 되돌리는 시도는 잘못된 전이로 막습니다.
DeleteWorkLogUseCase — 권한 3-tier 가시화
- 삭제는 파괴적인 admin 등급 작업이라
@RequiresPermission("worklog:close")로 막습니다.worklog:close는admin역할 묶음에만 부여되고user에는 주지 않습니다(샘플 모델에는 별도 "close" 유스케이스가 없어 delete 가 그 민감 작업 역할을 합니다). 이 한 군데가 3-tier 권한 모델을 눈에 보이게 만듭니다 — 인증된user는 여기서 거절되고admin만 통과합니다. (참고로 생성·수정· 일괄 생성은worklog:write로 막고, 이 권한은user/admin양쪽에 부여됩니다.)
읽기 모델(projection) — ListRecentWorkLogSummariesUseCase / WorkLogSummaryQueryPort / WorkLogSummary
- 이 세 타입은 CQRS-lite "쿼리 우회(query bypass)" 예시입니다. 보드/목록 화면처럼 가벼운 읽기는
WorkLog 애그리거트를 통째로 복원하지 않고, 필요한 컬럼만 뽑은 projection 으로 읽습니다
(
content/summary/techStack/links같은 무거운 상태를 빼서, lazy 컬렉션 조인 없는 컬럼-부분 SELECT 가 됩니다). - 이것은 강제 기본값이 아니라 선택적 최적화입니다. 애그리거트를 통해 읽는 길
(
WorkLogRepository)도 단순한 읽기에서는 똑같이 유효한 기본 경로입니다. - projection DTO 의 순수성: projection 은 application 타입이어야 합니다 — 도메인 애그리거트도,
JPA 엔티티도, 웹 DTO 도 아니어야 합니다. 도메인 값 타입(예:
WorkCategoryenum)을 참조하는 것은 허용됩니다(application 은 domain 에 의존 가능). 다만 쿼리 포트의 반환 시그니처에 도메인 애그리거트/ JPA 엔티티/웹 타입이 새면 안 됩니다(List<WorkLogSummary>같은 제네릭 인자까지 포함, ArchUnit 규칙query_ports_do_not_leak_domain_jpa_or_web_types가 강제). - 명명 규칙: projection 을 반환하는 읽기 포트 이름은
…QueryPort로 끝냅니다. - id 표현: projection 의
id는 애플리케이션의 표준 id 어휘인 36자 UUID 문자열입니다. 저장소 고유의UUID는 어댑터 안에서 변환되어, 읽기 모델은 저장 형식이 아니라 애플리케이션과 같은 식별자 형식을 말합니다. - 격식(ceremony)은 그대로 지킵니다: projection 읽기여도
QueryUseCase빈을 거치고 (@UseCaseCapability계약을 모든 읽기에 기계적으로 적용), 기본은tx.inRead안에서 읽습니다. projection 도 저장소 기반이면 여전히READ_REPOSITORY입니다 — "projection 이냐 애그리거트냐"는 반환 모양의 축이고, 저장소 접근 수준과는 별개라서 새 capability enum 이 필요 없습니다.
WorkLogReservedIntegrationEvent / …Mapper — integration(wire) 이벤트
- IntegrationEvent: 도메인 이벤트와 달리 아웃바운드 어댑터가 실제로 발행할(예: Kafka) 직렬화 가능한 경계 소유 형태입니다. 도메인 사실을 wire 계약으로 옮기는 일 — 값 객체를 원시 타입으로 평탄화, wire 표현 선택 — 은 application 의 관심사라서 application 계층에 둡니다. 도메인은 wire 를 모릅니다.
- Mapper: 도메인 이벤트 → integration 이벤트 변환을 application 경계에서 수행하고, 트랜잭션
outbox 용 JSON 페이로드 문자열로 직렬화합니다.
- JSON 직렬화(
toJson)는 의존성을 늘리지 않으려고 RFC 8259 §7 이스케이프까지 직접 구현했습니다. 실제 프로젝트라면 스키마 직렬화 계약(Avro/Protobuf, 스키마 레지스트리 붙은 Jackson)을 써야 합니다 — 샘플은 새 의존성 0을 유지합니다. 세 필드는 도메인 식별자와 enum 이름뿐이라 PII/토큰/ 원문 본문이 없습니다. - 이벤트 타입 이름
"worklog.reserved":도메인.동사패턴의 안정적인 점-구분 논리명입니다. outbox relay 규약(topic = eventType)에 따라 그대로 브로커 토픽 이름이 됩니다. integration 이벤트의 클래스 단순명을 쓰는 대안도 있지만, 클래스 이름을 바꾸면 토픽이 조용히 바뀌어 컨슈머가 깨질 수 있어, 안정적인 문자열 리터럴을 택했습니다(토픽 마이그레이션 계획 없이는 바꾸지 말 것).
- JSON 직렬화(
WorkLogReservedPayload / …ContractContribution — canonical contract fixture
WorkLogReservedPayload는 새 closed contract SPI에 제공하는 exact final record다. v1은workLogId하나만 소유하며, schema와 같은 canonical bounded identifier 규칙을 생성자에서 지킨다.contracts/messaging/portfolio.worklog.reserved/v1.schema.json과 golden vector는 sample fixture contract다. 공통 envelope는 WorkLog 필드를 알지 못하며 이 모듈을 삭제해도 production module build/runtime은 깨지지 않아야 한다.- contribution은 contract ID, exact payload type/component order, classpath resource ID, checked-in digest와 provider-neutral descriptor만 제공한다. JSON mapper, physical topic, Kafka 설정을 소유하지 않으며 매 호출 filesystem I/O도 하지 않는다.
- 기존 3-field
WorkLogReservedIntegrationEvent/hand-rolled mapper는 현재 legacy outbox characterization 경로에 남아 있다. 새 1-field canonical payload는 그 타입을 조용히 대체하지 않으며, closed catalog와 encoder가 조립되기 전까지 양쪽은 서로 호출하거나 직렬화하지 않는다. - 현재는 classpath scanning이나 runtime discovery가 없다. Task 5의 closed catalog 조립 전에는 자동 등록되지 않고, Task 6 validator qualification 전에는 Draft 2020-12 validator compatibility를 주장하지 않는다.
RepoStatsPort
- 외부 저장소 통계를 가져오는 아웃바운드 포트(구현은 adapter-outbound).
adapter/web — 인바운드 HTTP / 보안 / DTO
WorkLogController
- 포트폴리오 보드 엔드포인트. 리소스 이름은 AIP-122 를 따릅니다:
/worklogs(복수·소문자),/worklogs/repoStats(lowerCamelCase 하위 세그먼트), 콜론-동사 커스텀 메서드/worklogs:batchCreate./v1버전 접두사는PresentationWebConfig가 중앙에서 붙입니다. - GET /worklogs: 페이지네이션 + (선택) 정렬 + (선택) 평면 동등 필터. 정렬 문자열은
SortParam.parse가 파싱해 native 가 아닌 구문은 400 으로 거절하고, 허용 목록에 없는 필드도 거절합니다. offset 이 너무 깊으면 커서 사용을 권하는Deprecation헤더를 답니다. - GET /worklogs/{id}:
ETag를 내보내고,If-None-Match가 맞으면 본문 없이 304 를 답니다. - PATCH /worklogs/{id}:
If-Match가 오면 현재 ETag 와 같아야 하고, 다르면 412(낙관적 동시성).If-Match가 없으면 검사를 건너뜁니다 — 스켈레톤은 권장 패턴을 보여주되 강제하지는 않습니다.ETag/If-Match는 HTTP 전송 관심사(RFC 7232)라, 읽고-비교하는 로직을 일부러 웹 어댑터에만 두고 application/domain 은 ETag 를 전혀 보지 않게 합니다. - POST /worklogs:
Idempotency-Key헤더는 POST 요청 표면의 일부로 받아만 둡니다. 키의 모양·범위·재생(replay) 의미는 별도 계약(rate-limit-idempotency)의 몫이라, 그 브랜치가 replay 를 배선하기 전까지는 서버가 헤더를 그냥 허용(무시)합니다. - POST /worklogs:batchCreate: AIP-136 콜론-동사 일괄 생성. 동기 = 원자적이라 한 항목이라도
실패하면 배치 전체가 롤백되고 단일 4xx 가 납니다. 한 번에 보낼 수 있는 항목 수는
MAX_BATCH_SIZE(1000)로 제한하고, 넘으면 400 입니다. 요청 봉투는{ "requests": [ ... ] }형태입니다. - 소유자(owner) 스탬프: 생성되는 WorkLog 에는 인증된 호출자의 IdP subject 를 소유자로 찍습니다.
보안 컨텍스트에서 null 안전하게 읽으며, 이 값은 가공 전 원시 principal id 입니다 —
가명화(pseudonymization)는 별도 프라이버시 계약의 몫이라 여기서 적용하지 않고, 소유자 범위
인가(ABAC)는 의도적으로 뒤로 미룬 확장입니다. 유스케이스에 건
worklog:write권한이 생성이 실제 실행될 시점엔 인증된 principal 이 있음을 보장합니다. - id 정규화(
toId): 대소문자 무관 canonical UUID 경로 입력을 받아, 도메인 id 를 만들기 전에 표준 36자 소문자 UUID 로 정규화합니다. 여기서 쓰는UUID.fromString(...)은 (생성기가 아니라) 파서라, 잘못된 id 면IllegalArgumentException을 던지고 전역 핸들러가 이를 HTTP 400 으로 매핑합니다.
OperationsController / SampleOperationStore / WorkLogExportResult — 장기 실행 작업(LRO) 예시
POST /worklogs:export는202 Accepted+ 폴링 경로를 가리키는Location헤더 +operationId/statusUrl을 담은 봉투를 돌려주고,GET /operations/{id}로 상태를 폴링합니다. 상태는 5값OperationStatusenum 에서 옵니다.- operation id 를 컨트롤러가 아니라
SampleOperationStore에서 만드는 이유: 컨트롤러/유스케이스는 id 를 직접 생성하면 안 된다는 규칙(no_uuid_random_in_controller) 때문입니다.SampleOperationStore는 컨트롤러도 application 서비스도 아닌 웹 인프라 컴포넌트라 여기서 id 를 발급해도 됩니다. - 단순화: 이 "export" 는 동기로 끝나므로, 클라이언트가 폴링할 때 저장된 operation 은 이미
SUCCEEDED입니다. 진짜 비동기 잡이라면 PENDING → RUNNING → 종료 로 전이하겠지만, wire 모양 (202 + Location + 폴링 enum)은 동일합니다.
SamplePolymorphicRequest — 안전한 다형 역직렬화(B5)
- 다형 JSON 을 안전하게 받는 표준 예시입니다.
@JsonTypeInfo(use = NAME, property = "kind")+ 명시적@JsonSubTypes허용 목록은, RCE 취약점(CVE-2019-14379)의 입구인ObjectMapper.enableDefaultTyping()대신 Jackson 이 권장하는 방식입니다. 목록에 없는 하위 타입은InvalidTypeIdException으로 거절되고, 계약 핸들러가 이를MAPPING_FAILED로 보냅니다. sealed키워드를 쓴 이유: 허용 목록과 타입 계층을 기계적으로 일치시키기 위해서입니다.permits만 추가하고@JsonSubTypes.Type을 안 넣거나(혹은 반대로) 하면 조용히 통과되는 게 아니라 컴파일/테스트에서 잡힙니다.
WorkLogWebMapper
- 요청 DTO ↔ application command 변환을 웹 어댑터가 전부 책임지게 모은 매퍼입니다. PATCH 도
컨트롤러에서 인라인으로 만들지 않고 매퍼를 거치게 해서 일관성을 지킵니다.
WorkLogId는 웹 edge 에서 파싱(UUID 경로 정규화)해 넘겨받아, id 구문 관심사를 컨트롤러에 둡니다. - 응답에는 소유자(owner)를 일부러 넣지 않습니다 — 원시 principal id 노출은 프라이버시 문제라, 가명화는 별도 프라이버시 계약의 몫입니다.
- 링크 URI 가 잘못되면
MappingException을 던져 전역 핸들러가MAPPING_FAILED(400)로 보냅니다.
WorkLogIdSerializer
WorkLogId를 record 기본 모양({"value":"..."})이 아니라 맨 36자 소문자 UUID 문자열로 직렬화합니다.@JsonComponent로 Spring Boot 가 자동 등록합니다.
DomainExceptionHandler / PortfolioErrorCode
- DomainExceptionHandler: 이 샘플의 도메인 예외를
Envelope로 매핑합니다. 운영/전송/보안 예외는 스켈레톤의 기본GlobalExceptionHandler가 처리하고, Spring 이 두 advice 를 함께 적용합니다. 이 핸들러를@Order(HIGHEST_PRECEDENCE)로 기본 핸들러보다 앞에 두는 이유는, 그렇지 않으면 기본 advice 의 포괄 핸들러(@ExceptionHandler(Exception.class))가 도메인 예외를 먼저 잡아 INTERNAL_ERROR 로 만들어 버리기 때문입니다. - PortfolioErrorCode: 이 샘플 전용 도메인 에러 코드. 운영/전송/보안 코드는 공용
OperationalError에 있습니다.
WorkLogValidationGroups
@GroupSequence용 검증 그룹 순서(형식 → 불변식)를 정의합니다 — 형식 검사가 실패하면 불변식 평가를 건너뛰게 합니다.
adapter/persistence — RDBMS / JPA 영속
WorkLogEntity
- WorkLog 애그리거트의 JPA 엔티티.
AuditableEntity를 상속해created_at/updated_at/created_by/ updated_by감사 컬럼을 물려받습니다. 감사 필드는 엔티티(영속 계층)에만 있고 도메인 WorkLog 에는 없습니다 — 값은WorkLogRepositoryAdapter가 Clock + 감사 컨텍스트로 찍어줍니다. - id 저장 형식: UUIDv7 을 varchar(36)가 아니라 PostgreSQL 16 의 native
uuid타입(16바이트 바이너리)으로 저장합니다. UUID 문자열↔native uuid 변환은WorkLogPersistenceMapper가 합니다. (tenant 범위 컬럼/복합 인덱스는 tenant 정책 계약으로 미뤄둠.) @Version version: 낙관적 락 버전이자 웹 경계 ETag/If-Match 의 출처. Hibernate 가 증가를 관리하고,null이면 새 행이라save()가 INSERT 합니다.
WorkLogPersistenceMapper — UUID 문자열 ↔ native uuid 변환
- 36자 canonical UUID 와 PostgreSQL native
uuid(128비트)를 서로 변환합니다.UuidCodec같은 공용 코덱이 아니라 JDKjava.util.UUID를 직접 쓰는 이유: 영속 어댑터는 경계 규칙상adapter-outbound(코덱이 있는 곳)에 의존하면 안 되기 때문입니다(stdlib 이라 의존 문제 자체가 없음). - 엔티티로 변환할 때 도메인
version을 그대로 실어, JPA 가 새 행(null→ INSERT)과 추적 중인 행 (non-null → 낙관적 merge)을 구분할 수 있게 합니다.
WorkLogRepositoryAdapter — 저장 + 감사 스탬프
WorkLogRepository포트 구현체. 감사 메타데이터(생성/수정 시각·주체)를 여기 영속 어댑터에서 찍습니다(생성자 주입 Clock + 감사 컨텍스트). 도메인 WorkLog 는 감사 필드를 들고 있지 않습니다.- INSERT(버전 null): 새 애그리거트라 created/updated 둘 다 지금 시각·주체로 초기화합니다.
- UPDATE(버전 non-null): 기존 행의 created 값을 읽어 그대로 이어가고(같은 트랜잭션에서 유스케이스가 이미 로드해둔 JPA 1차 캐시에서 served), updated 만 갱신합니다.
WorkLogJpaRepository
findByCategory: 계약이 허용하는 유일한 필터 구문인 평면 동등 필터를 데이터 계층 안에 둔 파생 쿼리입니다.findRecentSummaryRows: CQRS-lite projection 읽기. JPQLSELECT new생성자 표현식으로 요약 컬럼만 골라(content/summary/techStack/links제외)WorkLogSummaryRow로 담는 컬럼-부분 읽기라, 애그리거트 복원과 lazy@ElementCollection조인을 건너뜁니다. 닫힌 인터페이스 projection 대신SELECT new/JdbcTemplate을 택한 이유: 닫힌-projection 의 컬럼 가지치기는 구현체 의존적이고 Hibernate 6 에서 검증되지 않았기 때문입니다.
WorkLogSummaryQueryAdapter / WorkLogSummaryRow
- WorkLogSummaryQueryAdapter: application
WorkLogSummaryQueryPort구현체. JPQL projection 쿼리에 위임하고, 저장소 nativeWorkLogSummaryRow(UUID id)를 applicationWorkLogSummary(UUID 문자열 id)로 매핑합니다. 저장소 nativeUUID는 이 어댑터를 벗어나지 않습니다 — application 에 노출되는 읽기 모델은 canonical UUID 문자열 형식으로 말합니다. - WorkLogSummaryRow: projection 의 영속-쪽 읽기 행. JPQL
SELECT new의 대상이며 저장 모양 (id 가 nativeUUID)을 그대로 반영합니다. 이 행을 persistence 패키지 안에 두는 것 자체가 nativeUUID가 어댑터를 벗어나지 못하게 막는 장치입니다.
JpaConfig
- Spring Boot 메인 클래스가
dev.caskeleton.bootstrap에 있어, 패키지 기준 기본 스캔이…sample.portfolio.adapter.persistence.*를 놓칩니다. 그래서 엔티티/리포지토리 스캔 위치를 여기서 명시해, 그 배선을 실제로 소유하는 모듈 안에 둡니다.
adapter/outbound — 아웃바운드 통합 (저장소 통계 ACL)
RepoStatsPortClient / RepoStatsAclMapper / RawRepoStatsResponse
- ACL(Anti-Corruption Layer) 패턴(B7) 예시입니다. 외부 응답이 도메인으로 들어오기 전에 반드시 ACL 매퍼를 거치게 해서, 원시 외부 타입이 도메인을 오염시키지 못하게 합니다.
- RepoStatsPortClient: 실제 HTTP 호출(
fetchRaw)을 추상화해 템플릿을 가볍게 유지합니다 — 실제 프로젝트라면 여기에 WebClient/RestClient 를 끼웁니다. 원시 응답은 ACL 을 통과한 뒤에야 도메인 타입이 됩니다. - RepoStatsAclMapper: ACL 의 세 가지 일 — 정규화(full name 소문자화), 마스킹(echoedToken 은 버려서
도메인에 닿지 않음), 공개 필드 선택(
fullName/stargazers/pushedAt만RepoStats로). 필수 필드가 없으면MappingException. - RawRepoStatsResponse: 외부 제공자 원시 모양. 도메인으로 넘어가지 않으며 package-private 입니다.
adapter/identifier — ID 생성 (UUIDv7)
UuidWorkLogIdFactory / UuidOutboxEventIdFactory
- 도메인 id 포트(
WorkLogIdFactory,OutboxEventIdFactory)의 인프라 구현체입니다. - 둘 다
UuidCreator.getTimeOrderedEpochPlus1()을 씁니다 — RFC 9562 UUIDv7(time-ordered)이며 같은 밀리초 안에서도 단조 증가(monotonic)하고 secure random 으로 뒷받침됩니다. - 여기가 샘플에서
UuidCreator호출이 허용되는 유일한 곳입니다.no_uuid_random_in_controllerArchUnit 규칙이 웹/application 계층의 직접 id 생성을 금지하기 때문에, id 생성은 이 인프라 어댑터에만 둡니다.
bootstrap — 샘플 전용 구성 루트
이 패키지가 왜 존재하는가 (공통 배경): 프로덕션 조립은
app-bootstrap모듈이 합니다. 그런데sample-portfolio는 일부러app-bootstrap에 의존하지 않습니다(샘플이 부트스트랩을 끌어오면 안 됨). 그래서 스캔으로 자동 발견되지 않고 오직app-bootstrap에만 있는 구성 루트 빈들 (tracing/metrics/idempotency/privacy/management/domain-context 등)을, 여기bootstrap.*패키지가 샘플 전용으로 동등하게 복제해 제공합니다. 모두 일회용(disposable)입니다 —sample-portfolio모듈을 지우면 함께 사라지고 프로덕션에는 영향이 없습니다. 프로덕션(CaSkeletonApplication)은 원래app-bootstrap빈들을 그대로 씁니다.
SamplePortfolioApplication — 독립 실행 구성 루트
dev.caskeleton하위 전부를 스캔해 프로덕션 모듈의 Spring 컴포넌트(adapter-webSecurityConfig, adapter-persistencePersistenceJpaConfig등)를 자동으로 발견·배선합니다.app-bootstrap에만 있어 자동 발견되지 않는 구성 루트만 위bootstrap.*가 채웁니다.@SpringBootApplication대신@Configuration+@EnableAutoConfiguration+@ComponentScan을 쓴 이유:@SpringBootApplication은@SpringBootConfiguration을 포함하는데, 그러면 이 클래스가 Spring Boot 의AnnotatedClassFinder에 보입니다. 그 finder 가 이 클래스와 (같은 패키지·테스트 classpath 의)SamplePortfolioTestApplication둘 다 찾으면 "multiple @SpringBootConfiguration" 오류를 냅니다.@Configuration(=@SpringBootConfiguration아님)을 쓰면 finder 에 숨으면서도,@SpringBootTest(classes = SamplePortfolioApplication.class)로 부팅하는 데는 문제가 없습니다.@ComponentScan에 exclude 두 개를 둔 이유:PostgreSqlPersistenceConfig제외 — 이 클래스는@PersistenceContext EntityManager필드와FlywayConfigurationCustomizer @Bean을 함께 가져, 깨지지 않는 초기화 순환을 만듭니다 (flyway → customizer 수집 → PostgreSqlPersistenceConfig 인스턴스화 →@PersistenceContext가 entityManagerFactory 를 당김 → 그건 flywayInitializer 에 의존 → 순환). 대신SamplePostgreSqlPersistenceConfig가 static@Bean으로 동일 빈을 제공해 순환을 끊습니다(아래 참고).TestEnclosedConfigurationFilter제외 — 테스트 클래스 안에 중첩된@Configuration들이 넓은 컴포넌트 스캔에 잡혀, 두 테스트 픽스처가 같은 이름의 빈(예:clock)을 정의하면BeanDefinitionOverrideException이 나기 때문입니다.
SamplePostgreSqlPersistenceConfig — Flyway ↔ EntityManager 초기화 순환 끊기
adapter-persistence-postgresql의PostgreSqlPersistenceConfig를 샘플 전용으로 대체한 것입니다.- 왜 필요한가: 원본은
@PersistenceContext EntityManager필드와FlywayConfigurationCustomizer빈을 함께 등록합니다. Spring Boot 의 Flyway 자동 구성은entityManagerFactory가 준비되기 전에 모든 customizer 빈을 모읍니다(Flyway 가 먼저 마이그레이션을 돌려야 JPA 가 스키마를 검증할 수 있으니까). 그 customizer 를 모으려고PostgreSqlPersistenceConfig를 인스턴스화하면@PersistenceContext가entityManagerFactory를 당기는데, 그 빈은flywayInitializer완료에 의존합니다 →flyway → PostgreSqlPersistenceConfig → entityManagerFactory → flywayInitializer → flyway의 끊을 수 없는 순환이 생깁니다. - 어떻게 끊는가: customizer 를 static
@Bean으로 등록합니다. Spring 은 static@Bean을 소유 클래스 인스턴스화 없이 호출하므로, Flyway 초기화 동안@PersistenceContext가 처리되지 않습니다.EntityManager는 (런타임에 실제로 필요해질 때까지 미루도록) 빈 경로에서 늦게 해소됩니다. - 마이그레이션 위치 주의: 이 customizer 가 두 위치(프로덕션
db/migration/postgresql+ 샘플 전용db/sample-migration)를 모두 설정합니다.configuration.locations(...)는 기존 설정을 덮어쓰므로 (append 아님),application.yml의spring.flyway.locations는 런타임 효과 없는 문서용일 뿐이고 실제 기준은 이 customizer 입니다.
TestEnclosedConfigurationFilter — 테스트 중첩 @Configuration 제외
- 테스트 클래스 안에 중첩된
@Configuration을 컴포넌트 스캔에서 빼는 필터입니다. - 문제:
:sample-portfolio:test를 돌리면 테스트 클래스가 classpath 에 올라옵니다. 테스트 안 static 중첩@Configuration(예:WorkLogAuthorizationContractTest$AuthzTestConfig)이 평범한@Configuration이라, 전체 컨텍스트로 부팅할 때 스캔에 발견·등록됩니다. 두 테스트 설정이 같은 이름의 빈(예:clock)을 정의하면BeanDefinitionOverrideException이 납니다(override 기본 false). - 휴리스틱: 바이너리 이름에
$가 있고 그 바깥(enclosing) 클래스 이름이Test로 끝나면(자바 테스트 클래스 관례) 제외합니다. 테스트 프레임워크 의존성을 프로덕션 소스에 들이지 않고도 중첩 테스트 픽스처를 전부 거릅니다.
분산 추적 복제본 — SampleTracingConfig / …Settings / …SampleRateResolver / …EnvironmentPostProcessor / SampleMicrometerSpanErrorRecorder
app-bootstrap의 tracing 구성 루트를 샘플 전용으로 옮긴 것들입니다(위 "공통 배경" 참고). tracing 런타임(OTel/Micrometer 브리지)이 샘플 build.gradle 에 켜져 있지만, 그 빈들을 등록하는 조립 코드는app-bootstrap에만 있어서 복제했습니다.- per-profile 기본 샘플 레이트(SampleRateResolver):
prod=0.01,staging=0.10,dev/local=1.0(그 외 프로파일도 1.0). 설정값(APP_TRACING_SAMPLE_RATE)이[0.0, 1.0]범위로 들어오면 그 값이 per-profile 기본을 이깁니다. - EnvironmentPostProcessor: 해소된 effective 레이트를 native 키
management.tracing.sampling.probability로 이어줘, 실제 OTel sampler 가 gauge 값과 일치하게 합니다. 사용자가 native 키를 직접 지정했으면 그 값을 우선합니다. (이 PostProcessor 는 샘플 자신의META-INF/spring.factories로 등록됩니다.) - SampleMicrometerSpanErrorRecorder: 현재 span 에 예외를
error.code태그와 함께 기록(D12). Tracer 가 있으면 실제 recorder, 없으면 NOOP 로 동작합니다(adapter-webGlobalExceptionHandler가ObjectProvider로 집어 씀).
그 밖의 부트스트랩 복제본
- SampleMetricsContractConfig: Micrometer
MeterFilter두 개를 meter 등록 전에 설치합니다 — 고-cardinality 태그 키를 막는 필터(D8)와 SLO 기반 히스토그램 분포 필터(D9, 소유한 timer 이름에만 적용). - SampleIdempotencyConfig / …Settings: 스캔된
IdempotencyStoreAdapter/IdempotencyReaper와IdempotencyExecutor가 모두Clock을 필요로 하고,@EnableScheduling이 reaper 의@Scheduled정리를 켭니다. TTL 은 양수이며 ≤ 72h 여야 합니다(아니면 시작 실패), reaper 주기 기본 10분. - SamplePseudonymizationConfig / SamplePrivacySettings: adapter-web
RequestLoggingFilter가UserPrincipalPseudonymizerPort를 주입받으므로 그 포트를 채웁니다. salt 는APP_PRIVACY_PSEUDONYMIZATION_SALT에서 오며, 비어 있으면 로컬/테스트가 시작은 되도록 dev 센티넬 salt 로 폴백합니다(프로덕션 전엔 실제 시크릿 값을 넣을 것). - SampleDomainContextConfig:
adapter-persistence-rdbms의DomainContextAuditContextPort가DomainContextPropagator빈을 무조건 주입받으므로, 기본THREAD_LOCAL전략 구현 (ThreadLocalDomainContextPropagator)을 제공합니다 — async 데코레이터를 안 켜는 WorkLog 데모에 적합합니다.