Files
llm-wiki/raw/branch-notes/feature-application-query-bypass-contract.md

422 lines
58 KiB
Markdown

---
title: branch / feature-application-query-bypass-contract
source_type: branch-note
status: raw
branch: feature-application-query-bypass-contract
parent_branch:
related_projects: [ca-skeleton]
governing_docs: [wiki/projects/ca-tmpl/clean-architecture-package-layout, wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]
tags: [branch, ca-skeleton, application, query, cqrs, read-model]
created: 2026-06-04
target_merge:
status_label: review
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-047
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-047
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1]
refines: []
overrides: []
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-035, WI-CA-SKELETON-OPERATIONAL-CONTRACT-012, WI-CA-SKELETON-OPERATIONAL-CONTRACT-024, WI-CA-SKELETON-OPERATIONAL-CONTRACT-007, WI-CA-SKELETON-OPERATIONAL-CONTRACT-006]
contract_packet: 1
contract_packet_sha256: 4c05fbdad06f0558c14b9c975d41ed0a9d49cce1c82ee4e842bc88c190ccb22d
---
# branch: feature-application-query-bypass-contract
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
> **계층 표기**: "root branch" 라는 별도 개념은 없음. project 의 직접 자식 branch 는 `parent_branch:` 를 **비워두고** `related_projects` 만 채움. 다른 branch 의 자식이면 `parent_branch: <부모 branch 이름>` 명시 + `## Parent` 섹션의 부모 wikilink 필수.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 운영 계약 중 **application read/query 경로** 영역의 결정/근거/금지 사항을 정제한다.
선택 (관련 형제 branch):
- [[raw/branch-notes/feature-application-port-usecase-contract]] — command/query use case 분리, `QueryUseCase`(`READ_ONLY` + `READ_REPOSITORY` 강제), `TransactionPort.inRead` 를 고정한 **직접 선행 계약**. 본 branch 가 우회를 논하는 "기존 표준 경로" 가 이 branch 의 산출물.
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — isolation/propagation SSOT (read tx 의 격리 수준 위임처).
- [[raw/branch-notes/feature-cache-consistency-contract]] — read 경로의 cache bypass(strict consistency) 와 인접. 본 branch 는 *데이터소스/모델* 우회, cache 계약은 *캐시* 우회.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: query bypass의 허용 경계·mapping·transaction 영향과 검증 test가 명시된다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1` | application은 Spring transaction annotation 대신 TransactionPort 또는 TransactionalUseCaseRunner를 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1` | 수기 mapper와 record canonical constructor가 default이며 MapStruct는 optional profile이다 | 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 -->
## 목표
선행 계약 `feature-application-port-usecase-contract`**모든 읽기**를 `QueryUseCase` → repository port(`READ_REPOSITORY`) → `TransactionPort.inRead` 경로로 강제하고, 그 port 가 도메인 aggregate 또는 projection 을 반환하도록 고정했다 (`QueryUseCase` Javadoc: "projection or domain object"). 그 branch 는 의도적으로 **"CQRS 인프라 강제" 를 out-of-scope** 로 미뤘다.
이 branch 는 그 미뤄둔 read-side 질문을 *스켈레톤 기본 계약*으로 확정한다: **읽기 경로가 표준 write-side 스택(도메인 aggregate / repository port / use-case / transaction)을 언제·어떻게 우회(bypass)해도 되는가.** 도메인-특화 답이 아니라, 재사용 가능한 clean-architecture 스켈레톤이 **보편적으로 가져갈 기본값 + opt-in 상향**을 정하는 것이 목표다.
> **결정 방식 (사용자 지시 2026-06-04)**: bypass 의 구체 범위를 사전에 못박지 않는다. 외부 조사(`wiki-decision-researcher`)로 *기존 through-aggregate 방식 대비* clean-architecture 스켈레톤이 보편적으로 채택해야 할 방식을 도출하고, 그것이 진짜 *선택*인 지점만 대안과 함께 결정으로 남긴다. 따라서 아래 §결정/§Decision Evidence Map 의 셀은 조사 완료 전까지 `RESEARCH_PENDING` 으로 둔다 — 추측 금지(CLAUDE.md §11).
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
> ⚠️ 아래 In/Out scope 의 **경계선 자체가 조사로 확정될 결정**이다(예: "use-case 우회 허용" 이 in 인지 out 인지). 현재는 *조사 대상 축*을 나열하며, 조사 후 D-결정에 따라 확정한다.
### 포함 범위 (조사로 확정할 축)
- **읽기 모델 우회 축**: 읽기가 도메인 aggregate 로딩을 건너뛰고 전용 read port 로 projection(native/JPQL DTO)을 반환할지 — through-aggregate vs read-model/projection vs 별도 read store.
- **읽기 경로 ceremony 축**: 단순 조회가 application use-case 를 거쳐야 하는지, thin read path(adapter-web → query service/read port 직접)를 허용할지.
- **읽기 트랜잭션 축**: 읽기가 `TransactionPort.inRead` 경계를 항상 거쳐야 하는지, no-tx read 를 허용할 조건이 있는지.
- 위 축들의 **정적 강제(ArchUnit) 가능성**`RepositoryAccess`/`@UseCaseCapability` 계약과의 정합.
### 제외 범위
> 의도적으로 제외. 면접 등에서 "이건 범위에 없었습니다" 근거.
- 특정 도메인의 구체 read model 스키마/쿼리 (도메인-특화 — skeleton 범위 밖).
- 격리 수준(REPEATABLE_READ/SERIALIZABLE) — `feature-transaction-concurrency-contract` SSOT.
- 캐시 일관성/캐시 우회 — `feature-cache-consistency-contract` SSOT (본 branch 는 *모델/데이터소스* 우회만).
- idempotency key 정책 — `feature-rate-limit-idempotency-contract`.
- 특정 CQRS 프레임워크(Axon 등) 강제 — 채택은 조사 결과에 따르되, 프레임워크 lock-in 은 비목표.
## 근거 (필수, 최소 1개+)
> 이 branch의 결정 근거. `wiki-decision-researcher` 2개 lane(R1: 읽기 모델 우회 전략, R2: 읽기 경로 ceremony) 의 조사 산출물. **company-tech-blog 는 `company-case-study`/`engineering-blog` 로만 취급 — 공식 best practice 승격 금지(CLAUDE.md §5).**
| Source | 등급 | 정당화하는 결정 |
|---|---|---|
| [[raw/official-docs/cqrs-pattern-azure-architecture-center]] | official-vendor-doc | D1 (single-store CQRS = "foundational level"), D2 (separate-store = "advanced", escalation) |
| [[raw/official-docs/spring-data-jpa-projections-spring-official]] | official-vendor-doc | D1 (closed projection = column-subset 최적화 메커니즘) |
| [[raw/official-docs/spring-data-jpa-transactionality-spring-official]] | official-vendor-doc | D4 (CrudRepository readOnly tx 기본 + "unit of work" 권고) |
| [[raw/official-docs/spring-tx-management-reference]] | official-vendor-doc | D4 (readOnly 속성 적용 범위 SPRING-TX-MGR-C6) |
| [[raw/official-docs/cqrs-fowler-bliki]] | engineering-blog | D1/D3 (CQRS 분리 개념 + "be very cautious"/"significant complexity" 경고 → Alt 3 기각 근거) |
| [[raw/official-docs/domain-vaughn-vernon-aggregate-root]] | engineering-blog | D1 (small aggregate 가정 — through-aggregate fallback 조건) |
| [[raw/company-tech-blogs/cqrs-lite-clean-architecture-read-path-bypass-wakita]] | engineering-blog | D1 (read port = application-layer port, no domain type, logical split) |
| [[raw/company-tech-blogs/explicit-architecture-ddd-hexagonal-cqrs-hgraca]] | engineering-blog | D3 (query side 가 Application Service 없이 optimized query + DTO 반환 가능) |
| [[raw/company-tech-blogs/read-only-tx-hibernate-optimization-vladmihalcea]] | engineering-blog | D4 (readOnly 이득은 entity 多일 때 — trivial read 의 no-tx 비용 근거) |
| [[raw/company-tech-blogs/netflix-tudum-cqrs-separate-read-store-evolution]] | company-case-study (medium — 2차 출처) | D2 (separate read store 의 운영 friction) |
## TODO
각 항목 옆에 증거 등급 표기.
- [x] D1: read projection port 계약 정의 — `QueryUseCase` 가 도메인 aggregate 대신 application-layer projection DTO 를 반환하도록 read port 분리 — 등급: `locally-verified` (sample-portfolio 시연: `WorkLogSummaryQueryPort` + `WorkLogSummary` record + `ListRecentWorkLogSummariesUseCase` + 영속 `WorkLogSummaryQueryAdapter`(JPQL `SELECT new``WorkLogSummaryRow` → ULID 변환))
- [x] D1: projection DTO 가 도메인 type / JPA entity / web DTO 가 아님을 강제하는 ArchUnit rule — 등급: `locally-verified` (`query_ports_do_not_leak_domain_jpa_or_web_types`, custom `ArchCondition<JavaMethod>``JavaType.getAllInvolvedRawTypes()`**generic type argument 까지** 검사 — raw/generic/over-block 3 fixture 로 역검증)
- [x] D3: `QueryUseCase` 경유를 default 로 유지(Strict). thin read path 폐기 — 등급: `actually-implemented` (코드: 모든 read 가 `QueryUseCase` bean 경유; 신규 rule 불필요 — 선행 계약 capability fitness function 재사용. application-core/CLAUDE.md §Read/query path 문서화)
- [x] D4: `TransactionPort.inRead` default 유지. no-tx bypass opt-in 조건(OSIV=false + projection-only + no lazy) 명문화 — 등급: `actually-implemented` (코드: `ListRecentWorkLogSummariesUseCase``tx.inRead` 경유 + test `inReadCalled` 검증; CLAUDE.md 에 opt-in 선결조건 문서화)
- [x] D5: projection read 의 capability 표기 = `READ_REPOSITORY` 재사용 — 등급: `actually-implemented` (코드: 영속-backed projection use case 가 `repositoryAccess = READ_REPOSITORY`; 신규 enum 없음)
- [ ] D2: Full CQRS separate read store 는 본 branch out-of-scope — escalation trigger 만 문서화하고 별도 branch 로 위임 — 등급: `documented-only` (코드 없음, 의도적)
## 진행 중 메모
- ca-tmpl 현황(2026-06-04 ground-truth): 읽기는 이미 `QueryUseCase`(`GetRepoStatsUseCase`/`ListWorkLogsUseCase`/`GetWorkLogUseCase`) 경유 = Strict baseline 실재. `GetRepoStatsUseCase` 는 dedicated `RepoStatsPort.fetch()` 를 쓰지만 **반환이 도메인 type `RepoStats`** 이고 capability 가 `RepositoryAccess.NONE` 으로 선언됨 — projection-as-application-DTO 와 read-projection capability 어휘가 아직 없음(= 본 branch 가 채울 gap, D1/D5).
- OSIV: `application-test.yml=open-in-view:false`, `application.yml=${DB_OPEN_IN_VIEW}`(env). test 는 OSIV off → no-tx bypass(D4) 의 안전 전제 일부 충족하나, lazy access 가 tx 밖이면 `LazyInitializationException` → projection-only 조건이 그래서 필수.
- web→application 경계 rule 은 "web 이 persistence/outbound adapter 의존 금지"만 있고 "web 은 QueryUseCase 만 호출" rule 은 없음 → thin read path(D3) 는 기존 rule 과 충돌하진 않으나 mandatory `@UseCaseCapability` rule 을 *우회*하게 됨(D3 Open Risk).
## 결정 사항
> 핵심 헤드라인: **"query bypass" = 도메인 aggregate 우회(projection read port)** 를 skeleton 이 *능력으로 제공*한다 — 우회하는 건 *도메인 모델*뿐. 단 **projection 은 강제 디폴트가 아니라 read 마다의 선택**이고, 코어가 강제하는 건 **purity 가드레일**(read port 가 도메인/JPA/web 타입을 누출하지 않음)뿐이다(아래 D1 의 *코어 vs 선택* 분할). **use-case ceremony 는 Strict 로 확정**(읽기는 무조건 `QueryUseCase` 경유, thin-path **폐기**), **transaction 은 default `inRead` 유지**(no-tx 만 *opt-in*). separate read store(Full CQRS)는 out-of-scope escalation.
- 2026-06-04 (D1, 2026-06-05 코어/선택 분할): CQRS-lite(single store) projection read 를 **능력으로 제공**한다 — `QueryUseCase` 가 도메인 aggregate 를 재구성하지 않고 dedicated read/query port 로 **application-layer projection DTO** 를 반환(Spring Data closed projection / `SELECT new` / JdbcTemplate). / **코어 vs 선택 분할 (보편 핵심 원칙)**:
- **코어로 강제 (모든 프로젝트 동일)** = **purity 가드레일** — read/query port 의 반환 type(generic argument 포함)이 domain/JPA/web 타입을 누출하지 않는다는 ArchUnit rule + read port 추상화의 *모양*. 이건 *projection 을 쓸 때* 깨끗함을 보장하는 가드레일이지, projection 을 *쓰라는* 강제가 아니다.
- **프로젝트 선택 (강제 금지)** = "projection 이냐 through-aggregate 냐". projection 은 *권장이자 제공된 능력*일 뿐 강제 디폴트가 아니다. 단순 읽기는 **Alt1 through-aggregate**(기존 repository port 로 도메인 aggregate 반환)가 정당한 동급 선택 — read shape = write aggregate 와 동일 **and** aggregate 가 단일 트랜잭션 불변식 경계 내 최소 크기일 때.
- **시연 위치** = projection 사용 *예시*는 `sample-portfolio` 에 둔다(교육용, `production_code_does_not_depend_on_sample_portfolio` 로 격리). 코어 enforcement 에 "projection 기본" 을 박지 않는다.
/ 이유: aggregate hydration overhead 제거 + read shape 독립 진화 + hexagonal purity 유지는 *원할 때* 얻는 이득이지 모든 도메인에 강제할 보편 사실이 아님(작은 CRUD 는 through-aggregate 가 더 단순). / 대안: Alt1 through-aggregate(위), Alt3 separate store(D2). / 근거: [[raw/official-docs/cqrs-pattern-azure-architecture-center]](AZURE-CQRS-C2), [[raw/official-docs/spring-data-jpa-projections-spring-official]](SPRING-PROJ-C2), [[raw/company-tech-blogs/cqrs-lite-clean-architecture-read-path-bypass-wakita]](WAKITA-CQRS-C2/C3).
- 2026-06-04 (D2): **Full CQRS(별도 물리 read store)는 본 branch out-of-scope** — escalation-only. / 이유: 단일 RDBMS skeleton 가정 위반 + eventual consistency + 운영 인프라(Kafka/CDC) 부담 + Fowler/Azure 의 "단순 도메인엔 부적합" 경고. / escalation trigger(별도 branch 결정, 정성): ① read/write 부하가 명확히 비대칭이어 단일 DB write-path 가 read latency SLA 미충족(정량 임계는 cited source 없음 → PoC 측정으로만 확정, `UNSUPPORTED_IMPL_DECISION`) **and** ② denormalized shape 가 single-DB column-subset SELECT 로 불가 **and** ③ 도메인이 수초 stale read 허용. / 근거: [[raw/official-docs/cqrs-pattern-azure-architecture-center]](AZURE-CQRS-C4/C6/C7), [[raw/official-docs/cqrs-fowler-bliki]](CQRS-FOWLER-C6), [[raw/company-tech-blogs/netflix-tudum-cqrs-separate-read-store-evolution]](NETFLIX-TUDUM-C2/C3, medium).
- 2026-06-04 (D3, 2026-06-05 Strict 확정): **use-case layer ceremony = Strict (단일 계약, opt-in 없음)** — 모든 읽기는 `QueryUseCase` bean 경유. **thin read path(web→read port 직접)는 폐기.** / 이유 (2026-06-05 재결정, §Audit & Findings 참조): 선행 계약은 capability 선언을 *use-case 모양*에 결합(`@UseCaseCapability` 는 use-case 구현체에만)했다. thin-path 는 use-case 가 아니므로 capability 를 달 곳이 없어 `inbound_port_implementations_declare_capability` fitness function 에 안 잡힌다 — 즉 thin-path 는 본 스켈레톤의 핵심 가치(아키텍처의 *기계 강제*)를 코드리뷰 신뢰로 격하시킨다. modest 한 ceremony 절감을 위해 기계 강제력을 포기할 가치가 없다고 판단 → thin-path 제거. capability 를 use-case 에서 분리하는 수술(port-level capability)은 thin-path 의 실익 증거가 생길 때 후속 계약으로 위임(현재 미생성). / 기각된 대안: Alt2 thin-by-default(HGRACA-CQRS-C1 의 "query side 는 Application Service 없이 가능" 학파 — "domain logic 없음" 의 정적 강제 불가로 기각), Alt3 query-handler(별도 infra 전제 → 기각). / 근거: [[raw/official-docs/cqrs-fowler-bliki]](CQRS-FOWLER-C5, "CQRS 복잡도에 매우 신중하라" → 보수적 Strict 지지), [[raw/company-tech-blogs/explicit-architecture-ddd-hexagonal-cqrs-hgraca]](HGRACA-CQRS-C1, *기각된* thin-by-default 학파의 출처).
- 2026-06-04 (D4): **transaction boundary = default `TransactionPort.inRead`** 유지. no-tx(autocommit) read 는 *opt-in*`spring.jpa.open-in-view=false` **and** projection-only(lazy 접근 없음) **and** 단일 statement 일 때만. / 이유: Spring 권고는 "unit of work 시작 시 tx 경계 선언"(SPRING-DATA-TX-C3)이나 readOnly 이득은 entity 多 read 에서 큼(VM-READTX-C3) → trivial projection read 의 tx 비용 회피 여지. / 대안: 전면 no-tx(기각 — OSIV/ lazy 위험), CrudRepository 자체 readOnly tx 의존(부분 허용). / 근거: [[raw/official-docs/spring-data-jpa-transactionality-spring-official]](SPRING-DATA-TX-C1/C3), [[raw/company-tech-blogs/read-only-tx-hibernate-optimization-vladmihalcea]](VM-READTX-C3), [[raw/official-docs/spring-tx-management-reference]](SPRING-TX-MGR-C6).
- 2026-06-04 (D5, 2026-06-05 해소): projection read 의 **capability 어휘 = `READ_REPOSITORY` 재사용, 신규 enum 불필요.** / 근거 (code-grounded, ca-tmpl `RepositoryAccess.java` 확인): `RepositoryAccess`**repository 접근 *수준*** 축(`NONE`/`READ_REPOSITORY`/`WRITE_REPOSITORY`)이고, "aggregate 냐 projection 이냐"는 **반환 *모양*** 축이라 서로 **직교**한다. repository-backed projection read 는 repository 의 read 메서드를 호출하므로 그대로 `READ_REPOSITORY` 다 — projection 이라는 사실은 capability 에 영향을 주지 않는다. 반환 모양 purity(projection ≠ domain/JPA/web)는 capability enum 이 아니라 **D1 의 반환타입 ArchUnit rule** 이 담당한다. 두 축을 혼동한 게 `READ_PROJECTION` 신설 논쟁의 정체였음(§Audit & Findings). / `GetRepoStatsUseCase``NONE` 선언은 *gap 이 아니라 올바른 분류* — 그건 *outbound HTTP* read(`RepoStatsPort`)라 repository 를 안 건드린다. repository projection read 로 이관하는 경우에만 `READ_REPOSITORY` 로 선언.
## 결정-근거 매핑
> company-tech-blog 증거는 `company-case-study`/`engineering-blog` 로 표기(공식 best practice 승격 금지). `선택 조건` = 이 조건일 때 이 결정 / 다른 조건이면 어떤 대안.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | CQRS-lite projection read 를 **능력으로 제공**: `QueryUseCase` → dedicated read/query port → **application-layer projection DTO**(aggregate 우회), 동일 RDBMS. **코어 강제 = purity 가드레일만**(read port 가 domain/JPA/web 누출 금지); **projection 사용 자체는 프로젝트 선택**(강제 디폴트 아님), 시연은 sample | **선택 가이드**: projection = read shape 이 write 와 다르거나 hydration 비용을 피하고 싶을 때(권장). **Alt1 through-aggregate**(기존 repository port 로 도메인 aggregate 반환) = 동급 선택 = read shape = write aggregate 와 동일 **and** aggregate 가 단일 트랜잭션 불변식 경계 내 최소 크기(lazy collection 없음) — 단순 CRUD 의 기본; **Alt3 별도 store** = D2 trigger. — **UNSUPPORTED_IMPL_DECISION**: "필드 N개 이하" 같은 정량 임계는 cited source 없음(Vernon 은 정성 원칙만, VERNON-AGG-C3 가 정량 임계 부재 명시) → 정성 기준만 사용, 숫자 휴리스틱 금지 | `raw/official-docs/cqrs-pattern-azure-architecture-center.md#AZURE-CQRS-C2`, `raw/official-docs/spring-data-jpa-projections-spring-official.md#SPRING-PROJ-C2`(주의: "can optimize" — column-subset SELECT *보장 아님*, Claims To Verify #1), `raw/company-tech-blogs/cqrs-lite-clean-architecture-read-path-bypass-wakita.md#WAKITA-CQRS-C2`, `#WAKITA-CQRS-C3` | `official-vendor-doc`(Azure, Spring) + `engineering-blog`(Wakita) | closed projection 이 Hibernate 6 에서 실제 column-subset SELECT 를 생성하는지 통합 테스트 미검증(Claims#1). Wakita 는 Kotlin+jOOQ → Spring Data JPA 전이성 보강 필요 |
| D2 | Full CQRS(별도 물리 read store)는 out-of-scope escalation — trigger 문서화 후 별도 branch 위임 | escalation = read/write 부하가 명확히 비대칭이어 **단일 DB write-path 가 read latency SLA 를 못 맞추는 시점** **and** single-DB projection 불가(denormalized) **and** eventual consistency 허용; 아니면 D1. — **UNSUPPORTED_IMPL_DECISION**: 정량 임계(QPS 배수 등)는 cited source 없음(Azure/Fowler/Netflix 모두 비율 미명시) → PoC 측정값으로만 확정, 숫자 threshold 단정 금지 | `raw/official-docs/cqrs-pattern-azure-architecture-center.md#AZURE-CQRS-C4`, `#AZURE-CQRS-C6`, `#AZURE-CQRS-C7`, `raw/official-docs/cqrs-fowler-bliki.md#CQRS-FOWLER-C6`, `raw/company-tech-blogs/netflix-tudum-cqrs-separate-read-store-evolution.md#NETFLIX-TUDUM-C3` | `official-vendor-doc`(Azure) + `company-case-study`(Netflix, **medium — 2차 출처**) | NETFLIX-TUDUM 은 netflixtechblog SSL 오류로 ByteByteGo 2차 출처 — 직접 재검증 권장 |
| D3 | use-case ceremony = **Strict 단일 계약** — 모든 읽기 `QueryUseCase` 경유, thin read path **폐기** | 무조건 Strict. thin-path 같은 use-case 우회 읽기는 없음(capability 선언이 use-case 모양에 결합돼 정적 강제 불가 → 폐기). port-level capability 분리 수술은 thin-path 실익 증거 생길 때 후속 계약 위임 | `raw/official-docs/cqrs-fowler-bliki.md#CQRS-FOWLER-C5` (CQRS 복잡도 신중론 → 보수적 Strict 지지) / `raw/company-tech-blogs/explicit-architecture-ddd-hexagonal-cqrs-hgraca.md#HGRACA-CQRS-C1` (*기각된* thin-by-default 학파) | `engineering-blog`(Fowler caution; Graça = 기각 대안) | 해소됨(2026-06-05): thin-path 폐기로 "capability rule 우회" 구멍이 사라짐. 잔여 trade-off: 단순 조회도 `QueryUseCase` ceremony 부담을 짐 — 스켈레톤의 *기계 강제* 가치를 위해 의도적으로 수용 |
| D4 | transaction default `TransactionPort.inRead`; no-tx read 는 opt-in | no-tx = `open-in-view=false` + projection-only(lazy 없음) + 단일 statement; 아니면 inRead | `raw/official-docs/spring-data-jpa-transactionality-spring-official.md#SPRING-DATA-TX-C1`, `#SPRING-DATA-TX-C3`, `raw/company-tech-blogs/read-only-tx-hibernate-optimization-vladmihalcea.md#VM-READTX-C3`, `raw/official-docs/spring-tx-management-reference.md#SPRING-TX-MGR-C6` | `official-vendor-doc`(Spring) + `engineering-blog`(Vlad) | trivial read 에서 no-tx 가 inRead 대비 실측 이득이 있는지 PoC 미수행(UNVERIFIED). prod OSIV(`${DB_OPEN_IN_VIEW}`) 가 false 로 운영되는지 확인 필요 |
| D5 | projection read 의 capability = **`READ_REPOSITORY` 재사용, 신규 enum 불필요** (해소) | repository-backed projection read 는 항상 `READ_REPOSITORY`. outbound HTTP read 는 `NONE`(repository 미접근). 신규 `READ_PROJECTION` 없음 | (code-grounded) `ca-tmpl RepositoryAccess.java` = `{NONE, READ_REPOSITORY, WRITE_REPOSITORY}` — 접근 *수준* 축 | `project-decision` (code 확인) | 해소됨(2026-06-05): `RepositoryAccess`(접근 수준)와 반환 모양(aggregate/projection)은 직교 — 혼동이 논쟁의 정체였음. 반환 purity 는 D1 rule 이 담당. `GetRepoStatsUseCase``NONE` 은 outbound HTTP 라 올바른 분류(gap 아님) |
## 구현 가이드
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세 — *문서가 모호해서 구현자가 임의로 정해야 했던 결정* 카탈로그. 작성 목표는 다음 구현자가 *되묻지 않아도 코드를 작성할 수 있는 수준*.
>
> **본 §는 일률적 anchor list 를 강제하지 않는다.** branch 마다 구현 내용·범위가 다르므로 sub-section 은 *이 branch 의 결정과 근거에서 도출되는 것만* 작성. 어떤 branch 는 error mapping 표 + 정적 강제 카탈로그, 어떤 branch 는 migration 단계 + wiring, 어떤 branch 는 sequence + state machine. 형식 예시는 [[raw/branch-notes/feature-boundary-validation-mapping-contract]] 의 §구현 가이드 참조.
>
> **3-rule meta principle (필수 준수)**:
>
> 1. **R1. Reference 필수** — 각 sub-section / row / cell 은 본 branch 의 `Decision ID` (예: D1, D2) + 그 결정의 `Supporting Claim ID` (예: `RAW-SLUG-C1`) 를 reference. *근거 없는 결정 금지* — 모든 구현 detail 은 결정 + 근거의 *도출* 이어야 함.
> 2. **R2. UNSUPPORTED_IMPL_DECISION 명시** — 근거 raw 가 *원칙* 만 권고하고 *detail* (메커니즘 선택 / 클래스/rule 명명 / glob 패턴 / algorithm / factory API 모양 등) 은 권고하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` 라벨 + 사용자 trade-off 근거 한 줄. 이게 *근거 있는 결정 vs 사용자 임의 trade-off* 의 경계.
> 3. **R3. OUT_OF_BRANCH_SCOPE 정제** — 본 branch 결정 범위 밖 cell 은 §구현 가이드에 *남기지 않음*. 별도 branch 또는 canonical SSOT 로 이관 (이관 history 는 별도 § "Audit & Findings" 등에 보존). 도메인 특화 detail (ca-tmpl skeleton 범위 밖) 도 동일하게 정제.
>
> **각 sub-section 의 권장 헤더 패턴**:
>
> ```markdown
> ### N. <sub-section 제목>
>
> > **Trace**: <In-scope row 들 + Decision ID + Supporting Claim ID 의 매핑 (한 줄/한 단락)>
> >
> > - **UNSUPPORTED_IMPL_DECISION**: <근거 없는 사용자 임의 결정 항목들 + 각각의 trade-off 근거 한 줄>
>
> <표 또는 명확한 구조 — 자유 텍스트 = 모호함 = 되묻기 원인>
> ```
### 1. Read/query port 분리 + projection DTO 반환 (D1)
> **Trace**: D1 (AZURE-CQRS-C2 single-store CQRS, SPRING-PROJ-C2 closed projection, WAKITA-CQRS-C3 no-domain-type port). ca-tmpl anchor: 기존 `dev.caskeleton.application.usecase.QueryUseCase<Q,R>` + `application.query.Query` marker + sample 의 `RepoStatsPort`(precursor).
>
> - **UNSUPPORTED_IMPL_DECISION**: read port 인터페이스 **명명/패키지** (`*QueryPort` vs `*ReadPort`, `application.<domain>.port` vs `application.query.port`) — 근거 raw 는 "application-layer port" 원칙만 권고(WAKITA-CQRS-C3), 구체 suffix/패키지는 미권고. trade-off: outbound port 기존 `*Port` 컨벤션과 충돌 회피 위해 `*QueryPort` 제안(임의).
> - **UNSUPPORTED_IMPL_DECISION**: projection DTO 의 **배치 layer** — application 패키지에 record 로 둘지(반환 type 이 web/JPA 가 아니어야 하므로 application 이 자연) — 근거 원칙(no domain/web/JPA type)에서 도출되나 "record in application.query.result" 같은 구체 위치는 임의.
>
> - **⚠️ 코어 vs 선택 분할 (구현 시 반드시 지킬 것)**: 아래 표에서 **코어(모든 프로젝트 동일 강제)는 「정적 강제」 두 행 = read/query port 의 purity 가드레일뿐**이다. 「반환 type / 조회 메커니즘」은 *projection 경로를 택했을 때* 의 명세지 *모든 읽기에 projection 을 강제* 하는 게 아니다. **단순 읽기는 기존 repository port 로 도메인 aggregate 를 반환(through-aggregate)** 해도 되며 그 경로는 본 purity rule 대상이 아니다(아래 행). projection *사용 예시*는 `sample-portfolio` 에서 시연하고 코어 enforcement 에 "projection 기본" 을 박지 않는다.
| 항목 | 명세 | 근거/라벨 |
|---|---|---|
| 코어/선택 구분 | **코어 강제** = 「정적 강제」 행(purity rule). **프로젝트 선택** = projection 경로를 쓸지 vs through-aggregate(아래) | D1 (코어/선택 분할, 2026-06-05) |
| 반환 type (projection 경로) | 도메인 aggregate ❌ / web DTO ❌ / JPA entity ❌ → **application-layer projection DTO**(record 권장) | D1 / WAKITA-CQRS-C3 |
| through-aggregate 경로 (선택) | 단순 읽기: 기존 repository port → 도메인 aggregate 반환. **read/query port 가 아니므로 purity rule 비대상**. read shape=write **and** 최소 aggregate 일 때 동급 선택(Alt1) | D1 / VERNON-AGG-C3 (small aggregate) |
| 조회 메커니즘 | Spring Data **closed** interface projection 또는 `SELECT new <AppDto>(...)` JPQL 또는 JdbcTemplate RowMapper. **단, closed projection 의 column-subset SELECT 생성은 Claims#1 미검증(SPRING-PROJ-C2 는 "can optimize" 만 명시) → 검증 전까지 `SELECT new`/JdbcTemplate 를 1순위로 선호** | D1 / SPRING-PROJ-C2 |
| nested join 주의 | closed projection 의 nested property 는 full join materialize(SPRING-PROJ-C6) → 다중 join 조회는 `SELECT new`/JdbcTemplate 선호 | D1 / SPRING-PROJ-C6 |
| 정적 강제 (의도) | read port 메서드의 반환 type 이 `..domain..` / `..adapter..` / `jakarta.persistence..` / `org.springframework.web..` 에 속하지 않아야 함 — **직접 반환 type 뿐 아니라 generic type argument(`List<DomainType>`)까지** 차단. violations-as-data negative fixture(도메인 type 반환 read port)로 rule 이 실제로 잡는지 역검증 | D1 / WAKITA-CQRS-C3 (no-domain-type port 원칙) |
| 정적 강제 (ArchUnit 구체 API) | **UNSUPPORTED_IMPL_DECISION / Claims#2** — 정확한 ArchUnit 구성은 구현 시 사용 중인 ArchUnit 버전 Javadoc 으로 확정. 후보: (a) 직접 반환 type 은 `methods()...should().haveRawReturnType(DescribedPredicate)` 계열(predicate overload 존재 여부·이름은 버전 의존 → copy 전 확인 필수), (b) `List<DomainType>`**generic type argument 누출은 raw-type 검사로 못 잡으므로 custom `ArchCondition<JavaMethod>`** 가 메서드 반환의 type parameter 까지 들여다봐야 함. 즉 (a) 단독으로는 불충분 — 이 한계 자체가 trade-off 근거 | D1. **UNSUPPORTED_IMPL_DECISION**: 근거 raw 는 "domain type 미노출" 원칙(WAKITA-CQRS-C3)만 권고하고 정적 강제의 구체 API 는 미권고 → 위 (a)/(b) 조합은 구현 fixture 로 확정, 노트의 DSL 을 그대로 copy 하지 말 것 |
| 기존 자산 정합 | `GetRepoStatsUseCase` 는 D1 패턴의 precursor지만 `RepoStats`(도메인 type) 반환 → D1 적용 시 projection DTO 로 이관 후보(planned) | ca-tmpl ground-truth |
### 2. use-case ceremony = Strict 확정 (D3)
> **Trace**: D3 (CQRS-FOWLER-C5 CQRS 복잡도 신중론 → 보수적 Strict 지지). ca-tmpl anchor: 선행 계약의 `inbound_port_implementations_declare_capability` / `_end_with_use_case` / `_declare_capability` rule (actually-implemented) — `@UseCaseCapability` 는 **use-case 구현체에만** 부착되고 그 rule 들이 use-case 구현체를 대상으로 capability 를 강제한다.
>
> - **결정 (2026-06-05)**: 읽기 경로는 **단일 경로 = Strict.** thin read path(web→read port 직접)는 *폐기.* 근거: capability 선언이 use-case 모양에 결합돼 있어, use-case 가 아닌 thin-path read 는 capability fitness function 에 안 잡힌다(정적 강제 불가). 스켈레톤의 핵심 가치는 *기계 강제* 이므로 ceremony 절감을 위해 이를 포기하지 않는다. capability 를 use-case 에서 분리(port-level capability + rule)하는 수술은 **후속 계약으로 위임**(thin-path 실익 증거가 생길 때) — 현재 미생성.
| 경로 | 허용 | capability 선언 | 비고 |
|---|---|---|---|
| Strict (유일 경로) | `QueryUseCase` 구현 → read/query port | `@UseCaseCapability(transactionMode=READ_ONLY, repositoryAccess=READ_REPOSITORY)` mandatory (repository projection read). outbound HTTP read 는 `repositoryAccess=NONE` | 선행 계약 rule 그대로 — 무변경 |
| ~~thin read path~~ | **폐기** — web 이 read port 직접 호출하는 경로 없음 | — | use-case⇄capability 결합이 풀리는 후속 계약 전까지 열지 않음 |
> **F4 (해소)**: 이전엔 thin read path 가 `inbound_port_implementations_declare_capability` 를 우회하는 구멍이었고 D5 결정에 종속됐다. **thin-path 폐기로 구멍이 제거**됐다 — 모든 읽기가 `QueryUseCase` 이므로 capability 가 항상 선언·강제된다. capability 어휘(D5)는 `READ_REPOSITORY` 재사용으로 해소(신규 enum 불필요) → 본 §는 선행 계약 rule 을 그대로 쓰며 신규 ArchUnit rule 이 필요 없다.
### 3. read transaction 정책 (D4)
> **Trace**: D4 (SPRING-DATA-TX-C1 CrudRepository readOnly 기본, SPRING-DATA-TX-C3 unit-of-work 권고, VM-READTX-C3 readOnly 이득=entity 多). ca-tmpl anchor: `TransactionPort.inRead`(application-core) + `SpringTransactionPort`(READ_COMMITTED pinned) + OSIV `application-test.yml=false`/`application.yml=${DB_OPEN_IN_VIEW}`.
>
> - **UNSUPPORTED_IMPL_DECISION**: no-tx opt-in 의 **강제 방식** — 근거는 정책 권고만, "어떻게 막을지"(ArchUnit? 문서?) 미권고. trade-off: no-tx 조회가 lazy 를 건드리면 OSIV=false 에서 `LazyInitializationException` → **projection-only + 단일 statement** 를 전제로만 허용, 정적 강제 대신 read port 가 도메인 entity 를 반환 안 한다는 D1 rule 로 간접 보증.
> - **UNSUPPORTED_IMPL_DECISION**: no-tx 의 **latency/connection 이득** 자체 — VM-READTX-C3 는 *entity 多 bulk read 의 메모리 절약*만 지지하며 *trivial single-statement read 의 connection/latency 이득* 은 인용 범위 밖이다(역방향 추론). no-tx opt-in 의 정당화는 Claims#4 의 JMH/부하 PoC 결과로만 확정 — PoC 전까지 "no-tx 가 더 빠르다" 단정 금지.
| read 형태 | tx 정책 | 조건 |
|---|---|---|
| lazy 연관 접근 있는 read | **반드시** `TransactionPort.inRead` | OSIV=false 에서 tx 밖 lazy = 예외 |
| projection-only 단일 statement read | inRead default, no-tx opt-in 허용 | **선결 조건**: 배포 env/`env-keys.yaml``DB_OPEN_IN_VIEW` 기본값 = `false` 확인 필수(Claims#5). **미확인 시 no-tx opt-in 은 Disabled**`application.yml` 이 env 위임(`${DB_OPEN_IN_VIEW}`)이라 prod 값 미확정이면 tx 밖 lazy 안전 전제가 깨짐 |
> **OUT_OF_BRANCH_SCOPE 정제(R3)**: 격리 수준(REPEATABLE_READ 등)은 `feature-transaction-concurrency-contract`, 캐시 우회는 `feature-cache-consistency-contract`, idempotency 는 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] SSOT — 본 §에 detail 남기지 않음(링크만). Full CQRS read-store 구현(D2)도 별도 branch.
## 엣지·실패·의존
- **실패·엣지 경로**:
- **lazy + no-tx**: projection-only 가 아닌데 no-tx(D4 opt-in)로 조회 후 lazy 연관 접근 → OSIV=false 에서 `LazyInitializationException`. 기대 동작: read port 가 도메인 entity 를 반환 안 함(D1)으로 구조적 차단, 위반 시 ArchUnit 실패.
- **closed projection nested join**: nested property 포함 closed projection 은 full join materialize(SPRING-PROJ-C6) → 의도와 다른 over-fetch. 기대 동작: 다중 join 은 `SELECT new`/JdbcTemplate 로 명시.
- **~~thin path 남용~~ (해소, 2026-06-05)**: thin-path 자체를 폐기(D3 Strict 확정) → use-case 우회 read 경로가 없으므로 `@UseCaseCapability` 선언을 우회하는 read 가 구조적으로 불가능. 모든 읽기는 `QueryUseCase` 이고 선행 계약 rule 이 capability 를 강제.
- **capability 표기(D5 해소)**: repository projection read 는 `READ_REPOSITORY`(접근 수준 축), outbound HTTP read 는 `NONE`. 반환 모양(projection)은 capability 와 직교 — purity 는 D1 반환타입 rule 이 담당. fitness function 은 기존 그대로 권한 상향(read→write)을 잡는다.
- **다른 계약 의존**:
- [[raw/branch-notes/feature-application-port-usecase-contract]] 의 `D9`(`QueryUseCase`+`READ_REPOSITORY`+`inRead`) / `D1`(`*UseCase` 명명) / 판정기준(mandatory `@UseCaseCapability`) — 본 branch 는 그 계약의 read 경로를 *확장*(projection 반환 허용)할 뿐, **ceremony 는 그대로 Strict 유지**(thin-path 폐기로 *완화* 없음). 그 계약의 capability enum/rule 이 바뀌면 D1 영향. 또한 같은 계약의 `D12`(HikariCP pool sizing SSOT, `inNew` 전용)에 read `inRead` connection 점유의 pool 영향도 위임 — read no-tx(D4) 가 pool 경합을 바꾸면 D12 재검토. / **후속 위임**: capability 를 use-case 모양에서 분리(port-level capability)하는 수술은 thin-path 실익 증거가 생길 때 별도 계약(미생성)이 이 계약의 capability 메커니즘을 확장 — 본 branch 는 그 수술을 *하지 않기로* 결정(D3).
- [[raw/branch-notes/feature-transaction-concurrency-contract]] 의 `D3`(isolation default=`READ_COMMITTED`) — D4 의 read tx 격리는 여기에 위임. 그 `D3` 가 바뀌면 D4 opt-in 조건 재검토 필요.
- [[raw/branch-notes/feature-cache-consistency-contract]] — read 의 *캐시* 우회는 거기 SSOT. 본 branch 는 *모델/데이터소스* 우회만(경계 충돌 주의).
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — outbound HTTP read(`RepoStatsPort` 같은 external read adapter)의 RestClient/Resilience4j/timeout 계약은 거기 SSOT. 본 branch 는 그 read 의 capability 표기(`NONE` — repository 미접근, D5 해소)만 확인하고 HTTP 계약은 위임.
- [[raw/branch-notes/feature-persistence-failure-baseline]] — read projection query 의 오류 분류(SQLState `57014` query canceled / `08*` connection 등)는 거기 SSOT. 본 branch read 경로도 동일 오류 경로 사용 → 위임.
- (escalation 시) D2 → 별도 `feature-cqrs-read-store-contract`(미생성) 가 separate read store + 동기화 pipeline 소유.
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
> 구현 전/중/후에 실제로 검증해야 하는 주장을 분리한다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| Spring Data **closed projection** 이 Hibernate 6(ca-tmpl) 에서 실제로 column-subset SELECT 를 생성한다 | SPRING-PROJ-C2 는 "optimize" 만 명시, JPA provider 별 동작 보장 아님(SPRING-PROJ-C4) | Testcontainers + Hibernate SQL 로그로 SELECT 컬럼 목록 확인 (projection vs entity 비교) | `planned` |
| read/query port 반환 type 이 도메인/web/JPA type 이 아님을 ArchUnit 으로 정적 강제 가능 | 메서드 반환 type 의존성 검사 rule wording 미작성 | `methods().that().areDeclaredInClassesThat().haveSimpleNameEndingWith("QueryPort").should().notHaveRawReturnType(...)` 류 rule + violation fixture | `planned` |
| ~~thin read path 의 "domain logic 없음" 을 자동 강제할 수 없다~~ (D3 Strict 확정으로 **무효화**, 2026-06-05) | thin-path 자체를 폐기 → 검증 대상 아님 | (해당 없음 — thin-path 경로 제거) | `obsolete` |
| trivial projection read 에서 no-tx 가 `inRead` 대비 실측 이득(connection 점유/latency)이 있다(D4 opt-in 정당화) | VM-READTX-C3 는 메모리 절약만 — connection/latency 정량 미증명(역명제 비함의) | JMH/부하 테스트로 no-tx vs inRead 단일 row SELECT 비교 | `planned` |
| ca-tmpl prod 의 `${DB_OPEN_IN_VIEW}` 가 실제 `false` 로 운영된다(D4 안전 전제) | `application.yml` 은 env 위임 — 실제 값 미확인(test 만 false 확인됨) | 배포 env/`env-keys.yaml` registry 의 `DB_OPEN_IN_VIEW` 기본값 확인 | `needs-confirmation` |
| `GetRepoStatsUseCase`(`RepoStats` 도메인 type 반환)를 D1 projection-DTO 패턴으로 이관 가능 | 도메인 type 반환을 application projection record 로 바꾸는 작업 — 단 이건 *outbound HTTP* read 라 capability 는 `NONE` 유지(repository 미접근, D5 해소) | `RepoStats` → application projection record 이관 PoC. capability 는 `NONE` 그대로 | `planned` |
| Netflix Tudum separate-store friction 근거(D2) | netflixtechblog SSL 오류로 2차 출처(ByteByteGo) 의존 — 1차 미확인 | 원 netflixtechblog 글 직접 재fetch 또는 InfoQ 교차확인 | `needs-confirmation` |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs`)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
> 마지막 감사: 2026-06-04 (coverage-auditor) → **Covered** (Blocking 0 / Should-fix 3 → 위임 링크 추가로 해소 / Advisory 1). governing_docs: `clean-architecture-package-layout` + `data-layer-persistence-cache-outbound`.
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| Application layer 가 adapter/transport/JPA 에 의존하지 않음 (ArchUnit isolation) | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] | OK | `application_does_not_depend_on_adapters_or_transport` actually-implemented. §Edge 위임 링크 |
| QueryUseCase 의 `@UseCaseCapability` mandatory 선언 | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] | OK | `inbound_port_implementations_declare_capability`. D3 Open Risk + §Edge 링크 |
| QueryUseCase 명명(`*UseCase` suffix) | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] | OK | `inbound_port_implementations_end_with_use_case`. §Edge 링크 |
| thin read path 가 capability rule 을 우회하는 문제 | covered-here | — | — | D3 (2026-06-05 Strict 확정 = thin-path **폐기**) → 우회 경로 자체가 제거됨. 모든 읽기 `QueryUseCase` 경유 |
| Read/query port 의 application 패키지 배치(도메인·어댑터 아님) | covered-here | — | — | D1 (hexagonal purity, 도메인 type 미노출) |
| Read port 반환 type 의 domain/JPA/web 누출 방지 ArchUnit rule | covered-here | — | — | D1 §구현 가이드 §1 (DSL skeleton, `planned` — Claims#2) |
| Full CQRS 별도 read store 모듈 경계 | covered-here | — | — | D2 (escalation trigger 문서화, 별도 branch 위임) |
| Read-side 영속성 모델: aggregate vs projection | covered-here | — | — | D1 (코어=purity 가드레일 강제 / projection vs through-aggregate=프로젝트 선택, 2026-06-05 분할) |
| OSIV off 가 read transaction 경계에 미치는 영향 | covered-here | — | — | D4 (OSIV=false 전제 no-tx opt-in; prod env gap Claims#5) |
| Cache bypass(strict consistency read) | delegated | [[raw/branch-notes/feature-cache-consistency-contract]] | OK | §Out of scope + §Edge 위임 링크 |
| Read 격리 수준(REPEATABLE_READ 등) | delegated | [[raw/branch-notes/feature-transaction-concurrency-contract]] (D3) | OK | §Out of scope + §Edge 위임 링크 |
| 읽기용 Outbound HTTP 경로(`RepoStatsPort` 등 external read) | delegated | [[raw/branch-notes/feature-outbound-http-client-baseline]] | OK (해소) | §Edge 위임 링크 추가됨. D5 는 capability 어휘만 |
| Read-path connection pool 영향(HikariCP + no-tx) | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] (D12) | OK (해소) | §Edge 위임 링크 추가됨 |
| Read-path 오류 분류(SQLState 57014/08* 등) | delegated | [[raw/branch-notes/feature-persistence-failure-baseline]] | OK (해소) | §Edge 위임 링크 추가됨 |
| Capability 어휘 확장(`READ_PROJECTION` 신설 여부) | covered-here | — | — | D5 (2026-06-05 해소: 신규 enum 불필요 — projection read = `READ_REPOSITORY`, 접근 수준과 반환 모양은 직교) |
| Read replica lag/라우팅 정책 | missing | (없음) | ⚪ Advisory | data-layer doc 이 `documented-only` 로만 명시. projection query ≠ replica routing — 본 branch 범위 밖, 프로젝트 레벨 gap(비-Blocking) |
## 감사 이력
> branch-spec / depth / coverage 게이트가 남긴 감사 흔적. 위임 결정의 audit trail 과 깊이 보강 이력을 한 곳에 모은다(§Coverage 표·§Edge prose 와 중복이 아니라 *왜 그렇게 분류·수정했는지* 의 근거).
### 위임 audit trail (coverage)
본 branch 는 governing_docs(`clean-architecture-package-layout` + `data-layer-persistence-cache-outbound`)가 요구하는 관심사 중 다음을 **명시적으로 다른 owner branch 에 위임**한다. 모든 위임처는 `raw/branch-notes/` 에 실재하며 §Edge 에 wikilink 가 있다(2026-06-05 재검증).
| 위임 관심사 | owner branch | 위임 근거 |
|---|---|---|
| Application layer isolation / `@UseCaseCapability` mandatory / `*UseCase` 명명 | `feature-application-port-usecase-contract` | 본 branch 의 read 경로가 그 계약의 *확장*(projection 반환)일 뿐 ceremony 는 Strict 유지(thin-path 폐기). 계약 rule 자체는 그 branch 소유 |
| Read-path connection pool 영향(HikariCP + no-tx) | [[raw/branch-notes/feature-application-port-usecase-contract]] (D12) | `inNew` pool-sizing SSOT. read no-tx(D4) 가 pool 경합을 바꾸면 D12 재검토 — 위임이되 역영향 경로 명시 |
| Read 격리 수준(REPEATABLE_READ 등) | [[raw/branch-notes/feature-transaction-concurrency-contract]] (D3) | isolation SSOT. D4 의 read tx 격리는 여기 위임 |
| Cache bypass(strict consistency) | `feature-cache-consistency-contract` | 본 branch 는 *모델/데이터소스* 우회만, *캐시* 우회는 거기 SSOT |
| 읽기용 Outbound HTTP(`RepoStatsPort` external read) | [[raw/branch-notes/feature-outbound-http-client-baseline]] | RestClient/Resilience4j/timeout SSOT. 본 branch 는 capability 어휘(D5)만 |
| Read-path 오류 분류(SQLState 57014/08*) | `feature-persistence-failure-baseline` | SQLState classifier SSOT |
### 미할당 project-level gap (Advisory, 비-Blocking)
- **Read replica lag / 라우팅 정책**: `data-layer-persistence-cache-outbound``documented-only` 로만 명시, ca-tmpl `src/` 에 관련 코드 0건, 어느 branch 도 소유 안 함. projection query ≠ replica routing 이므로 본 branch 범위 밖. replica routing 이 운영상 필요해지면 별도 `feature-read-replica-routing-contract` 신설·할당 권고. 그 전까지 Advisory 로 유지.
### 깊이 게이트 보강 이력 (2026-06-05, depth-auditor 후속)
- **(Blocking 해소)** §구현 가이드 §1 「정적 강제」: ArchUnit DSL 을 copy 가능한 구체 호출로 제시하던 것을 *의도(intent)**구체 API(UNSUPPORTED_IMPL_DECISION/Claims#2)* 로 분리. `notHaveRawReturnType` 등 predicate overload 는 ArchUnit 버전 의존 + raw-type 만 검사해 `List<DomainType>` generic 누출을 못 잡으므로 custom `ArchCondition<JavaMethod>` 가 필요함을 명시 — 노트의 DSL 을 그대로 copy 금지.
- **(Should-fix 해소)** §1 조회 메커니즘: closed projection 의 column-subset SELECT 가 Claims#1 미검증임을 명시하고 `SELECT new`/JdbcTemplate 1순위 선호로 보강.
- **(Should-fix 해소)** §3: no-tx 의 latency/connection 이득이 VM-READTX-C3 직접 지지 범위 밖(역방향 추론)임을 UNSUPPORTED_IMPL_DECISION 으로 추가, Claims#4 PoC 종속.
- **(Should-fix 해소)** §3 no-tx opt-in 행: prod `DB_OPEN_IN_VIEW=false` 확인을 *선결 조건* 으로 승격(미확인=Disabled), Claims#5 연결.
### 계약 정합 재결정 (2026-06-05): thin-path 폐기 + D5 해소
> 사용자와의 설계 검토에서 "선행 계약(`feature-application-port-usecase-contract`)이 너무 강한 강제성을 두어 후속 계약의 선택 폭이 좁아지는 것 아닌가"라는 비판을 검토한 결과. **근본 원인 진단**: 선행 계약은 capability 선언을 *use-case 모양*에 결합(`@UseCaseCapability` 는 use-case 구현체에만 부착)했다. 따라서 use-case 가 아닌 thin-path read 는 capability 를 달 곳이 없어 fitness function 에 안 잡힌다 — 이게 thin-path 를 막던(=D5 종속) 진짜 원인이었고, "enum 어휘(`READ_PROJECTION`) 부재"는 표면 증상이었다.
- **D3 → Strict 단일 계약으로 확정 (thin-path 폐기).** 대안이었던 "capability 를 use-case 에서 분리(port-level capability + rule)"하는 foundation 수술은 *하지 않기로* 결정. 이유: thin-path 의 ceremony 절감은 modest 한데, 그걸 위해 스켈레톤의 핵심 가치인 *아키텍처 기계 강제* 를 코드리뷰 신뢰로 격하시키는 비용이 크다. thin-path 실익 증거가 생기면 그때 후속 계약(D14 의 freeze-with-guard 패턴처럼)으로 foundation 의 capability 메커니즘을 확장. → **foundation 무변경.**
- **D5 → 해소 (신규 enum 불필요).** `ca-tmpl/.../capability/RepositoryAccess.java` 확인 결과 `{NONE, READ_REPOSITORY, WRITE_REPOSITORY}` = repository 접근 *수준* 축. "aggregate/projection"은 반환 *모양* 축이라 직교 → repository projection read 는 `READ_REPOSITORY`, outbound HTTP read 는 `NONE`(올바른 분류, gap 아님). 반환 모양 purity 는 D1 의 반환타입 rule 이 담당. `READ_PROJECTION` 논쟁은 두 축의 혼동이었음.
- **영향 정리**: §결정 D3/D5, §Decision Evidence Map D3/D5, §구현 가이드 §2(thin-path row 제거 + F4 해소), §Edge(thin path 남용·capability 모호 항목 해소), Claims(thin-path domain-logic claim → `obsolete`; GetRepoStatsUseCase 이관 claim → capability `NONE` 유지로 명확화), §Coverage(thin-path·READ_PROJECTION row 해소) 일괄 갱신. D1·D2·D4 는 무영향.
### D1 코어/선택 분할 명문화 (2026-06-05)
> "스켈레톤은 *보편적으로 모두가 같게 쓰는 것*만 코어에 강제해야 한다(복사되는 물건이라 안 쓰는 코드 = 지울 수 없는 인지 비용)"는 원칙을 D1 에 적용. 구현 착수 전, projection 이 *강제 디폴트* 로 코어에 박히는 것을 방지하기 위함.
- **분할 결정**: D1 의 산출물 중 **코어(모든 프로젝트 동일 강제) = read/query port 의 purity 가드레일**(반환 type 이 domain/JPA/web 누출 금지 ArchUnit rule + read port 추상화 모양)뿐이다. **"projection 을 기본으로 써라"는 코어에 강제하지 않는다** — projection vs through-aggregate 는 *프로젝트 선택*(단순 CRUD 는 through-aggregate via 기존 repository port 가 동급·기본). projection *사용 예시*는 `sample-portfolio` 에서 시연(`production_code_does_not_depend_on_sample_portfolio` 로 격리).
- **근거**: purity 가드레일은 read port 를 *쓸 때* 깨끗함을 보장하는 보편 불변식(도메인 무관) → 코어 적합. 반면 projection 채택은 read 최적화라 *상황적*(read shape ≠ write 이거나 hydration 비용 회피 시 이득) → 강제 시 작은 CRUD 에 불필요한 over-engineering. ca-tmpl `RepositoryAccess`(접근 수준)와 직교한 반환 모양 축이므로 capability 강제와도 무관.
- **구현 지침**: read-port 추상화 + purity rule 은 코어(`application-core` + ArchUnit)에. projection record/조회 메커니즘 *예시*는 sample. 모든 읽기에 projection port 를 만들지 말 것 — 능력·가드레일만 코어, 사용은 read 마다 선택.
- **영향**: §목표 헤드라인, §결정 D1, §Decision Evidence Map D1, §구현 가이드 §1(코어/선택 구분 행 + through-aggregate 경로 행 추가), §Coverage(aggregate vs projection row) 갱신. D2·D3·D4·D5 무영향.
## 마주친 문제
> 짧은 메모만. 깊이 있는 트러블슈팅은 `raw/errors/` 로 분리하고 아래 Cluster에 연결.
- 이슈 1
- 원인:
- 시도:
- 해결: (또는 미해결이면 `needs-confirmation`)
- 별도 에러 노트로 분리됨: `[[raw/errors/<...>]]` (생성 시)
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/cqrs-lite-clean-architecture-read-path-bypass-wakita]]
- [[raw/company-tech-blogs/explicit-architecture-ddd-hexagonal-cqrs-hgraca]]
- [[raw/company-tech-blogs/netflix-tudum-cqrs-separate-read-store-evolution]]
- [[raw/company-tech-blogs/read-only-tx-hibernate-optimization-vladmihalcea]]
- [[raw/official-docs/cqrs-pattern-azure-architecture-center]]
- [[raw/official-docs/spring-data-jpa-projections-spring-official]]
- [[raw/official-docs/spring-data-jpa-transactionality-spring-official]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/archunit-generic-return-type-purity-query-port-2026-06-05]]
<!-- GENERATED: blog-topics:end -->
> 이 branch는 단일 노트가 아니라 **작업 묶음의 entry point**. 이 branch에서 파생된 모든 raw 노트를 카테고리별로 명시. 자식 노트가 forward link만 박아도 Obsidian backlink로 자동 발견되지만, 읽기 흐름과 분류를 위해 hub가 명시적으로 그룹화한다.
### Sub-branches (세부 작업)
- `[[raw/branch-notes/<sub-branch-1>]]` — <한 줄 요약>
- `[[raw/branch-notes/<sub-branch-2>]]` — <한 줄 요약>
### 오류 기록 (이 branch 작업 중 발생)
- (없음 — 구현 중 에러 없음. JPQL `SELECT new` / generic-arg ArchUnit API 모두 1차 통과)
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- (별도 노트 미생성 — 면접 각도는 아래 blog-topic 의 "type erasure 가 정적 분석 사각지대를 만든다" 로 충분히 커버. 필요 시 분리)
### 강의 (이 작업을 위해 학습한 강의)
- (없음)
### job-posting tie-ins (이 작업에서 파생된 글감)
- [[raw/blog-topics/archunit-generic-return-type-purity-query-port-2026-06-05]] — D1 purity rule 의 generic type argument 검사 기법(`JavaType.getAllInvolvedRawTypes()`) 단독 추출
- derived blog: 생성 전. 생성 시 `wiki/blog/<slug>-YYYY-MM-DD.md` 후보
## 관련 일일 노트
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
- `[[raw/daily-notes/YYYY-MM-DD]]`
- `[[raw/daily-notes/YYYY-MM-DD]]`
## 완료 후 정리
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
- PR 링크: (미생성 — ca-tmpl 작업 브랜치 `feature/business-rule-validation-contract` 위에서 구현)
- 리뷰 메모: 2026-06-05 구현 완료. D1(core+demo)/D3/D4/D5 코드화, D2 documented-only 유지.
- 머지 결과 / 배포 환경: **로컬 검증 완료(locally-verified)**. prod 미배포.
- **구현 산출물 (ca-tmpl, 2026-06-05)**:
- **D1 core (purity guardrail)** — `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`: `query_ports_do_not_leak_domain_jpa_or_web_types` ArchUnit rule + custom `ArchCondition<JavaMethod>` `notLeakDomainJpaOrWebThroughReturnType(...)` (사용 API: `JavaMethod.getReturnType().getAllInvolvedRawTypes()` → generic type argument 의 erasure 까지 평탄화. Claims#2 의 "raw-type 검사로는 `List<DomainType>` 못 잡음" 을 custom condition 으로 해소). 타겟: `..application..` + simple name `*QueryPort`. 금지 패키지: `..domain.. / ..adapter.. / jakarta.persistence.. / javax.persistence.. / org.springframework.web.. / org.hibernate..`.
- **D1 fixtures (violations-as-data + over-block)** — `.../violations/application/RawLeakQueryPort.java`(raw leak), `.../violations/application/GenericLeakQueryPort.java`(generic-only leak — generic 검사 증명), `.../allowed/application/CleanProjectionQueryPort.java`(over-block guard) + `ArchitectureViolationFixtureTest`의 3 isolated 테스트.
- **D1 demo (sample-portfolio, projection read 경로)** — `application/query/WorkLogSummary.java`(projection record), `application/query/ListRecentWorkLogSummariesQuery.java`, `application/port/WorkLogSummaryQueryPort.java`, `application/worklog/ListRecentWorkLogSummariesUseCase.java`, 영속 `adapter/persistence/repository/WorkLogSummaryRow.java` + `WorkLogJpaRepository.findRecentSummaryRows`(JPQL `SELECT new` column-subset) + `WorkLogSummaryQueryAdapter.java`(UUID→ULID 매핑). production 코어에 "projection 기본" 미강제 — 코어는 purity rule 만, 사용 시연은 sample 격리(`production_code_does_not_depend_on_sample_portfolio`).
- **D3/D4/D5 문서화** — `src/application-core/CLAUDE.md` §Read/query path(through-aggregate vs projection 표 + D1~D5) + §ArchUnit guardrails 에 신규 rule 등재.
- **검증 결과 (2026-06-05, cd src)**:
- `./gradlew :sample-portfolio:test` → BUILD SUCCESSFUL (신규 `ListRecentWorkLogSummariesUseCaseTest` 2/2, `WorkLogSummaryQueryAdapterTest` 2/2; `@SpringBootTest` 컨텍스트 부팅 = JPQL `SELECT new` 시동시 검증 통과)
- `./gradlew :app-bootstrap:test` → BUILD SUCCESSFUL (`CleanArchitectureTest` 36/36 — 신규 rule 포함; `ArchitectureViolationFixtureTest` 30/30 — 신규 D1 3 테스트 포함)
- `./gradlew verifyCleanArchitectureDependencies` → BUILD SUCCESSFUL
- **미해소 Claims (구현으로 닫지 않음, 의도적)**: Claims#1(closed projection column-subset — `SELECT new` 채택으로 회피, PoC 불요), Claims#4(no-tx latency PoC — `inRead` default 유지로 미수행), Claims#5(prod `DB_OPEN_IN_VIEW=false` — no-tx opt-in Disabled 전제로 유지). D2 escalation 정량 임계는 `UNSUPPORTED_IMPL_DECISION` 유지.
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목: D3(Strict ceremony), D4(inRead default), D5(`READ_REPOSITORY` 재사용)
- `locally-verified` 항목: D1(purity guardrail rule + projection demo)
- `prod-verified` 항목: (없음 — prod 미배포)
- **추출하지 않을 항목** (planned / documented-only / abandoned): D2(documented-only, separate read store)