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
..

설계서: 백엔드 - 영상 관리 + 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 와 식당 링크 배열이 포함된다 (evaluationJsonUtil.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. 단건 자막 수집: getTranscriptgetTranscriptBrowser (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 또는 skipapiNeeded 누적, 3~8s 랜덤 sleep. Pass2: api_pass{count} → 실패분만 getTranscriptApi, 결과에 따라 done/error+status=no_transcript. 최종 complete{success,failed}.
  5. SSE bulk-extract: 대상 findVideosForBulkExtract (transcript 있고 추출 미실행) → 영상별 3~8s waitprocessExtractdone{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: deleteVectorsByVideoOnlydeleteReviewsByVideoOnlydeleteFavoritesByVideoOnlydeleteRestaurantsByVideoOnlydeleteVideoRestaurantsdeleteVideo (단일 @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 표시) 부재.