<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->

# 설계서: 백엔드 - 영상 관리 + SSE (#269)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #269 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VideoService.java`, `backend-java/src/main/java/com/tasteby/service/YouTubeService.java`, `backend-java/src/main/java/com/tasteby/controller/VideoController.java`, `backend-java/src/main/java/com/tasteby/controller/VideoSseController.java` · 테스트: TBD (현재 없음)

## 1. 목적 (Why)
유튜브 채널의 영상 메타데이터를 스캔·저장하고, 자막(transcript)을 확보하며, 식당 추출 파이프라인 진입까지의 영상 생명주기를 관리한다. 다건 처리 진행 상황은 SSE 로 실시간 스트리밍하여 운영자가 어드민에서 모니터링한다.

## 2. 범위 (Scope)
- **포함**:
  - 영상 목록/상세 조회, 제목 수정, 상태(`pending|processing|done|skip|no_transcript|error`) 변경, 삭제.
  - 영상-식당 링크 단건 삭제 + 고아 식당/벡터/리뷰/즐겨찾기 정리.
  - YouTube Data API v3 기반 채널 스캔 (PlaylistItems 우선, Search API 폴백, Shorts 60초 이하 필터).
  - 자막 확보: Playwright Headed 브라우저(쿠키 로드, 광고 스킵, 한국어 우선) → 실패 시 `youtube-transcript-api` 폴백.
  - 운영자가 브라우저 확장 등으로 수집한 transcript 업로드.
  - SSE 스트림: `bulk-transcript`, `bulk-extract`, `remap-cuisine`, `remap-foods`, `rebuild-vectors`.
  - 단건 추출 트리거 (`POST /api/videos/{id}/extract`)와 수동 식당 추가 (`/restaurants/manual`).
- **제외 (out of scope)**:
  - LLM 기반 식당 추출 본체와 Geocoding (→ #270).
  - 검색/벡터 추천 질의 (→ #271).
  - 채널 마스터 CRUD (→ #273).
  - 프론트엔드 어드민 UI (→ #282).

## 3. 인수조건 (Acceptance Criteria)
- [ ] `GET /api/videos?status=pending` 호출 시 상태별 영상 목록을 반환하고, 상세 (`GET /api/videos/{id}`)에는 transcript 와 식당 링크 배열이 포함된다 (`evaluation` 은 `JsonUtil.normalizeEvaluation` 으로 정규화).
- [ ] `DELETE /api/videos/{id}` 가 단일 트랜잭션으로 벡터·리뷰·즐겨찾기·식당·링크·영상 순으로 정리해 고아 레코드를 남기지 않는다.
- [ ] 채널 스캔(`YouTubeService.scanChannel`)은 PlaylistItems API 로 전체 업로드를 페이징하며, `publishedAfter` 이후 영상만 가져오고 Shorts(60초 이하)를 제거한 뒤 `saveVideosBatch` 로 중복 없이 저장한다.
- [ ] `POST /api/videos/{id}/fetch-transcript` 가 브라우저 → API 순으로 자막을 시도하고, 성공 시 길이/소스(`browser`/`manual (ko)`/`generated (en)` 등)를 응답에 포함한다.
- [ ] `POST /api/videos/bulk-transcript` SSE 가 `start → processing → done|skip|error → api_pass → complete` 이벤트 시퀀스를 JSON 으로 송출하고, 30분 타임아웃 + 3~8초 랜덤 딜레이로 봇 탐지를 회피한다.
- [ ] 모든 admin 엔드포인트는 `AuthUtil.requireAdmin()` 가드를 통과해야 하며 캐시 변경 후 `CacheService.flush()` 가 호출된다.

## 4. 컨텍스트 & 제약
- **의존성**:
  - DB: Oracle 23ai (videos, video_restaurants, restaurants, restaurant_vectors, reviews, favorites).
  - 외부 API: YouTube Data API v3 (`app.google.youtube-api-key`).
  - 자막 라이브러리: `io.github.thoroldvix.api.YoutubeTranscriptApi` + Playwright Chromium.
  - 내부 서비스: `PipelineService`, `ExtractorService`, `RestaurantService`, `GeocodingService`, `OciGenAiService`, `CacheService` (`#270`/`#271`/`#276`).
- **제약**:
  - YouTube API quota (PlaylistItems 1 unit/페이지, Search 100 unit/페이지 → 우선 PlaylistItems 사용).
  - Playwright Headed 모드는 Mac mini Dev 환경 가정 (`pm2 tasteby-api`); 헤드리스 환경(OKE prod) 미지원이므로 SSE bulk-transcript 는 dev 에서만 사용한다.
  - SSE Emitter 타임아웃: transcript 30 분, extract/remap 10 분.
  - LLM 호출 비용 → bulk 작업 시 3~8초 랜덤 딜레이로 호출량 제어.
  - transcript CLOB 저장, `MyBatis ClobTypeHandler` 로 매핑.
- **가정**:
  - 영상 ID(`videos.id`)는 32-char UUID(`IdGenerator.newId()`), `video_id` 는 YouTube 11자 ID.
  - admin 권한 사용자만 모든 mutation/SSE 를 호출한다.
  - 운영자는 `cookies.txt` 를 백엔드 작업 디렉토리에 두어 Playwright 로그인을 우회한다.

## 5. 아키텍처 개요
- 모듈/파일:
  - `controller/VideoController.java` — 동기 CRUD/단건 작업.
  - `controller/VideoSseController.java` — SSE 다건 작업 (Virtual Thread executor).
  - `service/VideoService.java` — Mapper 위임 + transcript/evaluation 정규화.
  - `service/YouTubeService.java` — YouTube API + Playwright + transcript-api.
  - `mapper/VideoMapper.java` (+ `mybatis/mapper/VideoMapper.xml`) — DB 접근.
  - 도메인: `VideoSummary`, `VideoDetail`, `VideoRestaurantLink`.
- I/O ↔ 순수 로직 경계:
  - **I/O**: YouTube REST 호출, Playwright 브라우저, DB INSERT/UPDATE, SSE emit.
  - **순수 로직**: `parseDuration`, `filterShorts` 필터 조건, `evaluation` JSON 정규화 (`JsonUtil.normalizeEvaluation`), 페이지네이션 중단 조건(`publishedAfter` 이전 발견 시 break).

```
[Admin UI] --HTTP--> VideoController ---> VideoService ---> VideoMapper ---> Oracle
                                  \---> YouTubeService --(WebClient)--> YouTube Data API v3
                                                       --(Playwright)--> youtube.com
                                                       --(transcript-api)--> timedtext

[Admin UI] --SSE--> VideoSseController --(VirtualThread)--> {
                       YouTubeService.createBrowserSession + getTranscriptWithPage
                       PipelineService.processExtract  (#270)
                       OciGenAiService.chat            (cuisine/foods remap)
                       RestaurantService.update*       (#268)
                    } --emit JSON event--> Admin UI

[Pipeline scan] cron/daemon --> YouTubeService.scanAllChannels
                            --> ChannelService + VideoService.saveVideosBatch
```

## 6. 데이터 모델
- **입력**:
  - `POST /{id}/fetch-transcript` 쿼리: `mode ∈ {auto, manual, generated}`.
  - `POST /{id}/upload-transcript` body: `{ text: string(≥1), source?: string }`.
  - `POST /{id}/extract` body(옵션): `{ prompt?: string }`.
  - `POST /{videoId}/restaurants/manual` body: `{ name(필수), address?, region?, cuisine_type?, price_range?, foods_mentioned?: string|string[], guests?: string|string[], evaluation?: string }`.
  - `PUT /{videoId}/restaurants/{restaurantId}` body: 위 필드 + 이름/주소 변경 시 재-geocode.
  - SSE body: `{ ids?: string[] }` (없으면 전체 pending).
- **출력**:
  - `VideoSummary` 목록 (id, videoId, title, url, status, publishedAt, channelName, hasTranscript, hasLlm, restaurantCount, matchedCount).
  - `VideoDetail` = summary + `transcript`(CLOB) + `restaurants: VideoRestaurantLink[]`.
  - `VideoRestaurantLink`: restaurantId, name, address, cuisineType, priceRange, region, foodsMentioned(@JsonRawValue JSON), evaluation(@JsonRawValue JSON), guests(@JsonRawValue JSON), googlePlaceId, lat/lng, `hasLocation` 파생.
  - SSE 이벤트 공통 키: `type ∈ {start, processing, done, skip, error, api_pass, wait, batch_done, retry, complete}`.
- **저장**:
  - `videos(id PK, channel_id FK, video_id, title, url, published_at, status, transcript_text CLOB, llm_response CLOB)`.
  - `video_restaurants(video_id, restaurant_id, foods_mentioned IS JSON, evaluation IS JSON, guests IS JSON)` — `evaluation` 컬럼은 DB CHECK `IS JSON` 제약.
- **검증 규칙**:
  - `title`, `text` blank 금지 → 400.
  - `evaluation` 문자열은 JSON 리터럴(`{`/`"` 시작)이 아니면 `JsonUtil.toJson` 으로 문자열 래핑.
  - transcript 8000자 초과는 ExtractorService 가 머리/꼬리만 남기고 절단(`#270`).

## 7. 함수 명세 (Function Specs)

| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `VideoController.list` | 상태별 영상 목록 | `list(String status)` | status (옵션) | `List<VideoSummary>` | 없음 (빈 배열) | 단순 |
| `VideoController.detail` | 영상 상세 + 식당 링크 | `detail(String id)` | id | `VideoDetail` | 404 NotFound | 단순 |
| `VideoController.updateTitle` | 제목 수정 | `updateTitle(id, body)` | id, title | `{ok}` | 400 blank, 403 admin | 단순 |
| `VideoController.skip` | 영상 skip 처리 | `skip(id)` | id | `{ok}` | 403 | 단순 |
| `VideoController.delete` | 영상 + 종속 cascade 삭제 | `delete(id)` | id | `{ok}` | 403, TX rollback | **복잡** |
| `VideoController.deleteVideoRestaurant` | 영상-식당 링크 + 고아 정리 | `deleteVideoRestaurant(videoId, restaurantId)` | id 2종 | `{ok}` | 403 | **복잡** |
| `VideoController.fetchTranscript` | 자막 자동 수집(browser→api) | `fetchTranscript(id, mode)` | id, mode | `{ok,length,source}` | 400 자막없음, 404 | **복잡** |
| `VideoController.uploadTranscript` | 외부 수집 자막 저장 | `uploadTranscript(id, body)` | id, text | `{ok,length,source}` | 400, 404 | 단순 |
| `VideoController.getExtractPrompt` | LLM 추출 프롬프트 조회 | `getExtractPrompt()` | — | `{prompt}` | 없음 | 단순 |
| `VideoController.extract` | 단건 LLM 추출 실행 | `extract(id, body)` | id, prompt? | `{ok,count}` | 400 transcript 없음 | **복잡** |
| `VideoController.bulkExtractPending` | 추출 대상 영상 목록 | `bulkExtractPending()` | — | `{count,videos}` | 없음 | 단순 |
| `VideoController.bulkTranscriptPending` | 자막 미보유 영상 목록 | `bulkTranscriptPending()` | — | `{count,videos}` | 없음 | 단순 |
| `VideoController.addManualRestaurant` | 수동 식당 추가 + geocode + 링크 | `addManualRestaurant(videoId, body)` | videoId, body | `{ok, restaurant_id}` | 400 name 없음 | **복잡** |
| `VideoController.updateVideoRestaurant` | 링크/식당 필드 수정 + 재-geocode | `updateVideoRestaurant(videoId, restaurantId, body)` | id 2종, fields | `{ok}` | 403 | **복잡** |
| `VideoSseController.bulkTranscript` | SSE 다건 자막 (browser→api 2pass) | `bulkTranscript(body)` | ids? | `SseEmitter` | emit error/skip | **복잡** |
| `VideoSseController.bulkExtract` | SSE 다건 LLM 추출 | `bulkExtract(body)` | ids? | `SseEmitter` | emit error | **복잡** |
| `VideoSseController.remapCuisine` | cuisine_type 재분류 (배치+retry) | `remapCuisine()` | — | `SseEmitter` | LLM 실패 시 retry | **복잡** |
| `VideoSseController.remapFoods` | foods_mentioned 재생성 | `remapFoods()` | — | `SseEmitter` | LLM 실패 시 retry | **복잡** |
| `VideoSseController.rebuildVectors` | 벡터 재생성 자리 | `rebuildVectors()` | — | `SseEmitter` | TODO 비어있음 | 단순 |
| `VideoSseController.process` | 동기 N건 파이프라인 | `process(limit)` | limit (≤?) | `{count}` | 없음 | 단순 |
| `VideoService.findDetail` | 상세 + 링크 + evaluation 정규화 | `findDetail(id)` | id | `VideoDetail|null` | null 가능 | 단순 |
| `VideoService.delete` | 트랜잭션 6단계 정리 | `delete(id)` | id | void | TX rollback | **복잡** |
| `VideoService.deleteVideoRestaurant` | 링크 + 고아 cleanup | `deleteVideoRestaurant(...)` | id 2종 | void | TX rollback | **복잡** |
| `VideoService.saveVideosBatch` | 중복 제외 신규 영상 일괄 insert | `saveVideosBatch(channelId, videos)` | dbId, list | 저장 건수 | 부분 실패 시 catch 없음 | 단순 |
| `VideoService.findPendingVideos` | pending 상태 영상 N개 | `findPendingVideos(limit)` | limit | `List<Map>` | 없음 | 단순 |
| `VideoService.findVideosForBulkExtract` | transcript 보유/추출 미실행 | `findVideosForBulkExtract()` | — | `List<Map>` (CLOB 읽음) | CLOB read 실패 | 단순 |
| `VideoService.findVideosWithoutTranscript` | 자막 미보유 영상 | `findVideosWithoutTranscript()` | — | `List<Map>` | 없음 | 단순 |
| `VideoService.updateTranscript` / `updateStatus` / `updateTitle` / `updateVideoFields` | 컬럼 갱신 | — | id + values | void | 없음 | 단순 |
| `VideoService.updateVideoRestaurantFields` | foods/evaluation/guests JSON 갱신 | — | ids + 3 JSON | void | DB JSON 제약 위반 시 throw | 단순 |
| `YouTubeService.fetchChannelVideos` | 업로드 플레이리스트 페이징 | `(channelId, after, excludeShorts)` | params | `List<Map>` | 예외 시 Search 폴백 | **복잡** |
| `YouTubeService.fetchChannelVideosViaSearch` | Search API 폴백 | 동일 | params | `List<Map>` | 파싱 실패 시 break | **복잡** |
| `YouTubeService.filterShorts` | 50개씩 duration 조회 후 60초↑ 필터 | `(videos)` | list | filtered list | 배치 실패 시 default 61 (포함) | **복잡** |
| `YouTubeService.parseDuration` | ISO8601 → 초 | `(dur)` | `PT#H#M#S` | int | regex unmatch → 0 | 단순 |
| `YouTubeService.scanChannel` | 채널 단건 스캔 + 저장 | `(channelId, full)` | id, full | `{total_fetched,new_videos,filtered}` | 채널 미존재 → null | **복잡** |
| `YouTubeService.scanAllChannels` | 활성 채널 전부 스캔 | `()` | — | int | 채널별 예외 catch+log | **복잡** |
| `YouTubeService.getTranscript` | 브라우저 → API 자막 | `(videoId, mode)` | id, mode | `TranscriptResult|null` | null 반환 | **복잡** |
| `YouTubeService.getTranscriptApi` | thoroldvix API 호출 | `(videoId, mode)` | id, mode | `TranscriptResult|null` | 예외 시 null | **복잡** |
| `YouTubeService.getTranscriptWithPage` | 기존 Page 재사용 | `(page, videoId)` | page, id | 동일 | 동일 | **복잡** |
| `YouTubeService.createBrowserSession` | Playwright+Browser+Page lifecycle | `()` | — | `BrowserSession` (`AutoCloseable`) | 실패 시 throw | **복잡** |
| `YouTubeService.fetchTranscriptFromPage` | 페이지 조작 + 세그먼트 스크롤 수집 | `(page, videoId)` | page, id | result|null | 다단계 catch | **복잡** |
| `YouTubeService.skipAds` / `selectKorean` / `loadCookies` | 페이지 보조 동작 | — | page | void | 무시 가능 | 단순 |

> 복잡 표시 함수는 외부 I/O + 다단계 폴백/상태기계 포함. 별도 `fn-*.md` 가 필요한 경우 우선순위는 `fetchTranscriptFromPage`, `bulkTranscript`, `delete`(영상 cascade), `scanChannel`.

## 8. 흐름 / 알고리즘
1. **채널 스캔 (daemon/cron):**
   `scanAllChannels` → 각 채널에 `scanChannel(false)` → `fetchChannelVideos(channelId, latestPublishedAt, true)`. PlaylistItems(UC→UU) 50건 페이지 반복, `publishedAfter` 이전 항목 발견 시 즉시 중단. titleFilter + 기존 video_id 셋 비교 후 `saveVideosBatch` 로 신규만 insert.
2. **단건 자막 수집:**
   `getTranscript` → `getTranscriptBrowser` (Playwright headed, cookies.txt 로드, `--disable-blink-features=AutomationControlled`). `skipAds` (광고 스킵/음소거/끝 이동), 더보기 클릭, "스크립트 표시" 버튼 탐색(aria-label → text → engagement panel), 세그먼트 0→폴링 10회×1.5s, `selectKorean` 시도, 컨테이너 스크롤 50회로 전체 수집. 실패 시 `getTranscriptApi` (manual→generated, ko→en).
3. **단건 추출:**
   `VideoController.extract` → transcript 검증 → `PipelineService.processExtract(video, transcript, prompt)` 호출(상세 흐름 #270). 결과 식당 수 응답.
4. **SSE bulk-transcript:**
   대상 결정(ids vs 전체) → `start{total}` emit → Pass1: 단일 `BrowserSession` 으로 순회, 각 영상 `processing{method=browser}` → `done` 또는 `skip` 후 `apiNeeded` 누적, 3~8s 랜덤 sleep. Pass2: `api_pass{count}` → 실패분만 `getTranscriptApi`, 결과에 따라 `done`/`error`+`status=no_transcript`. 최종 `complete{success,failed}`.
5. **SSE bulk-extract:**
   대상 `findVideosForBulkExtract` (transcript 있고 추출 미실행) → 영상별 3~8s `wait` → `processExtract` → `done{restaurants}` / `error`. 총 결과 > 0 이면 `cache.flush()`.
6. **SSE remap-cuisine / remap-foods:**
   대상 식당을 BATCH(20/15)로 묶어 LLM 일괄 분류 호출 → 결과 매핑 후 누락 식당은 `missed` 로 격리. 누락 항목은 size 5 배치로 최대 3회 retry. 각 단계마다 `batch_done`/`retry`/`complete` emit, 종료 시 `cache.flush()`.
7. **단일 영상 삭제 cascade:** `deleteVectorsByVideoOnly` → `deleteReviewsByVideoOnly` → `deleteFavoritesByVideoOnly` → `deleteRestaurantsByVideoOnly` → `deleteVideoRestaurants` → `deleteVideo` (단일 `@Transactional`).

## 9. 엣지케이스 & 에러 처리
- **PlaylistItems 실패**: try/catch → Search API 폴백. Search 도 실패하면 빈 리스트 → 신규 0.
- **publishedAfter 이전 영상 발견**: 업로드 재생목록은 시간 역순이므로 즉시 nextPage=null 로 페이지 종료 (불필요 호출 차단).
- **Shorts duration API 실패**: 해당 배치의 모든 video 는 `default=61` (포함) 으로 처리해 누락 방지.
- **transcript 없음**: 단건은 400, bulk Pass2 실패 시 status=`no_transcript`, error emit.
- **YouTube 봇 탐지**: Pass1 에서 cookies.txt 로드 + headed + 3~8s 랜덤 지연 + navigator.webdriver=false 마스킹.
- **광고 무한 루프**: `skipAds` 최대 30회 (≈30s) 후 강제 진행.
- **세그먼트 미수신**: 1.5s × 10회 폴링 후 0이면 빈 응답 → API 폴백.
- **CLOB 직렬화**: `JsonUtil.readClob` 으로 안전 변환, `@JsonRawValue` 로 JSON 컬럼은 원형 유지.
- **evaluation 형식 깨짐**: `JsonUtil.normalizeEvaluation` 으로 평문→JSON 문자열 래핑 + 300자 제한.
- **SSE 클라이언트 중단**: `emit` 내부 `Exception` 은 debug 로그만 남기고 emitter 종료. timeout(30/10분) 초과 시 자동 종료.
- **LLM 응답 누락**: remap 시 `CuisineTypes.isValid` 가 false 이면 missed 로 옮겨 retry, 끝까지 실패하면 그대로 노출 (`missed` 카운트).
- **DB IS JSON 제약**: `evaluation` 문자열 → `{`/`"` 검사 후 `JsonUtil.toJson` 래핑.
- **고아 데이터 차단**: 영상 삭제와 링크 단건 삭제 모두 `cleanupOrphan*` 호출.
- **안전 기본값**: YouTube API 통째 실패 시 빈 결과, transcript 실패 시 상태만 변경, LLM/Geocoding 실패는 식당 미생성으로 종결 (DB 손상 차단).

## 10. 테스트 계획
- **단위(JUnit5 + Mockito) — VideoService**
  - `delete` 호출 순서: 6개 mapper 메서드 호출 검증 (벡터→리뷰→즐겨찾기→식당→링크→영상).
  - `findDetail` null/빈 restaurants 케이스 normalizeEvaluation 호출 검증.
  - `saveVideosBatch` 중복 비율 (existing set hit 시 0, 미스 시 새 ID 생성).
- **단위 — YouTubeService**
  - `parseDuration` 경계값 (`PT60S=60`, `PT1M1S=61`, `PT1H=3600`, 빈 문자열=0, 오작동 입력=0).
  - `filterShorts`: duration map 60 이하 제외, 누락 ID 는 기본 61 (포함).
  - `fetchChannelVideos` 페이징 중단 (publishedAfter 이전 발견 즉시 break).
  - `getTranscriptApi` mode 분기 (manual/generated/auto).
- **통합 (Spring + WireMock/MockWebServer)**
  - YouTube API 모킹 → `scanChannel` 가 신규 N개 저장.
  - LLM 모킹 → SSE `bulkExtract` 가 start/processing/done/complete 시퀀스 emit.
  - `bulkTranscript` 는 Playwright 모킹이 어려우므로 `getTranscriptWithPage` 를 Mockito 로 대체.
- **E2E (수동 dev)**
  - Playwright headed transcript 수집 1건 / bulk 10건.
  - `DELETE /api/videos/{id}` 후 식당/링크/벡터 카운트 0 확인 (SQL).
- **인수조건 매핑**: AC1↔detail unit, AC2↔delete cascade unit+SQL, AC3↔scanChannel 통합, AC4↔fetchTranscript 통합, AC5↔bulkTranscript E2E, AC6↔모든 admin 엔드포인트 403 unit.
- **모킹/드라이런**: YouTube/Google API → MockWebServer, `OciGenAiService.chat` → Mockito stub (고정 JSON 반환).

## 11. 리스크 & 대안 검토
- **Playwright Headed (선택)**: ko 자막 정확도/봇 탐지 회피 우수. 단, Mac mini Dev 환경 의존. 대안: youtube-transcript-api (제한 많음), Whisper STT (비용/시간). → 운영(OKE)에서는 사용 안 함, 자막은 dev 에서 사전 확보.
- **SSE (선택)**: 30분 작업 진행 표시 단순. 대안: WebSocket(과한 양방향), 폴링(부정확). 트레이드오프: 한 작업이 emitter 1개 점유 → 동시 다발 사용 시 메모리 압박 (현재 admin 단독 사용 가정).
- **LLM 재분류 단일 트랜잭션 없음**: 결과 즉시 update + missed 별도 retry → 부분 성공 허용. 대안: 전부 임시 테이블 stage → 검토 후 swap (운영 부담 증가). 현재 데이터 양 < 수천 식당이라 부분 적용 수용.
- **video cascade 삭제**: 향후 ON DELETE CASCADE FK 적용 시 mapper 6단계 → 1단계로 단순화 가능 → **ADR 후보** (`adr/0001-video-cascade.md`).
- **transcript CLOB 크기**: 8000자 truncate 는 ExtractorService 가 담당, DB 는 CLOB 그대로 보관.

## 12. 미해결 질문 (Open Questions)
- `rebuildVectors` SSE 가 TODO 상태 — 전 식당 벡터 재계산 시 OCI GenAI 호출 비용/시간 산정 필요.
- `scanAllChannels` 일정 (daemon 주기? cron?) 은 #275 에서 확정 예정.
- `bulkTranscript` 가 Playwright 헤드모드를 요구해 prod 미지원 — 헤드리스 우회/Whisper STT 도입 여부.
- `evaluation`/`foods_mentioned` JSON 스키마 표준화 (현재 문자열/배열 혼재).
- Search API 폴백 quota 초과 시 사용자 메시지 (UI 표시) 부재.
