설계 패키지의 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>
2595 lines
103 KiB
Markdown
2595 lines
103 KiB
Markdown
# 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 위에)
|
||
```
|