설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고 슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과 listStudioCatalog를 구현했다. 계약과 생성 - src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록 - openapi-generator로 DTO(model)만 생성한다. generateApis 대신 globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다 - useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이 깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다 - 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다 오류 계약 - StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier) - StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을 바꾸지 않기 위해서다 - 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는 로그 전용이다(ApiErrorCarrier javadoc의 요구) - 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다 게이트 - TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를 늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다 - StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증 - StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라 새 컨트롤러가 자동으로 걸린다 - StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의 csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를 테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다 스키마 - V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존 idempotency_record 재사용)와 범위 밖 6종을 제외했다 - 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway 세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다 알려진 제약 - getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을 반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로 200을 만들지 않았다 - 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다 이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로 작성됐다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
871 lines
46 KiB
Markdown
871 lines
46 KiB
Markdown
# Tech Log Studio Backend — 설계
|
||
|
||
- 작성일: 2026-08-18
|
||
- 대상 저장소: `tech-log-backend` (`clean-architecture-backend-template` 스냅샷)
|
||
- 브랜치: `feature/techlog-studio-backend`
|
||
- 설계 원본: `/home/donghyeon/workspace/tech-log-design-package`
|
||
- 소비자: `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` (Studio SPA, 구현 완료)
|
||
|
||
---
|
||
|
||
## 1. 배경
|
||
|
||
설계 패키지가 Tech Log의 제품·정보구조·계약·DB·백엔드 모듈을 확정했다. 프론트는
|
||
Studio SPA가 이미 구현되어 있고 백엔드만 없다. 이 문서는 **설계 패키지의 결정을
|
||
이 저장소의 구조·게이트에 맞춰 어떻게 구현할지**를 정의한다.
|
||
|
||
설계 패키지의 `docs/plans/02-backend-core-plan.md`는 본문에 STALE 배너가 붙어 있고
|
||
파일맵도 `services/api/...`라 이 저장소에 적용되지 않는다. 이 문서가 그 자리를
|
||
대신한다.
|
||
|
||
### 1.1 계약 정합 (검증됨)
|
||
|
||
`tech-log-design-package/scripts/check-contract-parity.py` 실행 결과:
|
||
|
||
```text
|
||
Frontend operation 19개 / Backend primary operation 19개
|
||
[OK] Frontend operation 13개가 모두 Backend primary 계약에 존재한다.
|
||
[OK] 공통 operation의 method/path가 모두 일치한다.
|
||
RecordKind FE=BE=['CASE','PROJECT_DECISION','QUESTION','REFERENCE']
|
||
오류 코드 FE=23개 / BE=23개 [OK]
|
||
```
|
||
|
||
즉 `contracts/openapi/studio-v1.yaml`이 프론트가 실제로 호출하는 계약 그대로다.
|
||
|
||
---
|
||
|
||
## 2. 범위
|
||
|
||
### 2.1 In scope
|
||
|
||
`studio-v1.yaml`의 19개 operation 전부.
|
||
|
||
| operation | method · path |
|
||
| --- | --- |
|
||
| `getStudioSession` | GET `/api/v1/studio/session` |
|
||
| `getStudioDashboard` | GET `/api/v1/studio/dashboard` |
|
||
| `listStudioDocuments` | GET `/api/v1/studio/documents` |
|
||
| `createStudioDocument` | POST `/api/v1/studio/documents` |
|
||
| `getStudioDocument` | GET `/api/v1/studio/documents/{documentId}` |
|
||
| `saveStudioDocument` | PUT `/api/v1/studio/documents/{documentId}` |
|
||
| `validateStudioDocument` | POST `/api/v1/studio/documents/{documentId}/validate` |
|
||
| `createStudioPreview` | POST `/api/v1/studio/documents/{documentId}/preview` |
|
||
| `getCurrentStudioPreview` | GET `/api/v1/studio/documents/{documentId}/preview` |
|
||
| `publishStudioDocument` | POST `/api/v1/studio/documents/{documentId}/publish` |
|
||
| `listStudioPublications` | GET `/api/v1/studio/publications` |
|
||
| `unpublishStudioPublication` | POST `/api/v1/studio/publications/{publicationId}/unpublish` |
|
||
| `getStudioPublicationSnapshot` | GET `/api/v1/studio/publications/{publicationEventId}/preview` |
|
||
| `listStudioCatalog` | GET `/api/v1/studio/catalog` |
|
||
| `listStudioAssets` | GET `/api/v1/studio/assets` |
|
||
| `uploadStudioAsset` | POST `/api/v1/studio/assets` |
|
||
| `getStudioAsset` | GET `/api/v1/studio/assets/{assetId}` |
|
||
| `updateStudioAsset` | PUT `/api/v1/studio/assets/{assetId}` |
|
||
| `deleteStudioAsset` | DELETE `/api/v1/studio/assets/{assetId}` |
|
||
|
||
`RecordKind` 4종(`CASE`, `REFERENCE`, `QUESTION`, `PROJECT_DECISION`)을 하나의 문서
|
||
편집 흐름으로 다룬다.
|
||
|
||
### 2.2 Out of scope (이번 브랜치)
|
||
|
||
- `contracts/openapi/public-v1.yaml` (Public 조회 API) — 소비자(Astro Public 사이트)가
|
||
아직 없다.
|
||
- `contracts/openapi/studio-management-v1.yaml` (secondary capability 보존 계약).
|
||
- `release`, `topic`/`tag` 관리 UI, `identity`(SiteConfig/Profile/HomeFocus) 편집 API.
|
||
- 이번 범위에 필요한 만큼의 `topic`/`project` **조회**는 catalog에서 다루되, 그
|
||
편집 API는 만들지 않는다.
|
||
|
||
---
|
||
|
||
## 3. 상위 결정
|
||
|
||
| ID | 결정 | 근거 |
|
||
| --- | --- | --- |
|
||
| D1 | 새 Gradle leaf를 만들지 않고 기존 18 leaf **안의 하위 패키지**로 배치한다 | `src/settings.gradle`이 `expectedModuleCount = 18`로 fail-closed 검증. leaf 추가는 registry·settings·dependency gate·ArchUnit을 동시에 바꾸는 template-level 변경이고 `template.lock.json` 기반 향후 sync와 충돌한다 |
|
||
| D2 | 패키지는 `dev.caskeleton.{domain,application,adapter...}` **루트 아래**에 `techlog` 하위로 넣는다 | 일부 ArchUnit 규칙이 `dev.caskeleton.application..`처럼 루트를 고정한다(`CleanArchitectureTest.java:221`). 다른 루트에 두면 가드레일이 조용히 미적용된다 |
|
||
| D3 | 계약 우선(openapi-generator)으로 **DTO(model)만** 생성하고 controller는 얇게 손으로 쓴다 (`globalProperties.set(['models': ''])`, `useOneOfInterfaces=false`) — 실제 설정은 §5.2, 실측과의 차이는 §3.1 | ADR-004를 유지하되 D4와 충돌하지 않는 형태. 생성 API interface는 봉투 wrapper 타입을 반환하게 되고, 그 타입은 `dev.caskeleton.shared.response.Envelope`가 아니라서 `EnvelopeBodyAdvice`가 **한 번 더 감싼다**(이중 래핑). 드리프트 위험의 실체는 93개 schema·enum·required 필드이고 그건 model 생성으로 덮인다. controller 19개 signature 드리프트는 §5.5 회귀 테스트가 잡는다 |
|
||
| D4 | `/api/v1/studio/**`도 템플릿 응답 봉투를 **그대로 쓴다**. 계약을 봉투 형태로 재정의한다 | 봉투는 `shared-contract/README.md:230`의 문서화된 결정(boundary D5/D6)이고 정보 손실이 없다. 적응 코드를 백엔드 *템플릿* 파일이 아니라 프론트 *제품* 코드에 두는 편이 template sync 충돌이 없다(§5.3) |
|
||
| D5 | `studio_idempotency` 테이블을 만들지 않고 기존 `idempotency_record`를 쓴다 | 기존 스키마가 설계 요구의 상위집합이고 `IdempotencyExecutorV2`까지 있다(§8.2) |
|
||
| D6 | Studio 통합 목록은 물리 인덱스 테이블 없이 union query로 시작한다 | 설계 08장 §4. 성능이 입증되면 read model 추가 |
|
||
| D7 | 커밋은 하지 않는다 | `AGENTS.md:64` — commit 정책 `human-only` |
|
||
|
||
### 3.1 D3 정정 — 실제 구현과의 차이 (2026-08-19, final whole-branch review B2)
|
||
|
||
이 절 작성 시점(§12 슬라이스 0 계획 단계)에는 openapi-generator 7.x가 이 저장소의 Spring Boot
|
||
버전과 함께 검증된 바 없었고, §5.2가 예정한 `generateApis=false` / `generateModels=true`는
|
||
실제 스파이크(슬라이스 0)에서 두 가지가 틀린 것으로 드러났다. 아래는 spec이 스스로 요구한
|
||
"폴백을 쓰면 D3을 갱신한다"(구 §5.2)는 약속이 이행되지 않고 있던 것을 바로잡는 정정이다 —
|
||
구현이 폴백으로 넘어간 것은 아니고(생성기는 여전히 model을 생성한다), **생성기 설정 자체가
|
||
설계 시점에 존재하지 않는 API를 가정**했던 것이 실측으로 확인됐다.
|
||
|
||
- **`generateApis`/`generateModels`는 openapi-generator-gradle-plugin 7.18.0의 `openApiGenerate`
|
||
확장에 존재하지 않는다** (디컴파일로 확인, task-4-report.md). 대신 CLI `--global-property`와
|
||
같은 뜻인 `globalProperties.set(['models': ''])`로 "models만" 생성하도록 제한한다. 실제 설정은
|
||
§5.2를 그대로 참조.
|
||
- **생성 소스는 `sourceSets.main.java.srcDir`에 있지 않다.** 별도 `generatedOpenapi` sourceSet에
|
||
있고, `main`/`test`의 compile·runtime classpath와 `jar` 산출물에 각각 명시적으로 이어 붙인다
|
||
(`src/adapter/inbound/web/build.gradle:22-38`). 이유: 생성 코드가 deprecated
|
||
`org.springframework.lang.Nullable`을 참조하는데, 저장소 루트 `build.gradle`의
|
||
`-Werror`/`-Xlint:deprecation`이 이걸 컴파일 실패로 승격한다. `main`에 직접 넣으면 그 플래그가
|
||
생성 코드에도 적용돼 빌드가 깨진다 — 별도 sourceSet으로 분리하고 그 sourceSet의
|
||
`compileGeneratedOpenapiJava` 태스크에서만 `-Werror`/`-Xlint:deprecation`을 뺀다.
|
||
- **spec에 없던 `useOneOfInterfaces=false`가 결정적 옵션으로 추가됐다.** discriminator(`oneOf`)
|
||
union을 부모 Java interface로 생성하면(`useOneOfInterfaces=true`, openapi-generator 기본값)
|
||
discriminator getter가 항상 `String`을 반환하도록 SpringCodegen이 고정하는데, 하위 타입의 실제
|
||
getter 타입(공유 enum이든 아니든)과 충돌해 컴파일이 깨진다. 판별 필드를 하위 타입에서
|
||
narrowing하지 않도록 계약을 고쳐도 동일하게 깨지는 것까지 스크래치에서 직접 검증했다(3개 설정
|
||
조합, task-4-report.md) — 계약 쪽에서 우회할 수 없는 SpringCodegen 자체의 제약이다.
|
||
|
||
**`useOneOfInterfaces=false`의 대가 — 생성 union 5종이 파손됐다.** 이 경고는 지금
|
||
`src/adapter/inbound/web/build.gradle:139-155`의 주석에만 있고 spec 본문에는 없었다 — Plan 02
|
||
작성자가 읽는 문서는 이 spec이므로 여기 옮긴다.
|
||
|
||
`useOneOfInterfaces=false`는 컴파일은 통과시키지만, discriminator union 5종
|
||
(`WorkingCopyInput`, `WorkingCopy`, `Inline`, `CaseRenderBlock`, `PublicRenderModel`)의 하위
|
||
타입이 Java `implements` 관계를 전혀 갖지 않는 독립 클래스로 생성된다. 실측 결과 Jackson
|
||
양방향 배선도 계약을 어긴다:
|
||
|
||
- **역직렬화**는 `InvalidTypeIdException`으로 실패한다 (예: `Class CaseInput not subtype of
|
||
WorkingCopyInput`).
|
||
- **직렬화**는 생성된 `@JsonIgnoreProperties(value="kind", allowSetters=true)` 때문에 실제
|
||
discriminator 값 대신 클래스 simple name이 나간다 (예: 응답에 `"kind":"WorkingCopyInput"`).
|
||
|
||
이 union들을 요청·응답에 직접 또는 (409 `VersionConflictDetails`처럼) 간접적으로 포함하는
|
||
operation은 최소 `createStudioDocument`/`getStudioDocument`/`saveStudioDocument`
|
||
(`WorkingCopyInput`/`WorkingCopy`)와 `createStudioPreview`/`getCurrentStudioPreview`/
|
||
`getStudioPublicationSnapshot`(`PublicRenderModel`)이며, `validateStudioDocument`를 포함해
|
||
409 conflict 응답 경로로 더 넓게 새어 들어갈 수 있다 — Task 8/9(`getStudioSession`,
|
||
`listStudioCatalog`)는 이 union들을 쓰지 않아 막히지 않았을 뿐, 영향받는 operation의 정확한
|
||
목록과 대응 전략(수동 Jackson `@JsonTypeInfo`/`@JsonSubTypes` 재작성, 계약 재구조화, 또는 별도
|
||
수기 DTO)은 **Plan 02가 착수 전에 확정해야 한다.**
|
||
|
||
---
|
||
|
||
## 4. 코드 배치
|
||
|
||
### 4.1 패키지
|
||
|
||
```text
|
||
:domain-core
|
||
dev.caskeleton.domain.techlog.content Document / CaseDetail / ReferenceDetail
|
||
dev.caskeleton.domain.techlog.inquiry OpenQuestion
|
||
dev.caskeleton.domain.techlog.project ProjectDecision
|
||
dev.caskeleton.domain.techlog.asset Asset
|
||
dev.caskeleton.domain.techlog.publication Publication / PublicationEvent
|
||
dev.caskeleton.domain.techlog.<ctx>.vo Value Object
|
||
|
||
:application-core
|
||
dev.caskeleton.application.techlog.<ctx>.port.in Command · Query · *UseCase 인터페이스
|
||
dev.caskeleton.application.techlog.<ctx>.port.out Repository · Renderer · Clock 포트
|
||
dev.caskeleton.application.techlog.<ctx>.service *UseCase 구현
|
||
dev.caskeleton.application.techlog.studio.facade WorkingCopy / Validation / Preview / Publication
|
||
dev.caskeleton.application.techlog.studio.query Dashboard / Document / Publication / Catalog
|
||
dev.caskeleton.application.techlog.studio.mapper API 용어 ↔ Domain 용어
|
||
dev.caskeleton.application.techlog.studio.nextaction NextAction 계산
|
||
dev.caskeleton.application.techlog.studio.port.out Studio 자신의 read 포트(예: CatalogQueryPort) —
|
||
§4.3 경계 규칙 2가 금지하는 것은 studio가
|
||
*타* context의 port.out을 참조하는 것이지,
|
||
studio 자신의 port.out이 아니다
|
||
|
||
:adapter:outbound:persistence-jpa
|
||
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.entity
|
||
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.repository
|
||
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.mapper
|
||
dev.caskeleton.adapter.outbound.persistence.techlog.query JdbcClient union query
|
||
|
||
:adapter:inbound:web
|
||
dev.caskeleton.adapter.inbound.web.techlog.studio.controller
|
||
dev.caskeleton.adapter.inbound.web.techlog.studio.mapper
|
||
dev.caskeleton.adapter.inbound.web.techlog.studio.problem
|
||
|
||
:app-bootstrap
|
||
dev.caskeleton.bootstrap.techlog 조립·설정만
|
||
```
|
||
|
||
`modules.json`, `settings.gradle`, `verifyCleanArchitectureDependencies`는 변경하지
|
||
않는다.
|
||
|
||
### 4.2 상속되는 기존 가드레일
|
||
|
||
신규 코드에 자동으로 적용된다.
|
||
|
||
- `..domain..` 순수성 (프레임워크·전송·DB 의존 금지)
|
||
- `..domain.vo..` / `@ValueObject`: public 무인자 생성자 금지
|
||
- `@AggregateRoot`: `set*` 메서드 public 금지
|
||
- `@DomainEvent`: record 필수
|
||
- `..application..`: `@Transactional` 금지 → `TransactionPort` 사용
|
||
- `..application..`: `ApplicationContext` 의존 금지
|
||
- `CommandUseCase`/`QueryUseCase` 구현: 이름이 `UseCase`로 끝나야 하고
|
||
`@UseCaseCapability` 필수
|
||
- `verifyApplicationCoreDependencyPurity`: application-core 생산 의존은 project-only,
|
||
클래스패스에 Spring/slf4j/logback/micrometer 금지
|
||
|
||
### 4.3 추가할 경계 규칙 — `TechLogBoundaryArchTest`
|
||
|
||
`:app-bootstrap` 테스트에 추가한다.
|
||
|
||
1. `techlog.content` / `techlog.inquiry` / `techlog.project` / `techlog.asset`은
|
||
서로 의존하지 않는다.
|
||
2. `application.techlog.studio`는 타 context의 `port.in`만 참조한다.
|
||
타 context의 `domain`, `port.out`, `service` 직접 참조는 위반이다. (studio 자신의
|
||
`application.techlog.studio.port.out`은 이 규칙의 대상이 아니다 — §4.1 참조.)
|
||
3. 타 context는 `application.techlog.studio`를 참조하지 않는다 (역방향 금지).
|
||
4. `domain.techlog.publication`을 제외한 어떤 domain 패키지도 `Publication`을
|
||
직접 변경하지 않는다.
|
||
|
||
설계 08장의 "`studio`는 도메인 모듈이 아니다"를 빌드로 강제하는 장치다.
|
||
|
||
---
|
||
|
||
## 5. 계약 → 코드
|
||
|
||
### 5.1 계약 원본
|
||
|
||
`studio-v1.yaml`을 `src/config/openapi/studio-v1.yaml`로 복사한다
|
||
(`src/config/architecture`, `src/config/messaging`과 같은 authority 위치).
|
||
`src/config/openapi/MANIFEST.sha256`에 해시를 기록해 설계 패키지와의 드리프트를
|
||
가시화한다.
|
||
|
||
### 5.2 생성
|
||
|
||
`:adapter:inbound:web`에 `org.openapi.generator` 플러그인을 추가한다. 아래는 실제 구현 설정
|
||
(`src/adapter/inbound/web/build.gradle`)이다 — 이 절이 원래 예정했던 `generateApis=false`/
|
||
`generateModels=true`는 openapi-generator-gradle-plugin 7.18.0에 존재하지 않는 프로퍼티였다.
|
||
무엇이 왜 달라졌는지는 §3.1 참조.
|
||
|
||
```text
|
||
generatorName spring
|
||
globalProperties ['models': ''] ← "models만" 생성 (CLI --global-property와 동치)
|
||
modelPackage dev.caskeleton.adapter.inbound.web.techlog.studio.api.model
|
||
useSpringBoot3 true
|
||
useJakartaEe true
|
||
openApiNullable true (jackson-databind-nullable 이미 선언됨)
|
||
useOneOfInterfaces false ← §3.1 — discriminator union 5종의 Jackson 배선이 깨지는 대가
|
||
output build/generated/openapi
|
||
```
|
||
|
||
- 생성 소스는 `sourceSets.main.java.srcDir`가 아니라 **별도 `generatedOpenapi` sourceSet**에
|
||
있다. 생성 코드가 deprecated `org.springframework.lang.Nullable`을 쓰는데 저장소 루트의
|
||
`-Werror`가 이를 빌드 실패로 승격하기 때문이다 — 자세한 배선은 §3.1과
|
||
`build.gradle:1-38`의 주석 참조. `jar`/`test` 클래스패스에는 별도로 명시적으로 얹는다.
|
||
- spotless / checkstyle / spotbugs / errorprone 대상에서 제외한다.
|
||
- `adapter/inbound/web/gradle.lockfile`을 `--write-locks`로 재생성한다.
|
||
|
||
#### 왜 API interface를 생성하지 않는가
|
||
|
||
D4로 응답 봉투를 유지하면 계약의 성공 응답 스키마가 봉투 wrapper가 된다. 그러면
|
||
생성 interface의 signature가 `ResponseEntity<StudioSessionEnvelope>`가 되는데,
|
||
`StudioSessionEnvelope`는 생성된 별개 클래스라 `dev.caskeleton.shared.response.Envelope`가
|
||
아니다. `EnvelopeBodyAdvice.beforeBodyWrite`는 `Envelope`/`BulkEnvelope`만 통과시키므로
|
||
이 본문을 **한 번 더 감싼다**.
|
||
|
||
```text
|
||
controller → StudioSessionEnvelope
|
||
EnvelopeBodyAdvice → Envelope<StudioSessionEnvelope>
|
||
wire → {"success":true,"data":{"success":true,"data":{...},"meta":{...}},"meta":{...}}
|
||
```
|
||
|
||
이걸 피하려면 `EnvelopeBodyAdvice`(템플릿 파일)를 고쳐야 하는데, 그건 D4가 피하려던
|
||
바로 그 template sync 충돌이다.
|
||
|
||
따라서 **model만 생성하고 controller는 손으로 쓴다.**
|
||
|
||
```java
|
||
@RestController
|
||
final class StudioSessionController {
|
||
@GetMapping("/api/v1/studio/session")
|
||
StudioSession getStudioSession() { // 생성된 payload DTO를 그대로 반환
|
||
return mapper.toApi(facade.currentSession());
|
||
}
|
||
}
|
||
// EnvelopeBodyAdvice가 여기서 정확히 한 번 감싼다.
|
||
```
|
||
|
||
controller는 facade 호출과 매핑만 하고 비즈니스 로직을 두지 않는다.
|
||
|
||
봉투 wrapper 스키마도 함께 생성되지만 백엔드는 쓰지 않는다. 계약의 wrapper는
|
||
소비자(프론트·외부 도구)를 위한 기술이고, 백엔드에서는 advice가 그 역할을 한다.
|
||
|
||
**선행 검증 결과:** openapi-generator 7.x × 이 저장소의 Spring Boot 조합은 슬라이스 0의 폐기용
|
||
스파이크로 검증했다. 폴백(생성기 1회 실행 후 수기 유지)으로 넘어가지는 않았다 — 생성기는 지금도
|
||
빌드마다 model을 생성한다. 대신 §5.2 상단에 적은 세 가지(`globalProperties`, 별도 sourceSet,
|
||
`useOneOfInterfaces=false`)가 스파이크에서 드러난 실제 조건이었다. §3.1이 그 정정 기록이다.
|
||
|
||
### 5.3 wire format — 템플릿 봉투를 그대로 쓴다
|
||
|
||
성공과 실패가 한 모양을 공유한다.
|
||
|
||
```jsonc
|
||
// 성공 (200 / 201)
|
||
{ "success": true, "data": { /* studio-v1 payload */ }, "meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
|
||
|
||
// 실패 (4xx / 5xx) — HTTP status는 그대로 의미를 갖는다
|
||
{ "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT",
|
||
"message": "...", "retryable": false,
|
||
"details": { /* code별 polymorphic */ } },
|
||
"meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
|
||
|
||
// 204 (deleteStudioAsset) — 본문 없음. EnvelopeBodyAdvice는 null body를 감싸지 않는다.
|
||
```
|
||
|
||
미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 쓰지
|
||
않는다.
|
||
|
||
#### 왜 이 방향인가
|
||
|
||
- 봉투는 이 템플릿의 **문서화된 결정**이다. `shared-contract/README.md:230` —
|
||
"RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", "D5 가 RFC 7807 ProblemDetail 을
|
||
거부하고, D10 이 `category`를 1급 필드로 추가했다".
|
||
- **정보 손실이 없다.** §5.4의 매핑표 참조. `error.category`는 봉투 쪽이 추가로 준다.
|
||
- **적응 코드의 위치가 결정적이다.** 봉투를 벗기려면 `EnvelopeBodyAdvice.supports()`를
|
||
고쳐야 하는데 이는 템플릿 파일이고, 이 저장소는 `template.lock.json`의
|
||
`"materialization": "tracked-snapshot"`이라 이후 모든 template sync의 충돌 지점이 된다.
|
||
반대로 봉투를 유지하면 적응은 프론트 **제품 코드**
|
||
(`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`) 안에서
|
||
끝나고 프론트 플랫폼(`src/adapters/http/http-execution-v3.ts`)도 무변경이다.
|
||
|
||
#### 백엔드가 해야 할 일
|
||
|
||
**봉투 관련 신규 작업은 없다.** `EnvelopeBodyAdvice`, `ErrorResponseFactory`,
|
||
`GlobalExceptionHandler`, `EnvelopeAuthenticationEntryPoint`,
|
||
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`를 그대로 쓴다. 필요한 것은
|
||
§5.4의 오류 코드 등록과 Studio 전용 예외 → `ApiError` 매핑뿐이다.
|
||
|
||
#### 계약과 프론트가 해야 할 일
|
||
|
||
이 결정은 세 저장소에 걸친다. 상세는 §5.6.
|
||
|
||
```text
|
||
tech-log-design-package studio-v1.yaml을 봉투 형태로 재정의 (+ 06장 · ADR · MASTER_SPEC · 검증 스크립트)
|
||
tech-log-frontend 계약 재생성 + validator 2개를 봉투 언랩으로 교체
|
||
tech-log-backend 오류 코드 등록 + 예외 매핑 (봉투 자체는 무변경)
|
||
```
|
||
|
||
### 5.4 오류 코드
|
||
|
||
계약의 오류 코드 23개를 `ApiError.code`에 그대로 싣는다.
|
||
|
||
```text
|
||
AUTHENTICATION_REQUIRED STUDIO_ACCESS_DENIED DOCUMENT_NOT_FOUND
|
||
VERSION_CONFLICT REQUEST_VALIDATION_FAILED VALIDATION_FAILED
|
||
VALIDATION_STALE PREVIEW_NOT_FOUND PREVIEW_STALE
|
||
PREVIEW_EXPIRED PUBLICATION_NOT_FOUND PUBLICATION_CONFLICT
|
||
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
|
||
WARNING_ACKNOWLEDGEMENT_REQUIRED IDEMPOTENCY_KEY_REUSED
|
||
ASSET_NOT_FOUND ASSET_NOT_READY ASSET_IN_USE
|
||
ASSET_QUARANTINED PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
|
||
STUDIO_UNAVAILABLE
|
||
```
|
||
|
||
#### 필드 매핑 — 정보 손실 없음
|
||
|
||
| 기존 계약 `ProblemDetails` | 봉투 |
|
||
| --- | --- |
|
||
| `code` | `error.code` |
|
||
| `retryable` | `error.retryable` (1급) |
|
||
| `detail` | `error.message` (client-safe. stack trace·내부 ID 금지) |
|
||
| `status` | HTTP status (봉투도 실제 status를 유지한다) |
|
||
| `traceId` | `meta.traceId` (D7 — 응답에서 절대 null이 아니다) |
|
||
| `fieldErrors` | `error.details` = `ValidationErrorDetails` |
|
||
| `latestDocument` | `error.details` = `VersionConflictDetails` |
|
||
| `latestPublication` | `error.details` = `PublicationConflictDetails` |
|
||
| `conflictingFields` | 위 두 details 안 |
|
||
| `type` / `title` | 버린다. 프론트가 이미 `code`에서 합성한다(`studio-error-mapping.ts`의 `synthetic()`) |
|
||
| — | `error.category` — 봉투가 추가로 준다 |
|
||
|
||
`details`는 code별 polymorphic이므로 계약에서 `oneOf` 타입 변형으로 선언한다.
|
||
`Object` 자유형으로 두지 않는다.
|
||
|
||
#### 레지스트리 등록
|
||
|
||
`docs/registries/error-codes.yaml`에 23개 row를 additive로 추가한다. row 스키마가
|
||
요구하는 필드를 모두 채운다 — `category`(10-value `Category` enum), `http_status`,
|
||
`retryable`, `owner_layer`, `client_safe_message`, `log_level`, `runbook_link`,
|
||
`compatibility_impact: additive`, `required_test`.
|
||
|
||
runbook 정책: `retryable=false`이고 category가 `AUTH`/`AUTHZ`/`RATE_LIMIT`/`INTERNAL`/
|
||
`TRANSIENT_DEPENDENCY`/`PERMANENT_DEPENDENCY`면 `runbook_link`가 필수다.
|
||
`VALIDATION`/`NOT_FOUND`/`CONFLICT`/`DATA_INTEGRITY`는 client-error로 면제 가능하다.
|
||
따라서 `AUTHENTICATION_REQUIRED`(AUTH), `STUDIO_ACCESS_DENIED`(AUTHZ),
|
||
`STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY)는 runbook을 함께 작성한다.
|
||
|
||
category 배정:
|
||
|
||
```text
|
||
AUTH AUTHENTICATION_REQUIRED
|
||
AUTHZ STUDIO_ACCESS_DENIED
|
||
NOT_FOUND DOCUMENT_NOT_FOUND PREVIEW_NOT_FOUND PUBLICATION_NOT_FOUND
|
||
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
|
||
ASSET_NOT_FOUND
|
||
CONFLICT VERSION_CONFLICT PUBLICATION_CONFLICT IDEMPOTENCY_KEY_REUSED
|
||
VALIDATION_STALE PREVIEW_STALE PREVIEW_EXPIRED
|
||
ASSET_IN_USE ASSET_NOT_READY
|
||
VALIDATION REQUEST_VALIDATION_FAILED VALIDATION_FAILED
|
||
WARNING_ACKNOWLEDGEMENT_REQUIRED
|
||
PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
|
||
DATA_INTEGRITY ASSET_QUARANTINED
|
||
TRANSIENT_DEPENDENCY STUDIO_UNAVAILABLE
|
||
```
|
||
|
||
### 5.5 계약 회귀 테스트
|
||
|
||
**실제 구현 범위 (Task 10, `StudioContractDriftTest`, `:app-bootstrap`의 functional test
|
||
소스셋):** springdoc이 노출하는 `/v3/api-docs`를 `src/config/openapi/studio-v1.yaml`과 대조해
|
||
**operation id · method · path**가 어긋나면 실패시킨다 (`published ⊆ contract` 방향 — 아직
|
||
구현하지 않은 operation이 있어도 green을 유지한다). `dev.caskeleton.adapter.inbound.web.techlog`
|
||
아래를 `@ComponentScan`하므로 그 패키지 트리 밖에 컨트롤러를 두면 이 게이트가 못 본다 (스캔
|
||
범위를 벗어나는 즉시 유효성을 잃는다는 뜻이므로 §4.1의 패키지 배치를 반드시 따른다). 별도로
|
||
`getStudioSession`/`listStudioCatalog` 각 1개 케이스에 대해 성공 응답이
|
||
`{success:true, data, meta}` 모양이고 이중 래핑이 없는지도 고정한다 — **모든 operation**의
|
||
봉투 래핑을 확인하는 것은 아니다.
|
||
|
||
**아직 구현하지 않은 것 (Plan 02 몫):**
|
||
|
||
- 응답 media type 대조 (`application/json` 고정 여부)
|
||
- 스키마 필수 필드 대조 (93개 schema·enum·required 필드가 model 생성으로 덮인다는 D3의 전제를
|
||
실제로 검증하는 회귀 테스트는 없다 — 지금은 생성이 컴파일에 성공하는 것으로 암묵 검증한다)
|
||
- 나머지 17개 operation에 대한 봉투 래핑 확인
|
||
- `deleteStudioAsset` 204 본문 없음 확인 (해당 operation 미구현)
|
||
|
||
**401/403/429 코드 주장 — 삭제.** 이 절은 원래 401/403/429가 각각
|
||
`AUTHENTICATION_REQUIRED`/`STUDIO_ACCESS_DENIED`/rate-limit 코드로 난다고 적었으나 이는
|
||
코드상 사실이 아니다. 기존(템플릿 소유, 무변경 재사용) 경로가 실제로 내는 코드는:
|
||
|
||
- 401 — `EnvelopeAuthenticationEntryPoint` → `SecurityErrorClassifier.classifyAuthentication`
|
||
(`src/adapter/inbound/web/src/main/java/.../auth/SecurityErrorClassifier.java:21-35`)이
|
||
`AUTH_TOKEN_MISSING`(`InsufficientAuthenticationException`) 또는 `AUTH_TOKEN_MALFORMED`(그 외
|
||
분류 불가 인증 실패)를 낸다.
|
||
- 403 — `EnvelopeAccessDeniedHandler` → `SecurityErrorClassifier.classifyAccessDenied` (같은
|
||
파일:39)가 `AUTHZ_INSUFFICIENT_PERMISSION`을 낸다.
|
||
- 429 — `RateLimitInterceptor:75`가 `RATE_LIMIT_EXCEEDED`를 낸다.
|
||
|
||
이 세 코드 모두 계약(studio-v1.yaml) 23종 `ApiError.code` enum에 없다. 429는 계약에
|
||
rate-limit 코드 자체가 존재하지 않는다. §5.3 "백엔드가 해야 할 일"이 "봉투 관련 신규 작업은
|
||
없다"고 못 박았고 이 경로들은 템플릿 파일(`EnvelopeAuthenticationEntryPoint`,
|
||
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`, `SecurityErrorClassifier`)만으로
|
||
동작해 Studio가 손댈 수 없다 — 계약을 이 세 코드로 확장할지, 별도 Studio 매핑 계층을 둘지는
|
||
이 spec이 결정하지 않은 채 남아 있다. **Plan 02가 착수 전에 결정해야 한다.**
|
||
|
||
### 5.6 계약 재정의 명세
|
||
|
||
`studio-v1.yaml`을 다음과 같이 바꾼다. 이것이 세 저장소의 공유 SSOT가 된다.
|
||
|
||
**추가 스키마**
|
||
|
||
```text
|
||
ResponseMeta requestId, traceId, correlationId, page(nullable)
|
||
ApiError code(enum 23), category(enum 10), message, retryable, details(oneOf|null)
|
||
ErrorEnvelope success(const false), error(ApiError), meta(ResponseMeta)
|
||
ValidationErrorDetails fieldErrors[]
|
||
VersionConflictDetails latestDocument, conflictingFields[]
|
||
PublicationConflictDetails latestPublication
|
||
```
|
||
|
||
**성공 응답 래핑** — JSON 본문을 갖는 18개 operation(200 15개 / 201 3개)의 응답
|
||
스키마를 `{success: const true, data: <기존 payload>, meta: ResponseMeta}` 래퍼로
|
||
교체한다. OpenAPI 3.1에 제네릭이 없으므로 payload별 래퍼 스키마를 만든다.
|
||
기존 payload 스키마(`StudioSession`, `WorkingCopyDetail`, …)는 **그대로 남긴다** —
|
||
프론트가 `components["schemas"]["StudioSession"]`로 도메인 타입을 계속 뽑아 쓴다.
|
||
|
||
**오류 응답 교체** — `components.responses`의 16개 항목 content를
|
||
`application/problem+json` + `ProblemDetails`에서 `application/json` + `ErrorEnvelope`로
|
||
바꾼다. `ProblemDetails` 스키마는 제거한다.
|
||
|
||
`deleteStudioAsset`의 204는 변경 없다.
|
||
|
||
**`public-v1.yaml` / `studio-management-v1.yaml`** — 두 계약도 `ProblemDetails`를
|
||
쓰지만 이번 구현 범위 밖이고 소비자가 없다. 지금 변환하지 않고 각 파일 상단에
|
||
"봉투 결정(ADR-006) 반영 대기" 배너를 붙인다. 설계 패키지가 `docs/plans/02`에 쓴
|
||
STALE 배너와 같은 방식이다. 구현에 착수할 때 변환한다.
|
||
|
||
**따라오는 파일**
|
||
|
||
```text
|
||
tech-log-design-package
|
||
contracts/openapi/studio-v1.yaml 위 변경
|
||
docs/specs/06-api-contract-design.md 7장 오류 계약 재작성
|
||
decisions/ADR-006-response-envelope.md 신규 — 봉투 채택 근거와 RFC 7807 미채택 기록
|
||
TECH_LOG_MASTER_SPEC.md scripts/build-master-spec.sh 재생성
|
||
MANIFEST.sha256 scripts/update-manifest.sh 재생성
|
||
scripts/check-contract-parity.py ProblemDetails.code → ApiError.code 참조 변경
|
||
scripts/check-consistency.py 동일
|
||
|
||
tech-log-frontend
|
||
src/features/tech-log/contracts/studio/studio-api.openapi.yaml 동기화
|
||
src/features/tech-log/contracts/studio/generated.ts 재생성
|
||
src/features/tech-log/contracts/studio/canonical-source.json 재생성
|
||
src/features/tech-log/contracts/studio/contract.ts 타입 export 확인
|
||
src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts
|
||
outputValidator passthrough → 봉투 data 언랩
|
||
problemValidator PROBLEM(bare) → 봉투 error 언랩 + code enum 검증 유지
|
||
src/features/tech-log/adapters/http/studio-error-mapping.ts ApiError → StudioGatewayError
|
||
관련 테스트 · 픽스처의 HTTP 본문
|
||
```
|
||
|
||
`mock-studio-gateway.ts` 등 `StudioGateway` 포트를 직접 구현하는 mock은 전송 경계
|
||
아래가 아니라 위에 있으므로 **변경 대상이 아니다.** 앱·도메인·프레젠테이션 계층도
|
||
언랩이 경계에서 끝나므로 무변경이다.
|
||
|
||
---
|
||
|
||
## 6. 도메인 모델
|
||
|
||
### 6.1 Aggregate
|
||
|
||
| Aggregate | 소유 테이블 | 비고 |
|
||
| --- | --- | --- |
|
||
| `Document` | `document`, `case_detail`, `reference_detail`, `document_tag`, `document_relation` | `RecordKind.CASE` / `REFERENCE` |
|
||
| `OpenQuestion` | `open_question`, `question_point`, `question_update`, `question_tag`, `question_document_link` | `RecordKind.QUESTION` |
|
||
| `ProjectDecision` | `project_decision` | `RecordKind.PROJECT_DECISION` |
|
||
| `Asset` | `asset`, `asset_reference` | |
|
||
| `Publication` | `publication`, `publication_event`, `publication_snapshot` | Event·Snapshot은 불변 |
|
||
|
||
`WorkingCopy`는 Aggregate가 아니라 **API projection**이다. `working_copy` 범용
|
||
테이블을 만들지 않는다.
|
||
|
||
### 6.2 상태 기계 (설계 09장)
|
||
|
||
```text
|
||
Document DRAFT → IN_REVIEW → PUBLISHED → ARCHIVED (+ unpublish: PUBLISHED → DRAFT)
|
||
OpenQuestion OPEN → INVESTIGATING → PAUSED → RESOLVED → ARCHIVED
|
||
Publication PUBLISHED → REPUBLISHED → UNPUBLISHED → REPUBLISHED
|
||
```
|
||
|
||
**API 용어와 Domain 용어를 분리한다.**
|
||
|
||
```text
|
||
API questionStatus=OPEN ← Domain OPEN | INVESTIGATING | PAUSED
|
||
API questionStatus=RESOLVED ← Domain RESOLVED
|
||
```
|
||
|
||
`saveStudioDocument`는 편집 가능한 content field만 저장하며 **lifecycle 전이를
|
||
유발하지 않는다.** OPEN 계열 안에서의 값 변화는 무시한다. 프론트가 축약 상태를
|
||
보냈다는 이유로 `INVESTIGATING`을 `OPEN`으로 덮어쓰면 조사 이력이 소실된다.
|
||
|
||
### 6.3 저장하지 않는 값
|
||
|
||
`nextAction`, `publicationStatus`, `hasUnpublishedChanges`, Preview state는
|
||
**조회 시점 계산**이다. `workflow_status` 같은 domain 컬럼에 저장하지 않는다.
|
||
|
||
---
|
||
|
||
## 7. 애플리케이션 계층
|
||
|
||
### 7.1 use case 규약
|
||
|
||
context별 use case는 `CommandUseCase<C, R>` / `QueryUseCase<Q, R>`를 구현하고
|
||
이름이 `UseCase`로 끝나며 `@UseCaseCapability`를 선언한다.
|
||
|
||
```java
|
||
@UseCaseCapability(
|
||
transactionMode = TransactionMode.WRITE,
|
||
idempotency = Idempotency.KEYED,
|
||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||
final class PublishCaseUseCase implements CommandUseCase<PublishCaseCommand, PublishResult> { }
|
||
```
|
||
|
||
트랜잭션 경계는 `@Transactional`이 아니라 `TransactionPort`로 연다.
|
||
`TransactionMode`는 `WRITE` / `READ_ONLY` / `REQUIRES_NEW` 계열이고,
|
||
`Idempotency`는 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`,
|
||
`RepositoryAccess`는 `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`다.
|
||
`Idempotency-Key`를 쓰는 mutation은 `KEYED`로 선언한다.
|
||
|
||
### 7.2 studio facade
|
||
|
||
```text
|
||
StudioWorkingCopyFacade create / get / save / list → 유형별 port.in dispatch
|
||
StudioValidationFacade validate → validation artifact 영속
|
||
StudioPreviewFacade create / get preview artifact + state 계산
|
||
StudioPublicationFacade publish / unpublish / 이력 / snapshot 조회
|
||
StudioDashboardQuery union query 기반 대시보드
|
||
StudioDocumentQuery union query 기반 문서 목록
|
||
StudioCatalogQuery TOPIC | PROJECT | RELATION | EVIDENCE
|
||
NextActionCalculator §7.4
|
||
StudioDocumentLocator documentId(UUID) → (sourceKind, sourceId)
|
||
```
|
||
|
||
facade는 domain object를 직접 수정하지 않는다. 타 context의 `port.in`만 호출하고
|
||
결과를 통합 DTO로 조립한다.
|
||
|
||
`documentId`는 계약상 **source aggregate id를 그대로 쓴다**(별도 surrogate 없음).
|
||
`StudioDocumentLocator`가 `document` / `open_question` / `project_decision` union
|
||
query로 kind를 해소한다.
|
||
|
||
### 7.3 Validation / Preview artifact
|
||
|
||
```text
|
||
studio_validation validationId, sourceKind, sourceId, validatedVersion,
|
||
status(INVALID|WARNINGS|VALID), issues(jsonb),
|
||
dependencyRevision, validatedAt, validUntil, createdBy
|
||
|
||
studio_preview previewId, sourceKind, sourceId, sourceVersion,
|
||
validationId(FK), dependencyRevision,
|
||
renderModel(jsonb), createdAt, expiresAt, createdBy
|
||
```
|
||
|
||
`dependencyRevision`은 검증에 쓴 외부 의존 상태(topic/project publishability,
|
||
relation target 상태, asset READY/QUARANTINED, slug/route ownership, catalog
|
||
revision, renderer/content-format version)의 identity+version을 정규화해 만든
|
||
해시다. 전역 카운터가 아니다.
|
||
|
||
상태는 저장하지 않고 조회 시 계산한다.
|
||
|
||
```text
|
||
Validation 유효 validatedVersion == 현재 working version
|
||
AND dependencyRevision == 현재 계산값
|
||
AND now() < validUntil
|
||
|
||
Preview CURRENT sourceVersion == 현재 working version
|
||
AND dependencyRevision == 현재 계산값
|
||
AND now() < expiresAt
|
||
STALE version 또는 dependencyRevision 불일치
|
||
EXPIRED now() >= expiresAt
|
||
```
|
||
|
||
`VALIDATION_FAILED`(지금 검증하면 실패)와 `VALIDATION_STALE`(통과했으나 전제가
|
||
바뀜)은 다른 사건이며 코드도 다르다.
|
||
|
||
### 7.4 nextAction 계산
|
||
|
||
```text
|
||
저장 가능한 형태조차 미달 → CONTINUE_EDITING
|
||
현재 version에 대한 Validation 없음 → VALIDATE
|
||
현재 Validation = INVALID → FIX_VALIDATION
|
||
유효 Validation + 현재 version Preview 없음 → CREATE_PREVIEW
|
||
Preview가 STALE 또는 EXPIRED → CREATE_PREVIEW
|
||
현재 Validation + 현재 Preview
|
||
+ Publication version 불일치 → PUBLISH
|
||
Publication version == working version → NONE
|
||
```
|
||
|
||
### 7.5 Publish 트랜잭션 (설계 07장 §14, 20단계)
|
||
|
||
```text
|
||
0. idempotency 선검사 — 동일 key + 동일 fingerprint면 최초 응답 재생, 이하 미실행
|
||
1. Document SELECT FOR UPDATE
|
||
2. expectedVersion 검증 불일치 → VERSION_CONFLICT
|
||
3. validationId 조회 + dependencyRevision 재계산 비교 불일치 → VALIDATION_STALE
|
||
4. previewId 조회 + sourceVersion / dependencyRevision 비교
|
||
불일치 → PREVIEW_STALE, 만료 → PREVIEW_EXPIRED
|
||
5. acknowledgedWarningCodes가 WARNING 집합을 덮는지
|
||
미달 → WARNING_ACKNOWLEDGEMENT_REQUIRED
|
||
6. 유형별 publication validation (게시 필수 필드 / slug 충돌)
|
||
7. Markdown 분석
|
||
8. Asset READY 및 사용 위치 alt/decorative 검증
|
||
9. 공개 payload 생성
|
||
10. Publication Event 생성 (PUBLISHED | REPUBLISHED)
|
||
11. Publication Snapshot 생성 (immutable)
|
||
12. public_resource_projection upsert
|
||
13. public_route canonical/alias 변경
|
||
14. public_resource_tag / project link 교체
|
||
15. PUBLISHED asset_reference 교체
|
||
16. asset.first_published_at 갱신
|
||
17. Publication aggregate 갱신 (latest_event_id, publication_revision)
|
||
18. Document publish metadata 갱신
|
||
19. 선택적 ProjectActivity 생성
|
||
20. Commit
|
||
```
|
||
|
||
전 단계가 하나의 트랜잭션이다.
|
||
|
||
**Snapshot의 render model은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한
|
||
Preview의 render model을 그대로 쓴다.** 재렌더링하면 승인한 화면과 공개된 화면이
|
||
달라질 수 있다.
|
||
|
||
Unpublish는 `expectedPublicationRevision` 검증 → `UNPUBLISHED` Event(반드시
|
||
`sourcePublishedEventId` 보유) → Publication 상태 전환 및 revision 증가 →
|
||
Projection `ACTIVE → WITHDRAWN` → Working `workflow_status → DRAFT` → route 유지 →
|
||
commit. Snapshot은 삭제하지 않는다.
|
||
|
||
---
|
||
|
||
## 8. 영속화
|
||
|
||
### 8.1 마이그레이션
|
||
|
||
`PostgreSqlPersistenceConfig.java:57`이 Flyway location을
|
||
`classpath:db/migration/postgresql`로 고정한다. 기존 최대 버전이 V6이고
|
||
`out-of-order: false`이므로 **`V7__techlog_core.sql`부터** 추가한다.
|
||
|
||
이번 범위에 필요한 테이블:
|
||
|
||
```text
|
||
topic tag document case_detail reference_detail document_tag document_relation
|
||
open_question question_point question_update question_tag question_document_link
|
||
project project_decision project_document_link project_question_link project_activity
|
||
asset asset_reference
|
||
studio_validation studio_preview
|
||
publication publication_event publication_snapshot
|
||
public_resource_projection public_route public_resource_tag public_resource_project_link
|
||
```
|
||
|
||
`publication.latest_event_id` ↔ `publication_event.publication_id` 순환 FK는
|
||
`DEFERRABLE INITIALLY DEFERRED`로 선언한다. 즉시 검사로 두면 첫 게시가 불가능하다.
|
||
|
||
`publication_event` / `publication_snapshot`은 어떤 cleanup job도 삭제하지 않는다.
|
||
|
||
### 8.2 멱등 — 기존 자산 재사용 (D5)
|
||
|
||
설계의 `studio_idempotency` 테이블을 만들지 않는다.
|
||
|
||
| 설계 `studio_idempotency` | 기존 `idempotency_record` |
|
||
| --- | --- |
|
||
| `idempotency_key` | `idempotency_key` |
|
||
| `operation_id` | `use_case_name` |
|
||
| `request_fingerprint` | `request_hash` (char(64) sha256) |
|
||
| `response_status` / `response_body` | `response_payload` |
|
||
| `created_at` / `expires_at` | `created_at` / `expires_at` |
|
||
| `created_by` | `principal` (+ `tenant`) |
|
||
|
||
기존 것이 상위집합이고 `IdempotencyExecutorV2`, `IdempotencyStorePortV2`,
|
||
`IdempotencyKeySupport`(header 추출 · scope · sha256 fingerprint · JSON codec)까지
|
||
있다. 설계 대비 결손은 두 가지뿐이며 web 계층에서 채운다.
|
||
|
||
1. 재생 시 `Idempotency-Replayed: true` 응답 헤더
|
||
2. fingerprint 불일치 시 `IDEMPOTENCY_KEY_REUSED` ProblemDetails (409)
|
||
|
||
적용 대상: create / save / validate / create preview / publish / unpublish /
|
||
asset upload · update · delete.
|
||
|
||
### 8.3 읽기·쓰기 분리
|
||
|
||
- 쓰기: JPA aggregate + `@Version` 낙관적 잠금
|
||
- Studio 목록 / 대시보드 / 카탈로그: `JdbcClient` union query (도메인 repository 우회)
|
||
- 공통 CRUD repository를 만들지 않는다
|
||
|
||
---
|
||
|
||
## 9. 재사용 매핑 (신규 구현 금지)
|
||
|
||
| 설계 요구 | 재사용 대상 |
|
||
| --- | --- |
|
||
| `Idempotency-Key` | `application/idempotency/v2`, `web/idempotency/IdempotencyKeySupport`, `idempotency_record` |
|
||
| `expectedVersion` / 409 | JPA `@Version`, `web/conditional/ETags`, `PreconditionFailedException` |
|
||
| Studio cursor 페이지네이션 | `web/cursor/CursorCodec`, `web/pagination/PageParams` |
|
||
| 오류 응답 골격 | `shared/response/{Envelope,ApiError,ResponseMeta}`, `web/error/ErrorResponseFactory`, `GlobalExceptionHandler` — **무변경 재사용** |
|
||
| 401 / 403 / 429 | `EnvelopeAuthenticationEntryPoint`, `EnvelopeAccessDeniedHandler`, `RateLimitInterceptor` — **무변경 재사용** |
|
||
| Keycloak 세션 · CSRF | `web/auth/SecurityConfig`, `JwtDecoderConfig`, `PrimitiveSessionSecurityContextRepository`, `RedisSessionWebConfig` |
|
||
| 권한 | `application/security/RequiresPermission`, `web/authz/RolePermissionPolicy`, `RequiresPermissionAuthorizationManager` |
|
||
| Asset 업로드 · 저장 | `adapter:outbound:fileserver`, `adapter:outbound:objectstorage` |
|
||
| ID 생성 | `adapter:outbound:identifier` |
|
||
| 캐시 | `application/cache`, `adapter:outbound:cache-redis` (필요 입증 시에만) |
|
||
| 관측 | `web/observability`, `docs/registries/{metrics,mdc-keys,headers}.yaml` |
|
||
| 트랜잭션 | `application/transaction/TransactionPort` |
|
||
|
||
**Asset은 새 저장 계층을 만들지 않는다.** `asset` 테이블은 metadata·`asset_key`·
|
||
lifecycle만 소유하고 바이너리 저장·전송은 fileserver/objectstorage 어댑터에 위임한다.
|
||
`asset_key`(안정 참조)와 `object_key`(저장 위치)를 분리한다 — 본문에 object storage
|
||
URL을 저장하지 않는다.
|
||
|
||
---
|
||
|
||
## 10. 렌더러
|
||
|
||
`PublicRenderModel`은 Markdown을 의미 블록으로 변환한 결과다. 계약이 정의하는
|
||
블록·인라인 타입:
|
||
|
||
```text
|
||
블록 HeadingBlock ParagraphBlock CodeBlock BlockquoteBlock CalloutBlock
|
||
OrderedListBlock UnorderedListBlock DataTableBlock EvidenceFigureBlock
|
||
인라인 InlineText InlineStrong InlineEmphasis InlineCode InlineLink InlineStatus
|
||
```
|
||
|
||
제약:
|
||
|
||
- Public과 Studio Preview가 **같은 의미의 렌더 결과**를 써야 한다 (ADR-005).
|
||
- Snapshot은 `contentFormatVersion`, `rendererContractVersion`, asset manifest를 함께
|
||
보존한다. 이후 Asset이 교체되어도 과거 Snapshot의 표현이 변하지 않는다.
|
||
- Asset은 `assetKey`로 참조하고 렌더 시 `ResolvedAsset`으로 해소한다.
|
||
|
||
**이 항목이 이번 구현의 최대 리스크다.** 슬라이스 3 착수 전에 프론트의 기존 렌더
|
||
모델 구현(`src/features/tech-log/adapters/mock/project-public-render-model.ts` 및
|
||
static content 경로)을 기준선으로 대조해 블록 의미가 일치하는지 확인한다.
|
||
|
||
---
|
||
|
||
## 11. 테스트 전략
|
||
|
||
leaf별 템플릿 정책을 그대로 따른다.
|
||
|
||
| 대상 | 방식 |
|
||
| --- | --- |
|
||
| `domain-core` | 순수 JUnit. 상태 전이·불변 조건 |
|
||
| `application-core` | 손수 만든 fake 포트. 웹·영속 컨텍스트 금지 |
|
||
| `adapter:inbound:web` | 전송 slice 테스트 + §5.5 봉투 래핑·오류 코드 회귀 테스트 |
|
||
| `adapter:outbound:persistence-jpa` | 매핑·포트 계약 테스트. 벤더 의미가 필요한 것(deferrable FK, `SELECT FOR UPDATE`)은 `postgresqlIntegrationTest` 소스셋 |
|
||
| `app-bootstrap` | 배선·`TechLogBoundaryArchTest`·계약 회귀 테스트 |
|
||
|
||
필수 시나리오:
|
||
|
||
- publish 재시도가 중복 `publication_event`를 만들지 않는다
|
||
- `VALIDATION_STALE`과 `VALIDATION_FAILED`가 구분된다
|
||
- `saveStudioDocument`가 `INVESTIGATING`을 `OPEN`으로 되돌리지 않는다
|
||
- unpublish 후 과거 Snapshot이 그대로 조회된다
|
||
- 성공 응답이 `{success:true, data, meta}`이고 `meta.traceId`가 non-null이다
|
||
- 오류 응답이 `{success:false, error, meta}`이고 `error.code`가 계약의 23개 중 하나다
|
||
- 409가 `error.details`로 `latestDocument` / `latestPublication`을 싣는다
|
||
|
||
검증 명령 (`src/`에서):
|
||
|
||
```bash
|
||
./gradlew :domain-core:test --console=plain
|
||
./gradlew :application-core:test --console=plain
|
||
./gradlew :adapter:inbound:web:test --console=plain
|
||
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
|
||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||
./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain
|
||
```
|
||
|
||
---
|
||
|
||
## 12. 슬라이스
|
||
|
||
| # | 내용 | 완료 판정 |
|
||
| --- | --- | --- |
|
||
| 0 | **계약 봉투 재정의(§5.6) → 프론트 재생성·validator 교체**, 생성기 스파이크 → 배선, 계약 복사, `TechLogBoundaryArchTest`, 오류코드 레지스트리 23개, `V7__techlog_core.sql` | 빌드·아키텍처 검증 통과 + 3 저장소 계약 parity 통과 |
|
||
| 1 | `getStudioSession`, `listStudioCatalog` | 프론트 로그인·카탈로그 실동작 |
|
||
| 2 | `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument` (+ 낙관적 잠금, 멱등) | 프론트 편집 실동작 |
|
||
| 3 | `validateStudioDocument`, `createStudioPreview`, `getCurrentStudioPreview`, `dependencyRevision`, `nextAction`, 렌더러 | 프론트 검증·미리보기 실동작 |
|
||
| 4 | `publishStudioDocument`, `unpublishStudioPublication`, `listStudioPublications`, `getStudioPublicationSnapshot`, `getStudioDashboard` | 프론트 게시 실동작 |
|
||
| 5 | asset 5종 | 프론트 자산 실동작 |
|
||
|
||
`getStudioDashboard`는 publication·validation 데이터에 의존하므로 슬라이스 4에서
|
||
완성한다.
|
||
|
||
---
|
||
|
||
## 13. 리스크
|
||
|
||
| 리스크 | 완화 |
|
||
| --- | --- |
|
||
| openapi-generator × Spring Boot 4.0.0 미검증 | 슬라이스 0 첫 스텝을 폐기용 스파이크로. 실패 시 §5.2 폴백 |
|
||
| 3 저장소(계약·프론트·백엔드) 동기화 실패 | 슬라이스 0에서 계약을 먼저 확정하고 `check-contract-parity.py`를 게이트로 삼는다. 백엔드는 §5.5 계약 회귀 테스트로 고정 |
|
||
| 프론트 언랩 누락 시 조용한 실패 (`outputValidator`가 passthrough라 전송 계층이 잡지 못한다) | 언랩 validator에 `success`/`data` 존재 검증을 넣어 하드 실패로 바꾼다 |
|
||
| 렌더 모델 의미 불일치 | 슬라이스 3 착수 전 프론트 구현 대조 |
|
||
| publish 20단계 트랜잭션 복잡도 | 단계별 실패 코드를 먼저 테스트로 고정한 뒤 구현 |
|
||
| 설계 DDL과 템플릿 스키마 충돌 | `studio_idempotency` 제거(D5) 외에는 이름 충돌 없음을 V7 작성 시 재확인 |
|
||
|
||
---
|
||
|
||
## 14. 운영 제약
|
||
|
||
- **커밋 금지** — `AGENTS.md:64` commit 정책 `human-only`. 브랜치 생성과 파일 작성까지
|
||
수행하고 stage/commit/push는 사용자가 한다. 이 제약은 `tech-log-backend`의 규약이며
|
||
`tech-log-design-package` / `tech-log-frontend`에는 적용되지 않는다.
|
||
- 이 설계는 **세 저장소**를 건드린다(§5.6). 계약이 SSOT이므로 순서는
|
||
`tech-log-design-package` → `tech-log-frontend` → `tech-log-backend`다.
|
||
- `git flow init`은 워킹트리에 미커밋 삭제분(`*-superpowers-package/`,
|
||
`scripts/verify-httpclient-docs.py`)이 있어 중단되었다. gitflow 설정은 기록했고
|
||
`develop` / `feature/techlog-studio-backend` 브랜치는 수동 생성했다. 미커밋 삭제분은
|
||
손대지 않았다.
|
||
|
||
---
|
||
|
||
## 15. 참조
|
||
|
||
- 설계: `tech-log-design-package/docs/specs/{06,07,08,09}`, `decisions/ADR-00{1..5}`
|
||
- 계약: `tech-log-design-package/contracts/openapi/studio-v1.yaml`
|
||
- 프론트 계약: `tech-log-frontend/src/features/tech-log/contracts/studio/studio-api.openapi.yaml`
|
||
- 프론트 실행 경로: `tech-log-frontend/src/adapters/http/http-execution-v3.ts`
|
||
- 저장소 권위: `src/config/architecture/modules.json`, `src/settings.gradle`, `AGENTS.md`
|