# 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 없음. `
`을 렌더하고 CSS가 `overflow-x:auto`·`max-width:100%`를
준다. 정적 공개 문서에 CODE_BLOCK이 0건이라 발견하지 못한 것이며, Studio 편집기에
직접 넣어 확인하니 정상 렌더되고 페이지 가로 오버플로도 없었다. §4 PASS.
## 새로 발견한 결함
| # | 항목 | 근거 |
|---|---|---|
| **N-1** | **로그아웃할 방법이 없다** | `signOut` 포트와 `app-shell.tsx`의 세션 버튼(`action.signOut`="로그아웃")은 존재하지만, **TechLog는 자체 셸(`public-shell.tsx` + Studio 셸)을 쓰고 `AppShell`을 렌더하지 않는다.** 로그인 후 Public·Studio 어느 화면에서도 로그아웃 버튼이 없다. §1.4 "로그아웃", "로그아웃 후 보호된 데이터가 UI 상태에 남지 않는다" 미충족 |
| **N-2** | **중복 관계 생성이 방지되지 않는다** | 같은 대상을 두 번 연결해 저장해도 경고가 없다. 계약에 `uniqueItems` 제약이 없고(`relations: maxItems 20`뿐), `validate-working-copy.ts`도 slug 중복만 검사한다(`SLUG_DUPLICATE`). **백엔드를 구현해도 계약이 허용하므로 같은 결과가 난다.** §3.2 미충족 |
| **N-3** | **CLS 0.192 (기준 0.1)** | 원인 단일: `FOOTER.site-footer`가 t=538ms에 0.1922 이동. 나머지 shift는 0.0001. 세 라우트 모두 동일 값 → 앱 셸 마운트 시점의 footer 점프. §4 "주요 화면의 Layout Shift가 없다" 미충족 |
| **N-4** | **`navigation_path`(slug 조회)에 인덱스가 없다** | 20,000행 기준 `Seq Scan`, `Rows Removed by Filter: 19999`, **236ms**. `enable_seqscan=off`로도 인덱스를 못 쓴다 → 존재하지 않는다. 체크리스트가 명시한 "Slug 조회" 쿼리 패턴 |
| **N-5** | 검색 trgm 인덱스가 플래너에 선택되지 않음 | GIN trgm 인덱스는 존재하고 강제하면 3.96ms로 동작하나, 20k 규모에서 플래너가 Seq Scan(10.3ms)을 고른다. 운영 규모에서 재확인 필요 |
| **N-6** | `/api/v3/api-docs`가 500 | `/swagger-ui`·`/v3/api-docs`는 404로 미배포(정상)인데, path prefix가 붙은 `/api/v3/api-docs`만 500 INTERNAL_ERROR |
## 검증 결과 — 절별
### §1.2 Routing · §1.3 경계 · §2 기능 — 27/27 PASS
```
§1.2 존재하지 않는 Case / 잘못된 explore kind / 없는 프로젝트 / 없는 릴리스
→ 전부 "페이지를 찾을 수 없습니다."
§1.2 Not Found 화면, Back/Forward (/explore→/projects→back→forward) 정상
§1.3 Public 6개 화면에 studio 링크 0건, Draft 표식 0건
§2.1 탐색 목록 6건 · 중복 0 · 필터 적용 6→2건
§2.2 검색창 열림 / Focus 이동 / Overlay 겹침 없음 / 입력 중 과요청 0
결과 없음 UI / ESC 닫기 / 빈 검색어 정책 / 결과 클릭 → 상세 이동
§2.3 프로젝트 목록 2건 · 상세("Backend Skeleton") · 포함 문서 6건
§2.4 변경 기록 목록·상세, 연결 문서 7건, 시간순 정렬 일관
```
### §3 Studio — 20/22 PASS (mock 기준)
```
§3.1 새 문서(유형 4종) → 편집 진입 → 저장 → 상태 전달 PASS
§3.1 저장 버튼 3연타 → 문서 수 8→9 (증가 1) PASS ← 멱등성 실동작
§3.1 미저장 변경 이동 경고 [머무르기/변경 버리기/저장 후 이동] PASS
§3.1 머무르기 후 입력값 보존 PASS
§3.1 검증 화면("저장본 검증") / 게시 화면("게시 준비") PASS
§3.2 관계 추가·순서 이동·삭제, 대상 카탈로그 4건 PASS
§3.2 중복 관계 방지 FAIL (N-2)
§3.3 즉시 미리보기 렌더 / Public Preview 화면 PASS
§3.3 게시 기록 8건 · 게시 취소 버튼 3개 PASS
§20 저장 충돌(409) 사용자 안내 PASS
콘솔 오류 0건
```
문서 **삭제**는 계약에 오퍼레이션 자체가 없다(`deleteStudioAsset`만 존재). §3.1의 "삭제"는 설계 범위 밖.
### §4 UX/UI · §5 접근성 · §6 성능 — 15/18 PASS
```
§4 Layout Shift FAIL CLS=0.1924 (N-3)
§4 Header가 콘텐츠를 가리지 않음 PASS
§4 긴 제목(150자)/긴 본문/긴 URL PASS scrollWidth==clientWidth 1440
§4 코드 블록 (pre overflow-x:auto) PASS
§5 Modal Focus 이동 / role=dialog / Focus Trap / 닫은 뒤 복귀 PASS
§5 키보드 순회 19개 요소 · Focus 표시 전부 존재 PASS
§6 긴 문서 렌더링 296ms PASS
§6 이미지 lazy loading · width/height 명시 PASS
§6 동일 요청 중복 0 · 2초간 DOM 변경 0건(render loop 없음) PASS
§6 검색 21자 입력+반영 847ms PASS
```
### §14 데이터베이스
```
Constraint PK 33 · FK 36 · UNIQUE 18 · CHECK 89 · NOT NULL 267 · PK 없는 테이블 0 PASS
Index 실행계획 (20,000행 기준)
Public 목록(최신순) Index Scan idx_public_latest 0.113ms PASS
유형별 조회 Index Scan idx_public_type 0.129ms PASS
Topic별 조회 Bitmap Index Scan idx_public_topic 0.229ms PASS
검색(trgm) Seq Scan (인덱스 미선택) 10.3ms 주의 (N-5)
slug 조회 Seq Scan (인덱스 부재) 236ms FAIL (N-4)
```
`public_resource_projection`의 인덱스들이 `WHERE publication_state='ACTIVE' AND
visibility='PUBLIC'` 부분 인덱스로 정의되어 있다 — Public/Private 경계를 인덱스 수준에서
강제하는 좋은 설계다(§12를 구현할 때 그대로 활용 가능).
### §19 악용 방지 · §28 Swagger
```
pagination 최대 크기 (limit=1000) 422 REQUEST_VALIDATION_FAILED PASS
q 길이 제한 (500자) 422 REQUEST_VALIDATION_FAILED PASS
Rate Limit APP_RATE_LIMIT_ENABLED=false 미적용
대용량 Body 쓰기 엔드포인트 부재로 검증 불가
/swagger-ui, /v3/api-docs 404 (미배포) PASS
/api/v3/api-docs 500 주의 (N-6)
```
### §26 의존성 장애
```
PostgreSQL Down catalog 503 DB_UNAVAILABLE(retryable) 30s · readiness 503 DOWN
liveness 200 UP 유지 · 복구 후 27ms 정상 PASS
Keycloak Down JWKS 캐시로 기존 토큰 32ms/200 · 잘못된 서명 21ms/401
readiness 200 UP 유지(외부 IdP를 readiness에 걸지 않음)
복구 후 정상 PASS
Backend 단절 Public 화면 정상 유지(정적 소스) PASS
MinIO / Redis 해당 없음(미배선)
```
### §0 · §9 설정
```
src/.env 가 git에 커밋되어 있다 — 값은 local 프로파일용이지만 .gitignore에 .env가 없어
구조적으로 막혀 있지 않다. Redis HMAC은 secret://environment/... 간접 참조를 쓴다(좋은 패턴).
prod 5개 validator 실동작 확인 (정정 1)
show-sql=false · 로그에 토큰/쿠키/비밀번호 0건 · user= 는 가명화 해시
```
## 남은 것 — 로컬에서 불가능
| 절 | 이유 |
|---|---|
| §12 Public/Private 경계 | Public 엔드포인트·문서 엔드포인트 부재 |
| §15 N+1 / JPA Query | Tech Log에 JPA 리포지토리 0건 (catalog는 raw JDBC 단일 쿼리) |
| §16 Transaction | 쓰기 유스케이스 부재 |
| §17 파일/Object Storage | 업로드 엔드포인트·스토리지 배선 부재 |
| §18 HTTPS/HSTS/Redirect | TLS 종단 필요 |
| §22 Grafana·Loki 대시보드 | 관측 스택 필요 (수집 측 127개 메트릭은 확인 완료) |
| §24 Kubernetes | 매니페스트·오케스트레이터 부재 |
| §25 Ingress 라우팅 · X-Forwarded-* | 리버스 프록시 필요 |
| §27 Backup / Restore | 실제 볼륨·운영 DB 필요 |
| §30 Production Smoke Test | 운영 환경 부재 |
| §1.4 세션 만료 · 토큰 만료 후 프론트 동작 | demo 어댑터에 만료 개념이 없음 (외부 IdP 연동 필요) |