# Tech Log Studio Backend — Plan 01: Foundation & First Vertical > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Studio 계약을 응답 봉투 형태로 확정하고 세 저장소를 정합시킨 뒤, `getStudioSession`과 `listStudioCatalog`가 실제 백엔드에서 동작하게 만든다. **Architecture:** 계약(`studio-v1.yaml`)이 세 저장소의 SSOT다. 계약을 먼저 봉투로 재정의하고 → 프론트가 재생성·언랩하고 → 백엔드가 payload DTO만 생성해 얇은 controller로 서빙한다. 백엔드 코드는 새 Gradle leaf 없이 기존 18 leaf 안의 `techlog` 하위 패키지에 들어간다. 응답 봉투는 기존 `EnvelopeBodyAdvice`가 그대로 씌운다. **Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle (dependency lock), Flyway, PostgreSQL, JPA + JdbcClient, openapi-generator (models only), ArchUnit, JUnit 5 / AssertJ · TypeScript, Vite, Vitest, openapi-typescript 7.9.1 · Python 3 (설계 패키지 검증 스크립트) **Spec:** `docs/superpowers/specs/2026-08-18-techlog-studio-backend-design.md` ## Global Constraints - **저장소 3개.** 작업 순서는 `DP → FE → BE`로 고정한다. 계약이 SSOT이기 때문이다. - `DP` = `/home/donghyeon/workspace/tech-log-design-package` (branch `master`) - `FE` = `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` - `BE` = `/home/donghyeon/workspace/desktop-server-git/tech-log-backend` (branch `feature/techlog-studio-backend`) - **BE는 커밋하지 않는다.** `AGENTS.md:64` — commit 정책 `human-only`. BE 태스크의 마지막 스텝은 커밋이 아니라 "변경 파일 목록 보고"다. DP·FE는 커밋한다. - **BE 패키지 루트를 바꾸지 않는다.** 신규 코드는 `dev.caskeleton.domain.techlog.*`, `dev.caskeleton.application.techlog.*`, `dev.caskeleton.adapter.outbound.persistence.techlog.*`, `dev.caskeleton.adapter.inbound.web.techlog.*`에 넣는다. `dev.caskeleton.techlog.*`처럼 루트를 벗어나면 ArchUnit 규칙(`CleanArchitectureTest.java:221`)이 미적용된다. - **`src/config/architecture/modules.json`과 `src/settings.gradle`을 수정하지 않는다.** leaf 18개는 fail-closed 불변식이다. - **`application-core`에 외부 의존을 추가하지 않는다.** `verifyApplicationCoreDependencyPurity`가 Spring·slf4j·logback·micrometer를 클래스패스에서 금지한다. 트랜잭션은 `@Transactional`이 아니라 `dev.caskeleton.application.transaction.TransactionPort`를 쓴다. - **`CommandUseCase`/`QueryUseCase` 구현은 이름이 `UseCase`로 끝나야 하고 `@UseCaseCapability`를 선언해야 한다.** enum 값은 `TransactionMode.{WRITE,READ_ONLY}`, `Idempotency.{IDEMPOTENT,KEYED,NOT_IDEMPOTENT}`, `RepositoryAccess.{NONE,READ_REPOSITORY,WRITE_REPOSITORY}`. - **템플릿 파일을 고치지 않는다.** 특히 `EnvelopeBodyAdvice.java`, `GlobalExceptionHandler.java`, `SecurityConfig.java`, `ErrorResponseFactory.java`. 확장은 새 클래스로 한다. - **Flyway 마이그레이션은 `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/` 에 `V7`부터 추가한다.** 기존 최대는 `V6`이고 `out-of-order: false`다. 적용된 마이그레이션은 절대 수정하지 않는다. - **CSRF 헤더 이름은 `X-CSRF-TOKEN`이다.** 계약이 `const`로 고정한다. 템플릿 기본값은 `X-XSRF-TOKEN`이므로 설정으로 바꾼다. - **계약 오류 코드 23개는 그대로 쓴다.** 새 코드를 발명하지 않는다. --- ## File Structure ### DP — 계약 (SSOT) | 파일 | 책임 | | --- | --- | | `contracts/openapi/studio-v1.yaml` | Studio HTTP 계약. 이번에 봉투 형태로 재정의 | | `decisions/ADR-006-response-envelope.md` | 봉투 채택과 RFC 7807 미채택 근거 (신규) | | `docs/specs/06-api-contract-design.md` | 7장 오류 계약을 봉투로 재작성 | | `contracts/openapi/public-v1.yaml` | 배너만 추가 (변환은 구현 착수 시) | | `contracts/openapi/studio-management-v1.yaml` | 배너만 추가 | | `scripts/check-consistency.py` | `ProblemDetails.code` → `ApiError.code` 참조 변경 | | `scripts/check-contract-parity.py` | 동일 | ### FE — 소비자 | 파일 | 책임 | | --- | --- | | `src/features/tech-log/contracts/studio/{studio-api.openapi.yaml,generated.ts,canonical-source.json}` | 생성물. `pnpm generate:tech-log-contract`가 만든다 | | `src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts` | 봉투 언랩 validator 2개 | | `src/features/tech-log/adapters/http/studio-error-mapping.ts` | `ApiError` → `StudioGatewayError` | ### BE — 구현 | 파일 | 책임 | | --- | --- | | `src/config/openapi/studio-v1.yaml` | 생성 입력 (DP에서 vendor) | | `src/adapter/inbound/web/build.gradle` | openapi-generator 배선 | | `src/application-core/.../application/techlog/error/StudioError.java` | 23개 코드 enum (`ApiErrorCode` 구현) | | `src/application-core/.../application/techlog/error/StudioException.java` | `ApiErrorCarrier` 예외 | | `src/adapter/inbound/web/.../web/techlog/StudioExceptionHandler.java` | `StudioException` → 봉투 | | `src/adapter/inbound/web/.../web/techlog/studio/controller/*.java` | 얇은 controller | | `src/adapter/outbound/persistence-jpa/.../db/migration/postgresql/V7__techlog_core.sql` | Tech Log 스키마 | | `src/app-bootstrap/src/test/.../architecture/TechLogBoundaryArchTest.java` | bounded context 경계 규칙 | | `docs/registries/error-codes.yaml` | 23개 row 추가 | --- ## Task 1: 계약을 봉투 형태로 재정의 (DP) **Files:** - Create: `DP/decisions/ADR-006-response-envelope.md` - Modify: `DP/contracts/openapi/studio-v1.yaml` **Interfaces:** - Produces: `ErrorEnvelope`, `ApiError`, `ResponseMeta`, `ValidationErrorDetails`, `VersionConflictDetails`, `PublicationConflictDetails` 스키마와 15개 `Envelope` 래퍼. Task 3(FE)과 Task 4(BE)가 이 계약에서 타입을 생성한다. - Removes: `ProblemDetails` 스키마. Task 2가 이를 참조하는 스크립트를 고친다. - [ ] **Step 1: 현재 상태를 기록해 둔다** ```bash cd /home/donghyeon/workspace/tech-log-design-package git status --short grep -c 'ProblemDetails' contracts/openapi/studio-v1.yaml ``` Expected: 워킹트리 clean, `ProblemDetails` 참조 17개 (스키마 정의 1 + 응답 16). - [ ] **Step 2: ADR-006을 쓴다** `DP/decisions/ADR-006-response-envelope.md`: ```markdown # ADR-006: 응답 봉투를 wire format으로 채택한다 - 상태: 채택 - 날짜: 2026-08-18 - 관련: ADR-004 (Contract First OpenAPI) ## 맥락 Backend 구현 저장소 `tech-log-backend`는 `clean-architecture-backend-template` 스냅샷이며, 모든 JSON 응답을 봉투로 감싸는 것이 그 템플릿의 문서화된 결정이다. > `shared-contract/README.md` — "RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", > "D5 가 RFC 7807 ProblemDetail 을 거부하고, D10 이 `category`를 1급 필드로 추가했다" 반면 이 설계 패키지의 `studio-v1.yaml`은 bare payload + RFC 7807 `ProblemDetails`를 쓰고 있었다. 두 문서화된 결정이 충돌한다. ## 결정 **Studio 계약의 wire format을 봉투로 통일한다.** ```jsonc // 성공 { "success": true, "data": { ... }, "meta": { "requestId": "...", "traceId": "...", "correlationId": null, "page": null } } // 실패 (HTTP status는 그대로 의미를 갖는다) { "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT", "message": "...", "retryable": false, "details": { ... } }, "meta": { ... } } ``` 미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 쓰지 않는다. ## 근거 1. **정보 손실이 없다.** `ProblemDetails`가 담던 것을 전부 옮길 수 있고, `error.category`(10-value enum)가 덤으로 붙는다. `type`/`title`은 버리되 Frontend가 이미 `code`에서 합성하고 있다. 2. **적응 코드의 위치.** 봉투를 벗기려면 Backend 템플릿 파일 `EnvelopeBodyAdvice`를 고쳐야 하는데, 그 저장소는 tracked-snapshot이라 이후 모든 template sync의 충돌 지점이 된다. 봉투를 유지하면 적응이 Frontend 제품 코드 안에서 끝나고 Frontend 플랫폼도 무변경이다. 3. **두 규약 모두 실무 표준이다.** RFC 9457은 IETF 표준이고 봉투는 1st-party SPA 조합에서 널리 쓰인다. 어느 쪽도 틀리지 않으므로 변경 표면이 작은 쪽을 고른다. ## 대가 - OpenAPI 계약이 봉투를 기술하게 되어 payload 스키마가 한 겹 안으로 들어간다. - Backend는 생성 API interface를 쓸 수 없다. 생성 interface가 봉투 wrapper 타입을 반환하면 `EnvelopeBodyAdvice`가 한 번 더 감싸 이중 래핑이 된다. 따라서 Backend는 **model만 생성하고 controller를 손으로 쓴다.** ## 범위 이번 개정은 `studio-v1.yaml`에만 적용한다. `public-v1.yaml`과 `studio-management-v1.yaml`은 소비자가 없으므로 배너만 붙이고 구현 착수 시 변환한다. ``` - [ ] **Step 3: 계약 버전을 올린다** 봉투 도입은 breaking change다. ```bash cd /home/donghyeon/workspace/tech-log-design-package sed -i 's/^ version: 2\.0\.0$/ version: 3.0.0/' contracts/openapi/studio-v1.yaml grep -n '^ version:' contracts/openapi/studio-v1.yaml ``` Expected: `version: 3.0.0` - [ ] **Step 4: 오류 응답 16개를 봉투로 바꾼다** flow-style이라 한 줄 치환으로 끝난다. ```bash cd /home/donghyeon/workspace/tech-log-design-package sed -i 's|application/problem+json: { schema: { \$ref: "#/components/schemas/ProblemDetails" } }|application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } }|g' contracts/openapi/studio-v1.yaml grep -c 'ErrorEnvelope' contracts/openapi/studio-v1.yaml grep -c 'application/problem+json' contracts/openapi/studio-v1.yaml ``` Expected: `ErrorEnvelope` 16개, `application/problem+json` 0개. - [ ] **Step 5: 성공 응답 18개를 래퍼로 바꾼다** `paths` 구간(1행 ~ `components:` 직전)에서, `requestBody`가 없는 줄의 payload `$ref`만 `Envelope`로 바꾼다. requestBody는 봉투로 감싸지 않는다. ```bash cd /home/donghyeon/workspace/tech-log-design-package python3 - <<'PY' PATH_ = "contracts/openapi/studio-v1.yaml" PAYLOADS = ["StudioSession","StudioDashboard","DocumentPage","WorkingCopyDetail","WorkingCopy", "ValidationReport","PreviewDetail","PublicPreview","PublishResult","PublicationPage", "PublicationSnapshot","CatalogPage","AssetPage","AssetDetail","Asset"] lines = open(PATH_, encoding="utf-8").read().split("\n") end = next(i for i, l in enumerate(lines) if l.startswith("components:")) changed = 0 for i in range(end): line = lines[i] if "requestBody" in line or "application/json" not in line: continue for p in PAYLOADS: # 긴 이름부터 매칭돼야 Asset이 AssetPage를 먹지 않는다 old = f'"#/components/schemas/{p}"' if old in line: lines[i] = line.replace(old, f'"#/components/schemas/{p}Envelope"') changed += 1 break open(PATH_, "w", encoding="utf-8").write("\n".join(lines)) print("wrapped:", changed) PY ``` Expected: `wrapped: 18` - [ ] **Step 6: 치환 결과를 눈으로 확인한다** ```bash cd /home/donghyeon/workspace/tech-log-design-package grep -nE 'Envelope"' contracts/openapi/studio-v1.yaml | head -20 grep -n 'requestBody' contracts/openapi/studio-v1.yaml | grep Envelope ``` Expected: 첫 명령은 18줄, 두 번째 명령은 **출력 없음**(requestBody가 감싸이지 않았다). - [ ] **Step 7: 봉투 스키마를 추가하고 `ProblemDetails`를 제거한다** `components.schemas`의 맨 앞(`StudioSession:` 바로 위)에 아래를 넣는다. ```yaml # ------------------------------------------------------------- envelope # wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고 # 응답만 Envelope으로 감싼다. ResponseMeta: type: object additionalProperties: false required: [requestId, traceId] properties: requestId: { type: string, minLength: 1, maxLength: 200 } traceId: { type: string, minLength: 1, maxLength: 200 } correlationId: { type: [string, "null"], maxLength: 200 } page: { type: "null", description: Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다 } ApiError: type: object additionalProperties: false required: [code, category, message, retryable] properties: code: type: string enum: [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] category: type: string enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL] message: { type: string, minLength: 1, maxLength: 5000 } retryable: { type: boolean } details: oneOf: - $ref: "#/components/schemas/ValidationErrorDetails" - $ref: "#/components/schemas/VersionConflictDetails" - $ref: "#/components/schemas/PublicationConflictDetails" - type: "null" ErrorEnvelope: type: object additionalProperties: false required: [success, error, meta] properties: success: { type: boolean, const: false } error: { $ref: "#/components/schemas/ApiError" } meta: { $ref: "#/components/schemas/ResponseMeta" } ValidationErrorDetails: type: object additionalProperties: false required: [fieldErrors] properties: fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } } VersionConflictDetails: type: object additionalProperties: false required: [latestDocument] properties: latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" } conflictingFields: type: array uniqueItems: true maxItems: 200 items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" } PublicationConflictDetails: type: object additionalProperties: false required: [latestPublication] properties: latestPublication: { $ref: "#/components/schemas/PublicationAggregate" } ``` 이어서 15개 래퍼를 같은 위치에 넣는다. ``는 Step 5의 `PAYLOADS` 목록과 같다. ```yaml StudioSessionEnvelope: type: object additionalProperties: false required: [success, data, meta] properties: success: { type: boolean, const: true } data: { $ref: "#/components/schemas/StudioSession" } meta: { $ref: "#/components/schemas/ResponseMeta" } ``` 나머지 14개(`StudioDashboardEnvelope`, `DocumentPageEnvelope`, `WorkingCopyEnvelope`, `WorkingCopyDetailEnvelope`, `ValidationReportEnvelope`, `PreviewDetailEnvelope`, `PublicPreviewEnvelope`, `PublishResultEnvelope`, `PublicationPageEnvelope`, `PublicationSnapshotEnvelope`, `CatalogPageEnvelope`, `AssetPageEnvelope`, `AssetEnvelope`, `AssetDetailEnvelope`)도 `data`의 `$ref`만 바꿔 동일하게 쓴다. 그리고 기존 `ProblemDetails:` 스키마 블록 전체를 삭제한다. - [ ] **Step 8: 계약이 파싱되고 참조가 다 풀리는지 확인한다** ```bash cd /home/donghyeon/workspace/tech-log-design-package ./scripts/check-openapi.py contracts/openapi/studio-v1.yaml ``` Expected: PASS. `$ref` 미해결이나 operationId 중복이 없어야 한다. 실패하면 오타난 래퍼 이름을 고친다. - [ ] **Step 9: 봉투 구조를 프로그램으로 검산한다** ```bash cd /home/donghyeon/workspace/tech-log-design-package python3 - <<'PY' import yaml d = yaml.safe_load(open("contracts/openapi/studio-v1.yaml", encoding="utf-8")) s, M = d["components"]["schemas"], {"get","put","post","delete","patch"} assert "ProblemDetails" not in s, "ProblemDetails가 남아 있다" assert len(s["ApiError"]["properties"]["code"]["enum"]) == 23 bad = [] for p, item in d["paths"].items(): for m, op in item.items(): if m not in M: continue for code, r in op["responses"].items(): ct = r.get("content") if not ct: continue ref = ct["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] if not ref.endswith("Envelope"): bad.append(f"{op['operationId']} {code} {ref}") for name, r in d["components"]["responses"].items(): ref = r["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1] if ref != "ErrorEnvelope": bad.append(f"responses.{name} {ref}") print("FAIL:", bad) if bad else print("OK: 모든 응답이 봉투다") PY ``` Expected: `OK: 모든 응답이 봉투다` - [ ] **Step 10: 커밋** ```bash cd /home/donghyeon/workspace/tech-log-design-package git add contracts/openapi/studio-v1.yaml decisions/ADR-006-response-envelope.md git commit -m "contract: Studio 응답을 봉투 형태로 재정의하고 ADR-006 기록" ``` --- ## Task 2: 설계 패키지 정합 복구 (DP) Task 1이 `ProblemDetails`를 제거했으므로 이를 참조하는 스크립트와 문서가 깨져 있다. **Files:** - Modify: `DP/scripts/check-consistency.py:179`, `:262` - Modify: `DP/scripts/check-contract-parity.py` (오류 코드 비교 블록) - Modify: `DP/docs/specs/06-api-contract-design.md` (7장) - Modify: `DP/contracts/openapi/public-v1.yaml`, `DP/contracts/openapi/studio-management-v1.yaml` (배너) - Modify: `DP/TECH_LOG_MASTER_SPEC.md`, `DP/MANIFEST.sha256` (재생성) **Interfaces:** - Consumes: Task 1의 `ApiError.code` enum - Produces: 통과하는 검증 스크립트 4종. Task 3(FE)이 `check-contract-parity.py`로 자기 변경을 검증한다. - [ ] **Step 1: 깨진 것을 먼저 확인한다** ```bash cd /home/donghyeon/workspace/tech-log-design-package ./scripts/check-consistency.py; echo "exit=$?" ./scripts/check-contract-parity.py; echo "exit=$?" ``` Expected: 둘 다 `KeyError: 'ProblemDetails'` 또는 non-zero exit. - [ ] **Step 2: `check-consistency.py`의 참조를 옮긴다** `179`행 부근: ```python codes = set(S["ProblemDetails"]["properties"]["code"]["enum"]) ``` 를 ```python codes = set(S["ApiError"]["properties"]["code"]["enum"]) ``` 로 바꾸고, `262`행 부근의 ```python fe_codes = set(fe["components"]["schemas"]["ProblemDetails"]["properties"]["code"]["enum"]) ``` 를 ```python fe_codes = set(fe["components"]["schemas"]["ApiError"]["properties"]["code"]["enum"]) ``` 로 바꾼다. - [ ] **Step 3: `check-contract-parity.py`의 참조를 옮긴다** `ProblemDetails`를 읽는 두 줄(FE/BE 오류 코드 비교)을 `ApiError`로 바꾼다. ```bash cd /home/donghyeon/workspace/tech-log-design-package sed -i 's/\["ProblemDetails"\]/["ApiError"]/g' scripts/check-contract-parity.py scripts/check-consistency.py grep -n 'ApiError' scripts/check-contract-parity.py scripts/check-consistency.py ``` Expected: 각 파일에서 치환된 줄이 보이고 `ProblemDetails` 잔재가 없다. - [ ] **Step 4: `check-consistency.py`가 통과하는지 본다** ```bash cd /home/donghyeon/workspace/tech-log-design-package ./scripts/check-consistency.py; echo "exit=$?" ``` Expected: `exit=0`. `check-contract-parity.py`는 FE가 아직 재생성 전이라 이 시점에 실패할 수 있다 — Task 3에서 통과시킨다. - [ ] **Step 5: 06장 오류 계약을 봉투로 재작성한다** `DP/docs/specs/06-api-contract-design.md`의 `## 7. 오류 계약` 절 본문을 아래로 바꾼다. 7.1~7.4 하위 절의 내용(HTTP 상태, 코드 목록, 혼동 금지, 충돌 부가 정보)은 유지하되 표현 매체만 봉투로 옮긴다. ```markdown ## 7. 오류 계약 wire format은 봉투다 (ADR-006). `application/problem+json`과 RFC 7807은 쓰지 않는다. ```jsonc { "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT", "message": "저장된 version이 더 최신입니다", "retryable": false, "details": { "latestDocument": { }, "conflictingFields": ["/title"] } }, "meta": { "requestId": "...", "traceId": "...", "correlationId": null, "page": null } } ``` HTTP status는 그대로 의미를 갖는다. `success: false`는 status를 대체하지 않고 중복 표기한다. `error.category`는 10개 값이며 클라이언트가 23개 코드를 전부 열거하지 않고도 굵게 분기할 수 있게 한다. ```text VALIDATION AUTH AUTHZ NOT_FOUND CONFLICT RATE_LIMIT TRANSIENT_DEPENDENCY PERMANENT_DEPENDENCY DATA_INTEGRITY INTERNAL ``` `error.details`는 code별 polymorphic이며 계약에서 `oneOf`로 선언한다. ```text REQUEST_VALIDATION_FAILED / VALIDATION_FAILED → ValidationErrorDetails fieldErrors[] VERSION_CONFLICT → VersionConflictDetails latestDocument, conflictingFields[] PUBLICATION_CONFLICT → PublicationConflictDetails latestPublication 그 외 → null ``` `traceId`는 `meta.traceId`에 있으며 응답에서 절대 null이 아니다. ``` - [ ] **Step 6: 나머지 두 계약에 배너를 붙인다** `public-v1.yaml`과 `studio-management-v1.yaml`의 `info.description` 맨 앞에 넣는다. ```text ⛔ 봉투 결정(ADR-006) 반영 대기 — 이 계약은 아직 bare payload + ProblemDetails다. 소비자가 없어 변환을 미뤘다. 구현에 착수할 때 studio-v1.yaml과 같은 방식으로 ErrorEnvelope / Envelope으로 변환한다. ``` - [ ] **Step 7: MASTER_SPEC과 MANIFEST를 재생성한다** ```bash cd /home/donghyeon/workspace/tech-log-design-package ./scripts/build-master-spec.sh ./scripts/update-manifest.sh ./scripts/build-master-spec.sh --check; echo "master=$?" ./scripts/update-manifest.sh --check; echo "manifest=$?" ``` Expected: 둘 다 `=0`. - [ ] **Step 8: 커밋** ```bash cd /home/donghyeon/workspace/tech-log-design-package git add scripts/ docs/specs/06-api-contract-design.md contracts/openapi/ TECH_LOG_MASTER_SPEC.md MANIFEST.sha256 git commit -m "spec/scripts: 봉투 결정에 맞춰 오류 계약과 검증 스크립트 정합" ``` --- ## Task 3: 프론트 계약 재생성과 봉투 언랩 (FE) **Files:** - Modify: `FE/src/features/tech-log/contracts/studio/studio-api.openapi.yaml` (생성물) - Modify: `FE/src/features/tech-log/contracts/studio/generated.ts` (생성물) - Modify: `FE/src/features/tech-log/contracts/studio/canonical-source.json` (생성물) - Modify: `FE/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts` - Modify: `FE/src/features/tech-log/adapters/http/studio-error-mapping.ts` **Interfaces:** - Consumes: Task 1의 `studio-v1.yaml` v3.0.0 - Produces: 봉투를 언랩하는 `envelopeData` / `envelopeError` validator. 앱·도메인 계층은 기존과 같은 payload 타입을 계속 받는다 — `StudioGateway` 포트 시그니처는 바뀌지 않는다. - [ ] **Step 1: 작업 브랜치를 만든다** 현재 브랜치 `fix/techlog-alignment-followups`가 최신 작업 상태이므로 그 위에서 딴다. ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend git status --short git checkout -b feature/studio-response-envelope ``` Expected: 새 브랜치로 전환. 워킹트리가 더러우면 먼저 사용자에게 보고하고 멈춘다. - [ ] **Step 2: 드리프트 게이트가 지금은 통과하는지 확인한다 (기준선)** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend corepack pnpm check:tech-log-contract; echo "exit=$?" ``` Expected: `exit=0` (아직 vendor된 구 계약과 digest가 맞는다). - [ ] **Step 3: 계약을 재생성한다** 생성기는 `$TECH_LOG_DESIGN_PACKAGE/contracts/openapi/studio-v1.yaml`을 읽고 `pnpm dlx openapi-typescript@7.9.1`로 타입을 만든다. ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend corepack pnpm generate:tech-log-contract git diff --stat src/features/tech-log/contracts/studio/ ``` Expected: 세 파일 모두 변경. `pnpm dlx`가 네트워크를 쓰므로 실패하면 오프라인이 원인이다 — 그 경우 사용자에게 보고하고 멈춘다(수기 편집으로 우회하지 않는다. `--check`가 digest로 잡아낸다). - [ ] **Step 4: 생성 타입에 봉투가 들어왔는지 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend grep -c 'Envelope' src/features/tech-log/contracts/studio/generated.ts grep -c 'ProblemDetails' src/features/tech-log/contracts/studio/generated.ts ``` Expected: `Envelope` 다수, `ProblemDetails` 0. - [ ] **Step 5: 언랩 validator의 실패 테스트를 쓴다** `FE/tests/features/tech-log/studio-envelope-unwrap.test.ts`: ```ts import { describe, expect, it } from "vitest"; import { envelopeData, envelopeError } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; describe("studio 봉투 언랩", () => { it("성공 봉투에서 data를 꺼낸다", () => { const result = envelopeData("getStudioSessionOutput").safeParse({ success: true, data: { authenticated: true, displayName: "d", roles: [], csrfToken: "t", csrfHeaderName: "X-CSRF-TOKEN" }, meta: { requestId: "r", traceId: "t", correlationId: null, page: null }, }); expect(result.success).toBe(true); if (result.success) expect(result.data).toMatchObject({ displayName: "d" }); }); it("봉투가 아닌 본문을 거절한다", () => { const result = envelopeData("getStudioSessionOutput").safeParse({ displayName: "d" }); expect(result.success).toBe(false); }); it("오류 봉투를 ProblemDetails 형태로 옮긴다", () => { const result = envelopeError().safeParse({ success: false, error: { code: "VERSION_CONFLICT", category: "CONFLICT", message: "conflict", retryable: false, details: null }, meta: { requestId: "r", traceId: "tr", correlationId: null, page: null }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.code).toBe("VERSION_CONFLICT"); expect(result.data.status).toBe(0); expect(result.data.title).toBe("VERSION_CONFLICT"); } }); it("계약 밖 코드를 거절한다", () => { const result = envelopeError().safeParse({ success: false, error: { code: "NOT_A_STUDIO_CODE", category: "INTERNAL", message: "x", retryable: false, details: null }, meta: { requestId: "r", traceId: "tr", correlationId: null, page: null }, }); expect(result.success).toBe(false); }); }); ``` - [ ] **Step 6: 테스트를 돌려 실패를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend corepack pnpm vitest run tests/features/tech-log/studio-envelope-unwrap.test.ts ``` Expected: FAIL — `envelopeData`/`envelopeError` export가 없다. - [ ] **Step 7: validator를 구현한다** `FE/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`에서 `passthrough`/`problemSchema`/`PROBLEM` 정의부를 아래로 교체한다. ```ts /** * wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는 * 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신 * 때마다 두 곳을 고치게 만든다. 다만 봉투 자체는 반드시 검증한다: 여기서 통과시키면 * 잘못된 모양이 앱 계층까지 조용히 흘러간다. */ const metaSchema = z .object({ requestId: z.string().min(1), traceId: z.string().min(1) }) .loose(); export const envelopeData = (schemaId: string): RuntimeValidator => zodValidator( schemaId, z .object({ success: z.literal(true), data: z.unknown(), meta: metaSchema }) .loose() .transform((envelope) => envelope.data as T) as unknown as z.ZodType, ); const apiErrorSchema = z .object({ code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]), category: z.string().min(1), message: z.string().min(1).max(5000), retryable: z.boolean(), }) .loose(); /** * 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은 * 그 모양을 계속 쓰므로 매핑을 여기서 끝내면 아래 계층이 무변경이다. * `status`는 봉투에 없다 — 전송 계층이 실제 HTTP status를 따로 들고 있으므로 * 0으로 두고 `toStudioGatewayError`가 outcome의 status로 덮는다. */ export const envelopeError = (): RuntimeValidator => zodValidator( "StudioErrorEnvelope", z .object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema }) .loose() .transform((envelope) => ({ type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`, title: envelope.error.code, status: 0, detail: envelope.error.message, code: envelope.error.code, retryable: envelope.error.retryable, category: envelope.error.category, details: (envelope.error as { details?: unknown }).details ?? null, })) as unknown as z.ZodType, ); export type StudioProblemShape = Readonly<{ type: string; title: string; status: number; detail: string; code: string; retryable: boolean; category: string; details: unknown; }>; const PROBLEM = envelopeError(); ``` 그리고 `safeOperation`과 mutating operation 정의의 ```ts outputValidator: passthrough(`${operationId}Output`), ``` 을 전부 ```ts outputValidator: envelopeData(`${operationId}Output`), ``` 로 바꾼다. `inputValidator`는 요청 본문이라 그대로 `passthrough`를 쓴다. - [ ] **Step 8: `status`를 실제 HTTP status로 덮는다** `FE/src/features/tech-log/adapters/http/studio-error-mapping.ts`의 `PROBLEM` 분기에서 `outcome.problem`의 `status`가 0이면 전송 계층이 아는 status로 채운다. ```ts case "PROBLEM": { const problem = outcome.problem as ProblemDetails; const status = problem.status === 0 ? (outcome.metadata?.httpStatus ?? 0) : problem.status; if (!CODES.has(problem.code)) { return synthetic("STUDIO_UNAVAILABLE", status, problem.detail, true); } return new StudioGatewayError({ ...problem, status }); } ``` `outcome.metadata`의 status 필드명이 다르면 `src/adapters/http/http-execution-v3.ts`의 `SafeResponseMetadata` 정의를 읽어 맞춘다. - [ ] **Step 9: 테스트를 돌려 통과를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend corepack pnpm vitest run tests/features/tech-log/studio-envelope-unwrap.test.ts ``` Expected: 4개 PASS. - [ ] **Step 10: tech-log 전체 테스트와 드리프트 게이트를 돌린다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend corepack pnpm check:tech-log-contract; echo "contract=$?" corepack pnpm test:tech-log; echo "tests=$?" corepack pnpm exec tsc -p tsconfig.app.json --noEmit; echo "types=$?" ``` Expected: 셋 다 `=0`. mock 게이트웨이는 전송 경계 위에 있어 영향받지 않아야 한다. 깨지면 그 테스트가 HTTP 본문을 직접 만들고 있는 것이므로 봉투로 감싸 고친다. - [ ] **Step 11: 계약 parity를 확인한다** ```bash cd /home/donghyeon/workspace/tech-log-design-package ./scripts/check-contract-parity.py; echo "exit=$?" ``` Expected: `exit=0`, operation 19/19 일치, 오류 코드 23/23 보존. - [ ] **Step 12: 커밋** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend git add src/features/tech-log tests/features/tech-log/studio-envelope-unwrap.test.ts git commit -m "feat: Studio 응답 봉투를 전송 경계에서 언랩한다" ``` --- ## Task 4: 백엔드 계약 vendor와 model 생성 배선 (BE) **Files:** - Create: `BE/src/config/openapi/studio-v1.yaml` - Create: `BE/src/config/openapi/MANIFEST.sha256` - Modify: `BE/src/adapter/inbound/web/build.gradle` - Modify: `BE/src/adapter/inbound/web/gradle.lockfile` (재생성) **Interfaces:** - Produces: `dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.*` 패키지의 생성 DTO. Task 8·9의 controller가 이 타입을 반환한다. - [ ] **Step 1: 브랜치와 워킹트리를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend git branch --show-current git status --short | grep -v '^ D ' | head ``` Expected: `feature/techlog-studio-backend`. 미커밋 삭제분(`*-superpowers-package/`, `scripts/verify-httpclient-docs.py`)은 이미 있던 것이므로 건드리지 않는다. - [ ] **Step 2: 계약을 vendor하고 해시를 기록한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend mkdir -p src/config/openapi cp /home/donghyeon/workspace/tech-log-design-package/contracts/openapi/studio-v1.yaml src/config/openapi/studio-v1.yaml ( cd src/config/openapi && sha256sum studio-v1.yaml > MANIFEST.sha256 ) cat src/config/openapi/MANIFEST.sha256 ``` Expected: ` studio-v1.yaml` 한 줄. > **2026-08-18 실측 — 이 스파이크는 실패했고 계약을 고쳐 해소했다.** > > 1. `generateApis` / `generateModels`는 openapi-generator-gradle-plugin 7.18.0에 > **존재하지 않는 속성**이다. 설정하면 Gradle 평가에서 > `Could not set unknown property 'generateApis'`로 죽는다. 실제 API는 > `globalProperties`이고 `['models': '']`가 "model만 생성"을 뜻한다. > 2. 올바른 설정으로도 생성이 NPE로 죽는다 — > `DefaultCodegen.setEnumDiscriminatorDefaultValue`에서 > `"var.allowableValues" is null` (model `WorkingCopyInput` 처리 중). > 계약의 5개 discriminator `oneOf` union이 하위 타입의 discriminator 필드를 > OpenAPI 3.1 관용구인 `const`로 좁히기 때문이다. > `legacyDiscriminatorBehavior: false`로도 동일하게 실패한다. 네트워크 문제 아님. > 3. **해소:** discriminator로 쓰이는 필드의 `const: X`를 의미가 같은 단일값 > `enum: [X]`로 바꾼다. `discriminator` 키워드는 3.1의 `const`보다 앞서 만들어졌고 > 생태계 관용구가 단일값 `enum`이라, 우회가 아니라 계약을 표준 관용구에 맞추는 것이다. > 봉투 래퍼의 `success: { const: true|false }` 16개와 `csrfHeaderName`의 `const`는 > discriminator가 아니므로 **그대로 둔다.** > 4. spec §5.2의 "1회 생성 후 커밋" 폴백은 **쓸 수 없다** — 생성이 한 번은 성공한다는 > 전제인데 생성 자체가 실패하기 때문이다. - [ ] **Step 3: 생성기 조합을 폐기용 스파이크로 검증한다** openapi-generator 7.x × Spring Boot 4.0.0 조합은 이 저장소에서 검증된 적이 없다. 본 배선 전에 별도 디렉터리에서 먼저 돌려본다. ```bash cd /tmp/claude-1000/-home-donghyeon-workspace-tech-log-design-package/e90b7626-9073-4d69-acc3-41bfc22d3e83/scratchpad mkdir -p genspike && cd genspike cat > build.gradle <<'EOF' plugins { id 'java'; id 'org.openapi.generator' version '7.18.0' } repositories { mavenCentral() } openApiGenerate { generatorName = 'spring' inputSpec = '/home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/openapi/studio-v1.yaml' outputDir = "$projectDir/out".toString() apiPackage = 'spike.api' modelPackage = 'spike.model' globalProperties.set(['models': '']) // model만 생성 (generateApis/generateModels는 존재하지 않는 속성) configOptions = [useSpringBoot3: 'true', useJakartaEe: 'true', openApiNullable: 'true'] } EOF cat > settings.gradle <<'EOF' rootProject.name = 'genspike' EOF /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/gradlew --project-dir . openApiGenerate --console=plain 2>&1 | tail -20 ls out/src/main/java/spike/model | head ls out/src/main/java/spike/model | wc -l ``` Expected: BUILD SUCCESSFUL, `spike/model`에 100개 안팎의 `.java`. 실패하면 **여기서 멈추고** 보고한다. 위 실측 노트가 이미 원인과 해소를 담고 있다. 이 디렉터리는 폐기물이며 저장소에 남기지 않는다. - [ ] **Step 4: web 모듈에 생성기를 배선한다** `BE/src/adapter/inbound/web/build.gradle` 맨 위 `plugins` 블록이 없으면 파일 첫 줄에 추가하고, 파일 끝에 아래를 붙인다. ```groovy // --------------------------------------------------------------------------- // Studio 계약 DTO 생성 (ADR-004 / ADR-006). // generateApis=false: 계약이 봉투를 기술하므로 생성 API interface는 봉투 wrapper // 타입을 반환하게 되고, 그 타입은 dev.caskeleton.shared.response.Envelope가 아니라서 // EnvelopeBodyAdvice가 한 번 더 감싼다(이중 래핑). controller는 손으로 쓴다. // --------------------------------------------------------------------------- openApiGenerate { generatorName = 'spring' inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString() outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model' globalProperties.set(['models': '']) // model만 생성 (generateApis/generateModels는 존재하지 않는 속성) generateModelTests = false generateModelDocumentation = false generateSupportingFiles = false configOptions = [useSpringBoot3: 'true', useJakartaEe: 'true', openApiNullable: 'true'] } sourceSets.main.java.srcDir( layout.buildDirectory.dir('generated/openapi/src/main/java')) tasks.named('compileJava') { dependsOn 'openApiGenerate' } // 생성 코드는 품질 게이트 대상이 아니다. 단 기존 web 코드의 게이트는 유지한다 — // SpotBugs를 모듈 전체에서 끄면 손으로 쓴 controller도 검사받지 않는다. tasks.matching { it.name.startsWith('spotless') }.configureEach { dependsOn 'openApiGenerate' } spotless { java { targetExclude('build/generated/**') } } tasks.withType(com.github.spotbugs.snom.SpotBugsTask).configureEach { dependsOn 'openApiGenerate' excludeFilter = file("${rootDir}/config/spotbugs/generated-openapi-exclude.xml") } ``` `BE/src/config/spotbugs/generated-openapi-exclude.xml`: ```xml ``` 기존 `config/spotbugs/`에 이미 exclude filter가 있으면 새 파일을 만들지 말고 그 파일에 위 `` 블록만 추가한다. ```bash ls /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/spotbugs/ ``` 플러그인 선언은 `BE/src/build.gradle`의 루트 `plugins` 블록에 추가한다. ```groovy id 'org.openapi.generator' version '7.18.0' apply false ``` 그리고 `BE/src/adapter/inbound/web/build.gradle` 첫 줄에 ```groovy plugins { id 'org.openapi.generator' } ``` - [ ] **Step 5: 생성과 컴파일을 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:inbound:web:openApiGenerate --console=plain ls build/../adapter/inbound/web/build/generated/openapi/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/api/model | wc -l ./gradlew :adapter:inbound:web:compileJava --console=plain ``` Expected: 생성 파일 100개 안팎, `BUILD SUCCESSFUL`. 기존 web 코드의 품질 게이트가 살아 있는지 확인한다. 아래가 통과해야 exclude filter가 생성 패키지만 좁게 뺀 것이다. ```bash ./gradlew :adapter:inbound:web:check --console=plain 2>&1 | tail -20 ``` - [ ] **Step 6: 의존 lock을 재생성한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:inbound:web:dependencies --write-locks --console=plain > /dev/null ./gradlew :adapter:inbound:web:verifyDependencyLocks --console=plain git diff --stat adapter/inbound/web/gradle.lockfile ``` Expected: `verifyDependencyLocks` 통과, lockfile 변경 있음. - [ ] **Step 7: 아키텍처 검증이 여전히 통과하는지 본다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain ``` Expected: 둘 다 PASS. - [ ] **Step 8: 변경 파일을 보고한다 (커밋하지 않는다)** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend git status --short | grep -v '^ D ' ``` `AGENTS.md:64`에 따라 stage/commit/push하지 않는다. 목록만 사용자에게 보고한다. --- ## Task 5: Studio 오류 코드와 예외 매핑 (BE) **Files:** - Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioError.java` - Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/StudioException.java` - Create: `BE/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java` - Test: `BE/src/application-core/src/test/java/dev/caskeleton/application/techlog/error/StudioErrorTest.java` - Test: `BE/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandlerTest.java` - Modify: `BE/docs/registries/error-codes.yaml` **Interfaces:** - Produces: `StudioError`(enum, `ApiErrorCode` 구현) — Task 8·9와 이후 모든 슬라이스가 이 코드로 실패를 표현한다. `StudioException.of(StudioError, String)` / `withDetails(StudioError, String, Object)`. - [ ] **Step 1: `StudioError` 실패 테스트를 쓴다** `StudioErrorTest.java`: ```java package dev.caskeleton.application.techlog.error; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.shared.error.Category; import java.util.Arrays; import org.junit.jupiter.api.Test; class StudioErrorTest { @Test void declaresExactlyTheTwentyThreeContractCodes() { assertThat(StudioError.values()).hasSize(23); } @Test void everyCodeCarriesACategoryAndAClientFacingStatus() { Arrays.stream(StudioError.values()) .forEach( error -> { assertThat(error.code()).matches("[A-Z][A-Z0-9_]*"); assertThat(error.category()).isNotNull(); assertThat(error.httpStatus()).isBetween(400, 599); }); } @Test void versionConflictIsAFourZeroNineConflict() { assertThat(StudioError.VERSION_CONFLICT.httpStatus()).isEqualTo(409); assertThat(StudioError.VERSION_CONFLICT.category()).isEqualTo(Category.CONFLICT); assertThat(StudioError.VERSION_CONFLICT.retryable()).isFalse(); } @Test void studioUnavailableIsRetryable() { assertThat(StudioError.STUDIO_UNAVAILABLE.httpStatus()).isEqualTo(503); assertThat(StudioError.STUDIO_UNAVAILABLE.category()).isEqualTo(Category.TRANSIENT_DEPENDENCY); assertThat(StudioError.STUDIO_UNAVAILABLE.retryable()).isTrue(); } } ``` - [ ] **Step 2: 테스트를 돌려 실패를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :application-core:test --tests '*StudioErrorTest' --console=plain ``` Expected: 컴파일 실패 — `StudioError` 없음. - [ ] **Step 3: `StudioError`를 구현한다** ```java package dev.caskeleton.application.techlog.error; import dev.caskeleton.shared.error.ApiErrorCode; import dev.caskeleton.shared.error.Category; /** * Studio 계약(`studio-v1.yaml`)의 `ApiError.code` enum 23종. 계약과 1:1이며 여기서 * 코드를 늘리거나 줄이면 계약과 `docs/registries/error-codes.yaml`을 함께 고쳐야 한다. */ public enum StudioError implements ApiErrorCode { AUTHENTICATION_REQUIRED(Category.AUTH, 401, false), STUDIO_ACCESS_DENIED(Category.AUTHZ, 403, false), DOCUMENT_NOT_FOUND(Category.NOT_FOUND, 404, false), VERSION_CONFLICT(Category.CONFLICT, 409, false), REQUEST_VALIDATION_FAILED(Category.VALIDATION, 422, false), VALIDATION_FAILED(Category.VALIDATION, 422, false), VALIDATION_STALE(Category.CONFLICT, 409, false), PREVIEW_NOT_FOUND(Category.NOT_FOUND, 404, false), PREVIEW_STALE(Category.CONFLICT, 409, false), PREVIEW_EXPIRED(Category.CONFLICT, 409, false), PUBLICATION_NOT_FOUND(Category.NOT_FOUND, 404, false), PUBLICATION_CONFLICT(Category.CONFLICT, 409, false), PUBLICATION_EVENT_NOT_FOUND(Category.NOT_FOUND, 404, false), PUBLICATION_SNAPSHOT_NOT_FOUND(Category.NOT_FOUND, 404, false), WARNING_ACKNOWLEDGEMENT_REQUIRED(Category.VALIDATION, 422, false), IDEMPOTENCY_KEY_REUSED(Category.CONFLICT, 409, false), ASSET_NOT_FOUND(Category.NOT_FOUND, 404, false), ASSET_NOT_READY(Category.CONFLICT, 409, false), ASSET_IN_USE(Category.CONFLICT, 409, false), ASSET_QUARANTINED(Category.DATA_INTEGRITY, 409, false), PAYLOAD_TOO_LARGE(Category.VALIDATION, 413, false), UNSUPPORTED_MEDIA_TYPE(Category.VALIDATION, 415, false), STUDIO_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, 503, true); private final Category category; private final int httpStatus; private final boolean retryable; StudioError(Category category, int httpStatus, boolean retryable) { this.category = category; this.httpStatus = httpStatus; this.retryable = retryable; } @Override public String code() { return name(); } @Override public Category category() { return category; } @Override public int httpStatus() { return httpStatus; } @Override public boolean retryable() { return retryable; } } ``` - [ ] **Step 4: 테스트를 돌려 통과를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :application-core:test --tests '*StudioErrorTest' --console=plain ``` Expected: 4개 PASS. - [ ] **Step 5: `StudioException`을 만든다** ```java package dev.caskeleton.application.techlog.error; import dev.caskeleton.shared.error.ApiErrorCarrier; import dev.caskeleton.shared.error.ApiErrorCode; /** * Studio use case와 facade가 던지는 유일한 실패 표현. 전송 계층은 * {@link ApiErrorCarrier}만 보고 봉투로 옮기므로 application이 HTTP를 알 필요가 없다. * *

{@code details}는 계약의 {@code ApiError.details}에 그대로 실린다 — * {@code VERSION_CONFLICT}면 최신 문서, {@code PUBLICATION_CONFLICT}면 최신 Publication. */ public final class StudioException extends RuntimeException implements ApiErrorCarrier { private final transient StudioError error; private final transient Object details; private StudioException(StudioError error, String message, Object details) { super(message); this.error = error; this.details = details; } public static StudioException of(StudioError error, String message) { return new StudioException(error, message, null); } public static StudioException withDetails(StudioError error, String message, Object details) { return new StudioException(error, message, details); } @Override public ApiErrorCode errorCode() { return error; } public StudioError studioError() { return error; } public Object details() { return details; } } ``` - [ ] **Step 6: 전송 매핑 실패 테스트를 쓴다** `StudioExceptionHandlerTest.java`: ```java package dev.caskeleton.adapter.inbound.web.techlog; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.shared.response.Envelope; import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.http.ResponseEntity; class StudioExceptionHandlerTest { private final StudioExceptionHandler handler = new StudioExceptionHandler(); @Test void mapsStudioExceptionToFailureEnvelopeWithContractCode() { ResponseEntity> response = handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음")); assertThat(response.getStatusCode().value()).isEqualTo(404); Envelope body = response.getBody(); assertThat(body).isNotNull(); assertThat(body.success()).isFalse(); assertThat(body.error().code()).isEqualTo("DOCUMENT_NOT_FOUND"); assertThat(body.error().category()).isEqualTo("NOT_FOUND"); assertThat(body.error().retryable()).isFalse(); } @Test void carriesDetailsForConflicts() { ResponseEntity> response = handler.handleStudio( StudioException.withDetails( StudioError.VERSION_CONFLICT, "충돌", Map.of("latestDocument", Map.of("version", 8)))); assertThat(response.getStatusCode().value()).isEqualTo(409); assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().error().details()).isNotNull(); } } ``` - [ ] **Step 7: 테스트를 돌려 실패를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:inbound:web:test --tests '*StudioExceptionHandlerTest' --console=plain ``` Expected: 컴파일 실패 — `StudioExceptionHandler` 없음. - [ ] **Step 8: 핸들러를 구현한다** `GlobalExceptionHandler`를 고치지 않고 별도 advice로 붙인다. Spring은 예외 타입이 더 구체적인 핸들러를 고르므로 `StudioException`은 이쪽으로 온다. ```java package dev.caskeleton.adapter.inbound.web.techlog; import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.shared.response.Envelope; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; /** * Studio 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 * 수정하지 않기 위해 별도 advice로 둔다 — 그 파일은 template sync 대상이다. */ @Order(Ordered.HIGHEST_PRECEDENCE) @RestControllerAdvice public class StudioExceptionHandler { @ExceptionHandler(StudioException.class) public ResponseEntity> handleStudio(StudioException ex) { return ErrorResponseFactory.envelope(ex.studioError(), ex.getMessage(), ex.details()); } } ``` - [ ] **Step 9: 테스트를 돌려 통과를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:inbound:web:test --tests '*StudioExceptionHandlerTest' --console=plain ``` Expected: 2개 PASS. - [ ] **Step 10: 오류 코드 레지스트리에 23개를 추가한다** `BE/docs/registries/error-codes.yaml`의 `errors:` 목록 끝에 아래 형식으로 23개를 넣는다. `StudioError`의 category/status/retryable과 **정확히 같아야 한다**. ```yaml # ============================================================ # TECH LOG STUDIO (studio-v1.yaml ApiError.code — 23종) # ============================================================ - code: AUTHENTICATION_REQUIRED category: AUTH http_status: 401 retryable: false retry_after_seconds: null owner_branch: feature-techlog-studio-backend owner_layer: presentation client_safe_message: "Studio 인증이 필요합니다" log_level: INFO runbook_link: runbook://auth/auth-token-missing compatibility_impact: additive required_test: StudioErrorTest - code: VERSION_CONFLICT category: CONFLICT http_status: 409 retryable: false retry_after_seconds: null owner_branch: feature-techlog-studio-backend owner_layer: application client_safe_message: "저장된 version이 더 최신입니다" log_level: INFO runbook_link: null compatibility_impact: additive required_test: StudioErrorTest ``` runbook 정책상 `runbook_link`가 필수인 것은 세 개다 — `AUTHENTICATION_REQUIRED`(AUTH), `STUDIO_ACCESS_DENIED`(AUTHZ), `STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY, retryable). 앞의 둘은 기존 `runbook://auth/*`, `runbook://authz/*` 문서를 재사용하고, `STUDIO_UNAVAILABLE`은 `BE/docs/runbooks/studio-unavailable.md`를 새로 쓴다. 나머지 20개는 client-error라 `runbook_link: null`이다. - [ ] **Step 11: 레지스트리와 enum이 일치하는지 테스트로 고정한다** `BE/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java`: ```java package dev.caskeleton.bootstrap.architecture; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.application.techlog.error.StudioError; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; class StudioErrorRegistryTest { @Test void everyStudioErrorHasARegistryRow() throws Exception { Path registry = Path.of("..", "..", "docs", "registries", "error-codes.yaml").normalize(); List lines = Files.readAllLines(registry); Set registered = lines.stream() .map(String::strip) .filter(line -> line.startsWith("- code:")) .map(line -> line.substring("- code:".length()).strip()) .collect(Collectors.toSet()); Set declared = Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet()); assertThat(registered).containsAll(declared); } } ``` 경로가 맞지 않으면 `./gradlew :app-bootstrap:test`를 돌려 나오는 실제 작업 디렉터리로 `Path.of(...)`를 조정한다. - [ ] **Step 12: 테스트를 돌린다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :app-bootstrap:test --tests '*StudioErrorRegistryTest' --console=plain ``` Expected: PASS. - [ ] **Step 13: 변경 파일을 보고한다 (커밋하지 않는다)** --- ## Task 6: bounded context 경계 규칙 (BE) **Files:** - Create: `BE/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java` **Interfaces:** - Produces: 이후 모든 슬라이스가 이 규칙 아래에서 코드를 놓는다. 규칙을 어기면 빌드가 깨진다. > **정정 (2026-08-19):** 아래 Step 1의 코드는 규칙 **5개**만 담고 있는데, spec §4.3은 **7개**를 > 요구한다. 누락 2건은 계획 결함이며 fix round에서 보완했다. > > - spec §4.3 규칙 1은 `content`/`inquiry`/`project`/`asset` **네 방향 모두**를 요구한다. > Step 1에는 앞의 셋만 있어 `asset`이 형제를 자유롭게 참조할 수 있었다. > - spec §4.3 규칙 4(`domain.techlog.publication` 외의 domain 패키지가 `Publication`을 직접 > 변경하지 않는다)가 Step 1에 아예 없다. ArchUnit으로 "변경"을 정적 표현할 수 없으므로 > **타 domain context가 `..domain.techlog.publication..`에 의존하는 것 자체를 금지**하는 더 > 엄격한 근사로 구현한다(형제 규칙들도 전면 금지이므로 일관된다). > > 교훈: ArchUnit 규칙은 `allowEmptyShould(true)` 때문에 **없는 규칙과 통과하는 규칙이 구분되지 > 않는다.** 규칙 목록을 쓸 때는 spec의 항목 수와 대조하고, 각 규칙을 RED로 검증해야 한다. - [ ] **Step 1: 규칙 테스트를 쓴다** 지금은 `techlog` 패키지에 클래스가 거의 없으므로 `allowEmptyShould(true)`로 두어 빈 상태에서도 통과하게 한다. 코드가 늘면 자동으로 효력이 생긴다. ```java package dev.caskeleton.bootstrap.architecture; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; /** * 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 * 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 방어선이다. */ @AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) class TechLogBoundaryArchTest { @ArchTest static final ArchRule CONTENT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = noClasses() .that() .resideInAPackage("..techlog.content..") .should() .dependOnClassesThat() .resideInAnyPackage("..techlog.inquiry..", "..techlog.project..", "..techlog.asset..") .as("techlog.content는 형제 context에 의존하지 않는다") .allowEmptyShould(true); @ArchTest static final ArchRule INQUIRY_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = noClasses() .that() .resideInAPackage("..techlog.inquiry..") .should() .dependOnClassesThat() .resideInAnyPackage("..techlog.content..", "..techlog.project..", "..techlog.asset..") .as("techlog.inquiry는 형제 context에 의존하지 않는다") .allowEmptyShould(true); @ArchTest static final ArchRule PROJECT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = noClasses() .that() .resideInAPackage("..techlog.project..") .should() .dependOnClassesThat() .resideInAnyPackage("..techlog.content..", "..techlog.inquiry..", "..techlog.asset..") .as("techlog.project는 형제 context에 의존하지 않는다") .allowEmptyShould(true); @ArchTest static final ArchRule STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS = noClasses() .that() .resideInAPackage("..application.techlog.studio..") .should() .dependOnClassesThat() .resideInAnyPackage( "..application.techlog.content.service..", "..application.techlog.content.port.out..", "..application.techlog.inquiry.service..", "..application.techlog.inquiry.port.out..", "..application.techlog.project.service..", "..application.techlog.project.port.out..", "..application.techlog.asset.service..", "..application.techlog.asset.port.out..", "..application.techlog.publication.service..", "..application.techlog.publication.port.out..", "..domain.techlog..") .as("studio facade는 타 context의 port.in만 호출한다 (domain·service·port.out 직접 접근 금지)") .allowEmptyShould(true); @ArchTest static final ArchRule NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE = noClasses() .that() .resideInAnyPackage( "..techlog.content..", "..techlog.inquiry..", "..techlog.project..", "..techlog.asset..", "..techlog.publication..") .should() .dependOnClassesThat() .resideInAPackage("..application.techlog.studio..") .as("도메인 context는 studio facade에 역방향 의존하지 않는다") .allowEmptyShould(true); } ``` - [ ] **Step 2: 테스트를 돌린다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain ``` Expected: 5개 규칙 PASS (아직 대상 클래스가 없어 vacuously true). `ProductionClassImportOption`을 import할 수 없으면 `CleanArchitectureTest.java`가 쓰는 정확한 패키지 경로를 확인해 맞춘다. - [ ] **Step 3: 규칙이 실제로 잡는지 확인한다 (일회성 검증)** `application-core`에 위반 클래스를 임시로 만들어 테스트가 실패하는지 본다. ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src mkdir -p application-core/src/main/java/dev/caskeleton/application/techlog/studio mkdir -p application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out cat > application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out/TempPort.java <<'EOF' package dev.caskeleton.application.techlog.content.port.out; public interface TempPort {} EOF cat > application-core/src/main/java/dev/caskeleton/application/techlog/studio/TempViolation.java <<'EOF' package dev.caskeleton.application.techlog.studio; import dev.caskeleton.application.techlog.content.port.out.TempPort; public final class TempViolation { TempPort port; } EOF ./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain 2>&1 | tail -15 ``` Expected: `STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS` 실패. - [ ] **Step 4: 임시 파일을 지우고 다시 통과시킨다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src rm application-core/src/main/java/dev/caskeleton/application/techlog/studio/TempViolation.java rm application-core/src/main/java/dev/caskeleton/application/techlog/content/port/out/TempPort.java ./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain ``` Expected: PASS. - [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** --- ## Task 7: Tech Log 코어 스키마 마이그레이션 (BE) **Files:** - Create: `BE/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql` - Test: `BE/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/TechLogSchemaMigrationTest.java` **Interfaces:** - Produces: `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`. Task 9(catalog)와 이후 슬라이스가 읽고 쓴다. - [ ] **Step 1: 원본 DDL을 가져와 차이를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend grep -nE "^CREATE TABLE" /home/donghyeon/workspace/tech-log-design-package/database/V1__init.sql | sed 's/ (.*//' grep -nE "^CREATE TABLE" src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/*.sql | sed 's/ (.*//' ``` Expected: 설계 DDL 테이블 목록과 기존 테이블 목록. **이름이 겹치는 것이 없어야 한다.** 겹치면 여기서 멈추고 보고한다. - [ ] **Step 2: V7을 만든다** 설계의 `database/V1__init.sql`을 기반으로 하되 세 가지를 바꾼다. 1. `studio_idempotency` 테이블과 그 인덱스(`idx_studio_idempotency_expiry`)를 **제외한다.** 기존 `idempotency_record`를 쓴다 (spec D5). 2. `release`, `site_config`, `profile_page`, `home_focus_config`, `topic_featured_document`, `project_topic` 테이블을 **제외한다.** 이번 범위 밖이다 (spec §2.2). 3. 나머지는 그대로 옮긴다. `publication.latest_event_id`의 순환 FK는 `DEFERRABLE INITIALLY DEFERRED`를 반드시 유지한다 — 즉시 검사로 바꾸면 첫 게시가 불가능하다. ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend DEST=src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql { echo "-- Tech Log 코어 스키마." echo "-- 원본: tech-log-design-package/database/V1__init.sql" echo "-- 제외: studio_idempotency (기존 idempotency_record 재사용, spec D5)," echo "-- release / site_config / profile_page / home_focus_config /" echo "-- topic_featured_document / project_topic (spec §2.2 범위 밖)." echo cat /home/donghyeon/workspace/tech-log-design-package/database/V1__init.sql } > "$DEST" wc -l "$DEST" ``` 그다음 편집기로 위 1·2에 해당하는 블록을 지운다. 지운 뒤 남은 참조가 없는지 확인한다. ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend DEST=src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql for t in studio_idempotency release site_config profile_page home_focus_config topic_featured_document project_topic; do echo "$t: $(grep -c "$t" "$DEST")" done grep -n "DEFERRABLE INITIALLY DEFERRED" "$DEST" ``` Expected: 7개 이름 모두 `0`, `DEFERRABLE INITIALLY DEFERRED` 1줄 이상. - [ ] **Step 3: 마이그레이션 적용 테스트를 쓴다** `TechLogSchemaMigrationTest.java`: ```java package dev.caskeleton.adapter.outbound.persistence.techlog; import static org.assertj.core.api.Assertions.assertThat; import java.sql.Connection; import java.sql.ResultSet; import java.util.ArrayList; import java.util.List; import javax.sql.DataSource; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; /** * V7이 실제 PostgreSQL에 적용되는지, 그리고 순환 FK가 deferrable로 선언됐는지 본다. * H2로는 검증할 수 없다 — deferrable 제약이 벤더 의미이기 때문이다. */ @SpringBootTest class TechLogSchemaMigrationTest { @Autowired private DataSource dataSource; @Test void createsEveryTechLogTable() throws Exception { List expected = List.of( "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"); List actual = new ArrayList<>(); try (Connection connection = dataSource.getConnection(); ResultSet rs = connection .createStatement() .executeQuery( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) { while (rs.next()) { actual.add(rs.getString(1)); } } assertThat(actual).containsAll(expected); } @Test void doesNotCreateAStudioIdempotencyTable() throws Exception { try (Connection connection = dataSource.getConnection(); ResultSet rs = connection .createStatement() .executeQuery( "SELECT count(*) FROM information_schema.tables " + "WHERE table_schema = 'public' AND table_name = 'studio_idempotency'")) { rs.next(); assertThat(rs.getInt(1)).isZero(); } } @Test void publicationLatestEventForeignKeyIsDeferrable() throws Exception { try (Connection connection = dataSource.getConnection(); ResultSet rs = connection .createStatement() .executeQuery( "SELECT condeferrable, condeferred FROM pg_constraint " + "WHERE conname = 'fk_publication_latest_event'")) { assertThat(rs.next()).as("fk_publication_latest_event 제약이 있어야 한다").isTrue(); assertThat(rs.getBoolean(1)).as("deferrable").isTrue(); assertThat(rs.getBoolean(2)).as("initially deferred").isTrue(); } } } ``` 제약 이름이 설계 DDL과 다르면 `grep -n "fk_publication_latest_event" $DEST`로 확인해 맞춘다. - [ ] **Step 4: 테스트를 돌린다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:outbound:persistence-jpa:postgresqlIntegrationTest --tests '*TechLogSchemaMigrationTest' --console=plain ``` Expected: 3개 PASS. Testcontainers가 Docker를 요구하므로 실패하면 Docker 데몬을 먼저 확인한다. 소스셋 이름이 다르면 `./gradlew :adapter:outbound:persistence-jpa:tasks --all | grep -i test`로 확인한다. - [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** --- ## Task 8: `getStudioSession` (BE) **Files:** - Create: `BE/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java` - Test: `BE/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionControllerTest.java` - Modify: `BE/src/app-bootstrap/src/main/resources/application-dev.yml` **Interfaces:** - Consumes: Task 4의 생성 DTO `dev.caskeleton...techlog.studio.api.model.StudioSession` - Produces: `GET /api/v1/studio/session` → 봉투에 담긴 `StudioSession` 세션은 순수 전송 상태(principal + CSRF 토큰)이므로 application use case를 만들지 않는다. `application-core`는 Spring을 볼 수 없어 `SecurityContext`에 접근할 수 없고, 여기에 use case를 끼우면 아무 도메인 규칙도 없는 통과 계층이 하나 늘 뿐이다. - [ ] **Step 1: 실패 테스트를 쓴다** ```java package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession; import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.security.web.csrf.DefaultCsrfToken; class StudioSessionControllerTest { private final StudioSessionController controller = new StudioSessionController(); @Test void reportsAuthenticatedPrincipalAndCsrfToken() { StudioSession session = controller.getStudioSession( new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")), new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "token-value")); assertThat(session.getAuthenticated()).isTrue(); assertThat(session.getDisplayName()).isEqualTo("donghyeon@example.com"); assertThat(session.getRoles()).containsExactly("STUDIO_EDITOR"); assertThat(session.getCsrfToken()).isEqualTo("token-value"); assertThat(session.getCsrfHeaderName()).isEqualTo("X-CSRF-TOKEN"); } @Test void fallsBackToIdpUserIdWhenEmailIsAbsent() { StudioSession session = controller.getStudioSession( new AuthenticatedPrincipal("sub-1", null, Set.of()), new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")); assertThat(session.getDisplayName()).isEqualTo("sub-1"); } } ``` 접근자는 JavaBean 스타일(`getAuthenticated()`)이며 `roles`는 `Set`이다 — 위 실측 블록 참조. - [ ] **Step 2: 테스트를 돌려 실패를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:inbound:web:test --tests '*StudioSessionControllerTest' --console=plain ``` Expected: 컴파일 실패 — `StudioSessionController` 없음. - [ ] **Step 3: controller를 구현한다** ```java package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession; import java.util.Set; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; /** * 세션은 순수 전송 상태다 — principal과 CSRF 토큰뿐이라 도메인 규칙이 없다. * application use case를 끼우지 않는 이유이고, application-core는 Spring을 볼 수 없어 * SecurityContext에 접근할 수도 없다. * *

반환값을 {@code Envelope}로 감싸지 않는다. {@code EnvelopeBodyAdvice}가 감싼다. */ @RestController public class StudioSessionController { @GetMapping("/api/v1/studio/session") public StudioSession getStudioSession( @AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) { StudioSession session = new StudioSession(); session.setAuthenticated(true); session.setDisplayName(displayNameOf(principal)); session.setRoles(Set.copyOf(principal.roles())); session.setCsrfToken(csrfToken.getToken()); session.setCsrfHeaderName("X-CSRF-TOKEN"); return session; } /** * `displayName`은 계약상 1자 이상이다. profile capability(identity 모듈)가 들어오기 * 전까지 email을 쓰고, 없으면 IdP subject로 대체한다. */ private static String displayNameOf(AuthenticatedPrincipal principal) { String email = principal.email(); return (email == null || email.isBlank()) ? principal.idpUserId() : email; } } ``` > **실측 (2026-08-19):** 생성된 `StudioSession`의 접근자는 다음과 같다. 추측하지 말 것. > > ```java > Boolean getAuthenticated() / setAuthenticated(Boolean) > String getDisplayName() / setDisplayName(String) > Set getRoles() / setRoles(Set) > String getCsrfToken() / setCsrfToken(String) > String getCsrfHeaderName() / setCsrfHeaderName(String) ← enum 아님 > ``` > > 즉 `CsrfHeaderNameEnum`은 **존재하지 않고** `roles`는 `List`가 아니라 `Set`이다. > 계약의 `const: X-CSRF-TOKEN`은 생성기가 `String` + `@Schema`로 냈다. - [ ] **Step 4: 테스트를 돌려 통과를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:inbound:web:test --tests '*StudioSessionControllerTest' --console=plain ``` Expected: 2개 PASS. - [ ] **Step 5: 세션 인증 모드와 CSRF 헤더 이름을 설정한다** 계약은 `csrfHeaderName`을 `X-CSRF-TOKEN`으로 고정하는데 템플릿 기본값은 `X-XSRF-TOKEN`이다. `application-dev.yml`에 추가한다. ```yaml ca-skeleton: security: # Studio는 브라우저 세션 기반이다. jwt 모드는 CSRF를 끄고 stateless로 간다. auth-mode: redis-session session: # 계약(studio-v1.yaml StudioSession.csrfHeaderName)이 const로 고정한 값. csrf-header-name: X-CSRF-TOKEN ``` - [ ] **Step 6: 봉투와 상태 코드를 슬라이스 테스트로 고정한다** `BE/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java`: ```java package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; /** 응답이 봉투로 정확히 한 번 감싸이는지 고정한다. 두 번 감싸이면 프론트가 조용히 깨진다. */ @WebMvcTest(controllers = StudioSessionController.class) class StudioSessionEnvelopeTest { @Autowired private MockMvc mvc; @Test @WithMockUser void wrapsTheSessionPayloadExactlyOnce() throws Exception { mvc.perform(get("/api/v1/studio/session")) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.csrfHeaderName").value("X-CSRF-TOKEN")) .andExpect(jsonPath("$.data.data").doesNotExist()) .andExpect(jsonPath("$.meta.traceId").isNotEmpty()); } } ``` `@WebMvcTest` 슬라이스에 `AuthenticatedPrincipal`과 `CsrfToken`을 넣는 방법은 기존 web 테스트(`adapter/inbound/web/src/test`)의 선례를 따른다. 선례가 없으면 `@WithMockUser` 대신 `SecurityMockMvcRequestPostProcessors.csrf()`와 커스텀 `authentication(...)`을 쓴다. - [ ] **Step 7: 테스트를 돌린다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:inbound:web:test --tests '*StudioSession*' --console=plain ``` Expected: 전부 PASS. - [ ] **Step 8: 변경 파일을 보고한다 (커밋하지 않는다)** --- ## Task 9: `listStudioCatalog` (BE) **Files:** - Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListCatalogQuery.java` - Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogEntryView.java` - Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/CatalogPageView.java` - Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/CatalogQueryPort.java` - Create: `BE/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java` - Create: `BE/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java` - Create: `BE/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java` - Test: `BE/src/application-core/src/test/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCaseTest.java` - Test: `BE/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapterTest.java` **Interfaces:** - Consumes: Task 7의 `topic` / `project` / `document` / `open_question` / `project_decision` / `asset` 테이블, Task 5의 `StudioError` - Produces: `CatalogQueryPort.search(CatalogEntryType, String query, String cursor, int limit)` → `CatalogPageView`. 이후 슬라이스의 relation picker가 같은 포트를 쓴다. - [ ] **Step 1: use case 실패 테스트를 쓴다** ```java package dev.caskeleton.application.techlog.studio.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; import dev.caskeleton.application.techlog.studio.query.CatalogEntryView; import dev.caskeleton.application.techlog.studio.query.CatalogPageView; import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; class ListCatalogUseCaseTest { private static CatalogQueryPort portReturning(CatalogPageView page) { return (type, query, cursor, limit) -> page; } @Test void returnsWhateverThePortFound() { CatalogEntryView entry = new CatalogEntryView(UUID.randomUUID(), CatalogEntryType.TOPIC, "Kafka", null, null, "rev-1"); ListCatalogUseCase useCase = new ListCatalogUseCase(portReturning(new CatalogPageView(List.of(entry), null))); CatalogPageView page = useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, "ka", null, 20)); assertThat(page.items()).containsExactly(entry); assertThat(page.nextCursor()).isNull(); } @Test void rejectsALimitAboveTheContractCeiling() { ListCatalogUseCase useCase = new ListCatalogUseCase(portReturning(new CatalogPageView(List.of(), null))); assertThatThrownBy(() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, null, 101))) .isInstanceOf(StudioException.class) .hasMessageContaining("limit"); } @Test void rejectsAMissingType() { ListCatalogUseCase useCase = new ListCatalogUseCase(portReturning(new CatalogPageView(List.of(), null))); assertThatThrownBy(() -> useCase.handle(new ListCatalogQuery(null, null, null, 20))) .isInstanceOf(StudioException.class); } } ``` - [ ] **Step 2: 테스트를 돌려 실패를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :application-core:test --tests '*ListCatalogUseCaseTest' --console=plain ``` Expected: 컴파일 실패. - [ ] **Step 3: application 타입과 포트를 만든다** ```java // query/CatalogEntryType.java package dev.caskeleton.application.techlog.studio.query; /** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */ public enum CatalogEntryType { TOPIC, PROJECT, RELATION, EVIDENCE } ``` ```java // query/CatalogEntryView.java package dev.caskeleton.application.techlog.studio.query; import java.util.UUID; /** * 계약 `CatalogEntry`의 application 표현. {@code kind}와 {@code publicPath}는 * TOPIC/PROJECT에는 없으므로 null이다. */ public record CatalogEntryView( UUID id, CatalogEntryType type, String label, String kind, String publicPath, String dependencyRevision) {} ``` ```java // query/CatalogPageView.java package dev.caskeleton.application.techlog.studio.query; import java.util.List; public record CatalogPageView(List items, String nextCursor) { public CatalogPageView { items = List.copyOf(items); } } ``` ```java // query/ListCatalogQuery.java package dev.caskeleton.application.techlog.studio.query; import dev.caskeleton.application.query.Query; public record ListCatalogQuery(CatalogEntryType type, String query, String cursor, int limit) implements Query {} ``` ```java // port/out/CatalogQueryPort.java package dev.caskeleton.application.techlog.studio.port.out; import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; import dev.caskeleton.application.techlog.studio.query.CatalogPageView; /** * Studio catalog는 도메인 Aggregate를 재구성하지 않는다. 전용 read 포트로 union query를 * 돌린다 (설계 08장 §4). */ @FunctionalInterface public interface CatalogQueryPort { CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit); } ``` `dev.caskeleton.application.query.Query`의 실제 시그니처를 먼저 확인하고 맞춘다. ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src cat application-core/src/main/java/dev/caskeleton/application/query/Query.java ``` - [ ] **Step 4: use case를 구현한다** ```java package dev.caskeleton.application.techlog.studio.service; import dev.caskeleton.application.capability.Idempotency; import dev.caskeleton.application.capability.RepositoryAccess; import dev.caskeleton.application.capability.UseCaseCapability; import dev.caskeleton.application.techlog.error.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; import dev.caskeleton.application.techlog.studio.query.CatalogPageView; import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery; import dev.caskeleton.application.transaction.TransactionMode; import dev.caskeleton.application.usecase.QueryUseCase; /** Studio catalog 조회. 도메인 상태를 바꾸지 않으므로 read-only다. */ @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) public final class ListCatalogUseCase implements QueryUseCase { private static final int MAX_LIMIT = 100; private final CatalogQueryPort catalogQueryPort; public ListCatalogUseCase(CatalogQueryPort catalogQueryPort) { this.catalogQueryPort = catalogQueryPort; } @Override public CatalogPageView handle(ListCatalogQuery input) { if (input.type() == null) { throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "type is required"); } if (input.limit() < 1 || input.limit() > MAX_LIMIT) { throw StudioException.of( StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT); } return catalogQueryPort.search(input.type(), input.query(), input.cursor(), input.limit()); } } ``` - [ ] **Step 5: 테스트를 돌려 통과를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :application-core:test --tests '*ListCatalogUseCaseTest' --console=plain ./gradlew :application-core:check --console=plain 2>&1 | tail -10 ``` Expected: 3개 PASS, `verifyApplicationCoreDependencyPurity` 통과. - [ ] **Step 6: 영속 어댑터 실패 테스트를 쓴다** `JdbcCatalogQueryAdapterTest.java`: ```java package dev.caskeleton.adapter.outbound.persistence.techlog.query; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; import dev.caskeleton.application.techlog.studio.query.CatalogPageView; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.simple.JdbcClient; @SpringBootTest class JdbcCatalogQueryAdapterTest { @Autowired private JdbcClient jdbcClient; @Autowired private JdbcCatalogQueryAdapter adapter; @Test void findsTopicsByPrefix() { jdbcClient .sql( "INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by) " + "VALUES (gen_random_uuid(), 'Kafka', 'kafka', 'kafka', 'test', 'test')") .update(); CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "kaf", null, 20); assertThat(page.items()).hasSize(1); assertThat(page.items().get(0).label()).isEqualTo("Kafka"); assertThat(page.items().get(0).dependencyRevision()).isNotBlank(); } @Test void returnsAnEmptyPageWhenNothingMatches() { CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "zzzz-none", null, 20); assertThat(page.items()).isEmpty(); assertThat(page.nextCursor()).isNull(); } } ``` **컬럼 이름은 사전 스캔에서 확인했다 — 추측하지 말 것.** 설계 DDL 기준: ```text topic id(uuid PK), name, normalized_name, slug, description, scope, status('ACTIVE'|'ARCHIVED'), version, created_at, created_by, updated_at, updated_by project id(uuid PK), slug, name, one_line_purpose, ..., phase, workflow_status, target_visibility, version, created_at, created_by, updated_at, updated_by ``` `topic_id` / `project_id` / `title` 컬럼은 **존재하지 않는다.** `created_by`와 `updated_by`는 NOT NULL이고 기본값이 없으므로 INSERT에 반드시 넣는다. Task 7 산출물과 어긋나면 아래로 재확인한다. ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend sed -n '/^CREATE TABLE topic (/,/^);/p' src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__techlog_core.sql ``` - [ ] **Step 7: 테스트를 돌려 실패를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:outbound:persistence-jpa:postgresqlIntegrationTest --tests '*JdbcCatalogQueryAdapterTest' --console=plain ``` Expected: 컴파일 실패 — `JdbcCatalogQueryAdapter` 없음. - [ ] **Step 8: 영속 어댑터를 구현한다** 이번 슬라이스에서는 `TOPIC`과 `PROJECT`만 실제 조회하고, `RELATION`/`EVIDENCE`는 빈 페이지를 반환한다. 두 종류는 document/question/decision/asset 데이터가 들어오는 슬라이스 2·5에서 채운다 — **빈 페이지는 계약상 유효한 응답이며 화면이 깨지지 않는다.** ```java package dev.caskeleton.adapter.outbound.persistence.techlog.query; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; import dev.caskeleton.application.techlog.studio.query.CatalogEntryView; import dev.caskeleton.application.techlog.studio.query.CatalogPageView; import java.util.List; import java.util.UUID; import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Repository; /** * catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4). * *

RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 * 이는 계약상 유효한 응답이다. */ @Repository public class JdbcCatalogQueryAdapter implements CatalogQueryPort { private final JdbcClient jdbcClient; public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) { this.jdbcClient = jdbcClient; } @Override public CatalogPageView search( CatalogEntryType type, String query, String cursor, int limit) { String pattern = (query == null || query.isBlank()) ? "%" : "%" + query.toLowerCase() + "%"; List items = switch (type) { case TOPIC -> searchTopics(pattern, limit); case PROJECT -> searchProjects(pattern, limit); case RELATION, EVIDENCE -> List.of(); }; return new CatalogPageView(items, null); } private List searchTopics(String pattern, int limit) { return jdbcClient .sql( "SELECT id, name, updated_at FROM topic " + "WHERE status = 'ACTIVE' AND lower(name) LIKE :pattern " + "ORDER BY name LIMIT :limit") .param("pattern", pattern) .param("limit", limit) .query( (rs, rowNum) -> new CatalogEntryView( UUID.fromString(rs.getString("id")), CatalogEntryType.TOPIC, rs.getString("name"), null, null, "topic:" + rs.getTimestamp("updated_at").toInstant())) .list(); } private List searchProjects(String pattern, int limit) { return jdbcClient .sql( "SELECT id, name, updated_at FROM project " + "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit") .param("pattern", pattern) .param("limit", limit) .query( (rs, rowNum) -> new CatalogEntryView( UUID.fromString(rs.getString("id")), CatalogEntryType.PROJECT, rs.getString("name"), "PROJECT", null, "project:" + rs.getTimestamp("updated_at").toInstant())) .list(); } } ``` `dependencyRevision`은 이번 슬라이스에서 "해당 행의 updated_at"으로 둔다. 정식 계산(설계 09장 §18A의 dependency set 해시)은 슬라이스 3에서 도입하고, 그때 이 어댑터도 같이 고친다. 계약상 `dependencyRevision`은 1..200자 문자열이면 되므로 지금 값도 유효하다. - [ ] **Step 9: 테스트를 돌려 통과를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :adapter:outbound:persistence-jpa:postgresqlIntegrationTest --tests '*JdbcCatalogQueryAdapterTest' --console=plain ``` Expected: 2개 PASS. - [ ] **Step 10: controller를 만든다** ```java package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntry; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntryType; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogPage; import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; import dev.caskeleton.application.techlog.studio.query.CatalogEntryView; import dev.caskeleton.application.techlog.studio.query.CatalogPageView; import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery; import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** 반환값을 Envelope로 감싸지 않는다 — EnvelopeBodyAdvice가 감싼다. */ @RestController public class StudioCatalogController { private final ListCatalogUseCase listCatalog; public StudioCatalogController(ListCatalogUseCase listCatalog) { this.listCatalog = listCatalog; } @GetMapping("/api/v1/studio/catalog") public CatalogPage listStudioCatalog( @RequestParam("type") CatalogEntryType type, // application enum. 웹 계층 enum과 이름이 같으니 import 주의 @RequestParam(value = "q", required = false) String q, @RequestParam(value = "cursor", required = false) String cursor, @RequestParam(value = "limit", defaultValue = "20") int limit) { CatalogPageView page = listCatalog.handle(new ListCatalogQuery(type, q, cursor, limit)); CatalogPage body = new CatalogPage(); body.setItems(page.items().stream().map(StudioCatalogController::toApi).toList()); body.setNextCursor(page.nextCursor()); return body; } private static CatalogEntry toApi(CatalogEntryView view) { CatalogEntry entry = new CatalogEntry(); entry.setId(view.id()); entry.setType(CatalogEntryType.fromValue(view.type().name())); entry.setLabel(view.label()); entry.setDependencyRevision(view.dependencyRevision()); if (view.kind() != null) { entry.setKind(CatalogEntry.KindEnum.fromValue(view.kind())); } entry.setPublicPath(view.publicPath()); return entry; } } ``` > **실측 (2026-08-19):** 생성 DTO의 실제 모양이다. 추측하지 말 것. > > ```java > CatalogPage List getItems()/setItems(...) String getNextCursor()/setNextCursor(...) > CatalogEntry UUID getId()/setId(UUID) ← String 아님 > CatalogEntryType getType()/setType(...) ← 최상위 별도 enum 클래스 > String getLabel()/setLabel(...) > @Nullable KindEnum getKind()/setKind(...) ← CatalogEntry 안의 중첩 enum > String getPublicPath(), String getDependencyRevision() > ``` > > 즉 **`CatalogEntry.TypeEnum`은 존재하지 않는다** — `type`은 최상위 > `CatalogEntryType`(값 TOPIC/PROJECT/RELATION/EVIDENCE, `fromValue(String)` 있음)이고, > `kind`만 중첩 `CatalogEntry.KindEnum`(CASE/REFERENCE/QUESTION/PROJECT/PROJECT_DECISION, > `fromValue(String)` 있음)이다. `id`는 `UUID`라 변환이 필요 없다. > > `dev.caskeleton.application.query.Query`는 빈 마커 인터페이스다 — 메서드가 없다. - [ ] **Step 11: use case 빈을 등록한다** `application-core`는 Spring을 모르므로 `ListCatalogUseCase`는 bootstrap에서 조립한다. `BE/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java`: ```java package dev.caskeleton.bootstrap.techlog; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** Tech Log Studio 조립. application-core는 Spring을 보지 않으므로 여기서 배선한다. */ @Configuration public class TechLogStudioConfig { @Bean ListCatalogUseCase listCatalogUseCase(CatalogQueryPort catalogQueryPort) { return new ListCatalogUseCase(catalogQueryPort); } } ``` - [ ] **Step 12: 전체 검증을 돌린다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :application-core:test :adapter:inbound:web:test --console=plain ./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*TechLogBoundaryArchTest' --tests '*StudioErrorRegistryTest' --console=plain ``` Expected: 전부 PASS. - [ ] **Step 13: 변경 파일을 보고한다 (커밋하지 않는다)** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend git status --short | grep -v '^ D ' ``` --- ## Task 10: 계약 회귀 테스트 (BE) spec §5.5. springdoc이 노출하는 실제 계약을 vendor된 `studio-v1.yaml`과 대조한다. 구현된 operation만 검사하므로 슬라이스가 늘어도 그대로 쓸 수 있고, 계약에 없는 엔드포인트가 새로 생기면 즉시 실패한다. **Files:** - Create: `BE/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java` **Interfaces:** - Consumes: Task 4의 `src/config/openapi/studio-v1.yaml`, Task 8·9의 controller - Produces: 이후 모든 슬라이스가 새 controller를 추가할 때 자동으로 걸리는 드리프트 게이트 **규약:** controller 메서드 이름은 계약의 `operationId`와 **같게 짓는다.** springdoc이 메서드 이름에서 operationId를 만들기 때문이며, 이 테스트가 그 규약을 강제한다. Task 8의 `getStudioSession`, Task 9의 `listStudioCatalog`가 이미 그렇다. - [ ] **Step 1: 드리프트 테스트를 쓴다** ```java package dev.caskeleton.bootstrap.contract; import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; /** * 구현된 Studio operation이 계약과 어긋나면 실패한다. 계약에 없는 `/api/v1/studio/**` * 엔드포인트가 생겨도 실패한다 — 계약 밖 표면이 조용히 늘어나는 것을 막는다. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class StudioContractDriftTest { @Autowired private TestRestTemplate restTemplate; @Test void publishedStudioOperationsMatchTheContract() throws Exception { JsonNode contract = new ObjectMapper(new YAMLFactory()) .readTree(Path.of("..", "config", "openapi", "studio-v1.yaml").normalize().toFile()); JsonNode published = new ObjectMapper().readTree(restTemplate.getForObject("/v3/api-docs", String.class)); List problems = new ArrayList<>(); JsonNode publishedPaths = published.path("paths"); Iterator> paths = publishedPaths.fields(); while (paths.hasNext()) { Map.Entry path = paths.next(); if (!path.getKey().startsWith("/api/v1/studio/")) { continue; } JsonNode contractPath = contract.path("paths").path(path.getKey()); if (contractPath.isMissingNode()) { problems.add("계약에 없는 path: " + path.getKey()); continue; } Iterator> methods = path.getValue().fields(); while (methods.hasNext()) { Map.Entry method = methods.next(); JsonNode contractOp = contractPath.path(method.getKey()); if (contractOp.isMissingNode()) { problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey()); continue; } String publishedId = method.getValue().path("operationId").asText(""); String contractId = contractOp.path("operationId").asText(""); if (!publishedId.equals(contractId)) { problems.add( "operationId 불일치 " + method.getKey() + " " + path.getKey() + ": published=" + publishedId + " contract=" + contractId); } } } assertThat(problems).isEmpty(); } @Test void everyStudioResponseIsWrappedInTheEnvelope() { String body = restTemplate.getForObject("/api/v1/studio/catalog?type=TOPIC", String.class); assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\""); assertThat(body).doesNotContain("\"data\":{\"success\""); } } ``` `jackson-dataformat-yaml`이 `functionalTest` 클래스패스에 없으면 `app-bootstrap/build.gradle`의 `functionalTestImplementation`에 추가하고 lockfile을 재생성한다. `Path.of("..", "config", ...)`가 맞지 않으면 실패 메시지에 찍히는 실제 작업 디렉터리로 조정한다. `/api/v1/studio/catalog`는 인증이 필요하므로 두 번째 테스트가 401을 받으면 `SECURITY_PUBLIC_PATHS`에 넣지 말고(보안 표면을 넓히면 안 된다) 기존 functional test가 쓰는 인증 헬퍼를 따라 인증된 요청으로 바꾼다. ```bash ls /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/ ``` - [ ] **Step 2: 테스트를 돌려 실패를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :app-bootstrap:functionalTest --tests '*StudioContractDriftTest' --console=plain ``` Expected: 컴파일 실패 또는 FAIL. 태스크 이름이 다르면 `./gradlew :app-bootstrap:tasks --all | grep -i test`로 확인한다. - [ ] **Step 3: 드리프트가 있으면 계약이 아니라 코드를 고친다** 계약이 SSOT다. `operationId` 불일치가 나오면 controller 메서드 이름을 계약에 맞춘다. 계약에 없는 path가 나오면 그 엔드포인트를 지운다. - [ ] **Step 4: 테스트를 돌려 통과를 확인한다** ```bash cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src ./gradlew :app-bootstrap:functionalTest --tests '*StudioContractDriftTest' --console=plain ``` Expected: 2개 PASS. - [ ] **Step 5: 변경 파일을 보고한다 (커밋하지 않는다)** --- ## 완료 판정 이 계획이 끝나면 다음이 참이다. ```text DP studio-v1.yaml v3.0.0이 봉투를 기술하고 검증 스크립트 4종이 통과한다 FE 계약이 재생성되고 전송 경계에서 봉투를 언랩하며 test:tech-log가 통과한다 BE feature/techlog-studio-backend에서 - 계약 DTO가 생성되고 컴파일된다 - StudioError 23종이 enum·레지스트리 양쪽에 있다 - bounded context 경계가 ArchUnit으로 강제된다 - V7 스키마가 PostgreSQL에 적용된다 - GET /api/v1/studio/session 이 봉투에 담긴 StudioSession을 돌려준다 - GET /api/v1/studio/catalog 가 TOPIC/PROJECT를 돌려준다 - 구현된 operation이 계약과 어긋나면 functionalTest가 실패한다 ``` ## 다음 계획 슬라이스 2~5는 각각 별도 계획으로 쓴다. 이 계획이 끝나 계약과 기반이 확정된 뒤에 써야 추측이 들어가지 않는다. ```text Plan 02 문서 CRUD 4종 + 낙관적 잠금 + 멱등 Plan 03 validate / preview / dependencyRevision / nextAction / 렌더러 착수 전 프론트 렌더 모델 구현을 기준선으로 대조한다 (spec §10) Plan 04 publish 20단계 / event / snapshot / unpublish / dashboard Plan 05 asset 5종 (fileserver · objectstorage 위에) ```