설계 패키지의 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>
103 KiB
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(branchmaster)FE=/home/donghyeon/workspace/desktop-server-git/tech-log-frontendBE=/home/donghyeon/workspace/desktop-server-git/tech-log-backend(branchfeature/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: 현재 상태를 기록해 둔다
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은
쓰지 않는다.
근거
- 정보 손실이 없다.
ProblemDetails가 담던 것을 전부 옮길 수 있고,error.category(10-value enum)가 덤으로 붙는다.type/title은 버리되 Frontend가 이미code에서 합성하고 있다. - 적응 코드의 위치. 봉투를 벗기려면 Backend 템플릿 파일
EnvelopeBodyAdvice를 고쳐야 하는데, 그 저장소는 tracked-snapshot이라 이후 모든 template sync의 충돌 지점이 된다. 봉투를 유지하면 적응이 Frontend 제품 코드 안에서 끝나고 Frontend 플랫폼도 무변경이다. - 두 규약 모두 실무 표준이다. 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이라 한 줄 치환으로 끝난다.
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.codeenum -
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
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를 재생성한다
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.yamlv3.0.0 -
Produces: 봉투를 언랩하는
envelopeData/envelopeErrorvalidator. 앱·도메인 계층은 기존과 같은 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.ts의 PROBLEM 분기에서
outcome.problem의 status가 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.ts의
SafeResponseMetadata 정의를 읽어 맞춘다.
- 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 실측 — 이 스파이크는 실패했고 계약을 고쳐 해소했다.
generateApis/generateModels는 openapi-generator-gradle-plugin 7.18.0에 존재하지 않는 속성이다. 설정하면 Gradle 평가에서Could not set unknown property 'generateApis'로 죽는다. 실제 API는globalProperties이고['models': '']가 "model만 생성"을 뜻한다.- 올바른 설정으로도 생성이 NPE로 죽는다 —
DefaultCodegen.setEnumDiscriminatorDefaultValue에서"var.allowableValues" is null(modelWorkingCopyInput처리 중). 계약의 5개 discriminatoroneOfunion이 하위 타입의 discriminator 필드를 OpenAPI 3.1 관용구인const로 좁히기 때문이다.legacyDiscriminatorBehavior: false로도 동일하게 실패한다. 네트워크 문제 아님.- 해소: discriminator로 쓰이는 필드의
const: X를 의미가 같은 단일값enum: [X]로 바꾼다.discriminator키워드는 3.1의const보다 앞서 만들어졌고 생태계 관용구가 단일값enum이라, 우회가 아니라 계약을 표준 관용구에 맞추는 것이다. 봉투 래퍼의success: { const: true|false }16개와csrfHeaderName의const는 discriminator가 아니므로 그대로 둔다.- 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.yaml의 errors: 목록 끝에 아래 형식으로 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_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:
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을 기반으로 하되 세 가지를 바꾼다.
studio_idempotency테이블과 그 인덱스(idx_studio_idempotency_expiry)를 제외한다. 기존idempotency_record를 쓴다 (spec D5).release,site_config,profile_page,home_focus_config,topic_featured_document,project_topic테이블을 제외한다. 이번 범위 밖이다 (spec §2.2).- 나머지는 그대로 옮긴다.
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())이며 roles는 Set<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은 존재하지 않고roles는List가 아니라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 헤더 이름을 설정한다
계약은 csrfHeaderName을 X-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 슬라이스에 AuthenticatedPrincipal과 CsrfToken을 넣는 방법은
기존 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_by와
updated_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: 영속 어댑터를 구현한다
이번 슬라이스에서는 TOPIC과 PROJECT만 실제 조회하고, 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)있음)이다.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:
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-yaml이 functionalTest 클래스패스에 없으면
app-bootstrap/build.gradle의 functionalTestImplementation에 추가하고 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 위에)