feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종
설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고 슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과 listStudioCatalog를 구현했다. 계약과 생성 - src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록 - openapi-generator로 DTO(model)만 생성한다. generateApis 대신 globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다 - useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이 깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다 - 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다 오류 계약 - StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier) - StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을 바꾸지 않기 위해서다 - 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는 로그 전용이다(ApiErrorCarrier javadoc의 요구) - 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다 게이트 - TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를 늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다 - StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증 - StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라 새 컨트롤러가 자동으로 걸린다 - StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의 csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를 테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다 스키마 - V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존 idempotency_record 재사용)와 범위 밖 6종을 제외했다 - 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway 세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다 알려진 제약 - getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을 반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로 200을 만들지 않았다 - 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다 이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로 작성됐다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
697fc740e6
commit
91e6d99654
@@ -916,3 +916,315 @@ errors:
|
||||
runbook_link: "runbook://management/actuator-forbidden"
|
||||
compatibility_impact: none
|
||||
required_test: contract-verification:management-actuator
|
||||
|
||||
# ============================================================
|
||||
# TECH LOG STUDIO (studio-v1.yaml ApiError.code — 23종, feature-techlog-studio-backend)
|
||||
#
|
||||
# StudioError(dev.caskeleton.application.techlog.error.StudioError)와 1:1 매핑.
|
||||
# category/http_status/retryable은 그 enum의 선언과 정확히 같아야 한다
|
||||
# (StudioErrorRegistryTest가 코드 존재만 보고, 값 일치는 이 파일의 리뷰 책임).
|
||||
#
|
||||
# PAYLOAD_TOO_LARGE / UNSUPPORTED_MEDIA_TYPE은 StudioError에도 있지만 별도 row를
|
||||
# 추가하지 않는다 — feature-api-contract-baseline이 이미 동일 code로 아래(L514,
|
||||
# L545)에 VALIDATION/413/415/false, VALIDATION/415/false row를 갖고 있고 값이
|
||||
# StudioError 선언과 정확히 일치한다. error-codes.yaml의 identity column은 `code`
|
||||
# 하나뿐이라 같은 code로 두 번째 row를 추가하면 ContractRegistrySchemaGovernanceTest
|
||||
# 의 "duplicate identity" 게이트가 깨진다. 즉 이 두 코드는 기존 row가 이미 커버한다.
|
||||
# ============================================================
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — AUTHENTICATION_REQUIRED (StudioError.AUTHENTICATION_REQUIRED)
|
||||
- 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/token-missing"
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — STUDIO_ACCESS_DENIED (StudioError.STUDIO_ACCESS_DENIED)
|
||||
- code: STUDIO_ACCESS_DENIED
|
||||
category: AUTHZ
|
||||
http_status: 403
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "이 Studio 리소스에 접근할 권한이 없습니다"
|
||||
log_level: WARN
|
||||
runbook_link: "runbook://authz/insufficient-permission"
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND)
|
||||
- code: DOCUMENT_NOT_FOUND
|
||||
category: NOT_FOUND
|
||||
http_status: 404
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "요청한 문서를 찾을 수 없습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — VERSION_CONFLICT (StudioError.VERSION_CONFLICT)
|
||||
- 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
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — REQUEST_VALIDATION_FAILED (StudioError.REQUEST_VALIDATION_FAILED)
|
||||
- code: REQUEST_VALIDATION_FAILED
|
||||
category: VALIDATION
|
||||
http_status: 422
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: presentation
|
||||
client_safe_message: "요청 형식이 올바르지 않습니다"
|
||||
log_level: WARN
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — DOCUMENT_VALIDATION_FAILED
|
||||
# (StudioError.DOCUMENT_VALIDATION_FAILED). 원래 계약 이름은 VALIDATION_FAILED였으나
|
||||
# 스켈레톤 전역 OperationalError.VALIDATION_FAILED(400, VALIDATION)와 code 문자열이
|
||||
# 충돌해(같은 문자열, 다른 http_status) 개명했다 — controller 판정, 2026-08-19.
|
||||
- code: DOCUMENT_VALIDATION_FAILED
|
||||
category: VALIDATION
|
||||
http_status: 422
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "문서 검증에 실패했습니다"
|
||||
log_level: WARN
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — VALIDATION_STALE (StudioError.VALIDATION_STALE)
|
||||
- code: VALIDATION_STALE
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — PREVIEW_NOT_FOUND (StudioError.PREVIEW_NOT_FOUND)
|
||||
- code: PREVIEW_NOT_FOUND
|
||||
category: NOT_FOUND
|
||||
http_status: 404
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "요청한 미리보기를 찾을 수 없습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — PREVIEW_STALE (StudioError.PREVIEW_STALE)
|
||||
- code: PREVIEW_STALE
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — PREVIEW_EXPIRED (StudioError.PREVIEW_EXPIRED)
|
||||
- code: PREVIEW_EXPIRED
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "미리보기가 만료되었습니다. 다시 생성해 주세요"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — PUBLICATION_NOT_FOUND (StudioError.PUBLICATION_NOT_FOUND)
|
||||
- code: PUBLICATION_NOT_FOUND
|
||||
category: NOT_FOUND
|
||||
http_status: 404
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "요청한 게시물을 찾을 수 없습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — PUBLICATION_CONFLICT (StudioError.PUBLICATION_CONFLICT)
|
||||
- code: PUBLICATION_CONFLICT
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "게시 작업이 다른 변경과 충돌했습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — PUBLICATION_EVENT_NOT_FOUND (StudioError.PUBLICATION_EVENT_NOT_FOUND)
|
||||
- code: PUBLICATION_EVENT_NOT_FOUND
|
||||
category: NOT_FOUND
|
||||
http_status: 404
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "요청한 게시 이벤트를 찾을 수 없습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — PUBLICATION_SNAPSHOT_NOT_FOUND (StudioError.PUBLICATION_SNAPSHOT_NOT_FOUND)
|
||||
- code: PUBLICATION_SNAPSHOT_NOT_FOUND
|
||||
category: NOT_FOUND
|
||||
http_status: 404
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "요청한 게시 스냅샷을 찾을 수 없습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — WARNING_ACKNOWLEDGEMENT_REQUIRED (StudioError.WARNING_ACKNOWLEDGEMENT_REQUIRED)
|
||||
- code: WARNING_ACKNOWLEDGEMENT_REQUIRED
|
||||
category: VALIDATION
|
||||
http_status: 422
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요"
|
||||
log_level: WARN
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — IDEMPOTENCY_KEY_REUSED (StudioError.IDEMPOTENCY_KEY_REUSED)
|
||||
- code: IDEMPOTENCY_KEY_REUSED
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "Idempotency 키가 다른 요청에 재사용되었습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — ASSET_NOT_FOUND (StudioError.ASSET_NOT_FOUND)
|
||||
- code: ASSET_NOT_FOUND
|
||||
category: NOT_FOUND
|
||||
http_status: 404
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "요청한 자산을 찾을 수 없습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — ASSET_NOT_READY (StudioError.ASSET_NOT_READY)
|
||||
- code: ASSET_NOT_READY
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "자산 처리가 아직 완료되지 않았습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — ASSET_IN_USE (StudioError.ASSET_IN_USE)
|
||||
- code: ASSET_IN_USE
|
||||
category: CONFLICT
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "자산이 사용 중이라 이 작업을 수행할 수 없습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — ASSET_QUARANTINED (StudioError.ASSET_QUARANTINED)
|
||||
- code: ASSET_QUARANTINED
|
||||
category: DATA_INTEGRITY
|
||||
http_status: 409
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: application
|
||||
client_safe_message: "자산이 격리 처리되어 사용할 수 없습니다"
|
||||
log_level: WARN
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — STUDIO_UNAVAILABLE (StudioError.STUDIO_UNAVAILABLE)
|
||||
- code: STUDIO_UNAVAILABLE
|
||||
category: TRANSIENT_DEPENDENCY
|
||||
http_status: 503
|
||||
retryable: true
|
||||
retry_after_seconds: 5
|
||||
owner_branch: feature-techlog-studio-backend
|
||||
owner_layer: infrastructure
|
||||
client_safe_message: "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요"
|
||||
log_level: ERROR
|
||||
runbook_link: "runbook://studio/unavailable"
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락)
|
||||
category: AUTH
|
||||
error_codes: [AUTH_TOKEN_MISSING]
|
||||
error_codes: [AUTH_TOKEN_MISSING, AUTHENTICATION_REQUIRED]
|
||||
severity: P3
|
||||
owner: oncall
|
||||
last_updated: 2026-06-15
|
||||
last_updated: 2026-08-18
|
||||
status: stub
|
||||
---
|
||||
|
||||
@@ -14,6 +14,9 @@ status: stub
|
||||
|
||||
- HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING`
|
||||
- Client missing Authorization header or Bearer token
|
||||
- Tech Log Studio (`studio-v1.yaml`) surfaces the same missing-authentication scenario as
|
||||
`error.code=AUTHENTICATION_REQUIRED` — same root cause, Studio-scoped code
|
||||
(feature-techlog-studio-backend, `StudioError.AUTHENTICATION_REQUIRED`)
|
||||
|
||||
## Diagnosis
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족)
|
||||
category: AUTHZ
|
||||
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION]
|
||||
error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, STUDIO_ACCESS_DENIED]
|
||||
severity: P3
|
||||
owner: oncall
|
||||
last_updated: 2026-06-15
|
||||
last_updated: 2026-08-18
|
||||
status: stub
|
||||
---
|
||||
|
||||
@@ -14,6 +14,9 @@ status: stub
|
||||
|
||||
- HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION`
|
||||
- Valid token but missing required role or permission
|
||||
- Tech Log Studio (`studio-v1.yaml`) surfaces the same resource-authorization scenario as
|
||||
`error.code=STUDIO_ACCESS_DENIED` — same root cause, Studio-scoped code
|
||||
(feature-techlog-studio-backend, `StudioError.STUDIO_ACCESS_DENIED`)
|
||||
|
||||
## Diagnosis
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Runbook — STUDIO_UNAVAILABLE (Tech Log Studio 일시 장애)
|
||||
category: TRANSIENT_DEPENDENCY
|
||||
error_codes: [STUDIO_UNAVAILABLE]
|
||||
severity: P1
|
||||
owner: oncall
|
||||
last_updated: 2026-08-18
|
||||
status: active
|
||||
---
|
||||
|
||||
# Runbook: STUDIO_UNAVAILABLE (`runbook://studio/unavailable`)
|
||||
|
||||
## 1. Trigger
|
||||
|
||||
이 runbook은 Tech Log Studio API(`studio-v1.yaml`)가 `error.code=STUDIO_UNAVAILABLE`
|
||||
(HTTP 503, category `TRANSIENT_DEPENDENCY`, `retryable=true`)을 응답할 때 발동됩니다.
|
||||
|
||||
- alert name: `studio_error_rate_critical` 또는 `studio_dependency_unavailable`
|
||||
- alert payload 필수 field: `operation`(문서/미리보기/게시 중 어느 슬라이스인지), `error.code`,
|
||||
`error.category`, `dependency_name`(가능하면), `runbook_link`
|
||||
- 임계:
|
||||
- P1: `STUDIO_UNAVAILABLE` 비율이 1분간 Studio 전체 트래픽의 10% 초과
|
||||
- P2: 단발성 spike이나 5분 내 자연 회복
|
||||
|
||||
`StudioExceptionHandler`(adapter/inbound/web)가 `StudioException`을 봉투로 변환하는
|
||||
지점이므로, 이 코드는 항상 Studio facade/use case(`application-core`)가 자신의 하위
|
||||
의존성(영속성, 캐시, 오브젝트 스토리지, 렌더링/미리보기 파이프라인 등) 실패를
|
||||
클라이언트에 안전한 단일 코드로 접어(classify) 던진 결과입니다 — 원인 그 자체가 아니라
|
||||
**Studio가 판단한 결과**라는 점을 유의합니다.
|
||||
|
||||
## 2. First Response (5분 이내)
|
||||
|
||||
### Step 1 — 확인
|
||||
1. 최근 배포 이력 확인 (`app-bootstrap` 롤아웃, config 변경) — 배포 직후 spike면 롤백 우선 검토
|
||||
2. 로그에서 `StudioException`이 감싸고 있던 실제 원인을 확인: `dev.caskeleton.application.techlog`
|
||||
패키지의 use case/facade 로그에서 `STUDIO_UNAVAILABLE`로 분류되기 직전의 원인 예외
|
||||
(`PersistenceFailureException`, `DependencyFailureException` 등)를 추적
|
||||
3. runtime-health Dependency Matrix에서 Studio가 의존하는 구성요소(DB, 캐시, object storage)의
|
||||
required/optional 분류와 현재 상태 확인
|
||||
4. 특정 slice(문서 편집/미리보기/게시)에 국한된 장애인지, Studio 전역 장애인지 구분
|
||||
|
||||
### Step 2 — 임시 격리
|
||||
- 원인이 특정 하위 의존성이면 해당 의존성의 runbook으로 전환 (예: `runbook://db/unavailable`,
|
||||
`runbook://cache/unavailable`) — `STUDIO_UNAVAILABLE`은 진입점일 뿐, 근본 원인 대응은
|
||||
하위 의존성 runbook이 담당
|
||||
- 클라이언트(Frontend)는 이미 `retryable=true`를 신뢰해 지수 백오프 재시도를 수행하므로,
|
||||
단기 spike는 자연 회복을 우선 관찰 (조기 개입으로 인한 추가 부하 유발 방지)
|
||||
|
||||
## 3. Diagnosis
|
||||
|
||||
- log query: `{service="app"} | error.code="STUDIO_UNAVAILABLE" | stats count by operation`
|
||||
- 원인 추적: 같은 요청의 correlation id로 use case 로그를 따라가 어떤 하위 호출이
|
||||
`StudioException.withDetails(StudioError.STUDIO_UNAVAILABLE, ...)`로 재분류됐는지 확인
|
||||
- metric panel:
|
||||
- `http_server_requests_seconds_count{uri=~"/api/v1/studio/.*", outcome="SERVER_ERROR"}`
|
||||
- 하위 의존성 metric (`hikaricp_connections_active`, `resilience4j_circuitbreaker_state`,
|
||||
object storage client 오류율)
|
||||
- 가능한 원인:
|
||||
- DB/캐시/오브젝트 스토리지 등 필수 의존성 장애가 Studio 계층까지 전파
|
||||
- Studio 자체 리소스 고갈 (스레드풀, 커넥션풀)
|
||||
- 미리보기/렌더링 파이프라인의 타임아웃 누적
|
||||
- 배포 직후 신규 코드 경로의 미검증 예외가 fallback으로 `STUDIO_UNAVAILABLE`에 접힘
|
||||
|
||||
## 4. Mitigation
|
||||
|
||||
- 단기: 근본 원인이 확인된 하위 의존성이면 해당 dependency runbook의 mitigation을 적용
|
||||
- Studio 자체 리소스 고갈이면 인스턴스 스케일아웃 또는 커넥션/스레드풀 상향 검토
|
||||
- 특정 slice(예: 미리보기)만 영향받는다면 해당 slice만 일시적으로 기능 차단하고
|
||||
나머지(문서 편집/게시)는 정상 유지 검토 — 전면 장애보다 부분 degrade 우선
|
||||
- 장기: 반복되는 하위 의존성 장애가 `STUDIO_UNAVAILABLE`로 잦게 나타나면, 해당 의존성의
|
||||
circuit breaker/timeout 임계를 재조정하고 fallback 경로 보강
|
||||
|
||||
## 5. Escalation
|
||||
|
||||
- P1 5분 내 회복 신호 없으면 해당 하위 의존성 오너 팀에 page
|
||||
- 여러 slice(문서/미리보기/게시)에서 동시에 발생하면 incident commander 호출
|
||||
(공유 인프라 계층 문제 의심)
|
||||
|
||||
## 6. Recovery / Verification
|
||||
|
||||
- 회복 확인 metric: `STUDIO_UNAVAILABLE` 비율이 5분간 1% 미만으로 유지
|
||||
- 하위 의존성 metric(circuit breaker CLOSED, connection pool 정상)도 함께 확인
|
||||
- post-incident:
|
||||
- 어떤 하위 의존성이 `STUDIO_UNAVAILABLE`로 접혔는지 기록하고 근본 원인 runbook에 링크
|
||||
- Studio 클라이언트(Frontend `STUDIO_ERROR_CODES`) 쪽 재시도/백오프 동작이 기대대로
|
||||
작동했는지 확인
|
||||
- 특정 slice 반복 장애면 chaos test 시나리오 추가 검토
|
||||
|
||||
## 7. Related
|
||||
|
||||
- error-codes.yaml row: `STUDIO_UNAVAILABLE` (feature-techlog-studio-backend,
|
||||
`dev.caskeleton.application.techlog.error.StudioError.STUDIO_UNAVAILABLE`)
|
||||
- 매핑 지점: `dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler`
|
||||
- 관련 runbook: `runbook://db/unavailable`, `runbook://cache/unavailable`
|
||||
- 관련 branch: [[feature-techlog-studio-backend]]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,870 @@
|
||||
# Tech Log Studio Backend — 설계
|
||||
|
||||
- 작성일: 2026-08-18
|
||||
- 대상 저장소: `tech-log-backend` (`clean-architecture-backend-template` 스냅샷)
|
||||
- 브랜치: `feature/techlog-studio-backend`
|
||||
- 설계 원본: `/home/donghyeon/workspace/tech-log-design-package`
|
||||
- 소비자: `/home/donghyeon/workspace/desktop-server-git/tech-log-frontend` (Studio SPA, 구현 완료)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
설계 패키지가 Tech Log의 제품·정보구조·계약·DB·백엔드 모듈을 확정했다. 프론트는
|
||||
Studio SPA가 이미 구현되어 있고 백엔드만 없다. 이 문서는 **설계 패키지의 결정을
|
||||
이 저장소의 구조·게이트에 맞춰 어떻게 구현할지**를 정의한다.
|
||||
|
||||
설계 패키지의 `docs/plans/02-backend-core-plan.md`는 본문에 STALE 배너가 붙어 있고
|
||||
파일맵도 `services/api/...`라 이 저장소에 적용되지 않는다. 이 문서가 그 자리를
|
||||
대신한다.
|
||||
|
||||
### 1.1 계약 정합 (검증됨)
|
||||
|
||||
`tech-log-design-package/scripts/check-contract-parity.py` 실행 결과:
|
||||
|
||||
```text
|
||||
Frontend operation 19개 / Backend primary operation 19개
|
||||
[OK] Frontend operation 13개가 모두 Backend primary 계약에 존재한다.
|
||||
[OK] 공통 operation의 method/path가 모두 일치한다.
|
||||
RecordKind FE=BE=['CASE','PROJECT_DECISION','QUESTION','REFERENCE']
|
||||
오류 코드 FE=23개 / BE=23개 [OK]
|
||||
```
|
||||
|
||||
즉 `contracts/openapi/studio-v1.yaml`이 프론트가 실제로 호출하는 계약 그대로다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
### 2.1 In scope
|
||||
|
||||
`studio-v1.yaml`의 19개 operation 전부.
|
||||
|
||||
| operation | method · path |
|
||||
| --- | --- |
|
||||
| `getStudioSession` | GET `/api/v1/studio/session` |
|
||||
| `getStudioDashboard` | GET `/api/v1/studio/dashboard` |
|
||||
| `listStudioDocuments` | GET `/api/v1/studio/documents` |
|
||||
| `createStudioDocument` | POST `/api/v1/studio/documents` |
|
||||
| `getStudioDocument` | GET `/api/v1/studio/documents/{documentId}` |
|
||||
| `saveStudioDocument` | PUT `/api/v1/studio/documents/{documentId}` |
|
||||
| `validateStudioDocument` | POST `/api/v1/studio/documents/{documentId}/validate` |
|
||||
| `createStudioPreview` | POST `/api/v1/studio/documents/{documentId}/preview` |
|
||||
| `getCurrentStudioPreview` | GET `/api/v1/studio/documents/{documentId}/preview` |
|
||||
| `publishStudioDocument` | POST `/api/v1/studio/documents/{documentId}/publish` |
|
||||
| `listStudioPublications` | GET `/api/v1/studio/publications` |
|
||||
| `unpublishStudioPublication` | POST `/api/v1/studio/publications/{publicationId}/unpublish` |
|
||||
| `getStudioPublicationSnapshot` | GET `/api/v1/studio/publications/{publicationEventId}/preview` |
|
||||
| `listStudioCatalog` | GET `/api/v1/studio/catalog` |
|
||||
| `listStudioAssets` | GET `/api/v1/studio/assets` |
|
||||
| `uploadStudioAsset` | POST `/api/v1/studio/assets` |
|
||||
| `getStudioAsset` | GET `/api/v1/studio/assets/{assetId}` |
|
||||
| `updateStudioAsset` | PUT `/api/v1/studio/assets/{assetId}` |
|
||||
| `deleteStudioAsset` | DELETE `/api/v1/studio/assets/{assetId}` |
|
||||
|
||||
`RecordKind` 4종(`CASE`, `REFERENCE`, `QUESTION`, `PROJECT_DECISION`)을 하나의 문서
|
||||
편집 흐름으로 다룬다.
|
||||
|
||||
### 2.2 Out of scope (이번 브랜치)
|
||||
|
||||
- `contracts/openapi/public-v1.yaml` (Public 조회 API) — 소비자(Astro Public 사이트)가
|
||||
아직 없다.
|
||||
- `contracts/openapi/studio-management-v1.yaml` (secondary capability 보존 계약).
|
||||
- `release`, `topic`/`tag` 관리 UI, `identity`(SiteConfig/Profile/HomeFocus) 편집 API.
|
||||
- 이번 범위에 필요한 만큼의 `topic`/`project` **조회**는 catalog에서 다루되, 그
|
||||
편집 API는 만들지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 상위 결정
|
||||
|
||||
| ID | 결정 | 근거 |
|
||||
| --- | --- | --- |
|
||||
| D1 | 새 Gradle leaf를 만들지 않고 기존 18 leaf **안의 하위 패키지**로 배치한다 | `src/settings.gradle`이 `expectedModuleCount = 18`로 fail-closed 검증. leaf 추가는 registry·settings·dependency gate·ArchUnit을 동시에 바꾸는 template-level 변경이고 `template.lock.json` 기반 향후 sync와 충돌한다 |
|
||||
| D2 | 패키지는 `dev.caskeleton.{domain,application,adapter...}` **루트 아래**에 `techlog` 하위로 넣는다 | 일부 ArchUnit 규칙이 `dev.caskeleton.application..`처럼 루트를 고정한다(`CleanArchitectureTest.java:221`). 다른 루트에 두면 가드레일이 조용히 미적용된다 |
|
||||
| D3 | 계약 우선(openapi-generator)으로 **DTO(model)만** 생성하고 controller는 얇게 손으로 쓴다 (`globalProperties.set(['models': ''])`, `useOneOfInterfaces=false`) — 실제 설정은 §5.2, 실측과의 차이는 §3.1 | ADR-004를 유지하되 D4와 충돌하지 않는 형태. 생성 API interface는 봉투 wrapper 타입을 반환하게 되고, 그 타입은 `dev.caskeleton.shared.response.Envelope`가 아니라서 `EnvelopeBodyAdvice`가 **한 번 더 감싼다**(이중 래핑). 드리프트 위험의 실체는 93개 schema·enum·required 필드이고 그건 model 생성으로 덮인다. controller 19개 signature 드리프트는 §5.5 회귀 테스트가 잡는다 |
|
||||
| D4 | `/api/v1/studio/**`도 템플릿 응답 봉투를 **그대로 쓴다**. 계약을 봉투 형태로 재정의한다 | 봉투는 `shared-contract/README.md:230`의 문서화된 결정(boundary D5/D6)이고 정보 손실이 없다. 적응 코드를 백엔드 *템플릿* 파일이 아니라 프론트 *제품* 코드에 두는 편이 template sync 충돌이 없다(§5.3) |
|
||||
| D5 | `studio_idempotency` 테이블을 만들지 않고 기존 `idempotency_record`를 쓴다 | 기존 스키마가 설계 요구의 상위집합이고 `IdempotencyExecutorV2`까지 있다(§8.2) |
|
||||
| D6 | Studio 통합 목록은 물리 인덱스 테이블 없이 union query로 시작한다 | 설계 08장 §4. 성능이 입증되면 read model 추가 |
|
||||
| D7 | 커밋은 하지 않는다 | `AGENTS.md:64` — commit 정책 `human-only` |
|
||||
|
||||
### 3.1 D3 정정 — 실제 구현과의 차이 (2026-08-19, final whole-branch review B2)
|
||||
|
||||
이 절 작성 시점(§12 슬라이스 0 계획 단계)에는 openapi-generator 7.x가 이 저장소의 Spring Boot
|
||||
버전과 함께 검증된 바 없었고, §5.2가 예정한 `generateApis=false` / `generateModels=true`는
|
||||
실제 스파이크(슬라이스 0)에서 두 가지가 틀린 것으로 드러났다. 아래는 spec이 스스로 요구한
|
||||
"폴백을 쓰면 D3을 갱신한다"(구 §5.2)는 약속이 이행되지 않고 있던 것을 바로잡는 정정이다 —
|
||||
구현이 폴백으로 넘어간 것은 아니고(생성기는 여전히 model을 생성한다), **생성기 설정 자체가
|
||||
설계 시점에 존재하지 않는 API를 가정**했던 것이 실측으로 확인됐다.
|
||||
|
||||
- **`generateApis`/`generateModels`는 openapi-generator-gradle-plugin 7.18.0의 `openApiGenerate`
|
||||
확장에 존재하지 않는다** (디컴파일로 확인, task-4-report.md). 대신 CLI `--global-property`와
|
||||
같은 뜻인 `globalProperties.set(['models': ''])`로 "models만" 생성하도록 제한한다. 실제 설정은
|
||||
§5.2를 그대로 참조.
|
||||
- **생성 소스는 `sourceSets.main.java.srcDir`에 있지 않다.** 별도 `generatedOpenapi` sourceSet에
|
||||
있고, `main`/`test`의 compile·runtime classpath와 `jar` 산출물에 각각 명시적으로 이어 붙인다
|
||||
(`src/adapter/inbound/web/build.gradle:22-38`). 이유: 생성 코드가 deprecated
|
||||
`org.springframework.lang.Nullable`을 참조하는데, 저장소 루트 `build.gradle`의
|
||||
`-Werror`/`-Xlint:deprecation`이 이걸 컴파일 실패로 승격한다. `main`에 직접 넣으면 그 플래그가
|
||||
생성 코드에도 적용돼 빌드가 깨진다 — 별도 sourceSet으로 분리하고 그 sourceSet의
|
||||
`compileGeneratedOpenapiJava` 태스크에서만 `-Werror`/`-Xlint:deprecation`을 뺀다.
|
||||
- **spec에 없던 `useOneOfInterfaces=false`가 결정적 옵션으로 추가됐다.** discriminator(`oneOf`)
|
||||
union을 부모 Java interface로 생성하면(`useOneOfInterfaces=true`, openapi-generator 기본값)
|
||||
discriminator getter가 항상 `String`을 반환하도록 SpringCodegen이 고정하는데, 하위 타입의 실제
|
||||
getter 타입(공유 enum이든 아니든)과 충돌해 컴파일이 깨진다. 판별 필드를 하위 타입에서
|
||||
narrowing하지 않도록 계약을 고쳐도 동일하게 깨지는 것까지 스크래치에서 직접 검증했다(3개 설정
|
||||
조합, task-4-report.md) — 계약 쪽에서 우회할 수 없는 SpringCodegen 자체의 제약이다.
|
||||
|
||||
**`useOneOfInterfaces=false`의 대가 — 생성 union 5종이 파손됐다.** 이 경고는 지금
|
||||
`src/adapter/inbound/web/build.gradle:139-155`의 주석에만 있고 spec 본문에는 없었다 — Plan 02
|
||||
작성자가 읽는 문서는 이 spec이므로 여기 옮긴다.
|
||||
|
||||
`useOneOfInterfaces=false`는 컴파일은 통과시키지만, discriminator union 5종
|
||||
(`WorkingCopyInput`, `WorkingCopy`, `Inline`, `CaseRenderBlock`, `PublicRenderModel`)의 하위
|
||||
타입이 Java `implements` 관계를 전혀 갖지 않는 독립 클래스로 생성된다. 실측 결과 Jackson
|
||||
양방향 배선도 계약을 어긴다:
|
||||
|
||||
- **역직렬화**는 `InvalidTypeIdException`으로 실패한다 (예: `Class CaseInput not subtype of
|
||||
WorkingCopyInput`).
|
||||
- **직렬화**는 생성된 `@JsonIgnoreProperties(value="kind", allowSetters=true)` 때문에 실제
|
||||
discriminator 값 대신 클래스 simple name이 나간다 (예: 응답에 `"kind":"WorkingCopyInput"`).
|
||||
|
||||
이 union들을 요청·응답에 직접 또는 (409 `VersionConflictDetails`처럼) 간접적으로 포함하는
|
||||
operation은 최소 `createStudioDocument`/`getStudioDocument`/`saveStudioDocument`
|
||||
(`WorkingCopyInput`/`WorkingCopy`)와 `createStudioPreview`/`getCurrentStudioPreview`/
|
||||
`getStudioPublicationSnapshot`(`PublicRenderModel`)이며, `validateStudioDocument`를 포함해
|
||||
409 conflict 응답 경로로 더 넓게 새어 들어갈 수 있다 — Task 8/9(`getStudioSession`,
|
||||
`listStudioCatalog`)는 이 union들을 쓰지 않아 막히지 않았을 뿐, 영향받는 operation의 정확한
|
||||
목록과 대응 전략(수동 Jackson `@JsonTypeInfo`/`@JsonSubTypes` 재작성, 계약 재구조화, 또는 별도
|
||||
수기 DTO)은 **Plan 02가 착수 전에 확정해야 한다.**
|
||||
|
||||
---
|
||||
|
||||
## 4. 코드 배치
|
||||
|
||||
### 4.1 패키지
|
||||
|
||||
```text
|
||||
:domain-core
|
||||
dev.caskeleton.domain.techlog.content Document / CaseDetail / ReferenceDetail
|
||||
dev.caskeleton.domain.techlog.inquiry OpenQuestion
|
||||
dev.caskeleton.domain.techlog.project ProjectDecision
|
||||
dev.caskeleton.domain.techlog.asset Asset
|
||||
dev.caskeleton.domain.techlog.publication Publication / PublicationEvent
|
||||
dev.caskeleton.domain.techlog.<ctx>.vo Value Object
|
||||
|
||||
:application-core
|
||||
dev.caskeleton.application.techlog.<ctx>.port.in Command · Query · *UseCase 인터페이스
|
||||
dev.caskeleton.application.techlog.<ctx>.port.out Repository · Renderer · Clock 포트
|
||||
dev.caskeleton.application.techlog.<ctx>.service *UseCase 구현
|
||||
dev.caskeleton.application.techlog.studio.facade WorkingCopy / Validation / Preview / Publication
|
||||
dev.caskeleton.application.techlog.studio.query Dashboard / Document / Publication / Catalog
|
||||
dev.caskeleton.application.techlog.studio.mapper API 용어 ↔ Domain 용어
|
||||
dev.caskeleton.application.techlog.studio.nextaction NextAction 계산
|
||||
dev.caskeleton.application.techlog.studio.port.out Studio 자신의 read 포트(예: CatalogQueryPort) —
|
||||
§4.3 경계 규칙 2가 금지하는 것은 studio가
|
||||
*타* context의 port.out을 참조하는 것이지,
|
||||
studio 자신의 port.out이 아니다
|
||||
|
||||
:adapter:outbound:persistence-jpa
|
||||
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.entity
|
||||
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.repository
|
||||
dev.caskeleton.adapter.outbound.persistence.techlog.<ctx>.mapper
|
||||
dev.caskeleton.adapter.outbound.persistence.techlog.query JdbcClient union query
|
||||
|
||||
:adapter:inbound:web
|
||||
dev.caskeleton.adapter.inbound.web.techlog.studio.controller
|
||||
dev.caskeleton.adapter.inbound.web.techlog.studio.mapper
|
||||
dev.caskeleton.adapter.inbound.web.techlog.studio.problem
|
||||
|
||||
:app-bootstrap
|
||||
dev.caskeleton.bootstrap.techlog 조립·설정만
|
||||
```
|
||||
|
||||
`modules.json`, `settings.gradle`, `verifyCleanArchitectureDependencies`는 변경하지
|
||||
않는다.
|
||||
|
||||
### 4.2 상속되는 기존 가드레일
|
||||
|
||||
신규 코드에 자동으로 적용된다.
|
||||
|
||||
- `..domain..` 순수성 (프레임워크·전송·DB 의존 금지)
|
||||
- `..domain.vo..` / `@ValueObject`: public 무인자 생성자 금지
|
||||
- `@AggregateRoot`: `set*` 메서드 public 금지
|
||||
- `@DomainEvent`: record 필수
|
||||
- `..application..`: `@Transactional` 금지 → `TransactionPort` 사용
|
||||
- `..application..`: `ApplicationContext` 의존 금지
|
||||
- `CommandUseCase`/`QueryUseCase` 구현: 이름이 `UseCase`로 끝나야 하고
|
||||
`@UseCaseCapability` 필수
|
||||
- `verifyApplicationCoreDependencyPurity`: application-core 생산 의존은 project-only,
|
||||
클래스패스에 Spring/slf4j/logback/micrometer 금지
|
||||
|
||||
### 4.3 추가할 경계 규칙 — `TechLogBoundaryArchTest`
|
||||
|
||||
`:app-bootstrap` 테스트에 추가한다.
|
||||
|
||||
1. `techlog.content` / `techlog.inquiry` / `techlog.project` / `techlog.asset`은
|
||||
서로 의존하지 않는다.
|
||||
2. `application.techlog.studio`는 타 context의 `port.in`만 참조한다.
|
||||
타 context의 `domain`, `port.out`, `service` 직접 참조는 위반이다. (studio 자신의
|
||||
`application.techlog.studio.port.out`은 이 규칙의 대상이 아니다 — §4.1 참조.)
|
||||
3. 타 context는 `application.techlog.studio`를 참조하지 않는다 (역방향 금지).
|
||||
4. `domain.techlog.publication`을 제외한 어떤 domain 패키지도 `Publication`을
|
||||
직접 변경하지 않는다.
|
||||
|
||||
설계 08장의 "`studio`는 도메인 모듈이 아니다"를 빌드로 강제하는 장치다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 계약 → 코드
|
||||
|
||||
### 5.1 계약 원본
|
||||
|
||||
`studio-v1.yaml`을 `src/config/openapi/studio-v1.yaml`로 복사한다
|
||||
(`src/config/architecture`, `src/config/messaging`과 같은 authority 위치).
|
||||
`src/config/openapi/MANIFEST.sha256`에 해시를 기록해 설계 패키지와의 드리프트를
|
||||
가시화한다.
|
||||
|
||||
### 5.2 생성
|
||||
|
||||
`:adapter:inbound:web`에 `org.openapi.generator` 플러그인을 추가한다. 아래는 실제 구현 설정
|
||||
(`src/adapter/inbound/web/build.gradle`)이다 — 이 절이 원래 예정했던 `generateApis=false`/
|
||||
`generateModels=true`는 openapi-generator-gradle-plugin 7.18.0에 존재하지 않는 프로퍼티였다.
|
||||
무엇이 왜 달라졌는지는 §3.1 참조.
|
||||
|
||||
```text
|
||||
generatorName spring
|
||||
globalProperties ['models': ''] ← "models만" 생성 (CLI --global-property와 동치)
|
||||
modelPackage dev.caskeleton.adapter.inbound.web.techlog.studio.api.model
|
||||
useSpringBoot3 true
|
||||
useJakartaEe true
|
||||
openApiNullable true (jackson-databind-nullable 이미 선언됨)
|
||||
useOneOfInterfaces false ← §3.1 — discriminator union 5종의 Jackson 배선이 깨지는 대가
|
||||
output build/generated/openapi
|
||||
```
|
||||
|
||||
- 생성 소스는 `sourceSets.main.java.srcDir`가 아니라 **별도 `generatedOpenapi` sourceSet**에
|
||||
있다. 생성 코드가 deprecated `org.springframework.lang.Nullable`을 쓰는데 저장소 루트의
|
||||
`-Werror`가 이를 빌드 실패로 승격하기 때문이다 — 자세한 배선은 §3.1과
|
||||
`build.gradle:1-38`의 주석 참조. `jar`/`test` 클래스패스에는 별도로 명시적으로 얹는다.
|
||||
- spotless / checkstyle / spotbugs / errorprone 대상에서 제외한다.
|
||||
- `adapter/inbound/web/gradle.lockfile`을 `--write-locks`로 재생성한다.
|
||||
|
||||
#### 왜 API interface를 생성하지 않는가
|
||||
|
||||
D4로 응답 봉투를 유지하면 계약의 성공 응답 스키마가 봉투 wrapper가 된다. 그러면
|
||||
생성 interface의 signature가 `ResponseEntity<StudioSessionEnvelope>`가 되는데,
|
||||
`StudioSessionEnvelope`는 생성된 별개 클래스라 `dev.caskeleton.shared.response.Envelope`가
|
||||
아니다. `EnvelopeBodyAdvice.beforeBodyWrite`는 `Envelope`/`BulkEnvelope`만 통과시키므로
|
||||
이 본문을 **한 번 더 감싼다**.
|
||||
|
||||
```text
|
||||
controller → StudioSessionEnvelope
|
||||
EnvelopeBodyAdvice → Envelope<StudioSessionEnvelope>
|
||||
wire → {"success":true,"data":{"success":true,"data":{...},"meta":{...}},"meta":{...}}
|
||||
```
|
||||
|
||||
이걸 피하려면 `EnvelopeBodyAdvice`(템플릿 파일)를 고쳐야 하는데, 그건 D4가 피하려던
|
||||
바로 그 template sync 충돌이다.
|
||||
|
||||
따라서 **model만 생성하고 controller는 손으로 쓴다.**
|
||||
|
||||
```java
|
||||
@RestController
|
||||
final class StudioSessionController {
|
||||
@GetMapping("/api/v1/studio/session")
|
||||
StudioSession getStudioSession() { // 생성된 payload DTO를 그대로 반환
|
||||
return mapper.toApi(facade.currentSession());
|
||||
}
|
||||
}
|
||||
// EnvelopeBodyAdvice가 여기서 정확히 한 번 감싼다.
|
||||
```
|
||||
|
||||
controller는 facade 호출과 매핑만 하고 비즈니스 로직을 두지 않는다.
|
||||
|
||||
봉투 wrapper 스키마도 함께 생성되지만 백엔드는 쓰지 않는다. 계약의 wrapper는
|
||||
소비자(프론트·외부 도구)를 위한 기술이고, 백엔드에서는 advice가 그 역할을 한다.
|
||||
|
||||
**선행 검증 결과:** openapi-generator 7.x × 이 저장소의 Spring Boot 조합은 슬라이스 0의 폐기용
|
||||
스파이크로 검증했다. 폴백(생성기 1회 실행 후 수기 유지)으로 넘어가지는 않았다 — 생성기는 지금도
|
||||
빌드마다 model을 생성한다. 대신 §5.2 상단에 적은 세 가지(`globalProperties`, 별도 sourceSet,
|
||||
`useOneOfInterfaces=false`)가 스파이크에서 드러난 실제 조건이었다. §3.1이 그 정정 기록이다.
|
||||
|
||||
### 5.3 wire format — 템플릿 봉투를 그대로 쓴다
|
||||
|
||||
성공과 실패가 한 모양을 공유한다.
|
||||
|
||||
```jsonc
|
||||
// 성공 (200 / 201)
|
||||
{ "success": true, "data": { /* studio-v1 payload */ }, "meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
|
||||
|
||||
// 실패 (4xx / 5xx) — HTTP status는 그대로 의미를 갖는다
|
||||
{ "success": false, "error": { "code": "VERSION_CONFLICT", "category": "CONFLICT",
|
||||
"message": "...", "retryable": false,
|
||||
"details": { /* code별 polymorphic */ } },
|
||||
"meta": { "requestId": "...", "traceId": "...", "correlationId": "..." } }
|
||||
|
||||
// 204 (deleteStudioAsset) — 본문 없음. EnvelopeBodyAdvice는 null body를 감싸지 않는다.
|
||||
```
|
||||
|
||||
미디어 타입은 성공·실패 모두 `application/json`이다. `application/problem+json`은 쓰지
|
||||
않는다.
|
||||
|
||||
#### 왜 이 방향인가
|
||||
|
||||
- 봉투는 이 템플릿의 **문서화된 결정**이다. `shared-contract/README.md:230` —
|
||||
"RFC 7807 ProblemDetail 을 대체한다(boundary D5/D6)", "D5 가 RFC 7807 ProblemDetail 을
|
||||
거부하고, D10 이 `category`를 1급 필드로 추가했다".
|
||||
- **정보 손실이 없다.** §5.4의 매핑표 참조. `error.category`는 봉투 쪽이 추가로 준다.
|
||||
- **적응 코드의 위치가 결정적이다.** 봉투를 벗기려면 `EnvelopeBodyAdvice.supports()`를
|
||||
고쳐야 하는데 이는 템플릿 파일이고, 이 저장소는 `template.lock.json`의
|
||||
`"materialization": "tracked-snapshot"`이라 이후 모든 template sync의 충돌 지점이 된다.
|
||||
반대로 봉투를 유지하면 적응은 프론트 **제품 코드**
|
||||
(`src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts`) 안에서
|
||||
끝나고 프론트 플랫폼(`src/adapters/http/http-execution-v3.ts`)도 무변경이다.
|
||||
|
||||
#### 백엔드가 해야 할 일
|
||||
|
||||
**봉투 관련 신규 작업은 없다.** `EnvelopeBodyAdvice`, `ErrorResponseFactory`,
|
||||
`GlobalExceptionHandler`, `EnvelopeAuthenticationEntryPoint`,
|
||||
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`를 그대로 쓴다. 필요한 것은
|
||||
§5.4의 오류 코드 등록과 Studio 전용 예외 → `ApiError` 매핑뿐이다.
|
||||
|
||||
#### 계약과 프론트가 해야 할 일
|
||||
|
||||
이 결정은 세 저장소에 걸친다. 상세는 §5.6.
|
||||
|
||||
```text
|
||||
tech-log-design-package studio-v1.yaml을 봉투 형태로 재정의 (+ 06장 · ADR · MASTER_SPEC · 검증 스크립트)
|
||||
tech-log-frontend 계약 재생성 + validator 2개를 봉투 언랩으로 교체
|
||||
tech-log-backend 오류 코드 등록 + 예외 매핑 (봉투 자체는 무변경)
|
||||
```
|
||||
|
||||
### 5.4 오류 코드
|
||||
|
||||
계약의 오류 코드 23개를 `ApiError.code`에 그대로 싣는다.
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
#### 필드 매핑 — 정보 손실 없음
|
||||
|
||||
| 기존 계약 `ProblemDetails` | 봉투 |
|
||||
| --- | --- |
|
||||
| `code` | `error.code` |
|
||||
| `retryable` | `error.retryable` (1급) |
|
||||
| `detail` | `error.message` (client-safe. stack trace·내부 ID 금지) |
|
||||
| `status` | HTTP status (봉투도 실제 status를 유지한다) |
|
||||
| `traceId` | `meta.traceId` (D7 — 응답에서 절대 null이 아니다) |
|
||||
| `fieldErrors` | `error.details` = `ValidationErrorDetails` |
|
||||
| `latestDocument` | `error.details` = `VersionConflictDetails` |
|
||||
| `latestPublication` | `error.details` = `PublicationConflictDetails` |
|
||||
| `conflictingFields` | 위 두 details 안 |
|
||||
| `type` / `title` | 버린다. 프론트가 이미 `code`에서 합성한다(`studio-error-mapping.ts`의 `synthetic()`) |
|
||||
| — | `error.category` — 봉투가 추가로 준다 |
|
||||
|
||||
`details`는 code별 polymorphic이므로 계약에서 `oneOf` 타입 변형으로 선언한다.
|
||||
`Object` 자유형으로 두지 않는다.
|
||||
|
||||
#### 레지스트리 등록
|
||||
|
||||
`docs/registries/error-codes.yaml`에 23개 row를 additive로 추가한다. row 스키마가
|
||||
요구하는 필드를 모두 채운다 — `category`(10-value `Category` enum), `http_status`,
|
||||
`retryable`, `owner_layer`, `client_safe_message`, `log_level`, `runbook_link`,
|
||||
`compatibility_impact: additive`, `required_test`.
|
||||
|
||||
runbook 정책: `retryable=false`이고 category가 `AUTH`/`AUTHZ`/`RATE_LIMIT`/`INTERNAL`/
|
||||
`TRANSIENT_DEPENDENCY`/`PERMANENT_DEPENDENCY`면 `runbook_link`가 필수다.
|
||||
`VALIDATION`/`NOT_FOUND`/`CONFLICT`/`DATA_INTEGRITY`는 client-error로 면제 가능하다.
|
||||
따라서 `AUTHENTICATION_REQUIRED`(AUTH), `STUDIO_ACCESS_DENIED`(AUTHZ),
|
||||
`STUDIO_UNAVAILABLE`(TRANSIENT_DEPENDENCY)는 runbook을 함께 작성한다.
|
||||
|
||||
category 배정:
|
||||
|
||||
```text
|
||||
AUTH AUTHENTICATION_REQUIRED
|
||||
AUTHZ STUDIO_ACCESS_DENIED
|
||||
NOT_FOUND DOCUMENT_NOT_FOUND PREVIEW_NOT_FOUND PUBLICATION_NOT_FOUND
|
||||
PUBLICATION_EVENT_NOT_FOUND PUBLICATION_SNAPSHOT_NOT_FOUND
|
||||
ASSET_NOT_FOUND
|
||||
CONFLICT VERSION_CONFLICT PUBLICATION_CONFLICT IDEMPOTENCY_KEY_REUSED
|
||||
VALIDATION_STALE PREVIEW_STALE PREVIEW_EXPIRED
|
||||
ASSET_IN_USE ASSET_NOT_READY
|
||||
VALIDATION REQUEST_VALIDATION_FAILED VALIDATION_FAILED
|
||||
WARNING_ACKNOWLEDGEMENT_REQUIRED
|
||||
PAYLOAD_TOO_LARGE UNSUPPORTED_MEDIA_TYPE
|
||||
DATA_INTEGRITY ASSET_QUARANTINED
|
||||
TRANSIENT_DEPENDENCY STUDIO_UNAVAILABLE
|
||||
```
|
||||
|
||||
### 5.5 계약 회귀 테스트
|
||||
|
||||
**실제 구현 범위 (Task 10, `StudioContractDriftTest`, `:app-bootstrap`의 functional test
|
||||
소스셋):** springdoc이 노출하는 `/v3/api-docs`를 `src/config/openapi/studio-v1.yaml`과 대조해
|
||||
**operation id · method · path**가 어긋나면 실패시킨다 (`published ⊆ contract` 방향 — 아직
|
||||
구현하지 않은 operation이 있어도 green을 유지한다). `dev.caskeleton.adapter.inbound.web.techlog`
|
||||
아래를 `@ComponentScan`하므로 그 패키지 트리 밖에 컨트롤러를 두면 이 게이트가 못 본다 (스캔
|
||||
범위를 벗어나는 즉시 유효성을 잃는다는 뜻이므로 §4.1의 패키지 배치를 반드시 따른다). 별도로
|
||||
`getStudioSession`/`listStudioCatalog` 각 1개 케이스에 대해 성공 응답이
|
||||
`{success:true, data, meta}` 모양이고 이중 래핑이 없는지도 고정한다 — **모든 operation**의
|
||||
봉투 래핑을 확인하는 것은 아니다.
|
||||
|
||||
**아직 구현하지 않은 것 (Plan 02 몫):**
|
||||
|
||||
- 응답 media type 대조 (`application/json` 고정 여부)
|
||||
- 스키마 필수 필드 대조 (93개 schema·enum·required 필드가 model 생성으로 덮인다는 D3의 전제를
|
||||
실제로 검증하는 회귀 테스트는 없다 — 지금은 생성이 컴파일에 성공하는 것으로 암묵 검증한다)
|
||||
- 나머지 17개 operation에 대한 봉투 래핑 확인
|
||||
- `deleteStudioAsset` 204 본문 없음 확인 (해당 operation 미구현)
|
||||
|
||||
**401/403/429 코드 주장 — 삭제.** 이 절은 원래 401/403/429가 각각
|
||||
`AUTHENTICATION_REQUIRED`/`STUDIO_ACCESS_DENIED`/rate-limit 코드로 난다고 적었으나 이는
|
||||
코드상 사실이 아니다. 기존(템플릿 소유, 무변경 재사용) 경로가 실제로 내는 코드는:
|
||||
|
||||
- 401 — `EnvelopeAuthenticationEntryPoint` → `SecurityErrorClassifier.classifyAuthentication`
|
||||
(`src/adapter/inbound/web/src/main/java/.../auth/SecurityErrorClassifier.java:21-35`)이
|
||||
`AUTH_TOKEN_MISSING`(`InsufficientAuthenticationException`) 또는 `AUTH_TOKEN_MALFORMED`(그 외
|
||||
분류 불가 인증 실패)를 낸다.
|
||||
- 403 — `EnvelopeAccessDeniedHandler` → `SecurityErrorClassifier.classifyAccessDenied` (같은
|
||||
파일:39)가 `AUTHZ_INSUFFICIENT_PERMISSION`을 낸다.
|
||||
- 429 — `RateLimitInterceptor:75`가 `RATE_LIMIT_EXCEEDED`를 낸다.
|
||||
|
||||
이 세 코드 모두 계약(studio-v1.yaml) 23종 `ApiError.code` enum에 없다. 429는 계약에
|
||||
rate-limit 코드 자체가 존재하지 않는다. §5.3 "백엔드가 해야 할 일"이 "봉투 관련 신규 작업은
|
||||
없다"고 못 박았고 이 경로들은 템플릿 파일(`EnvelopeAuthenticationEntryPoint`,
|
||||
`EnvelopeAccessDeniedHandler`, `RateLimitInterceptor`, `SecurityErrorClassifier`)만으로
|
||||
동작해 Studio가 손댈 수 없다 — 계약을 이 세 코드로 확장할지, 별도 Studio 매핑 계층을 둘지는
|
||||
이 spec이 결정하지 않은 채 남아 있다. **Plan 02가 착수 전에 결정해야 한다.**
|
||||
|
||||
### 5.6 계약 재정의 명세
|
||||
|
||||
`studio-v1.yaml`을 다음과 같이 바꾼다. 이것이 세 저장소의 공유 SSOT가 된다.
|
||||
|
||||
**추가 스키마**
|
||||
|
||||
```text
|
||||
ResponseMeta requestId, traceId, correlationId, page(nullable)
|
||||
ApiError code(enum 23), category(enum 10), message, retryable, details(oneOf|null)
|
||||
ErrorEnvelope success(const false), error(ApiError), meta(ResponseMeta)
|
||||
ValidationErrorDetails fieldErrors[]
|
||||
VersionConflictDetails latestDocument, conflictingFields[]
|
||||
PublicationConflictDetails latestPublication
|
||||
```
|
||||
|
||||
**성공 응답 래핑** — JSON 본문을 갖는 18개 operation(200 15개 / 201 3개)의 응답
|
||||
스키마를 `{success: const true, data: <기존 payload>, meta: ResponseMeta}` 래퍼로
|
||||
교체한다. OpenAPI 3.1에 제네릭이 없으므로 payload별 래퍼 스키마를 만든다.
|
||||
기존 payload 스키마(`StudioSession`, `WorkingCopyDetail`, …)는 **그대로 남긴다** —
|
||||
프론트가 `components["schemas"]["StudioSession"]`로 도메인 타입을 계속 뽑아 쓴다.
|
||||
|
||||
**오류 응답 교체** — `components.responses`의 16개 항목 content를
|
||||
`application/problem+json` + `ProblemDetails`에서 `application/json` + `ErrorEnvelope`로
|
||||
바꾼다. `ProblemDetails` 스키마는 제거한다.
|
||||
|
||||
`deleteStudioAsset`의 204는 변경 없다.
|
||||
|
||||
**`public-v1.yaml` / `studio-management-v1.yaml`** — 두 계약도 `ProblemDetails`를
|
||||
쓰지만 이번 구현 범위 밖이고 소비자가 없다. 지금 변환하지 않고 각 파일 상단에
|
||||
"봉투 결정(ADR-006) 반영 대기" 배너를 붙인다. 설계 패키지가 `docs/plans/02`에 쓴
|
||||
STALE 배너와 같은 방식이다. 구현에 착수할 때 변환한다.
|
||||
|
||||
**따라오는 파일**
|
||||
|
||||
```text
|
||||
tech-log-design-package
|
||||
contracts/openapi/studio-v1.yaml 위 변경
|
||||
docs/specs/06-api-contract-design.md 7장 오류 계약 재작성
|
||||
decisions/ADR-006-response-envelope.md 신규 — 봉투 채택 근거와 RFC 7807 미채택 기록
|
||||
TECH_LOG_MASTER_SPEC.md scripts/build-master-spec.sh 재생성
|
||||
MANIFEST.sha256 scripts/update-manifest.sh 재생성
|
||||
scripts/check-contract-parity.py ProblemDetails.code → ApiError.code 참조 변경
|
||||
scripts/check-consistency.py 동일
|
||||
|
||||
tech-log-frontend
|
||||
src/features/tech-log/contracts/studio/studio-api.openapi.yaml 동기화
|
||||
src/features/tech-log/contracts/studio/generated.ts 재생성
|
||||
src/features/tech-log/contracts/studio/canonical-source.json 재생성
|
||||
src/features/tech-log/contracts/studio/contract.ts 타입 export 확인
|
||||
src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts
|
||||
outputValidator passthrough → 봉투 data 언랩
|
||||
problemValidator PROBLEM(bare) → 봉투 error 언랩 + code enum 검증 유지
|
||||
src/features/tech-log/adapters/http/studio-error-mapping.ts ApiError → StudioGatewayError
|
||||
관련 테스트 · 픽스처의 HTTP 본문
|
||||
```
|
||||
|
||||
`mock-studio-gateway.ts` 등 `StudioGateway` 포트를 직접 구현하는 mock은 전송 경계
|
||||
아래가 아니라 위에 있으므로 **변경 대상이 아니다.** 앱·도메인·프레젠테이션 계층도
|
||||
언랩이 경계에서 끝나므로 무변경이다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 도메인 모델
|
||||
|
||||
### 6.1 Aggregate
|
||||
|
||||
| Aggregate | 소유 테이블 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `Document` | `document`, `case_detail`, `reference_detail`, `document_tag`, `document_relation` | `RecordKind.CASE` / `REFERENCE` |
|
||||
| `OpenQuestion` | `open_question`, `question_point`, `question_update`, `question_tag`, `question_document_link` | `RecordKind.QUESTION` |
|
||||
| `ProjectDecision` | `project_decision` | `RecordKind.PROJECT_DECISION` |
|
||||
| `Asset` | `asset`, `asset_reference` | |
|
||||
| `Publication` | `publication`, `publication_event`, `publication_snapshot` | Event·Snapshot은 불변 |
|
||||
|
||||
`WorkingCopy`는 Aggregate가 아니라 **API projection**이다. `working_copy` 범용
|
||||
테이블을 만들지 않는다.
|
||||
|
||||
### 6.2 상태 기계 (설계 09장)
|
||||
|
||||
```text
|
||||
Document DRAFT → IN_REVIEW → PUBLISHED → ARCHIVED (+ unpublish: PUBLISHED → DRAFT)
|
||||
OpenQuestion OPEN → INVESTIGATING → PAUSED → RESOLVED → ARCHIVED
|
||||
Publication PUBLISHED → REPUBLISHED → UNPUBLISHED → REPUBLISHED
|
||||
```
|
||||
|
||||
**API 용어와 Domain 용어를 분리한다.**
|
||||
|
||||
```text
|
||||
API questionStatus=OPEN ← Domain OPEN | INVESTIGATING | PAUSED
|
||||
API questionStatus=RESOLVED ← Domain RESOLVED
|
||||
```
|
||||
|
||||
`saveStudioDocument`는 편집 가능한 content field만 저장하며 **lifecycle 전이를
|
||||
유발하지 않는다.** OPEN 계열 안에서의 값 변화는 무시한다. 프론트가 축약 상태를
|
||||
보냈다는 이유로 `INVESTIGATING`을 `OPEN`으로 덮어쓰면 조사 이력이 소실된다.
|
||||
|
||||
### 6.3 저장하지 않는 값
|
||||
|
||||
`nextAction`, `publicationStatus`, `hasUnpublishedChanges`, Preview state는
|
||||
**조회 시점 계산**이다. `workflow_status` 같은 domain 컬럼에 저장하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 애플리케이션 계층
|
||||
|
||||
### 7.1 use case 규약
|
||||
|
||||
context별 use case는 `CommandUseCase<C, R>` / `QueryUseCase<Q, R>`를 구현하고
|
||||
이름이 `UseCase`로 끝나며 `@UseCaseCapability`를 선언한다.
|
||||
|
||||
```java
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.KEYED,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
final class PublishCaseUseCase implements CommandUseCase<PublishCaseCommand, PublishResult> { }
|
||||
```
|
||||
|
||||
트랜잭션 경계는 `@Transactional`이 아니라 `TransactionPort`로 연다.
|
||||
`TransactionMode`는 `WRITE` / `READ_ONLY` / `REQUIRES_NEW` 계열이고,
|
||||
`Idempotency`는 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`,
|
||||
`RepositoryAccess`는 `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`다.
|
||||
`Idempotency-Key`를 쓰는 mutation은 `KEYED`로 선언한다.
|
||||
|
||||
### 7.2 studio facade
|
||||
|
||||
```text
|
||||
StudioWorkingCopyFacade create / get / save / list → 유형별 port.in dispatch
|
||||
StudioValidationFacade validate → validation artifact 영속
|
||||
StudioPreviewFacade create / get preview artifact + state 계산
|
||||
StudioPublicationFacade publish / unpublish / 이력 / snapshot 조회
|
||||
StudioDashboardQuery union query 기반 대시보드
|
||||
StudioDocumentQuery union query 기반 문서 목록
|
||||
StudioCatalogQuery TOPIC | PROJECT | RELATION | EVIDENCE
|
||||
NextActionCalculator §7.4
|
||||
StudioDocumentLocator documentId(UUID) → (sourceKind, sourceId)
|
||||
```
|
||||
|
||||
facade는 domain object를 직접 수정하지 않는다. 타 context의 `port.in`만 호출하고
|
||||
결과를 통합 DTO로 조립한다.
|
||||
|
||||
`documentId`는 계약상 **source aggregate id를 그대로 쓴다**(별도 surrogate 없음).
|
||||
`StudioDocumentLocator`가 `document` / `open_question` / `project_decision` union
|
||||
query로 kind를 해소한다.
|
||||
|
||||
### 7.3 Validation / Preview artifact
|
||||
|
||||
```text
|
||||
studio_validation validationId, sourceKind, sourceId, validatedVersion,
|
||||
status(INVALID|WARNINGS|VALID), issues(jsonb),
|
||||
dependencyRevision, validatedAt, validUntil, createdBy
|
||||
|
||||
studio_preview previewId, sourceKind, sourceId, sourceVersion,
|
||||
validationId(FK), dependencyRevision,
|
||||
renderModel(jsonb), createdAt, expiresAt, createdBy
|
||||
```
|
||||
|
||||
`dependencyRevision`은 검증에 쓴 외부 의존 상태(topic/project publishability,
|
||||
relation target 상태, asset READY/QUARANTINED, slug/route ownership, catalog
|
||||
revision, renderer/content-format version)의 identity+version을 정규화해 만든
|
||||
해시다. 전역 카운터가 아니다.
|
||||
|
||||
상태는 저장하지 않고 조회 시 계산한다.
|
||||
|
||||
```text
|
||||
Validation 유효 validatedVersion == 현재 working version
|
||||
AND dependencyRevision == 현재 계산값
|
||||
AND now() < validUntil
|
||||
|
||||
Preview CURRENT sourceVersion == 현재 working version
|
||||
AND dependencyRevision == 현재 계산값
|
||||
AND now() < expiresAt
|
||||
STALE version 또는 dependencyRevision 불일치
|
||||
EXPIRED now() >= expiresAt
|
||||
```
|
||||
|
||||
`VALIDATION_FAILED`(지금 검증하면 실패)와 `VALIDATION_STALE`(통과했으나 전제가
|
||||
바뀜)은 다른 사건이며 코드도 다르다.
|
||||
|
||||
### 7.4 nextAction 계산
|
||||
|
||||
```text
|
||||
저장 가능한 형태조차 미달 → CONTINUE_EDITING
|
||||
현재 version에 대한 Validation 없음 → VALIDATE
|
||||
현재 Validation = INVALID → FIX_VALIDATION
|
||||
유효 Validation + 현재 version Preview 없음 → CREATE_PREVIEW
|
||||
Preview가 STALE 또는 EXPIRED → CREATE_PREVIEW
|
||||
현재 Validation + 현재 Preview
|
||||
+ Publication version 불일치 → PUBLISH
|
||||
Publication version == working version → NONE
|
||||
```
|
||||
|
||||
### 7.5 Publish 트랜잭션 (설계 07장 §14, 20단계)
|
||||
|
||||
```text
|
||||
0. idempotency 선검사 — 동일 key + 동일 fingerprint면 최초 응답 재생, 이하 미실행
|
||||
1. Document SELECT FOR UPDATE
|
||||
2. expectedVersion 검증 불일치 → VERSION_CONFLICT
|
||||
3. validationId 조회 + dependencyRevision 재계산 비교 불일치 → VALIDATION_STALE
|
||||
4. previewId 조회 + sourceVersion / dependencyRevision 비교
|
||||
불일치 → PREVIEW_STALE, 만료 → PREVIEW_EXPIRED
|
||||
5. acknowledgedWarningCodes가 WARNING 집합을 덮는지
|
||||
미달 → WARNING_ACKNOWLEDGEMENT_REQUIRED
|
||||
6. 유형별 publication validation (게시 필수 필드 / slug 충돌)
|
||||
7. Markdown 분석
|
||||
8. Asset READY 및 사용 위치 alt/decorative 검증
|
||||
9. 공개 payload 생성
|
||||
10. Publication Event 생성 (PUBLISHED | REPUBLISHED)
|
||||
11. Publication Snapshot 생성 (immutable)
|
||||
12. public_resource_projection upsert
|
||||
13. public_route canonical/alias 변경
|
||||
14. public_resource_tag / project link 교체
|
||||
15. PUBLISHED asset_reference 교체
|
||||
16. asset.first_published_at 갱신
|
||||
17. Publication aggregate 갱신 (latest_event_id, publication_revision)
|
||||
18. Document publish metadata 갱신
|
||||
19. 선택적 ProjectActivity 생성
|
||||
20. Commit
|
||||
```
|
||||
|
||||
전 단계가 하나의 트랜잭션이다.
|
||||
|
||||
**Snapshot의 render model은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한
|
||||
Preview의 render model을 그대로 쓴다.** 재렌더링하면 승인한 화면과 공개된 화면이
|
||||
달라질 수 있다.
|
||||
|
||||
Unpublish는 `expectedPublicationRevision` 검증 → `UNPUBLISHED` Event(반드시
|
||||
`sourcePublishedEventId` 보유) → Publication 상태 전환 및 revision 증가 →
|
||||
Projection `ACTIVE → WITHDRAWN` → Working `workflow_status → DRAFT` → route 유지 →
|
||||
commit. Snapshot은 삭제하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 영속화
|
||||
|
||||
### 8.1 마이그레이션
|
||||
|
||||
`PostgreSqlPersistenceConfig.java:57`이 Flyway location을
|
||||
`classpath:db/migration/postgresql`로 고정한다. 기존 최대 버전이 V6이고
|
||||
`out-of-order: false`이므로 **`V7__techlog_core.sql`부터** 추가한다.
|
||||
|
||||
이번 범위에 필요한 테이블:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
`publication.latest_event_id` ↔ `publication_event.publication_id` 순환 FK는
|
||||
`DEFERRABLE INITIALLY DEFERRED`로 선언한다. 즉시 검사로 두면 첫 게시가 불가능하다.
|
||||
|
||||
`publication_event` / `publication_snapshot`은 어떤 cleanup job도 삭제하지 않는다.
|
||||
|
||||
### 8.2 멱등 — 기존 자산 재사용 (D5)
|
||||
|
||||
설계의 `studio_idempotency` 테이블을 만들지 않는다.
|
||||
|
||||
| 설계 `studio_idempotency` | 기존 `idempotency_record` |
|
||||
| --- | --- |
|
||||
| `idempotency_key` | `idempotency_key` |
|
||||
| `operation_id` | `use_case_name` |
|
||||
| `request_fingerprint` | `request_hash` (char(64) sha256) |
|
||||
| `response_status` / `response_body` | `response_payload` |
|
||||
| `created_at` / `expires_at` | `created_at` / `expires_at` |
|
||||
| `created_by` | `principal` (+ `tenant`) |
|
||||
|
||||
기존 것이 상위집합이고 `IdempotencyExecutorV2`, `IdempotencyStorePortV2`,
|
||||
`IdempotencyKeySupport`(header 추출 · scope · sha256 fingerprint · JSON codec)까지
|
||||
있다. 설계 대비 결손은 두 가지뿐이며 web 계층에서 채운다.
|
||||
|
||||
1. 재생 시 `Idempotency-Replayed: true` 응답 헤더
|
||||
2. fingerprint 불일치 시 `IDEMPOTENCY_KEY_REUSED` ProblemDetails (409)
|
||||
|
||||
적용 대상: create / save / validate / create preview / publish / unpublish /
|
||||
asset upload · update · delete.
|
||||
|
||||
### 8.3 읽기·쓰기 분리
|
||||
|
||||
- 쓰기: JPA aggregate + `@Version` 낙관적 잠금
|
||||
- Studio 목록 / 대시보드 / 카탈로그: `JdbcClient` union query (도메인 repository 우회)
|
||||
- 공통 CRUD repository를 만들지 않는다
|
||||
|
||||
---
|
||||
|
||||
## 9. 재사용 매핑 (신규 구현 금지)
|
||||
|
||||
| 설계 요구 | 재사용 대상 |
|
||||
| --- | --- |
|
||||
| `Idempotency-Key` | `application/idempotency/v2`, `web/idempotency/IdempotencyKeySupport`, `idempotency_record` |
|
||||
| `expectedVersion` / 409 | JPA `@Version`, `web/conditional/ETags`, `PreconditionFailedException` |
|
||||
| Studio cursor 페이지네이션 | `web/cursor/CursorCodec`, `web/pagination/PageParams` |
|
||||
| 오류 응답 골격 | `shared/response/{Envelope,ApiError,ResponseMeta}`, `web/error/ErrorResponseFactory`, `GlobalExceptionHandler` — **무변경 재사용** |
|
||||
| 401 / 403 / 429 | `EnvelopeAuthenticationEntryPoint`, `EnvelopeAccessDeniedHandler`, `RateLimitInterceptor` — **무변경 재사용** |
|
||||
| Keycloak 세션 · CSRF | `web/auth/SecurityConfig`, `JwtDecoderConfig`, `PrimitiveSessionSecurityContextRepository`, `RedisSessionWebConfig` |
|
||||
| 권한 | `application/security/RequiresPermission`, `web/authz/RolePermissionPolicy`, `RequiresPermissionAuthorizationManager` |
|
||||
| Asset 업로드 · 저장 | `adapter:outbound:fileserver`, `adapter:outbound:objectstorage` |
|
||||
| ID 생성 | `adapter:outbound:identifier` |
|
||||
| 캐시 | `application/cache`, `adapter:outbound:cache-redis` (필요 입증 시에만) |
|
||||
| 관측 | `web/observability`, `docs/registries/{metrics,mdc-keys,headers}.yaml` |
|
||||
| 트랜잭션 | `application/transaction/TransactionPort` |
|
||||
|
||||
**Asset은 새 저장 계층을 만들지 않는다.** `asset` 테이블은 metadata·`asset_key`·
|
||||
lifecycle만 소유하고 바이너리 저장·전송은 fileserver/objectstorage 어댑터에 위임한다.
|
||||
`asset_key`(안정 참조)와 `object_key`(저장 위치)를 분리한다 — 본문에 object storage
|
||||
URL을 저장하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 렌더러
|
||||
|
||||
`PublicRenderModel`은 Markdown을 의미 블록으로 변환한 결과다. 계약이 정의하는
|
||||
블록·인라인 타입:
|
||||
|
||||
```text
|
||||
블록 HeadingBlock ParagraphBlock CodeBlock BlockquoteBlock CalloutBlock
|
||||
OrderedListBlock UnorderedListBlock DataTableBlock EvidenceFigureBlock
|
||||
인라인 InlineText InlineStrong InlineEmphasis InlineCode InlineLink InlineStatus
|
||||
```
|
||||
|
||||
제약:
|
||||
|
||||
- Public과 Studio Preview가 **같은 의미의 렌더 결과**를 써야 한다 (ADR-005).
|
||||
- Snapshot은 `contentFormatVersion`, `rendererContractVersion`, asset manifest를 함께
|
||||
보존한다. 이후 Asset이 교체되어도 과거 Snapshot의 표현이 변하지 않는다.
|
||||
- Asset은 `assetKey`로 참조하고 렌더 시 `ResolvedAsset`으로 해소한다.
|
||||
|
||||
**이 항목이 이번 구현의 최대 리스크다.** 슬라이스 3 착수 전에 프론트의 기존 렌더
|
||||
모델 구현(`src/features/tech-log/adapters/mock/project-public-render-model.ts` 및
|
||||
static content 경로)을 기준선으로 대조해 블록 의미가 일치하는지 확인한다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 테스트 전략
|
||||
|
||||
leaf별 템플릿 정책을 그대로 따른다.
|
||||
|
||||
| 대상 | 방식 |
|
||||
| --- | --- |
|
||||
| `domain-core` | 순수 JUnit. 상태 전이·불변 조건 |
|
||||
| `application-core` | 손수 만든 fake 포트. 웹·영속 컨텍스트 금지 |
|
||||
| `adapter:inbound:web` | 전송 slice 테스트 + §5.5 봉투 래핑·오류 코드 회귀 테스트 |
|
||||
| `adapter:outbound:persistence-jpa` | 매핑·포트 계약 테스트. 벤더 의미가 필요한 것(deferrable FK, `SELECT FOR UPDATE`)은 `postgresqlIntegrationTest` 소스셋 |
|
||||
| `app-bootstrap` | 배선·`TechLogBoundaryArchTest`·계약 회귀 테스트 |
|
||||
|
||||
필수 시나리오:
|
||||
|
||||
- publish 재시도가 중복 `publication_event`를 만들지 않는다
|
||||
- `VALIDATION_STALE`과 `VALIDATION_FAILED`가 구분된다
|
||||
- `saveStudioDocument`가 `INVESTIGATING`을 `OPEN`으로 되돌리지 않는다
|
||||
- unpublish 후 과거 Snapshot이 그대로 조회된다
|
||||
- 성공 응답이 `{success:true, data, meta}`이고 `meta.traceId`가 non-null이다
|
||||
- 오류 응답이 `{success:false, error, meta}`이고 `error.code`가 계약의 23개 중 하나다
|
||||
- 409가 `error.details`로 `latestDocument` / `latestPublication`을 싣는다
|
||||
|
||||
검증 명령 (`src/`에서):
|
||||
|
||||
```bash
|
||||
./gradlew :domain-core:test --console=plain
|
||||
./gradlew :application-core:test --console=plain
|
||||
./gradlew :adapter:inbound:web:test --console=plain
|
||||
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*TechLogBoundaryArchTest' --console=plain
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 슬라이스
|
||||
|
||||
| # | 내용 | 완료 판정 |
|
||||
| --- | --- | --- |
|
||||
| 0 | **계약 봉투 재정의(§5.6) → 프론트 재생성·validator 교체**, 생성기 스파이크 → 배선, 계약 복사, `TechLogBoundaryArchTest`, 오류코드 레지스트리 23개, `V7__techlog_core.sql` | 빌드·아키텍처 검증 통과 + 3 저장소 계약 parity 통과 |
|
||||
| 1 | `getStudioSession`, `listStudioCatalog` | 프론트 로그인·카탈로그 실동작 |
|
||||
| 2 | `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument` (+ 낙관적 잠금, 멱등) | 프론트 편집 실동작 |
|
||||
| 3 | `validateStudioDocument`, `createStudioPreview`, `getCurrentStudioPreview`, `dependencyRevision`, `nextAction`, 렌더러 | 프론트 검증·미리보기 실동작 |
|
||||
| 4 | `publishStudioDocument`, `unpublishStudioPublication`, `listStudioPublications`, `getStudioPublicationSnapshot`, `getStudioDashboard` | 프론트 게시 실동작 |
|
||||
| 5 | asset 5종 | 프론트 자산 실동작 |
|
||||
|
||||
`getStudioDashboard`는 publication·validation 데이터에 의존하므로 슬라이스 4에서
|
||||
완성한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 리스크
|
||||
|
||||
| 리스크 | 완화 |
|
||||
| --- | --- |
|
||||
| openapi-generator × Spring Boot 4.0.0 미검증 | 슬라이스 0 첫 스텝을 폐기용 스파이크로. 실패 시 §5.2 폴백 |
|
||||
| 3 저장소(계약·프론트·백엔드) 동기화 실패 | 슬라이스 0에서 계약을 먼저 확정하고 `check-contract-parity.py`를 게이트로 삼는다. 백엔드는 §5.5 계약 회귀 테스트로 고정 |
|
||||
| 프론트 언랩 누락 시 조용한 실패 (`outputValidator`가 passthrough라 전송 계층이 잡지 못한다) | 언랩 validator에 `success`/`data` 존재 검증을 넣어 하드 실패로 바꾼다 |
|
||||
| 렌더 모델 의미 불일치 | 슬라이스 3 착수 전 프론트 구현 대조 |
|
||||
| publish 20단계 트랜잭션 복잡도 | 단계별 실패 코드를 먼저 테스트로 고정한 뒤 구현 |
|
||||
| 설계 DDL과 템플릿 스키마 충돌 | `studio_idempotency` 제거(D5) 외에는 이름 충돌 없음을 V7 작성 시 재확인 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 운영 제약
|
||||
|
||||
- **커밋 금지** — `AGENTS.md:64` commit 정책 `human-only`. 브랜치 생성과 파일 작성까지
|
||||
수행하고 stage/commit/push는 사용자가 한다. 이 제약은 `tech-log-backend`의 규약이며
|
||||
`tech-log-design-package` / `tech-log-frontend`에는 적용되지 않는다.
|
||||
- 이 설계는 **세 저장소**를 건드린다(§5.6). 계약이 SSOT이므로 순서는
|
||||
`tech-log-design-package` → `tech-log-frontend` → `tech-log-backend`다.
|
||||
- `git flow init`은 워킹트리에 미커밋 삭제분(`*-superpowers-package/`,
|
||||
`scripts/verify-httpclient-docs.py`)이 있어 중단되었다. gitflow 설정은 기록했고
|
||||
`develop` / `feature/techlog-studio-backend` 브랜치는 수동 생성했다. 미커밋 삭제분은
|
||||
손대지 않았다.
|
||||
|
||||
---
|
||||
|
||||
## 15. 참조
|
||||
|
||||
- 설계: `tech-log-design-package/docs/specs/{06,07,08,09}`, `decisions/ADR-00{1..5}`
|
||||
- 계약: `tech-log-design-package/contracts/openapi/studio-v1.yaml`
|
||||
- 프론트 계약: `tech-log-frontend/src/features/tech-log/contracts/studio/studio-api.openapi.yaml`
|
||||
- 프론트 실행 경로: `tech-log-frontend/src/adapters/http/http-execution-v3.ts`
|
||||
- 저장소 권위: `src/config/architecture/modules.json`, `src/settings.gradle`, `AGENTS.md`
|
||||
Reference in New Issue
Block a user