Files
clean-architecture-backend-…/src/sample-portfolio/README.md
T

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/error
  • adapter/persistence — entity/repository/mapper
  • adapter/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 인지"를 구분하고, 웹 경계에서는 이 값이 HTTP ETag / 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-12 hex 그룹입니다.
  • 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/categorynull 이면 정렬/필터 없음을 뜻합니다. 전체 개수는 같은 필터 기준으로 세어 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 를 저장하면서 WorkLogReserved outbox 이벤트를 같은 쓰기 트랜잭션 안에서 함께 적재하는 예시입니다. 핵심은 repository.save(...)outboxAppendPort.append(...) 가 같은 tx.inWrite { ... } 블록 안에 있다는 점입니다. 저장이나 적재 중 하나라도 실패하면 트랜잭션 전체가 롤백됩니다 — "DB 한 번, 브로커 한 번" 식의 분리된 이중 쓰기(dual-write)를 막는 보장입니다.
  • 순서: ① 저장된 애그리거트로 도메인 이벤트 생성 → ② 매퍼로 integration 이벤트로 변환(경계는 application) → ③ 직접 만든 JSON 페이로드로 직렬화 → ④ 같은 트랜잭션에서 outbox 에 append.
  • eventIdOutboxEventIdFactory(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:closeadmin 역할 묶음에만 부여되고 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 도 아니어야 합니다. 도메인 타입(예: WorkCategory enum)을 참조하는 것은 허용됩니다(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 이벤트의 클래스 단순명을 쓰는 대안도 있지만, 클래스 이름을 바꾸면 토픽이 조용히 바뀌어 컨슈머가 깨질 수 있어, 안정적인 문자열 리터럴을 택했습니다(토픽 마이그레이션 계획 없이는 바꾸지 말 것).

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:export202 Accepted + 폴링 경로를 가리키는 Location 헤더 + operationId/ statusUrl 을 담은 봉투를 돌려주고, GET /operations/{id} 로 상태를 폴링합니다. 상태는 5값 OperationStatus enum 에서 옵니다.
  • 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 같은 공용 코덱이 아니라 JDK java.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 읽기. JPQL SELECT new 생성자 표현식으로 요약 컬럼만 골라(content/summary/techStack/links 제외) WorkLogSummaryRow 로 담는 컬럼-부분 읽기라, 애그리거트 복원과 lazy @ElementCollection 조인을 건너뜁니다. 닫힌 인터페이스 projection 대신 SELECT new/JdbcTemplate 을 택한 이유: 닫힌-projection 의 컬럼 가지치기는 구현체 의존적이고 Hibernate 6 에서 검증되지 않았기 때문입니다.

WorkLogSummaryQueryAdapter / WorkLogSummaryRow

  • WorkLogSummaryQueryAdapter: application WorkLogSummaryQueryPort 구현체. JPQL projection 쿼리에 위임하고, 저장소 native WorkLogSummaryRow(UUID id)를 application WorkLogSummary(UUID 문자열 id)로 매핑합니다. 저장소 native UUID 는 이 어댑터를 벗어나지 않습니다 — application 에 노출되는 읽기 모델은 canonical UUID 문자열 형식으로 말합니다.
  • WorkLogSummaryRow: projection 의 영속-쪽 읽기 행. JPQL SELECT new 의 대상이며 저장 모양 (id 가 native UUID)을 그대로 반영합니다. 이 행을 persistence 패키지 안에 두는 것 자체가 native UUID 가 어댑터를 벗어나지 못하게 막는 장치입니다.

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/pushedAtRepoStats 로). 필수 필드가 없으면 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_controller ArchUnit 규칙이 웹/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-web SecurityConfig, adapter-persistence PersistenceJpaConfig 등)를 자동으로 발견·배선합니다. 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-postgresqlPostgreSqlPersistenceConfig 를 샘플 전용으로 대체한 것입니다.
  • 왜 필요한가: 원본은 @PersistenceContext EntityManager 필드와 FlywayConfigurationCustomizer 빈을 함께 등록합니다. Spring Boot 의 Flyway 자동 구성은 entityManagerFactory 가 준비되기 전에 모든 customizer 빈을 모읍니다(Flyway 가 먼저 마이그레이션을 돌려야 JPA 가 스키마를 검증할 수 있으니까). 그 customizer 를 모으려고 PostgreSqlPersistenceConfig 를 인스턴스화하면 @PersistenceContextentityManagerFactory 를 당기는데, 그 빈은 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.ymlspring.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-web GlobalExceptionHandlerObjectProvider 로 집어 씀).

그 밖의 부트스트랩 복제본

  • SampleMetricsContractConfig: Micrometer MeterFilter 두 개를 meter 등록 전에 설치합니다 — 고-cardinality 태그 키를 막는 필터(D8)와 SLO 기반 히스토그램 분포 필터(D9, 소유한 timer 이름에만 적용).
  • SampleIdempotencyConfig / …Settings: 스캔된 IdempotencyStoreAdapter/IdempotencyReaperIdempotencyExecutor 가 모두 Clock 을 필요로 하고, @EnableScheduling 이 reaper 의 @Scheduled 정리를 켭니다. TTL 은 양수이며 ≤ 72h 여야 합니다(아니면 시작 실패), reaper 주기 기본 10분.
  • SamplePseudonymizationConfig / SamplePrivacySettings: adapter-web RequestLoggingFilterUserPrincipalPseudonymizerPort 를 주입받으므로 그 포트를 채웁니다. salt 는 APP_PRIVACY_PSEUDONYMIZATION_SALT 에서 오며, 비어 있으면 로컬/테스트가 시작은 되도록 dev 센티넬 salt 로 폴백합니다(프로덕션 전엔 실제 시크릿 값을 넣을 것).
  • SampleDomainContextConfig: adapter-persistence-rdbmsDomainContextAuditContextPortDomainContextPropagator 빈을 무조건 주입받으므로, 기본 THREAD_LOCAL 전략 구현 (ThreadLocalDomainContextPropagator)을 제공합니다 — async 데코레이터를 안 켜는 WorkLog 데모에 적합합니다.