Files
tech-log-backend/docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md
DongHyeonkaandClaude Opus 5 91e6d99654 feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종
설계 패키지의 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>
2026-08-19 15:14:52 +09:00

103 KiB
Raw Permalink Blame History

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 계약을 응답 봉투 형태로 확정하고 세 저장소를 정합시킨 뒤, getStudioSessionlistStudioCatalog가 실제 백엔드에서 동작하게 만든다.

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.jsonsrc/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.codeApiError.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 ApiErrorStudioGatewayError

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개 <Payload>Envelope 래퍼. Task 3(FE)과 Task 4(BE)가 이 계약에서 타입을 생성한다.

  • Removes: ProblemDetails 스키마. Task 2가 이를 참조하는 스크립트를 고친다.

  • Step 1: 현재 상태를 기록해 둔다

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:

# 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.yamlstudio-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이라 한 줄 치환으로 끝난다.

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<Payload>Envelope로 바꾼다. requestBody는 봉투로 감싸지 않는다.

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: 치환 결과를 눈으로 확인한다
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: 바로 위)에 아래를 넣는다.

    # ------------------------------------------------------------- envelope
    # wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고
    # 응답만 <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개 래퍼를 같은 위치에 넣는다. <Payload>는 Step 5의 PAYLOADS 목록과 같다.

    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: 계약이 파싱되고 참조가 다 풀리는지 확인한다
cd /home/donghyeon/workspace/tech-log-design-package
./scripts/check-openapi.py contracts/openapi/studio-v1.yaml

Expected: PASS. $ref 미해결이나 operationId 중복이 없어야 한다. 실패하면 오타난 래퍼 이름을 고친다.

  • Step 9: 봉투 구조를 프로그램으로 검산한다
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: 커밋
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: 깨진 것을 먼저 확인한다

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행 부근:

codes = set(S["ProblemDetails"]["properties"]["code"]["enum"])

codes = set(S["ApiError"]["properties"]["code"]["enum"])

로 바꾸고, 262행 부근의

fe_codes = set(fe["components"]["schemas"]["ProblemDetails"]["properties"]["code"]["enum"])

fe_codes = set(fe["components"]["schemas"]["ApiError"]["properties"]["code"]["enum"])

로 바꾼다.

  • Step 3: check-contract-parity.py의 참조를 옮긴다

ProblemDetails를 읽는 두 줄(FE/BE 오류 코드 비교)을 ApiError로 바꾼다.

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가 통과하는지 본다
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 상태, 코드 목록, 혼동 금지, 충돌 부가 정보)은 유지하되 표현 매체만 봉투로 옮긴다.

## 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개 코드를 전부 열거하지 않고도 굵게 분기할 수 있게 한다.

VALIDATION  AUTH  AUTHZ  NOT_FOUND  CONFLICT  RATE_LIMIT
TRANSIENT_DEPENDENCY  PERMANENT_DEPENDENCY  DATA_INTEGRITY  INTERNAL

error.details는 code별 polymorphic이며 계약에서 oneOf로 선언한다.

REQUEST_VALIDATION_FAILED / VALIDATION_FAILED  → ValidationErrorDetails      fieldErrors[]
VERSION_CONFLICT                                → VersionConflictDetails      latestDocument, conflictingFields[]
PUBLICATION_CONFLICT                            → PublicationConflictDetails  latestPublication
그 외                                            → null

traceIdmeta.traceId에 있으며 응답에서 절대 null이 아니다.


- [ ] **Step 6: 나머지 두 계약에 배너를 붙인다**

`public-v1.yaml`과 `studio-management-v1.yaml`의 `info.description` 맨 앞에 넣는다.

```text
⛔ 봉투 결정(ADR-006) 반영 대기 — 이 계약은 아직 bare payload + ProblemDetails다.
소비자가 없어 변환을 미뤘다. 구현에 착수할 때 studio-v1.yaml과 같은 방식으로
ErrorEnvelope / <Payload>Envelope으로 변환한다.
  • Step 7: MASTER_SPEC과 MANIFEST를 재생성한다
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: 커밋
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가 최신 작업 상태이므로 그 위에서 딴다.

cd /home/donghyeon/workspace/desktop-server-git/tech-log-frontend
git status --short
git checkout -b feature/studio-response-envelope

Expected: 새 브랜치로 전환. 워킹트리가 더러우면 먼저 사용자에게 보고하고 멈춘다.

  • Step 2: 드리프트 게이트가 지금은 통과하는지 확인한다 (기준선)
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로 타입을 만든다.

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: 생성 타입에 봉투가 들어왔는지 확인한다
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:

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: 테스트를 돌려 실패를 확인한다
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 정의부를 아래로 교체한다.

/**
 * wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
 * 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
 * 때마다 두 곳을 고치게 만든다. 다만 봉투 자체는 반드시 검증한다: 여기서 통과시키면
 * 잘못된 모양이 앱 계층까지 조용히 흘러간다.
 */
const metaSchema = z
  .object({ requestId: z.string().min(1), traceId: z.string().min(1) })
  .loose();

export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
  zodValidator<T>(
    schemaId,
    z
      .object({ success: z.literal(true), data: z.unknown(), meta: metaSchema })
      .loose()
      .transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
  );

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<StudioProblemShape> =>
  zodValidator<StudioProblemShape>(
    "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<StudioProblemShape>,
  );

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 정의의

      outputValidator: passthrough(`${operationId}Output`),

을 전부

      outputValidator: envelopeData(`${operationId}Output`),

로 바꾼다. inputValidator는 요청 본문이라 그대로 passthrough를 쓴다.

  • Step 8: status를 실제 HTTP status로 덮는다

FE/src/features/tech-log/adapters/http/studio-error-mapping.tsPROBLEM 분기에서 outcome.problemstatus가 0이면 전송 계층이 아는 status로 채운다.

    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.tsSafeResponseMetadata 정의를 읽어 맞춘다.

  • Step 9: 테스트를 돌려 통과를 확인한다
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 전체 테스트와 드리프트 게이트를 돌린다
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를 확인한다
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: 커밋
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: 브랜치와 워킹트리를 확인한다

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하고 해시를 기록한다
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: <sha256> 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개와 csrfHeaderNameconst는 discriminator가 아니므로 그대로 둔다.
  4. spec §5.2의 "1회 생성 후 커밋" 폴백은 쓸 수 없다 — 생성이 한 번은 성공한다는 전제인데 생성 자체가 실패하기 때문이다.
  • Step 3: 생성기 조합을 폐기용 스파이크로 검증한다

openapi-generator 7.x × Spring Boot 4.0.0 조합은 이 저장소에서 검증된 적이 없다. 본 배선 전에 별도 디렉터리에서 먼저 돌려본다.

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 블록이 없으면 파일 첫 줄에 추가하고, 파일 끝에 아래를 붙인다.

// ---------------------------------------------------------------------------
// 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 version="1.0" encoding="UTF-8"?>
<!-- openapi-generator 산출물은 손으로 고치지 않으므로 SpotBugs 대상이 아니다. -->
<FindBugsFilter>
  <Match>
    <Package name="~dev\.caskeleton\.adapter\.inbound\.web\.techlog\.studio\.api\.model.*"/>
  </Match>
</FindBugsFilter>

기존 config/spotbugs/에 이미 exclude filter가 있으면 새 파일을 만들지 말고 그 파일에 위 <Match> 블록만 추가한다.

ls /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/config/spotbugs/

플러그인 선언은 BE/src/build.gradle의 루트 plugins 블록에 추가한다.

    id 'org.openapi.generator' version '7.18.0' apply false

그리고 BE/src/adapter/inbound/web/build.gradle 첫 줄에

plugins { id 'org.openapi.generator' }
  • Step 5: 생성과 컴파일을 확인한다
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가 생성 패키지만 좁게 뺀 것이다.

./gradlew :adapter:inbound:web:check --console=plain 2>&1 | tail -20
  • Step 6: 의존 lock을 재생성한다
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: 아키텍처 검증이 여전히 통과하는지 본다
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: 변경 파일을 보고한다 (커밋하지 않는다)
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:

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: 테스트를 돌려 실패를 확인한다
cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src
./gradlew :application-core:test --tests '*StudioErrorTest' --console=plain

Expected: 컴파일 실패 — StudioError 없음.

  • Step 3: StudioError를 구현한다
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: 테스트를 돌려 통과를 확인한다
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을 만든다
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를 알 필요가 없다.
 *
 * <p>{@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:

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<Envelope<Void>> response =
        handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음"));

    assertThat(response.getStatusCode().value()).isEqualTo(404);
    Envelope<Void> 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<Envelope<Void>> 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: 테스트를 돌려 실패를 확인한다
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은 이쪽으로 온다.

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<Envelope<Void>> handleStudio(StudioException ex) {
    return ErrorResponseFactory.envelope(ex.studioError(), ex.getMessage(), ex.details());
  }
}
  • Step 9: 테스트를 돌려 통과를 확인한다
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.yamlerrors: 목록 끝에 아래 형식으로 23개를 넣는다. StudioError의 category/status/retryable과 정확히 같아야 한다.

  # ============================================================
  # 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_UNAVAILABLEBE/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:

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<String> lines = Files.readAllLines(registry);
    Set<String> registered =
        lines.stream()
            .map(String::strip)
            .filter(line -> line.startsWith("- code:"))
            .map(line -> line.substring("- code:".length()).strip())
            .collect(Collectors.toSet());

    Set<String> declared =
        Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet());

    assertThat(registered).containsAll(declared);
  }
}

경로가 맞지 않으면 ./gradlew :app-bootstrap:test를 돌려 나오는 실제 작업 디렉터리로 Path.of(...)를 조정한다.

  • Step 12: 테스트를 돌린다
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)로 두어 빈 상태에서도 통과하게 한다. 코드가 늘면 자동으로 효력이 생긴다.

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: 테스트를 돌린다
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에 위반 클래스를 임시로 만들어 테스트가 실패하는지 본다.

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: 임시 파일을 지우고 다시 통과시킨다
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을 가져와 차이를 확인한다

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를 반드시 유지한다 — 즉시 검사로 바꾸면 첫 게시가 불가능하다.
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에 해당하는 블록을 지운다. 지운 뒤 남은 참조가 없는지 확인한다.

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:

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<String> 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<String> 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: 테스트를 돌린다
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: 실패 테스트를 쓴다
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())이며 rolesSet<String>이다 — 위 실측 블록 참조.

  • Step 2: 테스트를 돌려 실패를 확인한다
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를 구현한다
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에 접근할 수도 없다.
 *
 * <p>반환값을 {@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의 접근자는 다음과 같다. 추측하지 말 것.

Boolean     getAuthenticated()   / setAuthenticated(Boolean)
String      getDisplayName()     / setDisplayName(String)
Set<String> getRoles()           / setRoles(Set<String>)
String      getCsrfToken()       / setCsrfToken(String)
String      getCsrfHeaderName()  / setCsrfHeaderName(String)    enum 아님

CsrfHeaderNameEnum존재하지 않고 rolesList가 아니라 Set이다. 계약의 const: X-CSRF-TOKEN은 생성기가 String + @Schema로 냈다.

  • Step 4: 테스트를 돌려 통과를 확인한다
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 헤더 이름을 설정한다

계약은 csrfHeaderNameX-CSRF-TOKEN으로 고정하는데 템플릿 기본값은 X-XSRF-TOKEN이다. application-dev.yml에 추가한다.

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:

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 슬라이스에 AuthenticatedPrincipalCsrfToken을 넣는 방법은 기존 web 테스트(adapter/inbound/web/src/test)의 선례를 따른다. 선례가 없으면 @WithMockUser 대신 SecurityMockMvcRequestPostProcessors.csrf()와 커스텀 authentication(...)을 쓴다.

  • Step 7: 테스트를 돌린다
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 실패 테스트를 쓴다

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: 테스트를 돌려 실패를 확인한다
cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src
./gradlew :application-core:test --tests '*ListCatalogUseCaseTest' --console=plain

Expected: 컴파일 실패.

  • Step 3: application 타입과 포트를 만든다
// query/CatalogEntryType.java
package dev.caskeleton.application.techlog.studio.query;

/** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */
public enum CatalogEntryType {
  TOPIC,
  PROJECT,
  RELATION,
  EVIDENCE
}
// 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) {}
// query/CatalogPageView.java
package dev.caskeleton.application.techlog.studio.query;

import java.util.List;

public record CatalogPageView(List<CatalogEntryView> items, String nextCursor) {

  public CatalogPageView {
    items = List.copyOf(items);
  }
}
// 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 {}
// 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의 실제 시그니처를 먼저 확인하고 맞춘다.

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를 구현한다
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<ListCatalogQuery, CatalogPageView> {

  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: 테스트를 돌려 통과를 확인한다
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:

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 기준:

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_byupdated_by는 NOT NULL이고 기본값이 없으므로 INSERT에 반드시 넣는다. Task 7 산출물과 어긋나면 아래로 재확인한다.

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: 테스트를 돌려 실패를 확인한다
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: 영속 어댑터를 구현한다

이번 슬라이스에서는 TOPICPROJECT만 실제 조회하고, RELATION/EVIDENCE는 빈 페이지를 반환한다. 두 종류는 document/question/decision/asset 데이터가 들어오는 슬라이스 2·5에서 채운다 — 빈 페이지는 계약상 유효한 응답이며 화면이 깨지지 않는다.

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).
 *
 * <p>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<CatalogEntryView> 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<CatalogEntryView> 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<CatalogEntryView> 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: 테스트를 돌려 통과를 확인한다
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를 만든다
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의 실제 모양이다. 추측하지 말 것.

CatalogPage    List<CatalogEntry> 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) 있음)이다. idUUID라 변환이 필요 없다.

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:

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: 전체 검증을 돌린다
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: 변경 파일을 보고한다 (커밋하지 않는다)
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: 드리프트 테스트를 쓴다
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<String> problems = new ArrayList<>();
    JsonNode publishedPaths = published.path("paths");
    Iterator<Map.Entry<String, JsonNode>> paths = publishedPaths.fields();
    while (paths.hasNext()) {
      Map.Entry<String, JsonNode> 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<Map.Entry<String, JsonNode>> methods = path.getValue().fields();
      while (methods.hasNext()) {
        Map.Entry<String, JsonNode> 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-yamlfunctionalTest 클래스패스에 없으면 app-bootstrap/build.gradlefunctionalTestImplementation에 추가하고 lockfile을 재생성한다. Path.of("..", "config", ...)가 맞지 않으면 실패 메시지에 찍히는 실제 작업 디렉터리로 조정한다.

/api/v1/studio/catalog는 인증이 필요하므로 두 번째 테스트가 401을 받으면 SECURITY_PUBLIC_PATHS에 넣지 말고(보안 표면을 넓히면 안 된다) 기존 functional test가 쓰는 인증 헬퍼를 따라 인증된 요청으로 바꾼다.

ls /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/
  • Step 2: 테스트를 돌려 실패를 확인한다
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: 테스트를 돌려 통과를 확인한다
cd /home/donghyeon/workspace/desktop-server-git/tech-log-backend/src
./gradlew :app-bootstrap:functionalTest --tests '*StudioContractDriftTest' --console=plain

Expected: 2개 PASS.

  • Step 5: 변경 파일을 보고한다 (커밋하지 않는다)

완료 판정

이 계획이 끝나면 다음이 참이다.

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는 각각 별도 계획으로 쓴다. 이 계획이 끝나 계약과 기반이 확정된 뒤에 써야 추측이 들어가지 않는다.

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 위에)