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 (백로그)
설계서: 백엔드 - 영상 관리 + SSE (#269)
상태: Approved 작성: [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-transcriptSSE 가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 로그인을 우회한다.
- 영상 ID(
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필터 조건,evaluationJSON 정규화 (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-transcriptbody:{ text: string(≥1), source?: string }.POST /{id}/extractbody(옵션):{ prompt?: string }.POST /{videoId}/restaurants/manualbody:{ 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 CHECKIS JSON제약.
- 검증 규칙:
title,textblank 금지 → 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. 흐름 / 알고리즘
- 채널 스캔 (daemon/cron):
scanAllChannels→ 각 채널에scanChannel(false)→fetchChannelVideos(channelId, latestPublishedAt, true). PlaylistItems(UC→UU) 50건 페이지 반복,publishedAfter이전 항목 발견 시 즉시 중단. titleFilter + 기존 video_id 셋 비교 후saveVideosBatch로 신규만 insert. - 단건 자막 수집:
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). - 단건 추출:
VideoController.extract→ transcript 검증 →PipelineService.processExtract(video, transcript, prompt)호출(상세 흐름 #270). 결과 식당 수 응답. - 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}. - SSE bulk-extract:
대상
findVideosForBulkExtract(transcript 있고 추출 미실행) → 영상별 3~8swait→processExtract→done{restaurants}/error. 총 결과 > 0 이면cache.flush(). - SSE remap-cuisine / remap-foods:
대상 식당을 BATCH(20/15)로 묶어 LLM 일괄 분류 호출 → 결과 매핑 후 누락 식당은
missed로 격리. 누락 항목은 size 5 배치로 최대 3회 retry. 각 단계마다batch_done/retry/completeemit, 종료 시cache.flush(). - 단일 영상 삭제 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 메서드 호출 검증 (벡터→리뷰→즐겨찾기→식당→링크→영상).findDetailnull/빈 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).getTranscriptApimode 분기 (manual/generated/auto).
- 통합 (Spring + WireMock/MockWebServer)
- YouTube API 모킹 →
scanChannel가 신규 N개 저장. - LLM 모킹 → SSE
bulkExtract가 start/processing/done/complete 시퀀스 emit. bulkTranscript는 Playwright 모킹이 어려우므로getTranscriptWithPage를 Mockito 로 대체.
- YouTube API 모킹 →
- 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)
rebuildVectorsSSE 가 TODO 상태 — 전 식당 벡터 재계산 시 OCI GenAI 호출 비용/시간 산정 필요.scanAllChannels일정 (daemon 주기? cron?) 은 #275 에서 확정 예정.bulkTranscript가 Playwright 헤드모드를 요구해 prod 미지원 — 헤드리스 우회/Whisper STT 도입 여부.evaluation/foods_mentionedJSON 스키마 표준화 (현재 문자열/배열 혼재).- Search API 폴백 quota 초과 시 사용자 메시지 (UI 표시) 부재.