Files
tech-log-backend/docs/superpowers/plans/2026-08-18-techlog-studio-backend-01-foundation.md
T
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

2595 lines
103 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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개 `<Payload>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``<Payload>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 스키마는 그대로 두고
# 응답만 <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` 목록과 같다.
```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 / <Payload>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 = <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 정의의
```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: `<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개와 `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
<?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>` 블록만 추가한다.
```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를 알 필요가 없다.
*
* <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`:
```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: 테스트를 돌려 실패를 확인한다**
```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<Envelope<Void>> 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<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: 테스트를 돌린다**
```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<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: 테스트를 돌린다**
```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<String>`이다 — 위 실측 블록 참조.
- [ ] **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에 접근할 수도 없다.
*
* <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`의 접근자는 다음과 같다. 추측하지 말 것.
>
> ```java
> Boolean getAuthenticated() / setAuthenticated(Boolean)
> String getDisplayName() / setDisplayName(String)
> Set<String> getRoles() / setRoles(Set<String>)
> 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<CatalogEntryView> 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<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: 테스트를 돌려 통과를 확인한다**
```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).
*
* <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: 테스트를 돌려 통과를 확인한다**
```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<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)` 있음)이다. `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<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-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 위에)
```