init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
# 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 예시)
|
||||
|
||||
```bash
|
||||
# 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](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`/`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 를 저장하면서 `WorkLogReserved` outbox 이벤트를 **같은 쓰기 트랜잭션 안에서** 함께
|
||||
적재하는 예시입니다. 핵심은 `repository.save(...)` 와 `outboxAppendPort.append(...)` 가 같은
|
||||
`tx.inWrite { ... }` 블록 안에 있다는 점입니다. 저장이나 적재 중 하나라도 실패하면 트랜잭션
|
||||
전체가 롤백됩니다 — "DB 한 번, 브로커 한 번" 식의 분리된 이중 쓰기(dual-write)를 막는 보장입니다.
|
||||
- 순서: ① 저장된 애그리거트로 도메인 이벤트 생성 → ② 매퍼로 integration 이벤트로 변환(경계는
|
||||
application) → ③ 직접 만든 JSON 페이로드로 직렬화 → ④ 같은 트랜잭션에서 outbox 에 append.
|
||||
- `eventId` 는 `OutboxEventIdFactory`(UUIDv7) 로 만들고, `idempotencyKey = eventId` 로 둡니다
|
||||
(이벤트 단위 중복 제거).
|
||||
- `correlationId` 는 MDC 의 `correlation_id` 슬롯에서 읽습니다(인바운드 HTTP 필터가 채워줌).
|
||||
스케줄러/배치/테스트처럼 그 필터를 거치지 않는 경로에서는 값이 없으므로 `eventId` 로 자기
|
||||
자신을 가리키게(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 도 아니어야 합니다. 도메인 *값* 타입(예: `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
|
||||
이벤트의 클래스 단순명을 쓰는 대안도 있지만, 클래스 이름을 바꾸면 토픽이 조용히 바뀌어 컨슈머가
|
||||
깨질 수 있어, 안정적인 문자열 리터럴을 택했습니다(토픽 마이그레이션 계획 없이는 바꾸지 말 것).
|
||||
|
||||
### 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값
|
||||
`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/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_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-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-web `GlobalExceptionHandler` 가
|
||||
`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 데모에
|
||||
적합합니다.
|
||||
Reference in New Issue
Block a user