Runs both repositories locally -- backend on PostgreSQL 16 behind a real Keycloak realm, frontend as a production-profile build -- and records what each checklist section actually did, with the command output behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
217 lines
14 KiB
Markdown
217 lines
14 KiB
Markdown
# Tech Log 운영 출시 전 체크리스트 — 실측 검증 보고서
|
||
|
||
**검증일** 2026-08-19 · **방식** 두 저장소를 로컬에서 실제 기동해 엔드포인트·브라우저 단위로 실측
|
||
|
||
| 대상 | 위치 | 리비전 |
|
||
|---|---|---|
|
||
| Frontend | `tech-log-frontend` | `main` eb86708 → `fix/release-gate-frontend` fff5e6f |
|
||
| Backend | `tech-log-backend` | `develop` ab0447a |
|
||
| Keycloak | 로컬 컨테이너 `local-keycloak` | 26.7.0 (`:18080`) |
|
||
| PostgreSQL | 로컬 컨테이너 `techlog-pg` | 16.15 (`:5433`) |
|
||
|
||
---
|
||
|
||
## 요약 판정: **출시 보류 (P0 미충족)**
|
||
|
||
체크리스트 §31의 P0 항목 중 **인증 우회 불가 · 인가 우회 불가 · Studio 주요 기능 정상 · Publish 정상**이
|
||
현재 충족되지 않는다. 아래 근거는 전부 실행 결과다.
|
||
|
||
### 가장 중요한 구조적 사실
|
||
|
||
백엔드는 계약(`studio-v1.yaml`)이 선언한 **18개 오퍼레이션 중 2개**만 구현되어 있다.
|
||
|
||
| 상태 | 오퍼레이션 |
|
||
|---|---|
|
||
| 구현됨 (2) | `getStudioSession`, `listStudioCatalog` |
|
||
| 미구현 (16) | `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioAssets`, `uploadStudioAsset`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` |
|
||
|
||
또한 **Public 읽기 엔드포인트는 계약에 아예 없다.** `studio-v1.yaml`은 Studio 전용이고,
|
||
프론트엔드의 Public 화면(`/`, `/explore`, `/projects`, `/releases`, 문서 상세)은
|
||
`src/features/tech-log/adapters/static/public-content.ts`의 **번들에 컴파일된 정적 콘텐츠**를 읽는다.
|
||
|
||
따라서 체크리스트의 다음 절은 검증 대상 자체가 존재하지 않는다:
|
||
§2(탐색·검색·프로젝트·변경기록의 백엔드 연동), §3.1~3.3(문서 작성·관계·Publish),
|
||
§12(Public/Private 데이터 경계), §17(파일/Object Storage), §29(E2E 시나리오).
|
||
|
||
---
|
||
|
||
## P0 — 출시 차단 결함
|
||
|
||
### P0-1. Studio 라우트가 인증을 검사하지 않았다 — **수정 완료**
|
||
|
||
`TECH_LOG_ROUTE_REGISTRY`가 모든 TechLog 라우트를 `access: "public"`으로 등록하고 있었다.
|
||
라우터에 `decideRouteAccessForDefinition` 가드가 존재하지만 Studio에 대해 무력화된 상태였다.
|
||
|
||
운영 프로파일 빌드(`AUTH_MODE=external`)로 실측한 수정 전:
|
||
|
||
```
|
||
/studio http=200 h1="작업 흐름" ← 비로그인 상태에서 Studio UI 렌더링
|
||
/studio/documents http=200 h1="작업본"
|
||
/studio/assets http=200 h1="Asset"
|
||
```
|
||
|
||
`spec.layoutGroup === "STUDIO"`에서 `access`를 유도하도록 수정한 뒤:
|
||
|
||
```
|
||
/studio http=200 h1="로그인 연동이 필요합니다."
|
||
/studio/documents http=200 h1="로그인 연동이 필요합니다."
|
||
/ , /explore 변화 없음
|
||
```
|
||
|
||
로그인 후 원래 요청 화면으로 복귀하는 것도 확인했다(`/studio/documents` → 로그인 → `작업본`).
|
||
|
||
커밋 `fff5e6f`.
|
||
|
||
### P0-2. 백엔드 Studio API에 인가 검사가 없다 — **미해결**
|
||
|
||
`SecurityConfig`는 `anyRequest().authenticated()`로 끝나고, Studio 컨트롤러에
|
||
`@RequiresPermission` 계열 애노테이션이 **하나도 없다**.
|
||
|
||
Keycloak에 Studio 권한이 없는 사용자(`plain`, realm role `plain-user`)를 만들어 확인:
|
||
|
||
```
|
||
GET /api/v1/studio/catalog?type=TOPIC
|
||
studio 사용자 (studio-author) → HTTP 200
|
||
plain 사용자 (권한 없음) → HTTP 200 ← 인가 우회
|
||
```
|
||
|
||
체크리스트 §11 "인증된 사용자라고 해서 무조건 Studio API를 호출할 수 있지 않다",
|
||
§31 P0 "인가 우회 불가" 미충족.
|
||
|
||
### P0-3. 모든 Studio 경로가 `/api/api/v1/...`에 매핑된다 (Double Prefix) — **미해결**
|
||
|
||
`PresentationWebConfig`가 `configurer.addPathPrefix("/api", c -> true)`로 전 컨트롤러에
|
||
`/api`를 붙이는데, Studio 컨트롤러는 `@GetMapping("/api/v1/studio/...")`로 이미 `/api`를 포함해 선언한다.
|
||
|
||
```
|
||
GET /api/v1/studio/catalog → 404 ROUTE_NOT_FOUND
|
||
GET /api/api/v1/studio/catalog → 200
|
||
GET /api/v1/studio/session → 404 ROUTE_NOT_FOUND
|
||
GET /api/api/v1/studio/session → 503
|
||
```
|
||
|
||
프론트엔드는 계약대로 `/api/v1/studio/...`를 호출하므로 **현재 상태로는 단 한 건도 연결되지 않는다.**
|
||
체크리스트 §25 "`/api` Prefix 처리에서 Double Prefix가 발생하지 않는다" 미충족.
|
||
|
||
### P0-4. `getStudioSession`이 항상 503을 반환한다 — **미해결**
|
||
|
||
`auth-mode: jwt`(저장소 기본값, `src/.env:115`)에서 `SecurityConfig`가 `csrf.disable()`로
|
||
`CsrfFilter`를 제거하므로 `CsrfToken` 파라미터가 항상 `null`이고, 컨트롤러는 이를
|
||
`STUDIO_UNAVAILABLE`(503)로 정직하게 보고한다.
|
||
|
||
```
|
||
GET /api/api/v1/studio/session (유효한 studio 토큰)
|
||
→ 503 {"code":"STUDIO_UNAVAILABLE","category":"TRANSIENT_DEPENDENCY","retryable":true}
|
||
로그: "CSRF token unavailable: CSRF protection is disabled for the active auth-mode"
|
||
```
|
||
|
||
프론트엔드 HTTP 모드는 `getStudioSession`으로 CSRF 토큰을 받아 부트스트랩하므로,
|
||
**이 한 건 때문에 Studio HTTP 경로 전체가 시작조차 못 한다.**
|
||
`auth-mode: redis-session`에 필요한 세션 빈이 저장소에 없다는 점은 백엔드 HANDOFF.md도 명시하고 있다.
|
||
|
||
### P0-5. `main` 브랜치의 dev 부팅이 깨져 있었다 — **수정 완료**
|
||
|
||
`eb86708`(계약 3.0.0 머지) 이후 `public/release-manifest.json`이 2.0.0으로 남아
|
||
부팅 시 contract-set 검증이 fail-closed → **빈 화면**. 이전에 한 번 겪은 것과 같은 실패 양식이다.
|
||
|
||
```
|
||
setDigest drift: manifest sha256:e0da7765…, build sha256:261ac630…
|
||
package drift: manifest 2.0.0 / ce2e748 vs build 3.0.0 / b20d7a2
|
||
```
|
||
|
||
`generate:dev-release-manifest`로 재생성하고, 같은 값을 하드코딩하던
|
||
`tests/runtime-schema/release-manifest.test.ts`도 함께 갱신했다. 커밋 `fff5e6f`.
|
||
|
||
### P0-6. 커밋된 `.env`로는 prod 프로파일이 부팅하지 않는다 — **미해결**
|
||
|
||
`src/.env:140`이 `APP_DATASOURCE_DDL_AUTO=update`인데, `application-prod.yml`이 문서화한
|
||
`JpaSchemaSafetyValidator`는 prod에서 `none|validate`만 허용하고 위반 시 exit 71로 종료한다.
|
||
|
||
### P0-7. `ddl-auto=validate`로는 PostgreSQL에서 부팅하지 않는다 — **미해결**
|
||
|
||
```
|
||
SchemaManagementException: Schema-validation: missing table [fs_cleanup_item]
|
||
```
|
||
|
||
`PostgreSqlPersistenceConfig`가 Flyway 위치를 `classpath:db/migration/postgresql`로 고정해
|
||
`db/migration/jpa/fileserver` 트리가 **한 번도 적용되지 않는데**, 해당 JPA 엔티티는 스캔된다.
|
||
`ddl-auto=update`가 이 사실을 가려 온 것이고, prod가 요구하는 `validate`로 바꾸는 순간 드러난다.
|
||
(본 검증은 `ddl-auto=none`으로 우회해 진행했다.)
|
||
|
||
---
|
||
|
||
## P1 — 출시 전 해결 권장
|
||
|
||
| # | 항목 | 실측 근거 |
|
||
|---|---|---|
|
||
| P1-1 | Keycloak realm 구성이 두 저장소 어디에도 없다 | compose에 keycloak 서비스 없음, realm export 파일 없음. 검증을 위해 `ca-skeleton` realm·클라이언트·audience 매퍼·테스트 사용자를 수기로 생성해야 했다. §27 "Keycloak Realm 설정을 복원할 수 있다" 미충족 |
|
||
| P1-2 | 프론트엔드에 로그인 구현이 없다 | OIDC/Keycloak 클라이언트 코드 0건. `AUTH_MODE=external`은 호스팅 페이지가 `window.__CA_FRONTEND_AUTH_OWNER__`를 주입하기를 기대하며, 없으면 `createUnavailableSessionAdapter`가 "로그인 연동이 필요합니다"를 띄운다. §1.4 인증 항목 전부 검증 불가 |
|
||
| P1-3 | production 런타임 설정이 플레이스홀더 | `API_BASE_URL: https://api.example.com/`, `TELEMETRY_ENDPOINT: https://telemetry.example.com/v1/events` |
|
||
| P1-4 | Rate Limit 비활성 | `APP_RATE_LIMIT_ENABLED=false`, `APP_RATE_LIMIT_PROVIDER=disabled`. 60회 연속 호출 전부 200 |
|
||
| P1-5 | 보안 헤더를 적용하는 주체가 없다 | `config/hosting/security-headers.json`에 CSP·HSTS·X-Frame-Options 등이 정의돼 있으나 `dist/server.mjs`는 **하나도 적용하지 않는다**. `verify:hosting-headers`는 기본적으로 fixture 모드로 동작해 실 서버를 검사하지 않는다 |
|
||
| P1-6 | 캐시 정책도 미적용 | `cache-policy.json`은 `/assets/*`에 `public, max-age=31536000, immutable`을 요구하나 실제 응답은 전부 `no-cache` |
|
||
| P1-7 | 프론트엔드 배포 아티팩트 부재 | Dockerfile·nginx conf·compose 없음. `dist/server.mjs`는 프리뷰용이지 운영 파일 서버가 아니다 |
|
||
| P1-8 | robots.txt / sitemap.xml 없음 | **Studio 경로가 검색 엔진에 차단되지 않는다.** §7 미충족 |
|
||
| P1-9 | Open Graph·canonical 메타데이터 없음 | `dist/index.html`에 `og:*`·canonical 없음. `<title>`은 라우트별로 정상 동작하나 **런타임에 설정**되므로 JS를 실행하지 않는 공유 미리보기 크롤러에는 "Tech Log" 고정값만 노출된다 |
|
||
| P1-10 | DB 타임아웃 30초 | `APP_DATASOURCE_CONNECTION_TIMEOUT=30000`. `application.yml`이 문서화한 D2 fail-fast 의도(기본 5s)와 어긋난다. 프론트엔드 `REQUEST_TIMEOUT_MS=10000`이므로 DB 장애 시 프론트가 항상 먼저 끊겨 `DB_UNAVAILABLE` 503을 보지 못한다 |
|
||
| P1-11 | Tech Log Asset의 Object Storage 배선 없음 | objectstorage 어댑터는 템플릿 자산으로 존재하나 techlog 참조 0건, MinIO/S3 환경변수 0건, `uploadStudioAsset` 엔드포인트 미구현 |
|
||
|
||
---
|
||
|
||
## 검증되어 통과한 항목
|
||
|
||
### Frontend
|
||
|
||
| 항목 | 결과 |
|
||
|---|---|
|
||
| Production Build | PASS (local·production 프로파일 모두) |
|
||
| TypeScript compile | PASS (`check:types` 6개 프로젝트) |
|
||
| ESLint | PASS (수정 후 0 error) |
|
||
| 전체 테스트 | 1,818 passed / 16 skipped / **1 기존 flake** (`provider-guardian-transaction` — 단독 실행 2회 모두 PASS, 부하 의존) |
|
||
| architecture / contract / dev-release-manifest / browser-security 게이트 | PASS |
|
||
| Production 번들에 dev·localhost URL 없음 | PASS (`localhost`·`127.0.0.1` 0건, `.local` 매치는 전부 `locale`/`localeCompare`) |
|
||
| Production 번들에 Mock API 미포함 | PASS (`createMockStudioGateway` 0건) |
|
||
| Source Map 비공개 | PASS (`.map` 0개) |
|
||
| Route 단위 Lazy Loading | PASS (30 청크, 총 954 KB / 최대 569 KB) |
|
||
| SPA 라우팅·새로고침 | PASS (열거형 allowlist 방식. 존재하지 않는 문서 경로는 의도적으로 404) |
|
||
| Route별 `<title>` | PASS (`탐색 · Tech Log`, `프로젝트 · Tech Log` …) |
|
||
| 반응형 | PASS — 360/414/768/1440 × 6개 Public 라우트 **24개 조합 전부 가로 스크롤 없음** |
|
||
| 접근성 | PASS — axe(wcag2a/2aa/21a/21aa) **serious+critical 0건** (Public 6 + Studio 4 라우트). h1 정확히 1개, heading 건너뜀 없음, alt 누락 0, 레이블 없는 icon button 0 |
|
||
| 로그인 흐름 | PASS (게이트 → 로그인 → 원래 화면 복귀) |
|
||
|
||
### Backend
|
||
|
||
| 항목 | 결과 |
|
||
|---|---|
|
||
| Production Profile Build | PASS — `:app-bootstrap:bootJar` 성공 |
|
||
| 전체 테스트 | PASS — **3,530 tests / 0 failures / 7 skipped** (BUILD SUCCESSFUL 8m 9s). app-bootstrap 797 · application-core 568 · cache-redis 423 · fileserver 398 · inbound-web 341 · httpclient 283 · shared-contract 224 · objectstorage 140 · persistence-jpa 122 · 그 외 |
|
||
| Docker Image Build | PASS — 623MB. `BUILD_VERSION`/`GIT_SHA`/`SOURCE_URL` build-arg를 강제하는 provenance 게이트가 있어 인자 없이는 의도적으로 실패한다 |
|
||
| Production Image 실제 실행 | PASS — 컨테이너에서 14.5초 기동, `healthcheck` 200 · `readiness` 200 · `catalog` 200(실데이터 2건) |
|
||
| Flyway 마이그레이션 (신규 DB, 처음부터) | PASS — 6개 적용, V7 techlog core 포함, 테이블 33개 생성 |
|
||
| 응답 봉투 일관성 | PASS — `{success,data,error,meta}` 전 경로 동일 |
|
||
| HTTP 상태 코드 | PASS — 401 / 404 / 405 / 422 / 500 / 503 모두 적절 |
|
||
| 인증 오류 코드 분리 | PASS — `AUTH_TOKEN_MISSING` / `AUTH_TOKEN_MALFORMED` / `AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_EXPIRED` |
|
||
| Validation | PASS — 잘못된 enum·필수 누락은 422 + `fieldErrors`, `limit` 상·하한 강제 |
|
||
| SQL Injection | PASS — `' OR 1=1--` 파라미터 바인딩되어 빈 결과 |
|
||
| Visibility 필터 | PASS — `ARCHIVED` 토픽이 catalog 결과에서 제외됨 |
|
||
| CORS | PASS — 허용 origin 200 + `Allow-Credentials: true`, 미허용 origin 403, 와일드카드 없음 |
|
||
| 보안 헤더 | PASS — `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Cache-Control: no-store` |
|
||
| 오류 정보 노출 | PASS — Stack trace·SQL·내부 클래스명 모두 미노출 (`details: null`) |
|
||
| 로그 위생 | PASS — 토큰·Authorization·쿠키·비밀번호 **0건**. `user=`는 가명화 해시 |
|
||
| 추적성 | PASS — 모든 요청 로그에 `req=`·`trace=`, `http_request method= uri_template= status= duration_ms=` |
|
||
| Metrics | PASS — Prometheus 127개 메트릭 패밀리 (`http_server_requests_seconds_bucket`, `jvm_gc_*`, `jvm_memory_*`, `hikaricp_connections_*`) |
|
||
| Liveness / Readiness 분리 | PASS — DB 중단 시 readiness 503 DOWN, liveness 200 UP 유지 |
|
||
| 의존성 장애 대응 | PASS(동작) — DB 중단 시 무한 대기 없이 **503 `DB_UNAVAILABLE` (retryable)** 반환, DB 복구 후 27ms/정상 데이터로 자동 회복. 단 응답까지 30초 소요(P1-10) |
|
||
|
||
---
|
||
|
||
## 다음 단계 (권장 순서)
|
||
|
||
1. **Double Prefix 해소** (P0-3) — 컨트롤러 매핑에서 `/api`를 제거하거나 `addPathPrefix` 대상에서 제외. 이걸 고치기 전에는 프론트-백엔드가 한 건도 연결되지 않으므로 최우선.
|
||
2. **세션 인프라** (P0-4) — `redis-session` 배선. 백엔드 HANDOFF.md도 Plan 02보다 앞선 선행 작업으로 지목하고 있다.
|
||
3. **Studio 인가** (P0-2) — Studio 컨트롤러에 권한 검사 추가 + 권한 없는 사용자 403 회귀 테스트.
|
||
4. **prod 부팅 설정** (P0-6, P0-7) — `.env`의 `ddl-auto`, fileserver 마이그레이션 위치.
|
||
5. **나머지 16개 오퍼레이션** — 백엔드 HANDOFF.md가 지적한 생성 union 5종의 Jackson 파손 전략 결정이 선행.
|
||
6. 배포 레이어 (P1-5·6·7) — nginx/CDN에 보안 헤더·캐시 정책 적용, 프론트엔드 이미지.
|
||
7. robots.txt로 Studio 차단 (P1-8).
|