412 lines
40 KiB
Markdown
412 lines
40 KiB
Markdown
---
|
||
title: branch / feature-domain-modeling-guardrails
|
||
source_type: branch-note
|
||
status: raw
|
||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-036
|
||
kind: project-work-item
|
||
project: ca-skeleton-operational-contract
|
||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-036
|
||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1]
|
||
refines: []
|
||
overrides: []
|
||
depends_on: []
|
||
contract_packet: 1
|
||
branch: feature-domain-modeling-guardrails
|
||
parent_branch:
|
||
governing_docs: [wiki/projects/ca-tmpl/privacy-file-domain-modeling, wiki/projects/ca-tmpl/clean-architecture-package-layout]
|
||
related_projects: [ca-skeleton]
|
||
tags: [branch, ca-skeleton, domain, modeling, guardrails]
|
||
created: 2026-05-22
|
||
target_merge:
|
||
status_label: in-progress
|
||
contract_packet_sha256: 12147734b0020a89b2ffd64b9840c0a563c7a44fa50b2c121596005623139fe7
|
||
---
|
||
|
||
# branch: feature-domain-modeling-guardrails
|
||
|
||
> Layer: `raw/branch-notes/` — domain layer가 framework와 persistence에 오염되지 않도록 modeling guardrail을 정의합니다.
|
||
|
||
<!-- 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 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||
|
||
<!-- GENERATED: branch-contract:start -->
|
||
<!-- section-id: branch-contract-packet -->
|
||
## 브랜치 계약 패킷
|
||
|
||
- **생성 시 프로젝트 개정**: `1`
|
||
- **패킷 스키마**: `contract_packet: 1`
|
||
- **완료 조건**: domain model forbidden dependency fixture가 실패한다
|
||
|
||
<!-- section-id: inherited-project-decisions -->
|
||
### 상속한 프로젝트 결정
|
||
|
||
| Decision Ref | Project Summary | Branch Application | Source |
|
||
|---|---|---|---|
|
||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | domain-core의 framework·persistence 의존 금지 경계에 적용 | [[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 -->
|
||
## 목표
|
||
|
||
도메인을 바로 얹을 수 있는 skeleton이 되려면 domain layer가 깨끗해야 합니다. entity, value object, domain service, domain event의 역할을 구분하고, framework annotation이나 persistence model이 domain으로 들어오는 것을 막습니다.
|
||
|
||
- 이슈:
|
||
- PR:
|
||
|
||
<!-- section-id: branch-scope -->
|
||
## 범위
|
||
|
||
### 포함 범위
|
||
|
||
- entity/value object/domain service/domain event 구분.
|
||
- domain invariant 위치.
|
||
- domain forbidden dependency.
|
||
- aggregate state mutation 기준.
|
||
- domain exception 범위.
|
||
|
||
### 제외 범위
|
||
|
||
- DDD 전술 패턴 전체 강제.
|
||
- 특정 aggregate 설계.
|
||
- business naming convention.
|
||
|
||
## TODO
|
||
|
||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Decisionized Work Items" / "테스트 계약" 참조. entity/value object/domain service, invariant 위치, aggregate mutation, forbidden dependency, domain exception, domain event modeling 모두 표 row 또는 결정 라인으로 반영됨. 잔존 TODO 없음.
|
||
|
||
## Work Item Contract
|
||
|
||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||
|
||
| field | required | rule |
|
||
| --- | --- | --- |
|
||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||
| Allowed | yes | 허용되는 예외와 조건 |
|
||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||
|
||
## 진행 중 메모
|
||
|
||
- 2026-06-05 ground-truth 대조 (`/branch-spec`): ca-tmpl `domain_is_pure` ArchUnit rule (`CleanArchitectureTest.java:36-57`) 이 `..domain..` 의 Spring/JPA/Hibernate/Lombok/cross-layer import 를 금지 — **owner 는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3** (rule 의 `.as(...)` 주석에 명시). 본 branch 의 D1(framework-neutral) 은 이 rule 을 *재정의하지 않고 위임/재사용* 한다 (자세한 정합/drift 는 §Audit & Findings).
|
||
- 본 branch 의 modeling-specific guardrail (VO constructor / aggregate mutator 가시성 / logger ban / domain event) 은 모두 **코드 미존재** = `planned`. `@ValueObject`·`@AggregateRoot`·`@DomainEvent` annotation 도 `src/` grep 결과 미존재. domain-core 모듈에는 현재 `identifier/ResourceId`·`IdFactory` 만 존재.
|
||
- 2026-06-05 **C2 구현 완료** (`locally-verified`): 위 5개 modeling-specific guardrail 을 전부 구현. 자세한 구현 facts/검증/상태 전이는 §구현 기록 (2026-06-05) 참조. §진행 중 메모의 "코드 미존재" 서술은 2026-06-05 이전 ground-truth 기준이며, 현재는 §구현 기록이 최신 상태를 가진다.
|
||
|
||
## 구현 기록 (2026-06-05)
|
||
|
||
> Phase C2 실 코드 작성. ca-tmpl repo `feature-domain-modeling-guardrails` branch. 증거 등급: 아래 모두 `locally-verified` (focused gradle test + verifyCleanArchitectureDependencies 통과).
|
||
|
||
### 변경 파일
|
||
|
||
- **domain-core (신규 marker 패키지 `dev.caskeleton.domain.stereotype`)**:
|
||
- `ValueObject.java`, `AggregateRoot.java`, `DomainEvent.java` — `@Target(TYPE)`, `@Retention(RUNTIME)`, `java.lang.annotation` 만 의존 (framework-neutral 유지, `domain_is_pure` 통과).
|
||
- `package-info.java` — marker 의도 문서화.
|
||
- **app-bootstrap `CleanArchitectureTest.java` (신규 규칙 5종 + custom condition 2종)**:
|
||
- `domain_has_no_logger` (D3) — `..domain..` 의 `org.slf4j..`/`java.util.logging..`/`ch.qos.logback..`/`org.apache.logging.log4j..` import 금지. `domain_is_pure` 와 **별도 규칙**(F1 owner 경계 보존).
|
||
- `value_objects_have_no_public_no_arg_constructor` (D5/D6) — `@ValueObject` OR `..domain.vo..` → public no-arg 생성자 부재. custom `notHaveAPublicNoArgConstructor()`.
|
||
- `aggregate_root_setters_are_not_public` (D7) — `@AggregateRoot` 의 `set.*` method `notBePublic()`.
|
||
- `domain_events_are_records` (D4/D8) — `@DomainEvent` 는 record. custom `beRecordTypes()` (`JavaClass.isRecord()`).
|
||
- `domain_events_are_transport_free` (D4/D8) — `@DomainEvent` 는 `org.apache.kafka..`/`org.springframework.http..`/`jakarta.ws.rs..` 의존 금지.
|
||
- **app-bootstrap violation fixtures (비공허 증명, violations-as-data)**: `violations/domain/LoggerUsingDomainFixture`, `AnnotatedPublicNoArgValueObjectFixture`, `vo/PackagePublicNoArgValueObjectFixture`, `PublicSetterAggregateFixture`, `event/{kafka,springhttp,jaxrs,nonrecord}/*Fixture` + `ArchitectureViolationFixtureTest` 에 11개 assertion(글로브별 격리 + over-block guard 2종).
|
||
- **app-bootstrap `build.gradle`**: `testCompileOnly kafka-clients`, `jakarta.ws.rs-api` (transport glob 격리 증명용, test scope).
|
||
- **sample-portfolio (positive coverage + Claims To Verify PoC)**:
|
||
- `WorkLog` `@AggregateRoot` + blank-title 불변식(`requireValidTitle` → `WorkLogInvariantException`).
|
||
- `Period`, `WorkLogId` `@ValueObject`.
|
||
- `WorkLogInvariantException`(+ safe `Reason` enum) — 도메인은 operational error code 모름(D2), logger 안 씀(D3).
|
||
- `WorkLogReserved`(`@DomainEvent` record, transport-free) → `application/event/WorkLogReservedIntegrationEvent` + `...Mapper` (경계 변환 PoC).
|
||
- 테스트: `WorkLogInvariantTest`, `WorkLogIdPropertyTest`(jqwik property-based), `WorkLogReservedIntegrationEventMapperTest`. `build.gradle` 에 `testImplementation net.jqwik:jqwik:1.9.1`.
|
||
|
||
### 검증 명령 / 결과
|
||
|
||
- `cd src && ./gradlew :domain-core:test :sample-portfolio:test :app-bootstrap:test verifyCleanArchitectureDependencies` → **BUILD SUCCESSFUL**.
|
||
- `ArchitectureViolationFixtureTest` → tests=40, failures=0, skipped=0 (신규 11개 포함).
|
||
- `WorkLogIdPropertyTest` → jqwik property 3종 통과.
|
||
- ca-architect-sentinel 작업트리 감사 → **PASS** (FAIL/WARN 0; domain framework-neutral 유지, application.event 의 domain→application 방향만 의존, 불변식이 aggregate 안에 위치).
|
||
|
||
### 함정
|
||
|
||
- `@DomainEvent` record 의 component 로 `testCompileOnly` transport type 을 두자 JUnit **test discovery** 가 통째로 실패(`ClassSelector resolution failed`). record component = canonical ctor 시그니처라 reflective discovery 가 즉시 resolve. method body `.class` 참조 + subpackage `importPackages` 로 회피. → [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] 2026-06-05 addendum.
|
||
|
||
### 상태 전이 (planned → locally-verified)
|
||
|
||
- D3 logger ban, D5/D6 VO 불변식, D7 aggregate mutator, D4/D8 domain event(record + transport-free): `planned` → `locally-verified`.
|
||
- §Claims To Verify 의 VO property / aggregate set* / logger import / transport-free mapping 항목: `planned` → `locally-verified` (PoC 코드 + 테스트 존재).
|
||
- Greg Young/Vernon paraphrased 근거 검증(외부 원전 대조)은 여전히 `needs-confirmation` — 코드 구현과 무관하게 미해결.
|
||
|
||
## 결정 사항
|
||
|
||
- 2026-05-22: domain은 Spring/JPA/HTTP/Security/Logging type을 알지 않음.
|
||
- 2026-05-22: domain exception은 business invariant만 표현하고 operational error code를 직접 알지 않음.
|
||
- 2026-05-22: domain logger는 금지. invariant 위반 사유는 domain exception의 safe reason enum/value로 표현하고 application layer가 로그로 번역.
|
||
- 2026-05-22: domain event는 transport-free fact만 표현하고 integration event mapping은 application/infrastructure 경계에서 수행.
|
||
|
||
## 판정 기준
|
||
|
||
| 구분 | 기준 |
|
||
| --- | --- |
|
||
| Decision | domain model은 framework-neutral pure model로 유지 |
|
||
| Allowed | domain event/value object 내부의 순수 validation |
|
||
| Forbidden | `@Entity`, `@Service`, HTTP/JPA/Security/Logger import |
|
||
| Required checks | forbidden import, public mutable state, domain-to-response direct exposure |
|
||
| Failure condition | domain이 infrastructure/presentation/application response type을 알면 실패 |
|
||
|
||
## Decisionized Work Items
|
||
|
||
| item | Decision | Allowed | Forbidden | Required test |
|
||
| --- | --- | --- | --- | --- |
|
||
| entity/value object | pure domain types only | immutable helper libraries | JPA entity as domain | forbidden import test |
|
||
| invariant | value object/entity constructor/factory | application pre-check for UX | DB-only invariant | invalid state test |
|
||
| mutation | aggregate method controls state | package-private constructor for ORM outside domain model | public mutable fields | mutation test |
|
||
| diagnostics | safe reason enum, application logs | no reason for security-sensitive cases | domain logger | logger import test |
|
||
| domain event | transport-free fact | internal-only event | Kafka/HTTP/Slack detail | event model test |
|
||
|
||
## 결정-근거 매핑
|
||
|
||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지 (D1~D8). Decisionized Work Items 표 row 와 1:1 매핑.
|
||
|
||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||
|---|---|---|---|---|
|
||
| D1 | domain 은 Spring/JPA/HTTP/Security/Logging type 을 알지 않음 (framework-neutral) | `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C1`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5`, `raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog.md#WOOWA-HEX-C2` | `engineering-blog + company-case-study` | Fowler bliki 는 `engineering-blog` 등급 (개인 블로그, `official-vendor-doc` 격상 금지). Logger ban 의 직접 출처 부재 — FOWLER-ANEMIC-C5 "validations/calculations/business rules" 에서 도출 가능하나 약함 |
|
||
| D2 | domain exception 은 business invariant 만 표현, operational error code 를 직접 알지 않음 | `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5`, `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C2` | `engineering-blog + needs-confirmation` | VERNON-AGG-C2 는 paraphrased (`needs-confirmation`) — PDF 본문 verbatim 미확보. "operational error code 와 domain exception 의 분리" 직접 출처 부재 |
|
||
| D3 | domain logger 금지, invariant 위반 사유는 safe reason enum/value 로 표현 후 application layer 가 로그로 번역 | UNSUPPORTED_DECISION | (Fowler/Vernon 모두 logger ban 명시 부재 — FOWLER-ANEMIC-C5 의 "domain logic = validations/calculations/business rules" 에서 logger 부재가 도출되나 직접 인용 아님) | Logger ban 의 공식 표준 출처 없음 — ca-tmpl 자체 결정. "safe reason enum" 패턴의 reference 부재 |
|
||
| D4 | domain event 는 transport-free fact 만 표현, integration event mapping 은 application/infrastructure 경계에서 수행 | `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C4`, `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C5` | `needs-confirmation + needs-confirmation` (paraphrased) | GY-CQRS-C4 는 `needs-confirmation` (Greg Young PDF 검증 실패, Confluent corroborate 만). VERNON-AGG-C5 도 paraphrased — transport-free 의 ca-tmpl 정의는 자체 차용 |
|
||
| D5 | entity / value object 는 pure domain types only, JPA entity 를 domain 으로 두지 않음 (Vernon Option A) | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C6`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C3`, `raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog.md#WOOWA-HEX-C2` | `needs-confirmation + engineering-blog + company-case-study` | VERNON-AGG-C6 paraphrased (`needs-confirmation`). 우아한형제들 사례는 Option A (POJO domain) 와 Option B (JPA in domain) 모두 보이는 vendor-specific — Vernon 의 Option A/B 분리 자체는 본 Claim 으로 직접 증명 안 됨 |
|
||
| D6 | invariant 는 value object/entity constructor/factory 에 위치, DB-only invariant 금지 | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C2`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5` | `needs-confirmation + engineering-blog` | VERNON-AGG-C2 paraphrased — "single transaction" 의 의미가 "constructor invariant" 와 정확히 매핑되는지 PDF verbatim 확인 필요 |
|
||
| D7 | aggregate mutation 은 root method 만 controls, public mutable field 금지, ORM 외부 매핑 (Option A) 으로 package-private constructor 사용 | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C6` | `needs-confirmation` (paraphrased — IDDD Ch.10 도서 인용, 페이지/문단 미지정) | VERNON-AGG-C6 verbatim 미확보. "package-private/protected" 가 Java 외 다른 JVM 언어 (Kotlin `internal`) 에 매핑되는지 별도 검증 필요 |
|
||
| D8 | domain event modeling 은 internal-only event 허용, Kafka/HTTP/Slack detail 금지 | `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C4`, `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C3` | `needs-confirmation` (Greg Young PDF 미검증) | "transport-free" 의 ca-tmpl 정의는 차용 (GY-CQRS-C4 Does not prove: transport-free 가능성 명시 부재) — 직접 출처 없음 |
|
||
|
||
## 구현 가이드
|
||
|
||
> *결정* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*". 본 branch 의 modeling guardrail 은 전부 `planned` (코드 미존재) 이므로, 아래는 C2 진입 시 *되묻지 않고 작성할 수 있는* 사전 명세다. anchor 는 §진행 중 메모 / §Audit 에서 확인한 *실제* ca-tmpl 구조(`domain_is_pure`, domain-core 모듈, `feature-architecture-enforcement-rules` owner)에 정합시킨다.
|
||
> 3-rule (CLAUDE.md §15.5): 각 cell 은 Decision ID + Supporting Claim ID reference (R1) / 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄 (R2) / 범위 밖은 §Audit 으로 이관 (R3).
|
||
|
||
### 1. 도메인 순수성 — 기존 rule 위임 (재정의 금지)
|
||
|
||
> **Trace**: D1 ↔ `FOWLER-ANEMIC-C1/C5`, `WOOWA-HEX-C2`. 단, 정적 강제의 **owner 는 본 branch 가 아님**.
|
||
>
|
||
> - **OUT_OF_BRANCH_SCOPE (위임)**: framework-neutral 정적 강제(`..domain..` 의 Spring/JPA/Hibernate/Lombok import 금지)는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 의 `domain_is_pure` (`CleanArchitectureTest.java:36-57`, `actually-implemented`) 가 소유. 본 branch 는 이 rule 을 **재정의/복제하지 않고** 모델링 결정의 전제로 *위임 참조*. 본 branch 가 추가하는 것은 아래 2~5 의 modeling-specific rule 뿐.
|
||
|
||
| 항목 | owner | 상태 | anchor |
|
||
|---|---|---|---|
|
||
| `..domain..` Spring/JPA/Hibernate/Lombok/cross-layer import 금지 | [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 | `actually-implemented` | `domain_is_pure` (`CleanArchitectureTest.java:36`) |
|
||
| controller 가 domain/entity 타입 직접 반환 금지 | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] D8 | `actually-implemented` | `controllers_do_not_return_domain_or_entity_types` (`CleanArchitectureTest.java:321`) |
|
||
|
||
> **Option A vs B 선택 근거는 추측이 아니라 코드로 증명된다 (D5·D7 강화)**: Vernon Option B(domain class 에 `@Entity`/JPA annotation 직접 부착)는 domain 패키지에 `jakarta.persistence..` import 를 유발한다. 이는 `domain_is_pure` 의 forbidden list (`CleanArchitectureTest.java:40-42` — `jakarta.persistence..`·`javax.persistence..`) 에서 **자동 위반**되어 빌드가 깨진다 (`actually-implemented`). 따라서 ca-tmpl 에서 Option A(ORM 외부 매핑)는 *선호*가 아니라 기존 정적 강제의 **논리적 귀결** — Option B 는 코드 레벨에서 이미 금지됨. 이 체인이 VERNON-AGG-C6 의 paraphrased 약점(도서 페이지 미확보)을 코드 ground-truth(L2)로 보완한다.
|
||
|
||
### 2. 도메인 logger ban 정적 강제 (D3)
|
||
|
||
> **Trace**: D3 (`UNSUPPORTED_DECISION` — logger ban 의 공식 출처 없음, ca-tmpl 자체 결정).
|
||
>
|
||
> - **GAP / `STALE_OWNER` 위험**: 코드 확인 결과 `domain_is_pure` 의 forbidden 목록에 **logging framework 가 없다** (`org.slf4j`·`java.util.logging`·`ch.qos.logback`·`org.apache.logging.log4j` 모두 미포함; test 파일 전체 grep 상 logger ban rule 부재). 따라서 "domain 이 Logger import 시 ArchUnit 실패" 는 현재 `planned` 이며 **어떤 rule 도 강제하지 않음**.
|
||
> - **근거 등급 확정 (되묻기 방지)**: logger ban 의 *공식 표준 출처는 존재하지 않는다* — 이는 clean-architecture 통념이지 official standard 가 아니다 (D3 `UNSUPPORTED_DECISION` 유지). 구현자는 "공식 근거를 더 찾아라"가 아니라 **ca-tmpl 자체 규약으로 확정하고 착수**한다. 사실 등급은 격상하지 않으며, 외부(면접/README)에서 "표준이라 막았다"고 말하지 않는다.
|
||
> - **PRE-DECISION (메커니즘 확정)**: 별도 rule **`domain_has_no_logger` 신설** (owner = 본 branch). `domain_is_pure` forbidden list 확장(대안)을 *택하지 않는* 이유는 코드 근거가 있다 — `domain_is_pure` 의 owner 는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 (`CleanArchitectureTest.java:54-56` `.as(...)` 명시, §Audit F1). 그 list 에 logger 를 끼우면 *본 branch 의 결정이 타 branch owner rule 에 섞여* owner 경계가 깨진다(F1 회피). 별도 rule 은 위반 메시지도 "domain logger 금지(D3)"로 명확. → trade-off 가 아니라 owner-boundary 로 강제됨.
|
||
|
||
| 강제 대상 | 메커니즘(제안) | 상태 |
|
||
|---|---|---|
|
||
| `..domain..` 의 `org.slf4j..`·`java.util.logging..`·`ch.qos.logback..`·`org.apache.logging.log4j..` import | 신규 rule `domain_has_no_logger` (owner = 본 branch; forbidden list 확장 아님 — F1 owner 경계 보존) | `planned` |
|
||
| invariant 위반 사유 = safe reason enum/value (noun 형태), 로그 번역은 application layer | domain exception 의 reason enum 필드 + application 에서 error.category 매핑 | `planned` |
|
||
|
||
### 3. Value Object invariant 강제 (D5·D6)
|
||
|
||
> **Trace**: D5 ↔ `VERNON-AGG-C6`·`FOWLER-ANEMIC-C3`·`WOOWA-HEX-C2`, D6 ↔ `VERNON-AGG-C2`·`FOWLER-ANEMIC-C5`.
|
||
>
|
||
> - **PRE-DECISION (탐지 기준·명명 확정)**: annotation `@ValueObject` 를 **primary marker**, `..domain.vo..` package convention 을 **fallback**(annotation 미부착 VO 도 포착)으로 *둘 다* 사용 — ArchUnit rule 의 `.areAnnotatedWith(...).or().resideInAPackage(...)` 가 양쪽을 OR 로 묶으므로 둘 중 택일이 아니라 합집합이 자연스럽다. annotation 패키지는 `dev.caskeleton.domain.stereotype` (domain-core 신규 marker 패키지; 현재 domain-core 는 `identifier` 패키지만 보유 → marker 패키지 신설). 근거 raw(Vernon/Fowler)는 *invariant 위치*만 권고하고 명명은 권고 안 하므로 `@ValueObject`·`stereotype` 명칭은 ca-tmpl 임의 — 사실 등급 비격상, 코드 미존재이므로 `planned`.
|
||
|
||
| 강제 대상 | 메커니즘(제안) | 상태 |
|
||
|---|---|---|
|
||
| `@ValueObject` 또는 `..domain.vo..` 의 record/class 에 public no-arg constructor 부재 | `classes().that().areAnnotatedWith(ValueObject.class).or().resideInAPackage("..domain.vo..").should().notHaveAccessibleNoArgConstructor()` | `planned` |
|
||
| 모든 VO constructor 가 invalid input 에 domain exception/`IllegalArgumentException` throw | property-based test (jqwik) — null/empty/boundary × N | `planned` |
|
||
| `@ValueObject` annotation 신설 | `dev.caskeleton.domain.stereotype.ValueObject` (domain-core 신규 marker 패키지) | `planned` (annotation 미존재) |
|
||
|
||
### 4. Aggregate root mutator 가시성 (D7)
|
||
|
||
> **Trace**: D7 ↔ `VERNON-AGG-C6` (`needs-confirmation` — IDDD Ch.10 페이지 미지정).
|
||
>
|
||
> - **PRE-DECISION (탐지 범위 확정)**: ArchUnit 정적 강제 범위 = **`set.*` prefix method 만** (`notBePublic()`). 이유: ca-tmpl 은 현재 Java-only (`src/` 전부 `.java`) 이므로 Kotlin `internal`/`copy()`·record wither 우회는 *지금 범위 밖*(D7 Open Risk 로 보존, Kotlin 도입 시 재검토). `set.*` 외의 state-changing method(예: `applyXxx`, `markAsXxx`)는 ArchUnit 로 일반 강제가 불가능 → 코드리뷰 + 네이밍 컨벤션으로 보완(정적 강제 아님 명시). annotation 패키지는 §3 과 동일하게 `dev.caskeleton.domain.stereotype.AggregateRoot`. `@AggregateRoot` 명명 ca-tmpl 임의(코드 미존재, `planned`).
|
||
|
||
| 강제 대상 | 메커니즘(제안) | 상태 |
|
||
|---|---|---|
|
||
| `@AggregateRoot` class 의 `set*`/state-changing method 가 public 아님(package-private/protected) | `methods().that().haveNameMatching("set.*").and().areDeclaredInClassesThat().areAnnotatedWith(AggregateRoot.class).should().notBePublic()` | `planned` |
|
||
| ORM 재구성용 constructor 가시성 = package-private (Vernon Option A, ORM 외부 매핑) | persistence mapper 가 domain 밖에서 재구성 (`WorkLog` ↔ `WorkLogJpaEntity`) | `planned` |
|
||
| `@AggregateRoot` annotation 신설 | `dev.caskeleton.domain.stereotype.AggregateRoot` | `planned` (annotation 미존재) |
|
||
|
||
### 5. Domain event transport-free 모델링 (D4·D8)
|
||
|
||
> **Trace**: D4 ↔ `GY-CQRS-C4`·`VERNON-AGG-C5` (둘 다 `needs-confirmation`), D8 ↔ `GY-CQRS-C3/C4`.
|
||
>
|
||
> - **근거 등급 확정 (되묻기 방지)**: "transport-free fact" 라는 *명칭/정의*는 ca-tmpl 차용이며 Greg Young 원전이 직접 보장하지 않는다(GY-CQRS-C4 `needs-confirmation`, D8 Open Risk). 구현자는 이 명칭의 출처를 더 추적하지 않는다 — **보수적 기본값으로 확정 후 착수**. 사실 등급 비격상.
|
||
> - **PRE-DECISION (경계 확정, 코드로 부분 강제됨)**: domain event 는 `..domain..` 의 immutable record 로 두고 integration event 변환은 application/adapter 경계의 mapper 책임. 이 경계는 *추측이 아니라 부분적으로 코드로 강제된다* — `domain_is_pure` 가 `..domain..` → `..adapter..` import 를 금지(`CleanArchitectureTest.java:47`)하므로, domain event 가 adapter 의 integration-event/transport 타입을 참조하면 자동 위반(`actually-implemented`). 단, Kafka/HTTP 클라이언트 SDK 패키지(`org.apache.kafka..` 등)는 현재 forbidden list 에 없으므로 *그 한 가지*는 본 branch 의 `domain_has_no_logger` 와 같은 추가 rule 또는 코드리뷰로 보완 (`planned`).
|
||
|
||
| 강제 대상 | 메커니즘(제안) | 상태 |
|
||
|---|---|---|
|
||
| domain event = immutable record, transport(Kafka/HTTP/Slack) 필드 부재 | `@DomainEvent` record + ArchUnit forbidden import — 금지 패키지: `org.apache.kafka..`(Kafka SDK), `org.springframework.http..`/`jakarta.ws.rs..`(HTTP), 슬랙 등 outbound client SDK. **UNSUPPORTED_IMPL_DECISION**: broker/transport 추가 시 목록 갱신 필요(현재 ca-tmpl 미사용 SDK 는 미열거) | `planned` |
|
||
| integration event 변환은 application/infrastructure 경계 | application mapper: `WorkLogReserved`(domain) → `WorkLogReservedIntegrationEvent`(application) → publish(infra) | `planned` |
|
||
|
||
## 엣지·실패·의존
|
||
|
||
> R4(깊이 게이트) 캡처용. 본 branch 의 modeling guardrail 이 구현 중 부딪힐 실패/엣지/계약 의존.
|
||
|
||
- **실패·엣지 경로**:
|
||
- **ORM 재구성이 invariant 를 우회** — package-private/no-arg constructor 를 ORM(Hibernate) 이 reflection 으로 호출해 객체를 만들 때 constructor invariant 가 *호출되지 않을 수 있음*. 기대 동작: ORM 재구성은 *이미 valid 한 영속 상태*에서만 일어난다는 전제 + 매핑은 domain 밖 mapper 책임(Vernon Option A). VO no-arg constructor 금지 rule 과 ORM 요구의 충돌은 "ORM 외부 매핑"으로 회피.
|
||
- **Kotlin `data class` `copy()` 우회** — copy() 가 constructor invariant 를 호출하지 않으면 invalid VO 생성 가능. 기대 동작: D7 Open Risk 로 이미 기록 — JVM 언어별 검증 필요.
|
||
- **safe reason enum 의 정보 노출** — security-sensitive invariant 위반 사유를 enum 으로 노출하면 client 에 단서 제공 가능. 기대 동작(Decisionized Work Items): security-sensitive case 는 reason 제공 안 함, application 이 일반화된 error.category 로만 번역.
|
||
- **다른 계약 의존**:
|
||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] 의 `domain_is_pure` (D3) 에 의존 — domain framework-neutrality 의 정적 강제 owner. 이 rule 의 forbidden list/package 패턴이 바뀌면 본 branch 의 D1 전제가 흔들린다.
|
||
- operational error code SSOT = `feature-operational-error-observability-foundation` + `docs/registries/error-codes.yaml`. safe reason enum → error.category 번역은 그 계약을 consume (domain 은 operational code 를 직접 알지 않음 = D2).
|
||
- persistence 매핑(Vernon Option A) → `feature-boundary-validation-mapping-contract` / persistence adapter 의 mapper 계약에 의존.
|
||
|
||
## 검증해야 할 주장
|
||
|
||
> 공식 문서나 사례는 근거지만, 내 프로젝트의 실 동작을 자동으로 보장하지 않음.
|
||
|
||
| Claim | Why uncertain | How to verify | Status |
|
||
|---|---|---|---|
|
||
| `..domain.vo..` package 의 모든 record/class 가 public no-arg constructor 없이 invariant 강제 가능 | VERNON-AGG-C2 paraphrased — VO 의 constructor invariant 가 모든 valid input 에서 작동하는지 property-based test 필요 | sample feature 의 VO 1개에 jqwik property-based test 적용 → null/empty/invalid input × N 종 자동 생성 → exception 확인 | `planned` |
|
||
| `@AggregateRoot` annotated class 의 모든 `set*` method 가 package-private/protected 이며 invariant 호출 포함 | VERNON-AGG-C6 paraphrased — ORM-friendly constructor 가시성의 verbatim 미확보 | ArchUnit `methodsThat().haveName("set.*").andAreDeclaredInClassesThat().areAnnotatedWith(@AggregateRoot.class).should().notBePublic()` 작성 + 위반 케이스 테스트 | `planned` |
|
||
| domain class 가 Logger import 시 ArchUnit 이 실패시킨다 | D3 UNSUPPORTED — logger ban 의 공식 출처 부재 | ArchUnit forbidden import test 작성 (slf4j, logback, log4j 모두 포함) → sample domain 에 임시 logger 추가 시 실패 케이스 capture | `planned` |
|
||
| Vernon Option A (domain ↔ JpaEntity 외부 매핑) 가 Option B (domain 에 JPA annotation) 보다 ca-tmpl 의 forbidden import 규칙과 더 정합 | VERNON-AGG-C6 paraphrased + 우아한형제들 WOOWA-HEX-C2 (Option A) + Option B reference 부재 | sample-portfolio 에 WorkLog(domain) ↔ WorkLogJpaEntity(infrastructure) 분리 PoC + MapStruct 매핑 → ArchUnit forbidden import test 통과 확인 | `needs-confirmation` |
|
||
| domain event 가 transport-free 로 정의되어도 application/infrastructure 경계에서 integration event 변환 가능 | D4 paraphrased only — Vernon eventual consistency / Greg Young event immutability 만 근거, transport mapping 패턴 직접 출처 부재 | sample feature 에 `WorkLogReserved` (domain event) → `WorkLogReservedIntegrationEvent` (application mapper) → Kafka publish (infrastructure) 흐름 PoC | `planned` |
|
||
| 한국 백엔드 현장에서 Spring 기본 튜토리얼이 anemic default 라는 메모가 ca-tmpl 강제 결정의 정당화에 충분 | FOWLER-ANEMIC-C2 의 일반 명제만 있고 "한국 현장 관찰" 의 별도 출처 없음 (메모) | 별도 raw 자료 (Inflearn / 김영한 강의 / 우아한형제들 hands-on) 의 default 패턴 추출 후 ingest | `planned` |
|
||
| Greg Young / Vernon 의 paraphrased claim 들이 PDF / IDDD 원전과 일치 | GY-CQRS-C1~C4, VERNON-AGG-C2~C6 모두 `needs-confirmation` | (a) Greg Young CQRS PDF 재페치 시도 (대안: archive.org / Fowler bliki cross-check) (b) IDDD Ch.10 도서 인용 페이지/문단 명시 추가 | `needs-confirmation` |
|
||
|
||
## 테스트 계약
|
||
|
||
- domain package가 Spring/JPA/HTTP/security/logging package를 import하면 실패.
|
||
- VO invalid state 검사: 모든 `@ValueObject` annotation이 붙은 class 또는 `features.*.domain.vo.` package의 record/class는 다음을 만족: (a) public no-arg constructor 없음 (b) 모든 constructor에서 invariant violation 시 `IllegalArgumentException` 또는 domain exception throw. 측정 방법: ArchUnit `classes().that().areAnnotatedWith(@ValueObject.class).or().resideInAPackage("..domain.vo..").should().notHaveAccessibleNoArgConstructor()` + property-based test on each VO with null/empty/invalid input → exception expected.
|
||
- aggregate mutation 검사: `@AggregateRoot` annotation이 붙은 class의 모든 mutator method (`set*` prefix 또는 state-changing method)는 (a) public이 아닌 package-private 또는 protected이고 (b) invariant 검증 로직 포함. 측정 방법: ArchUnit `methodsThat().haveName("set.*").andAreDeclaredInClassesThat().areAnnotatedWith(@AggregateRoot.class).should().notBePublic()`. setter가 public이거나 invariant 호출 없이 state 변경 시 fail.
|
||
- domain package가 Logger 또는 operational error code를 직접 알면 실패. (rule: `domain_has_no_logger`, D3 — owner = 본 branch. §구현 가이드 §2 참조. `domain_is_pure` 와 별개 rule)
|
||
|
||
## 근거 (필수, 최소 1개+)
|
||
|
||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||
|
||
| Source | 정당화하는 결정 |
|
||
|---|---|
|
||
| [[raw/official-docs/domain-vaughn-vernon-aggregate-root]] | Vernon "Effective Aggregate Design" 4 rules + small aggregate + ORM-friendly constructor (ca-tmpl Option A 채택 |
|
||
| [[raw/official-docs/domain-fowler-anemic-vs-rich-model]] | Fowler "Anemic Domain Model" anti-pattern (rich model 강제의 reference |
|
||
| [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]] | 우아한형제들 초기 글 사례; ca-tmpl forbidden import 규칙 위배라 거부 |
|
||
| [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]] | Greg Young; ca-tmpl 미채택, "transport-free fact" 정의만 차용 |
|
||
| [[raw/official-docs/cqrs-fowler-bliki]] | CQRS command/query 모델 분리 정의 + Fowler 의 "very cautious" 보수적 권고 (Fowler martinfowler.com bliki — `engineering-blog` 등급, `official-standard` 아님). ca-tmpl 의 command/query use case 분리 (Out of scope: read/write 데이터 모델 분리) 의 대비 reference. ca-tmpl 은 CQRS-FOWLER-C3 (개념 모델 분리) 만 차용, CQRS-FOWLER-C5/C6 (cautious + complexity) 에 따라 read/write 저장소 분리는 미채택 |
|
||
|
||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-J: Domain Modeling Guardrails)
|
||
|
||
본 branch의 VO with private constructor + aggregate root mutator package-private/protected + domain logger ban + safe reason enum + invariant in constructor + ORM 외부 매핑 결정에 대한 외부 source.
|
||
|
||
- **채택 결정 (Rich domain model + Vernon Aggregate Root Option A: ORM 외부 매핑)**:
|
||
- [[raw/official-docs/domain-vaughn-vernon-aggregate-root]] — Vernon "Effective Aggregate Design" 4 rules + small aggregate + ORM-friendly constructor (ca-tmpl Option A 채택)
|
||
- [[raw/official-docs/domain-fowler-anemic-vs-rich-model]] — Fowler "Anemic Domain Model" anti-pattern (rich model 강제의 reference)
|
||
- **검토한 대안**:
|
||
- **대안 1: Anemic domain model** — `domain-fowler-anemic-vs-rich-model` 동일 source에서 anti-pattern으로 정의 (ca-tmpl 거부)
|
||
- **대안 2: Vernon Option B (JPA direct annotation in domain)** — [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]] (우아한형제들 초기 글 사례; ca-tmpl forbidden import 규칙 위배라 거부)
|
||
- **대안 3: Event sourcing 전환 (domain events as state)** — [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]] (Greg Young; ca-tmpl 미채택, "transport-free fact" 정의만 차용)
|
||
- **대안 4: CQRS with separate read/write models** — 동일 Greg Young source (ca-tmpl 미채택, read 분리 없이 단일 model 유지)
|
||
- **대안 5: Functional domain modeling (Scala/F#)** — JVM이지만 패러다임 차이 + 팀 학습 비용 큼
|
||
- **비교 핵심**: ca-tmpl rich model은 Fowler/Vernon reference standard 정합. ORM 외부 매핑(Vernon Option A)이 forbidden import 규칙(domain logger/JPA ban)과 정합 — 우아한형제들 Option B는 same regulation 위배라 거부. Event sourcing/CQRS는 모델 자체 교체로 scope 다름, ca-tmpl은 "transport-free fact" 정의만 차용.
|
||
|
||
## 완료 후 wiki 추출 대상
|
||
|
||
- `wiki/projects/ca-skeleton-operational-contract.md`의 domain modeling canonical section.
|
||
|
||
## Audit & Findings
|
||
|
||
> 2026-06-05 `/branch-spec` ground-truth 대조 (ca-tmpl `src/` + `CleanArchitectureTest.java`) 에서 발견한 정합/drift. 사용자 작성 결정 영역은 자동 rewrite 하지 않고 *정합 권고만* 기록.
|
||
|
||
| ID | 유형 | 발견 | 권고 |
|
||
|---|---|---|---|
|
||
| F1 | OWNERSHIP | D1(domain framework-neutral) 의 정적 강제 `domain_is_pure` 는 본 branch 가 아니라 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 가 owner (`CleanArchitectureTest.java:36-57` `.as(...)` 주석 명시) | D1 은 본 branch 가 *복제/재정의하지 않고 위임*. §Coverage 에 `delegated` 로 표기 (완료) |
|
||
| F2 | GAP (logger ban 미강제) | D3(domain logger ban) — `domain_is_pure` forbidden list 에 logging framework 미포함 (`org.slf4j`·`java.util.logging`·`logback`·`log4j` 부재; test 전체 grep 상 logger ban rule 없음) | logger ban 은 현재 `planned`, 코드 미강제. C2 에서 별도 rule 또는 forbidden list 확장 필요 (§구현 가이드 2). "구현됐다" 로 말하면 안 됨 |
|
||
| F3 | NOT-IMPLEMENTED | `@ValueObject`·`@AggregateRoot`·`@DomainEvent` annotation 모두 `src/` grep 미존재. domain-core 모듈은 `identifier/ResourceId`·`IdFactory` 만 보유 | D5/D6/D7/D8 의 annotation-기반 ArchUnit rule 은 전부 `planned`. Claims To Verify 의 `planned` 표기와 일치 (정합 OK) |
|
||
| F4 | SCOPE 확인 | D2(domain exception 이 operational error code 를 직접 모름) 의 SSOT 는 `feature-operational-error-observability-foundation` + `error-codes.yaml` | safe reason enum → error.category 번역은 그 계약 consume. 본 branch 는 *domain 측 금지*만 소유, code enum 신설은 범위 밖 |
|
||
|
||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||
|
||
> `/coverage` 가 채우는 **생성물** — 손유지 금지. 기준: `rules/coverage-gate.md`. governing_docs: `privacy-file-domain-modeling` (§"Domain Modeling") + `clean-architecture-package-layout` (domain purity).
|
||
> 마지막 감사: 2026-06-05 `/branch-spec` 인라인 (정식 `coverage-auditor` 판정은 §8b 에서).
|
||
|
||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||
|--------|------|-------|--------|------|
|
||
| VO private constructor + factory, invariant in constructor | covered-here | — | — | D5·D6 (§구현 가이드 3, `planned`) |
|
||
| aggregate root mutator non-public (package-private/protected) | covered-here | — | — | D7 (§구현 가이드 4, `planned`) |
|
||
| domain layer logger ban | covered-here | — | 🟡 (F2 GAP) | D3 (`planned`, 코드 미강제 — §구현 가이드 2) |
|
||
| safe reason enum (거부 사유 noun enum, application 이 로그 번역) | covered-here | — | — | D3·D2 |
|
||
| Vernon Option A (ORM 외부 매핑) 채택, Option B 거절 | covered-here | — | — | D5·D7 |
|
||
| domain event = transport-free fact, integration mapping 은 경계 | covered-here | — | — | D4·D8 |
|
||
| domain framework-neutral (no Spring/JPA/Hibernate) 정적 강제 | delegated | [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 | — | owner `actually-implemented` (`domain_is_pure`, `CleanArchitectureTest.java:36`) |
|
||
| controller 가 domain/entity 타입 직접 반환 금지 | delegated | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] D8 | — | owner `actually-implemented` (`controllers_do_not_return_domain_or_entity_types`) |
|
||
|
||
## 마주친 문제
|
||
|
||
- 아직 없음(문서 단계).
|
||
|
||
## 묶음
|
||
|
||
<!-- GENERATED: sources:start -->
|
||
- [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]]
|
||
- [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]]
|
||
- [[raw/official-docs/cqrs-fowler-bliki]]
|
||
- [[raw/official-docs/domain-fowler-anemic-vs-rich-model]]
|
||
- [[raw/official-docs/domain-vaughn-vernon-aggregate-root]]
|
||
- [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]]
|
||
<!-- GENERATED: sources:end -->
|
||
|
||
<!-- GENERATED: interviews:start -->
|
||
- [[raw/interviews/domain-modeling-guardrails-archunit-2026-06-05]]
|
||
<!-- GENERATED: interviews:end -->
|
||
|
||
<!-- GENERATED: blog-topics:start -->
|
||
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]]
|
||
<!-- GENERATED: blog-topics:end -->
|
||
|
||
> 2026-06-05 Phase C2 구현으로 파생 자료 누적. 아래 derived note 들과 양방향 link 유지.
|
||
|
||
### 오류 기록 (본 feature 작업 중 발생)
|
||
|
||
- [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] — 2026-06-05 addendum: `@DomainEvent` record component 로 `testCompileOnly` 타입을 두면 JUnit discovery 가 죽음. method body `.class` 참조 + subpackage `importPackages` 로 회피 (4번째 패턴).
|
||
|
||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||
|
||
- [[raw/interviews/domain-modeling-guardrails-archunit-2026-06-05]] — stereotype 마커 + ArchUnit fitness function, owner 경계, logger ban 정직성, jqwik 불변식 검증, transport-free 이벤트.
|
||
|
||
### Blog topics (구현·트러블슈팅 글감)
|
||
|
||
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]] — DDD 전술 패턴을 빌드 깨짐으로 강제하기.
|
||
|
||
## 관련 일일 노트
|
||
|
||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||
|
||
- (Phase E 외부 근거 / 대안 조사 단계 — daily note 미연결. C2 구현 진입 시 작업일 추가)
|
||
|
||
## 완료 후 정리
|
||
|
||
> 머지/종료 시점에 채움.
|
||
|
||
- PR 링크:
|
||
- 리뷰 메모:
|
||
- 머지 결과 / 배포 환경:
|
||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||
- `actually-implemented` 항목:
|
||
- `locally-verified` 항목:
|
||
- `prod-verified` 항목:
|
||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|