Files
llm-wiki/raw/branch-notes/feature-persistence-auditing-contract.md
T

383 lines
39 KiB
Markdown

---
title: branch / feature-persistence-auditing-contract
source_type: branch-note
status: raw
branch: feature-persistence-auditing-contract
parent_branch:
related_projects: [ca-skeleton-operational-contract]
governing_docs: [raw/project-notes/ca-skeleton-operational-contract.md]
tags: [branch, persistence, auditing]
created: 2026-06-10
target_merge:
status_label: in-progress
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-055
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-055
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1]
refines: []
overrides: []
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-056, WI-CA-SKELETON-OPERATIONAL-CONTRACT-048, WI-CA-SKELETON-OPERATIONAL-CONTRACT-006, WI-CA-SKELETON-OPERATIONAL-CONTRACT-012, WI-CA-SKELETON-OPERATIONAL-CONTRACT-017]
contract_packet: 1
contract_packet_sha256: 82c57510c05700f3204c4b6da2ad9707172b3d695d0ced764b7f38c3c5d97099
---
# branch: feature-persistence-auditing-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 -->
## 부모 (필수)
> 이 branch 가 어느 작업 묶음에 속하는지. 모든 branch 는 예외 없이 upward link 보유.
이 branch 는 ca-skeleton 운영 계약 project 의 **직접 자식 branch** (project 분해표 §35 E영역 priority 8 row). `parent_branch:` 비어있음.
> 사용자가 "부모 브랜치 = CA Skeleton Operational Contract" 라고 표현했으나, `CA Skeleton Operational Contract` 는 *branch* 가 아니라 **project hub** 이다. 따라서 이 branch 는 *다른 branch 의 자식* 이 아니라 *project 의 직접 자식* 으로 모델링한다(`parent_branch:` 공란 + `related_projects` = project).
- **Project 의 직접 자식 branch**: [[raw/project-notes/ca-skeleton-operational-contract]] (§35 E영역 priority 8: `feature-persistence-auditing-contract` — "entity audit 컬럼 (CreatedBy/UpdatedBy) 도입 시점 / 도메인 오염 차단 메커니즘(`AuditPort` + adapter 가로채기)")
본 branch 가 *결정을 위임/소비* 하는 형제 branch (Edge·Dependency 참조):
- [[raw/branch-notes/feature-runtime-context-propagation-contract]] — `created_by`/`updated_by` 액터 ID 를 공급하는 runtime context seam (그 branch D1 port + D5 위임 맵). 본 branch 는 그 seam 의 *consumer*.
- [[raw/branch-notes/feature-persistence-failure-baseline]] — optimistic lock / conflict 분류 owner. 본 branch 의 `version` 컬럼은 그 branch 로 위임(OUT_OF_BRANCH_SCOPE).
- [[raw/branch-notes/feature-migration-startup-contract]] — audit 컬럼의 Flyway migration 이 그 startup 게이트를 통과해야 함.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: persistence audit actor·time·mapping·transaction contract 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-MAPPING-001@1` | 수기 mapper와 record canonical constructor가 default이며 MapStruct는 optional profile이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1` | database는 PostgreSQL 16 단일 stack이다 | 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 -->
## 목표
도메인 aggregate JPA 영속화 시 **누가 / 언제 만들고 고쳤는지**(`created_at` / `updated_at` / `created_by` / `updated_by`)를 일관되게 기록하되, 이 감사 메타데이터가 **domain-core aggregate 를 오염시키지 않도록** adapter-persistence 계층에만 가두는 *계약*을 정한다.
핵심 긴장: Clean Architecture 에서 audit 메타데이터는 *인프라 관심사*다. 도메인 엔티티가 `createdBy` 필드를 들고 있으면 (1) 도메인이 "누가 로그인했나"라는 보안/요청 컨텍스트를 알게 되어 의존 방향이 뒤집히고, (2) JPA/Spring 어노테이션이 domain-core 로 새어 들어온다. 본 branch 는 audit 을 *adapter 의 책임*으로 못박는 경계를 설계한다.
- 이슈: (미생성 — project §35 E영역 priority 8 신규 branch 권고)
- PR: (미생성 — 코드 착수 전 결정 계약 단계)
- governing: [[raw/project-notes/ca-skeleton-operational-contract]] §35 L2086 / L2030
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- audit 컬럼 집합 결정: `created_at` / `updated_at` / `created_by` / `updated_by` (D3)
- 도메인 오염 차단 메커니즘: audit 필드를 adapter-persistence 의 `@MappedSuperclass`(또는 adapter 명시 set)에만 두고 domain-core 는 0 필드 (D2)
- 캡처 메커니즘 선택 + wiring: Manual explicit-set(현 스켈레톤 선례) vs Spring Data JPA Auditing(엔티티 증가 시 성장 경로) (D1)
- 시간 소스: 기존 `Clock` bean 재사용 — Manual=adapter 주입, JPA-auditing=`DateTimeProvider` 가 Clock wrapping (D4)
- 액터 ID seam: `AuditorAware`/`AuditContextPort` 가 runtime-context-propagation seam consume + `"system"` fallback (D5)
- 적용 범위: 도메인 aggregate persistence entity 만, infra/immutable record 제외 (D6)
### 제외 범위
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
- **`version` / optimistic-lock 컬럼** — [[raw/branch-notes/feature-persistence-failure-baseline]] + [[raw/branch-notes/feature-transaction-concurrency-contract]] 가 owner (conflict 분류). audit 컬럼과 동거하나 본 branch 결정 아님.
- **전체 변경 이력 / revision history (Hibernate Envers `*_AUD` 테이블)** — 본 branch 는 "현재 행의 audit 메타 4필드"만. 시점별 스냅샷/삭제 이력은 별도(data-retention / 미래 Envers branch).
- **audit *log*(보안 이벤트 로그: actor/action/target/before_hash)** — [[raw/branch-notes/feature-log-management-contract]] + `feature-data-retention-privacy-contract` owner. 본 branch 는 *DB 행 메타데이터*이지 *구조화 로그*가 아님. (registry `mdc-keys.yaml` audit 키는 그 branch 소유)
- **`Instant.now()` 직접호출 차단 ArchUnit rule + `Clock` port 추상화** — project §35 F영역 "Time/Clock 주입" 미래 branch. 본 branch 는 *기존 Clock bean 재사용*까지만.
- **principal 값 의미론(보안 주체가 산출하는 문자열 형식/소스)** — [[raw/branch-notes/feature-authentication-authorization-contract]] owner. 본 branch 는 seam 타입(`AuditorAware<String>`)과 fallback 만 결정.
- **`IdempotencyRecordEntity` 등 infra/immutable 엔티티** — 자체 `created_at` 관리(immutable, updated_at 없음). audit base 미적용 (D6).
## 근거 (필수, 최소 1개+)
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**. 공식 문서·대기업 기술 블로그·강의 등 raw 자료를 인용. 같은 자료가 여러 결정의 근거면 결정 표시와 함께 여러 번 등장 가능.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/spring-data-jpa-auditing-official]] | D1/D2: `@CreatedDate`/`@LastModifiedDate`/`@CreatedBy`/`@LastModifiedBy` + `@EntityListeners(AuditingEntityListener.class)``@MappedSuperclass` 에 선언하는 공식 패턴 (C1, C2, C4). D5: `AuditorAware<T>` SPI 구현 의무 (C3). `@EnableJpaAuditing` 활성화 (C5). |
| [[raw/company-tech-blogs/vlad-mihalcea-postgresql-audit-logging-triggers]] | DB-level trigger auditing (standalone) 대안 기각: 트리거가 액터 ID 를 읽으려면 앱이 매 DML 전 `SET LOCAL var.logged_user` 로 세션 변수를 주입해야 하는 propagation seam 이 강제되고, DB 타임소스가 앱 Clock bean 과 분리된다 |
| [[raw/company-tech-blogs/thorben-janssen-hibernate-timestamp-clock-limitation]] | Hibernate-native `@CreationTimestamp`/`@UpdateTimestamp` 대안 거부: Clock 주입 불가(JVM 시간 직접 사용, C1) + `created_by`/`updated_by` 미지원(C2) |
근거 자료가 raw에 아직 없다면 먼저 `raw-source-template` 또는 `lecture-note-template` 으로 raw에 등록한 뒤 여기서 링크.
## TODO
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
- [x] `AuditableEntity` `@MappedSuperclass` 를 adapter-persistence 에 정의 (created_at/updated_at/created_by/updated_by) — 등급: `locally-verified` (`adapter-persistence/.../audit/AuditableEntity.java` + `AuditableEntityTest`)
- [x] 도메인 aggregate persistence entity 가 `AuditableEntity` 상속 (sample-portfolio `WorkLogEntity` 부터) — 등급: `locally-verified` (`WorkLogEntity extends AuditableEntity`, `:sample-portfolio:test` green)
- [x] 캡처 wiring: (현 스켈레톤) adapter explicit-set 패턴 구현 (D1 Manual = current default) — 등급: `locally-verified` (`WorkLogRepositoryAdapter` Clock+AuditContextPort, INSERT/UPDATE 분기 + `WorkLogRepositoryAdapterTest`). 성장 경로 `@EnableJpaAuditing`+`DateTimeProvider` 는 D1 deferred — 코드 javadoc + adapter-persistence CLAUDE.md 에 문서화, 미배선 — 등급: `documented-only`
- [x] `AuditContextPort` + `"system"` fallback 구현 (runtime-context-propagation seam consume) — 등급: `locally-verified` (`DomainContextAuditContextPort` + `DomainContextAuditContextPortTest`: 부재/blank → `"system"`, bound → actor). JPA-auditing path 의 `AuditorAware<String>` 는 deferred (D1 growth path).
- [x] audit 컬럼 Flyway migration (migration-startup 게이트 통과) — 등급: `documented-only` (`sample-portfolio/.../db/migration/V2__work_log.sql`, work_log + audit 4컬럼, V1 이후 in-order). 이 repo 에는 Flyway 를 부팅하는 테스트가 없어(@WebMvcTest 슬라이스 + custom test app) 런타임 실행 미검증.
- [x] domain-core 가 audit 필드/`jakarta.persistence` 를 모름을 ArchUnit rule 로 강제 — 등급: `locally-verified` (기존 `domain_is_pure``jakarta.persistence..`/`org.springframework..` 차단 + 신규 `domain_entities_do_not_carry_audit_fields` 가 createdAt/updatedAt/createdBy/updatedBy 필드 차단, `:app-bootstrap:test --tests '*CleanArchitectureTest'` green)
- [x] 결정 계약 + 근거 자료 5건 archive (이 branch-spec) — 등급: `actually-implemented`
## 진행 중 메모
- ground truth(`/home/donghyeon/workspace/ca-tmpl`): 현재 audit 어노테이션·`@MappedSuperclass`·`AuditorAware` **전무**. 유일한 시간 캡처 선례는 `IdempotencyStoreAdapter` 가 생성자에서 `clock.instant()` 를 명시 set 하는 패턴(= Manual 방식) + `IdempotencyConfig.systemClock()` (`Clock.systemUTC()`) bean. → D1 Manual path 는 *지어낸 것이 아니라 이미 확립된 패턴의 일반화*.
- `IdempotencyRecordEntity` 는 immutable(Vernon Option A 재구성) + `updated_at` 없음 → audit base 적용 대상 아님(D6).
- registry `mdc-keys.yaml``audit` 키(actor/action/target)는 *로그* 계약이지 *DB 컬럼* 아님 — log-management/data-retention 소유. 혼동 주의(Out of scope).
- 2026-06-10 구현 완료 (Manual path, D1 current default). 변경 파일:
- `adapter-persistence/.../audit/AuditableEntity.java` (`@MappedSuperclass`, plain `@Column` 4필드, `initializeAudit`/`carryCreation`/`applyModification`)
- `adapter-persistence/.../audit/AuditContextPort.java` (interface `currentActor()`)
- `adapter-persistence/.../audit/DomainContextAuditContextPort.java` (`@Component`, `DomainContextPropagator` 소비 + `"system"` fallback)
- `sample-portfolio/.../entity/WorkLogEntity.java` (`extends AuditableEntity`)
- `sample-portfolio/.../repository/WorkLogRepositoryAdapter.java` (`Clock` + `AuditContextPort` 주입, INSERT/UPDATE 분기 audit set)
- `sample-portfolio/.../db/migration/V2__work_log.sql` (work_log + audit 4컬럼)
- `app-bootstrap/.../architecture/CleanArchitectureTest.java` (`domain_entities_do_not_carry_audit_fields` 신규 rule)
- `adapter-persistence/CLAUDE.md` (Persistence auditing contract 섹션 추가)
- 테스트: `AuditableEntityTest`, `DomainContextAuditContextPortTest`, `WorkLogRepositoryAdapterTest`(audit 케이스 추가)
- 검증: `:adapter-persistence:test`, `:sample-portfolio:test`, `:app-bootstrap:test --tests '*CleanArchitectureTest'`, `verifyCleanArchitectureDependencies`, `./gradlew check` 모두 green.
- UPDATE 시 `created_*` 보존: adapter 가 `jpa.findById`(같은 tx → JPA L1 캐시 hit) 로 기존 행을 읽어 carry. `created_*``updatable=false` 로 SQL 레벨에서도 이중 보호.
- 구현자 임의 결정(spec UNSUPPORTED_IMPL_DECISION 충당): actor 키 이름 = `DomainContextKey.of("actor", String.class)` (runtime-context branch 가 canonical 키 확정 시 `DomainContextAuditContextPort` 한 곳만 수정).
## 결정 사항
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록. 상세 매핑은 아래 §Decision Evidence Map.
- 2026-06-10 (D1): **감사 캡처 메커니즘 = 조건부 — 현 스켈레톤은 adapter explicit-set(Manual), 엔티티 증가 시 Spring Data JPA Auditing(`@MappedSuperclass`+`@EnableJpaAuditing`).** 이유: Manual 은 기존 `IdempotencyStoreAdapter` 패턴과 일관 + `Clock` bean 직접 재사용 + 명시성. JPA-auditing 은 엔티티 多 시 선언적 누락 방지. / 검토한 대안: Hibernate `@CreationTimestamp`(Clock 주입 불가로 기각), DB trigger 단독(actor seam 복잡 + clock 분리로 기각). / 근거: [[raw/official-docs/spring-data-jpa-auditing-official]], [[raw/company-tech-blogs/thorben-janssen-hibernate-timestamp-clock-limitation]], [[raw/company-tech-blogs/vlad-mihalcea-postgresql-audit-logging-triggers]], 선례 `IdempotencyStoreAdapter`.
- 2026-06-10 (D2): **audit 필드는 adapter-persistence `@MappedSuperclass`(또는 adapter 명시 set)에만 — domain-core aggregate 0 필드.** 이유: audit = 인프라 관심사, 도메인이 알면 의존 역전 + 어노테이션 누출. / 대안: 도메인 엔티티에 audit 필드(= 오염, 기각). / 근거: [[raw/company-tech-blogs/arhohuttunen-hexagonal-architecture-spring-boot]], [[raw/official-docs/spring-data-jpa-auditing-official]](embedded/superclass), governing §35.
- 2026-06-10 (D3): **audit 컬럼 = `created_at`/`updated_at`/`created_by`/`updated_by` 4개. `version`(optimistic-lock)은 위임.** 근거: governing §35 L2086(CreatedBy/UpdatedBy), `spring-data-jpa-auditing-official`#C1.
- 2026-06-10 (D4): **시간 소스 = 기존 `Clock` bean 재사용.** Manual=adapter 주입, JPA-auditing=`DateTimeProvider` bean 이 Clock wrapping 후 `@EnableJpaAuditing(dateTimeProviderRef=...)`. 근거: `spring-data-jpa-enable-jpa-auditing-api`#C2, 선례 `IdempotencyConfig.systemClock`.
- 2026-06-10 (D5): **액터 ID = `AuditorAware`/`AuditContextPort` 가 runtime-context-propagation seam consume + `"system"` fallback.** principal *값 의미*는 authn-authz 위임(UNSUPPORTED). 근거: `spring-data-jpa-auditing-official`#C3, cross-contract [[raw/branch-notes/feature-runtime-context-propagation-contract]] D1/D5.
- 2026-06-10 (D6): **적용 범위 = 도메인 aggregate persistence entity 만. infra/immutable(`IdempotencyRecordEntity`) 제외.** 근거: repo ground-truth(immutable record + updated_at 부재).
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다. 예: `D1`, `D2`.
> `Supporting Claims` 는 `raw/<category>/<slug>.md#C1` 형식으로 연결한다.
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | 감사 캡처 메커니즘: 현 스켈레톤 = **adapter explicit-set(Manual)**, 엔티티 증가 시 = **Spring Data JPA Auditing**(`@MappedSuperclass`+`@EnableJpaAuditing`+`AuditingEntityListener`) | 엔티티 수 적고 명시성 우선 → Manual(선례 일관). 엔티티 증가/선언적 누락방지 필요 → JPA Auditing 으로 마이그레이션. **Hibernate `@CreationTimestamp`** = Clock 주입 불가로 기각, **DB trigger 단독** = actor seam 복잡+clock 분리로 기각 | `raw/official-docs/spring-data-jpa-auditing-official.md#C1`, `#C4`, `#C5`, `raw/company-tech-blogs/thorben-janssen-hibernate-timestamp-clock-limitation.md#C1`, `#C2`, `raw/company-tech-blogs/vlad-mihalcea-postgresql-audit-logging-triggers.md#C1`, `#C3` + 선례 `IdempotencyStoreAdapter` + governing §35 | `official-vendor-doc + company-tech-blog + repo-precedent + governing` | Manual path 의 set 누락(선언적 보장 없음); Manual→JPA-auditing 마이그레이션 *트리거 임계*(엔티티 N개) 미정 |
| D2 | audit 필드를 adapter-persistence 의 `@MappedSuperclass`(또는 adapter 명시 set)에만 — domain-core aggregate 0 필드 | 항상 (핵심 mandate, 분기 N/A) | `raw/company-tech-blogs/arhohuttunen-hexagonal-architecture-spring-boot.md#C2`, `raw/official-docs/spring-data-jpa-auditing-official.md#C2` + governing §35(도메인 오염 차단) + `ca-tmpl/src/adapter-persistence/CLAUDE.md`(CA layer rule) | `governing + official-vendor-doc + company-case-study` | domain↔entity 매핑 비용(arhohuttunen "cost of having to do mapping") |
| D3 | audit 컬럼 = `created_at`/`updated_at`/`created_by`/`updated_by` 4개. `version`은 제외 | 항상. optimistic-lock/conflict 필요 → `feature-persistence-failure-baseline`/`feature-transaction-concurrency-contract` 위임(OUT_OF_BRANCH_SCOPE) | governing §35 L2086(CreatedBy/UpdatedBy), `raw/official-docs/spring-data-jpa-auditing-official.md#C1` | `governing + official-vendor-doc` | `updated_at` INSERT 초기값(=`created_at`? `modifyOnCreate` 기본 true), 컬럼 타입(`timestamptz`) 미확정 |
| D4 | 시간 소스 = 기존 `Clock` bean 재사용. Manual=adapter 주입, JPA-auditing=`DateTimeProvider`(Clock wrapping)+`@EnableJpaAuditing(dateTimeProviderRef=...)` | 항상(`Instant.now()` 직접호출 금지). `Clock` port 추상화+차단 ArchUnit rule 은 F영역 future branch 위임(OUT_OF_BRANCH_SCOPE) | `raw/official-docs/spring-data-jpa-enable-jpa-auditing-api.md#C2` + 선례 `IdempotencyConfig.systemClock`/`IdempotencyStoreAdapter.clock.instant()` | `official-vendor-doc + repo-precedent` | JPA-auditing path 에서 `dateTimeProviderRef` 누락 시 `LocalDateTime.now()`(VM time) silent 회귀 |
| D5 | 액터 ID = `AuditorAware<String>`/`AuditContextPort` 가 runtime-context-propagation seam consume + `"system"` fallback | 도메인이 actor 추적 요구 시. principal 부재(scheduler/migration/anonymous) → `"system"`. **principal 값 의미론(보안 주체 문자열) = `UNSUPPORTED_DECISION`** (authn-authz 미착수) | `raw/official-docs/spring-data-jpa-auditing-official.md#C3` + cross-contract [[raw/branch-notes/feature-runtime-context-propagation-contract]] D1/D5 | `official-vendor-doc + cross-contract (sibling)` — principal 값은 `none (unsupported)` | authn-authz 미착수로 principal 타입/의미 미정; `"system"` fallback 자동 아님(구현체 명시 분기 필요); **runtime-context seam 자체도 미성숙**(그 branch `DomainContextKey` = `needs-confirmation`) → `AuditContextPort` 어댑터 1개로 격리하고 값 타입은 `String` 고정해 흡수 |
| D6 | 적용 범위 = 도메인 aggregate persistence entity 만. infra/immutable(`IdempotencyRecordEntity`) 제외 | 새 aggregate JPA entity → `AuditableEntity` 적용. infra/immutable record(자체 created_at, updated_at 부재) → 제외 | `ca-tmpl` ground-truth: `IdempotencyRecordEntity`(immutable, Vernon Option A), `V1__idempotency_record.sql`(updated_at 부재) | `repo-precedent` | "aggregate vs infra" 경계 판단 기준 모호 — 새 entity 추가 시 owner 가 분류 결정 필요 |
## 구현 가이드
> *결정 (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. `AuditableEntity` `@MappedSuperclass` + 컬럼 명세
> **Trace**: D2(도메인 오염 차단) + D3(컬럼 집합). Claims: `spring-data-jpa-auditing-official#C2`(metadata in superclass), `#C1`(4 annotations), `arhohuttunen#C2`(domain 분리).
>
> - **UNSUPPORTED_IMPL_DECISION**: 클래스명 `AuditableEntity`, 패키지 위치 `dev.caskeleton.adapter.persistence.audit`, 컬럼 SQL 타입(`timestamptz`/`varchar(256)`) 은 근거 raw 가 *원칙*만 권고 → 임의 trade-off: ca-tmpl 기존 컨벤션(`IdempotencyRecordEntity` 의 `timestamptz created_at`, `principal varchar(256)`)과 정합시켜 선택.
| 컬럼 | Java type | SQL type | nullable | listener/set 시점 |
|---|---|---|---|---|
| `created_at` | `Instant` | `timestamptz` | NOT NULL, updatable=false | INSERT (D4 Clock) |
| `updated_at` | `Instant` | `timestamptz` | NOT NULL | INSERT 시 = `created_at`, 매 UPDATE 갱신 (path별 보장 방식 ↓) |
| `created_by` | `String` | `varchar(256)` | NOT NULL, updatable=false | INSERT (D5 actor, fallback `"system"`) |
| `updated_by` | `String` | `varchar(256)` | NOT NULL | INSERT 시 = `created_by`, 매 UPDATE 갱신 (path별 ↓) |
> **`updated_*` INSERT 초기값 보장 — path별 분리** (depth audit #2): JPA-auditing path 는 `@EnableJpaAuditing` 의 `modifyOnCreate` 기본 `true`(C3 — `spring-data-jpa-enable-jpa-auditing-api#C4`)가 *자동으로* INSERT 시 `updated_*` 를 `created_*` 와 동일 set. **Manual path 에는 이 속성이 없으므로**, adapter 가 entity 생성 시 `updated_at=created_at`, `updated_by=created_by` 를 *명시 set* 해야 NOT NULL 충족 (D1 Manual + D4 도출 — `IdempotencyStoreAdapter` 의 생성자 명시 set 패턴 연장).
- `@MappedSuperclass` + `@EntityListeners(AuditingEntityListener.class)`(JPA-auditing path) 또는 어노테이션 없는 plain 필드 + adapter set(Manual path). 두 path 모두 클래스는 **adapter-persistence 모듈에만** 위치 → domain-core 는 이 클래스를 import 불가(D2).
### 2. 캡처 메커니즘 wiring (Manual vs JPA Auditing)
> **Trace**: D1(메커니즘) + D4(시간 소스). Claims: `spring-data-jpa-enable-jpa-auditing-api#C2`(dateTimeProviderRef), `spring-data-jpa-auditing-official#C5`(@EnableJpaAuditing), `thorben-janssen#C1`(Hibernate Clock 불가).
>
> - **UNSUPPORTED_IMPL_DECISION**: `@EnableJpaAuditing` 을 둘 config 클래스명/모듈(`app-bootstrap` 의 `JpaAuditingConfig` 권고 — `IdempotencyConfig` 선례 위치), `DateTimeProvider` bean 명(`auditingDateTimeProvider`) 은 임의 trade-off: 기존 `app-bootstrap` config 패턴과 정합.
- **Manual path (현 스켈레톤 default)**: persistence adapter 생성자에 `Clock` + `AuditContextPort` 주입 → entity 생성/재구성 시 `clock.instant()` + `auditContextPort.currentActor()` 를 명시 set. `IdempotencyStoreAdapter` 와 동일 패턴.
- **JPA Auditing path (성장 경로)**: `app-bootstrap``@EnableJpaAuditing(dateTimeProviderRef="auditingDateTimeProvider", auditorAwareRef="auditorAware")` + `DateTimeProvider` bean(`() -> Optional.of(clock.instant())`, 기존 `systemClock` 재사용). `dateTimeProviderRef` 누락 시 VM time 회귀(Open Risk D4) → §Claims 검증 대상.
### 3. 액터 ID seam (`AuditorAware`
> **Trace**: D5(액터 소스). Claims: `spring-data-jpa-auditing-official#C3`(AuditorAware SPI) + cross-contract [[raw/branch-notes/feature-runtime-context-propagation-contract]] D1/D5.
>
> - **UNSUPPORTED_IMPL_DECISION**: principal *값의 의미/형식*(user id? email? subject claim?)은 근거 없음 → `feature-authentication-authorization-contract` 착수 전까지 `AuditorAware<String>` 으로 타입만 고정하고 값 의미는 위임. seam 인터페이스명 `AuditContextPort` 는 임의(runtime-context 의 `DomainContextKey` 와 정합 검토).
- `AuditorAware<String>.getCurrentAuditor()` → runtime-context-propagation seam 에서 principal 조회. **부재 시 `Optional.of("system")`** 반환(scheduler/Flyway migration/anonymous). 자동 아님 — 구현체가 명시 분기.
- runtime-context-propagation branch 의 seam API 가 확정되기 전에는 `AuditContextPort.currentActor()` interface 1개로 추상화(그 branch D1 port 와 어댑터 연결).
### 4. 적용 범위 카탈로그
> **Trace**: D6(적용 범위). Claims: ca-tmpl ground-truth.
| Entity | audit base 적용? | 사유 |
|---|---|---|
| 도메인 aggregate persistence entity (예: `WorkLogEntity`) | ✅ 적용 | 도메인 변경 추적 대상 |
| `IdempotencyRecordEntity` | ❌ 제외 | immutable(Vernon Option A), 자체 `created_at`, `updated_at` 없음 |
| 신규 entity | 추가 시 owner 가 분류 | aggregate=적용 / infra·immutable=제외 (Open Risk D6) |
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거. 없으면 "해당 없음" 명시(공란 금지).
- **실패·엣지 경로**:
- *principal 부재* (scheduler / Flyway migration / anonymous / 시스템 작업): `AuditorAware``"system"` fallback set (D5). 자동 아님 — 구현체 명시.
- *bulk/native UPDATE* (`@Query` UPDATE, JDBC batch): JPA lifecycle listener 미발화 → `@LastModifiedDate`/`updated_by` 미갱신. 기대 동작: bulk path 는 audit 미보장임을 문서화 + 필요 시 명시 set.
- *`dateTimeProviderRef` 미연결* (JPA-auditing path 설정 누락): `LocalDateTime.now()`(VM time) silent 회귀 → 결정론 테스트 깨짐. 기대: 부팅 검증 또는 테스트로 fail-fast.
- *immutable record* (`IdempotencyRecordEntity`): audit base 미적용 — 자체 `created_at` 관리, `updated_at` 없음 (D6, 정상 경로).
- *INSERT 시 `updated_at`/`updated_by` 초기값*: `modifyOnCreate` 기본 true → created 값과 동일하게 채워짐(NOT NULL 충족).
- **다른 계약 의존**:
- [[raw/branch-notes/feature-runtime-context-propagation-contract]] 의 `D1`(context port) / `D5`(boundary→mechanism 위임 맵) 에 의존 — `created_by`/`updated_by` actor 를 그 seam 에서 consume. 그 port API 가 바뀌면 `AuditContextPort` 어댑터 수정 필요.
- [[raw/branch-notes/feature-authentication-authorization-contract]] 에 의존 — principal *값 의미*(무슨 문자열). 미착수 → D5 의 값 의미 `UNSUPPORTED`.
- [[raw/branch-notes/feature-persistence-failure-baseline]] / [[raw/branch-notes/feature-transaction-concurrency-contract]] 위임 — `version`/optimistic-lock 컬럼은 본 branch 밖. audit 컬럼과 같은 테이블에 공존하나 결정 주체 다름.
- [[raw/branch-notes/feature-migration-startup-contract]] 에 의존 — audit 컬럼 추가 Flyway migration 이 그 startup 게이트(baseline-on-migrate / out-of-order 방지)를 통과해야 함.
- project §35 F영역 "Time/Clock 주입" 미래 branch — `Instant.now()` 차단 ArchUnit rule + `Clock` port 추상화는 그쪽 소유. 본 branch 는 *기존 Clock bean 재사용*까지만.
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
> 구현 전/중/후에 실제로 검증해야 하는 주장을 분리한다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| JPA-auditing path 에서 `DateTimeProvider`(Clock wrapping)가 실제로 `@CreatedDate`/`@LastModifiedDate` 를 주입 Clock 으로 채운다 | `dateTimeProviderRef` 미연결 시 `LocalDateTime.now()`(VM) 로 silent 회귀 가능 (D4 Open Risk) | `Clock.fixed(...)` bean 으로 교체 → entity persist → `created_at` 이 고정 instant 와 일치하는 통합 테스트 | `deferred` — JPA-auditing 은 D1 growth path(미배선). Manual path 는 adapter 가 `clock.instant()` 를 직접 set 하므로 `Clock.fixed` 로 결정론 검증됨(`WorkLogRepositoryAdapterTest`) |
| `AuditContextPort` 가 principal 부재 시 `"system"` 을 채운다 (자동 아님) | Spring 은 `Optional.empty()` 면 필드를 *비움*`"system"` 은 구현체가 명시해야 (D5) | runtime-context 비운 채 `currentActor()``"system"` 단위 테스트 | `locally-verified` (`DomainContextAuditContextPortTest`: 부재/blank → `"system"`) |
| domain-core 가 audit 필드/`jakarta.persistence` 를 모른다 (오염 차단 D2 실제 강제) | 설계 의도일 뿐 컴파일이 막아주지 않음 — 누군가 도메인에 `@CreatedDate` 추가 가능 | ArchUnit: `domain-core``jakarta.persistence..`/`org.springframework.data..` import 금지 rule + audit 필드명 금지 rule | `locally-verified` (`domain_is_pure` + 신규 `domain_entities_do_not_carry_audit_fields`, CleanArchitectureTest green) |
| bulk/native UPDATE 시 `updated_at`/`updated_by` 미갱신 (capture 우회) | JPA lifecycle / adapter `save` 경로만 audit set — `@Modifying @Query` UPDATE 우회 | `@Modifying @Query` UPDATE 실행 후 `updated_at` 불변 확인 + 문서화 | `documented-only` — WorkLog 에 bulk UPDATE 쿼리 없음. adapter-persistence CLAUDE.md + AuditableEntity javadoc 에 "bulk path 는 명시 set 필요" 문서화 |
| `version`(optimistic-lock)이 audit 테이블에 들어가더라도 본 branch 가 아닌 failure-baseline owner | 같은 `@MappedSuperclass`/테이블에 공존 시 owner 혼동 위험 | failure-baseline §결정과 cross-check, audit base 에 `@Version` 미포함 확인 | `locally-verified``AuditableEntity``@Version` 없음(audit 4필드만). `WorkLogEntity` 가 자체 `@Version` 보유(불변경). |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs`)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
> **/coverage 결과 (2026-06-10): Covered (Blocking 0 / Should-fix 0 / Advisory 1)**. governing 적정성 OK (§35 E#8 + L2030 이 본 branch 명시 지정).
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| entity audit 컬럼 집합 (created_at/updated_at/created_by/updated_by) | covered-here | — | — | D3; governing L2086 |
| 도메인 오염 차단 메커니즘 (adapter-persistence 만, domain-core 0 필드) | covered-here | — | — | D2; governing L2086 (`AuditPort` + adapter 가로채기) |
| 감사 캡처 메커니즘 선택 + wiring (Manual vs JPA Auditing) | covered-here | — | — | D1; governing L2030 |
| 시간 소스 (기존 Clock bean 재사용) | covered-here | — | — | D4; `IdempotencyConfig.systemClock()` 선례 |
| 액터 ID seam (AuditorAware/AuditContextPort + "system" fallback) | covered-here | — | — | D5; `spring-data-jpa-auditing-official#C3` |
| 적용 범위 (도메인 aggregate만, infra/immutable 제외) | covered-here | — | — | D6; `V1__idempotency_record.sql` no updated_at |
| version / optimistic-lock 컬럼 | delegated | [[raw/branch-notes/feature-persistence-failure-baseline]] (D6) + [[raw/branch-notes/feature-transaction-concurrency-contract]] (D5) | OK | Out of scope + §Edge 명시 |
| principal 값 의미론 | delegated | [[raw/branch-notes/feature-authentication-authorization-contract]] | OK | Out of scope + D5 UNSUPPORTED |
| audit *log* (actor/action/target structured log) | delegated | [[raw/branch-notes/feature-log-management-contract]] (D9) + mdc-keys.yaml audit 키 | OK | Out of scope 명시 |
| Flyway migration gate | delegated | [[raw/branch-notes/feature-migration-startup-contract]] | OK | §Edge 의존 명시 |
| runtime-context seam (actor 공급 포트) | delegated | [[raw/branch-notes/feature-runtime-context-propagation-contract]] (D1/D5) | OK | Parent + §Edge 명시 |
| Instant.now() 차단 ArchUnit rule + Clock port 추상화 | delegated | F영역 future branch (명시적 deferred) | Advisory | Out of scope; governing §35 F "Time/Clock 주입" |
## 마주친 문제
> 짧은 메모만. 깊이 있는 트러블슈팅은 `raw/errors/` 로 분리하고 아래 Cluster에 연결.
- 이슈 1
- 원인:
- 시도:
- 해결: (또는 미해결이면 `needs-confirmation`)
- 별도 에러 노트로 분리됨: `[[raw/errors/<...>]]` (생성 시)
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/arhohuttunen-hexagonal-architecture-spring-boot]]
- [[raw/company-tech-blogs/thorben-janssen-hibernate-timestamp-clock-limitation]]
- [[raw/company-tech-blogs/vlad-mihalcea-postgresql-audit-logging-triggers]]
- [[raw/official-docs/spring-data-jpa-auditing-official]]
- [[raw/official-docs/spring-data-jpa-enable-jpa-auditing-api]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: errors:start -->
- [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]]
<!-- GENERATED: errors:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/persistence-audit-metadata-clean-architecture-2026-07-02]]
<!-- GENERATED: blog-topics:end -->
> 이 branch는 단일 노트가 아니라 **작업 묶음의 entry point**. 이 branch에서 파생된 모든 raw 노트를 카테고리별로 명시. 자식 노트가 forward link만 박아도 Obsidian backlink로 자동 발견되지만, 읽기 흐름과 분류를 위해 hub가 명시적으로 그룹화한다.
### 근거 자료
- [[raw/official-docs/spring-data-jpa-enable-jpa-auditing-api]] — D4: `dateTimeProviderRef` / `auditorAwareRef` / `modifyOnCreate` / `setDates` 속성 계약 (C1~C4)
- [[raw/company-tech-blogs/arhohuttunen-hexagonal-architecture-spring-boot]] — D2: JPA entity 와 domain model 분리 + persistence adapter 가 매핑 전담 패턴 (C1~C3)
- [[raw/company-tech-blogs/thorben-janssen-hibernate-timestamp-clock-limitation]] — Hibernate-native 대안 거부 근거: Clock 주입 불가(C1) + `created_by`/`updated_by` 미지원(C2)
### Sub-branches (세부 작업)
- 없음 — 단일 branch 안에서 구현 완료(2026-06-10). 세부 분할 불요.
### 오류 기록 (이 branch 작업 중 발생)
- 없음 — 구현·검증 중 실패/차단/샌드박스 이슈 없음. 모든 gradle 명령 첫 시도에 green. 별도 `raw/errors/` 노트 불필요.
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- 후보(노트 미생성): "Clean Architecture 에서 `created_by`/`updated_by` 같은 감사 메타데이터를 도메인 엔티티에 두면 왜 의존 방향이 뒤집히는가, 그리고 Vernon Option A 재구성(도메인이 audit 무지) 환경에서 UPDATE 시 `created_*` 를 어떻게 보존하는가"(adapter 가 기존 행 read + `updatable=false`). 정직하게 본 작업에서 도출 가능 — 필요 시 `raw/interviews/` 로 승격.
### 강의 (이 작업을 위해 학습한 강의)
- 없음.
### job-posting tie-ins (이 작업에서 파생된 글감)
- 후보(노트 미생성): "감사 컬럼을 도메인에서 몰아내기 — Manual explicit-set vs Spring Data JPA Auditing 의 트레이드오프와 Clock 주입/actor seam 설계". 본 branch 결정(D1/D2/D4/D5)에서 직접 도출되는 글감 — 필요 시 `raw/blog-topics/` 로 승격.
- 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 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경: (로컬/dev/staging/prod 어디까지 검증됐는지)
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목:
- `locally-verified` 항목:
- `prod-verified` 항목:
- **추출하지 않을 항목** (planned / documented-only / abandoned):