Files
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical).
- 17개 설계서를 Draft → Approved로 갱신
- #267(backend-user)은 critical 결함으로 06-Reviewer 유지
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영
  (critical 3 / major 46 / minor 75)
- docs/README.md에 18개 설계서 인덱스 추가
- CHANGELOG.md 2026-06-15 섹션 추가

Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
2026-06-15 11:08:18 +09:00

215 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 기능 설계서. 작성: [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 표시) 부재.