# 설계서: 백엔드 - 영상→식당 추출 파이프라인 (LLM+Geocoding) (#270) > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **추적성** — Redmine: #270 · 관련 ADR: 없음 > · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ExtractorService.java`, `backend-java/src/main/java/com/tasteby/service/PipelineService.java`, `backend-java/src/main/java/com/tasteby/service/OciGenAiService.java`, `backend-java/src/main/java/com/tasteby/service/GeocodingService.java` · 테스트: TBD (현재 없음) ## 1. 목적 (Why) 유튜브 영상 자막에서 식당 정보를 LLM 으로 구조화하고, Google Maps 로 좌표/메타데이터를 보강한 뒤 DB+벡터 인덱스에 저장하여 지도/검색이 즉시 노출되도록 한다. 운영자가 단건/대량 모두 동일한 멱등 파이프라인을 호출할 수 있어야 한다. ## 2. 범위 (Scope) - **포함**: - LLM 프롬프트 정의(`ExtractorService.EXTRACT_PROMPT`) — 7개 필드 추출, `CuisineTypes` 표준 카테고리 강제, 한국어 응답 강제. - OCI Generative AI (Cohere/Llama 계열) Chat & Embed 호출 + 결과 JSON 견고 파싱 (마크다운 블록 제거, 트레일링 콤마 제거, 부분 array 복구). - Google Maps Places Text Search → Place Details → Geocoding API 폴백. - 한국어 주소 → `나라|시/도|구/군` 형식 region 파싱. - 추출 결과로 식당 upsert + 영상↔식당 링크 + 벡터 임베딩 저장. - 파이프라인 상태 전이: `pending → processing → done/error`. - **제외 (out of scope)**: - 자막 확보(`YouTubeService`) — #269. - 검색/추천 질의 (`VectorService.searchSimilar`) — #271. - 식당 CRUD/병합 — #268. - 어드민 UI 트리거 화면 — #282. ## 3. 인수조건 (Acceptance Criteria) - [ ] `ExtractorService.extractRestaurants(title, transcript, prompt?)` 는 transcript 8000자 초과 시 머리 7000 + 꼬리 1000 으로 절단하고, LLM 응답이 JSON array/object/빈값 어떤 형태든 `List` 으로 정규화한다. - [ ] `PipelineService.processExtract` 는 추출된 각 식당에 대해 (a) Geocoding → (b) `RestaurantService.upsert` → (c) `linkVideoRestaurant` → (d) `VectorService.saveRestaurantVectors` 순으로 실행하고, 0건이어도 영상 상태를 `done` 으로 갱신한다. - [ ] `OciGenAiService.parseJson` 은 ```json``` 코드 블록, 트레일링 콤마, 잘린 array 를 자동 복구하며, 끝내 실패하면 `RuntimeException("JSON parse failed: ...")` 을 던진다. - [ ] `GeocodingService.geocodeRestaurant` 는 Places Text Search 성공 시 phone/website 까지 채워 반환하고, 실패 시 Geocoding API 로 폴백하며, 둘 다 실패하면 `null` 을 반환한다 (식당은 좌표 없이 저장됨). - [ ] `evaluation` 필드는 항상 DB 의 `IS JSON` 제약을 통과하도록 JSON 문자열 리터럴(`"..."`) 또는 객체 JSON 으로 변환된 뒤 저장된다. - [ ] `processVideo` 가 자막 미존재 시 status=`done` 으로 종결하고, 예외 발생 시 `error` 로 마킹하여 다음 daemon 실행을 차단하지 않는다. ## 4. 컨텍스트 & 제약 - **의존성**: - OCI Generative AI Inference SDK (`com.oracle.bmc.generativeaiinference`) — `~/.oci/config` 기반 인증, compartment/endpoint/model 은 `app.oci.*` 프로퍼티. - Google Maps Platform — `app.google.maps-api-key` (Places + Geocoding 동일 키). - Oracle 23ai (`restaurants`, `video_restaurants`, `restaurant_vectors` VECTOR 컬럼). - 내부: `YouTubeService` (transcript), `RestaurantService.upsert/linkVideoRestaurant`, `VectorService.saveRestaurantVectors`, `VideoService.updateVideoFields`, `CacheService.flush`. - 유틸: `CuisineTypes.CUISINE_LIST_TEXT`, `JsonUtil.toJson`. - **제약**: - OCI Chat `maxTokens=8192`, `temperature=0.0`, Embed batch 최대 96. - Google Maps 호출당 10초 타임아웃, 일일 quota/요금 관리 필요. - transcript 8000자 절단(LLM context 한계 회피). - `restaurant_vectors.embedding` 은 Oracle VECTOR(float[]), `MapSqlParameterSource` 로 직접 바인딩 (#271 VectorService). - LLM 응답이 비결정적이므로 cuisine_type 검증/재맵핑은 사후 워크플로(`remap-cuisine` SSE)에 의존. - **가정**: - 영상 1건당 식당 평균 1~5개, 전체 transcript 평균 ~3000자. - OCI 인증이 없으면 (`PostConstruct` 경고) chat/embed 호출은 `IllegalStateException` → 추출 파이프라인 전체 실패 (`processVideo` 가 `error` 로 마킹). - Google 한국어(`language=ko`) 결과를 신뢰; 해외 식당은 Places 결과 그대로 사용. ## 5. 아키텍처 개요 - 모듈/파일: - `service/ExtractorService.java` — 프롬프트 + LLM 호출 + 결과 정규화. - `service/PipelineService.java` — 워크플로 오케스트레이션 + 상태 전이. - `service/OciGenAiService.java` — OCI GenAI SDK 어댑터 (chat/embed/JSON 복구). - `service/GeocodingService.java` — Google Maps WebClient 클라이언트 + 주소 region 파싱. - 협력: `YouTubeService` (#269), `RestaurantService` (#268), `VectorService` (#271), `VideoService` (#269), `CacheService` (#276), `util/CuisineTypes`, `util/JsonUtil`. - I/O ↔ 순수 로직 경계: - **I/O**: OCI GenAI 호출, Google Maps HTTP, DB 쓰기, transcript 호출. - **순수 로직**: `EXTRACT_PROMPT` 합성, transcript 절단, `parseJson` 복구 로직, `parseRegionFromAddress`, `VectorService.buildChunks`, evaluation JSON 정규화. ``` ┌─────────────┐ ┌──────────────────┐ │ daemon/cron │ ─────▶ │ PipelineService │ └─────────────┘ │ processVideo() │ └────────┬─────────┘ │ 1. transcript ▼ ┌──────────────────┐ │ YouTubeService │ (#269) └────────┬─────────┘ │ 2. LLM extract ▼ ┌──────────────────┐ chat(prompt, 8192) │ ExtractorService │───────────────────────▶ OCI GenAI └────────┬─────────┘ parseJson(raw) (Chat model) │ List> ▼ ┌──────────── PipelineService.processExtract ────────────┐ │ │ ▼ ▼ ┌─────────────────┐ ┌──────────────────┐ │ GeocodingService │──HTTP──▶ Google Maps │ ExtractorService │ │ placesTextSearch │ Places + Geocode │ (재사용 가능) │ │ → placeDetails │ └──────────────────┘ │ → geocode(폴백) │ └────────┬─────────┘ │ {lat,lng,formatted_address,phone,...} ▼ ┌──────────────────────────┐ │ RestaurantService.upsert │──▶ Oracle restaurants │ + linkVideoRestaurant │──▶ Oracle video_restaurants (IS JSON) └────────┬─────────────────┘ │ restId ▼ ┌──────────────────────────────┐ embedTexts(chunks) │ VectorService.saveRestVectors│───────────────────────▶ OCI GenAI Embed │ buildChunks(...) │ → Oracle restaurant_vectors └────────┬─────────────────────┘ │ count ▼ videoService.updateVideoFields(status=done, llmRaw) │ ▼ cacheService.flush() ``` ## 6. 데이터 모델 - **입력 (LLM 프롬프트 출력 = 파이프라인 입력)**: ```jsonc [{ "name": "string (필수)", "address": "string|null", "region": "나라|시/도|구/군 (string|null)", "cuisine_type": "CuisineTypes.CUISINE_LIST_TEXT 중 하나", "price_range": "string|null", "foods_mentioned": ["string", ...] // 최대 10, 한글 "evaluation": "string ≤ 100자", "guests": ["string", ...] }] ``` - **중간 데이터 (Geocoding 결과)**: ```jsonc { "latitude": double, "longitude": double, "formatted_address": "string", "google_place_id": "string", "business_status": "OPERATIONAL|CLOSED_TEMPORARILY|...", "rating": double, "rating_count": int, "phone": "string", "website": "string" } ``` - **저장 구조**: - `restaurants`: name, address(=geo.formatted_address || LLM.address), region(LLM 우선), latitude/longitude, cuisine_type, price_range, google_place_id, phone, website, business_status, rating, rating_count. - `video_restaurants(video_id, restaurant_id, foods_mentioned IS JSON, evaluation IS JSON, guests IS JSON)`. - `restaurant_vectors(id, restaurant_id, chunk_text CLOB, embedding VECTOR)` — `VectorService.buildChunks` 결과(name/region/cuisine/foods/evaluation/price/video_title)를 한 chunk 로 임베딩. - `videos.status, transcript_text CLOB, llm_response CLOB`. - **검증 규칙**: - `name`이 null 인 식당 항목은 skip. - transcript > 8000 → 절단. - evaluation: 객체→`JsonUtil.toJson`, 문자열→`JsonUtil.toJson(s)` (DB IS JSON 통과 보장). - cuisine_type 표준 목록 위반은 저장은 허용하되 `remap-cuisine` SSE 로 사후 보정. - `transcript_text` is blank → ExtractorService 호출 전 단건 API 가 400 반환 (#269). ## 7. 함수 명세 (Function Specs) | 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | |------|-----------|----------------|------|------|-----------|-------| | `ExtractorService.getPrompt` | 기본 프롬프트 반환 | `String getPrompt()` | — | EXTRACT_PROMPT | 없음 | 단순 | | `ExtractorService.extractRestaurants` | LLM 추출 + JSON 정규화 | `ExtractionResult extractRestaurants(title, transcript, prompt?)` | title, transcript, prompt | `{restaurants: List, rawResponse}` | catch 후 빈 결과 + log | **복잡** | | `PipelineService.processVideo` | 단건 영상 end-to-end | `int processVideo(Map)` | video map | 식당 수 | 예외 → status=error | **복잡** | | `PipelineService.processExtract` | 기존 transcript 로 LLM+저장 | `int processExtract(video, transcript, prompt?)` | 동일 | 식당 수 | 부분 실패 catch (vector save) | **복잡** | | `PipelineService.processPending` | N건 일괄 처리 | `int processPending(int limit)` | limit | 총 식당 수 | 빈 결과시 0 | 단순 | | `PipelineService.updateVideoStatus` (private) | 상태/transcript/llm 갱신 | `void(...)` | id, status, ... | void | DB 예외 throw | 단순 | | `OciGenAiService.init` (PostConstruct) | OCI 클라이언트 초기화 | `void init()` | — | void | 인증 실패 시 log warn | 단순 | | `OciGenAiService.destroy` (PreDestroy) | 클라이언트 종료 | `void destroy()` | — | void | 없음 | 단순 | | `OciGenAiService.chat` | LLM Chat 호출 | `String chat(prompt, maxTokens)` | prompt, max | text | SDK 예외, null client | **복잡** | | `OciGenAiService.embedTexts` | 텍스트 임베딩 (96 배치) | `List> embedTexts(texts)` | texts | 임베딩 매트릭스 | SDK 예외 | **복잡** | | `OciGenAiService.embedBatch` (private) | 단일 배치 임베딩 | `(texts)` | ≤96 texts | 임베딩 | SDK 예외 | 단순 | | `OciGenAiService.parseJson` | LLM 응답 견고 파싱 | `Object parseJson(String raw)` | raw text | List/Map/scalar | 부분 복구 후 throw | **복잡** | | `GeocodingService.geocodeRestaurant` | Places → Geocoding 폴백 | `Map geocodeRestaurant(name, address)` | name+addr | geo map\|null | 두 단계 모두 catch | **복잡** | | `GeocodingService.placesTextSearch` (private) | Places Text Search | `(query)` | string | map\|null | 4xx/5xx catch | **복잡** | | `GeocodingService.placeDetails` (private) | phone/website 보강 | `(placeId)` | string | map\|null | catch | 단순 | | `GeocodingService.geocode` (private) | Geocoding API | `(query)` | string | map\|null | catch | 단순 | | `GeocodingService.parseRegionFromAddress` (static) | 한국 주소 → region 코드 | `(address)` | string | `한국\|시\|구` 또는 null | 빈 입력 → null | 단순 | > 복잡 표시 함수: 모두 외부 I/O + 다단계 복구 경로. `parseJson` 과 `processExtract` 는 동작 다이어그램 별도 작성 권장. ## 8. 흐름 / 알고리즘 1. **transcript 확보 (단건 daemon 경로)**: `processVideo` → `updateVideoStatus(processing)` → `YouTubeService.getTranscript(videoId, "auto")`. null/blank → `done` 마킹 후 0 반환. 2. **transcript 컨텍스트 정리**: `ExtractorService.extractRestaurants` 입장에서 길이>8000 이면 `head(0,7000) + "...(중략)..." + tail(len-1000)` 으로 가운데를 잘라낸다. 3. **프롬프트 합성**: `customPrompt ?: EXTRACT_PROMPT` 에 `{title}`, `{transcript}` 단순 치환. `CUISINE_LIST_TEXT` 는 컴파일 타임에 포맷됨. 4. **LLM 호출**: `OciGenAiService.chat(prompt, 8192)` — `GenericChatRequest(temperature=0.0)` 으로 `UserMessage(TextContent)` 전송. 응답에서 `GenericChatResponse.choices[0].message.content[0].text` 추출 후 trim. 5. **JSON 복구**: `parseJson` 절차 1) ` ```(json)? ... ``` ` 제거. 2) `, (?=[}\]])` 트레일링 콤마 제거. 3) `mapper.readValue` 1차 시도. 4) 실패 + `[`로 시작하면 인덱스 스캔으로 객체 단위 점진 파싱 → 최대한 많이 복구. 마지막에도 0건이면 `RuntimeException`. 6. **결과 정규화**: List → 그대로, Map → 단건 List 로 감쌈, 그 외 → 빈 List + raw 반환. 7. **식당 단위 후처리 (processExtract for-each)**: a) `name == null` skip. b) `geocodeRestaurant(name, address)` → Places Text Search (language=ko, type=restaurant) 1순위 결과 → `place/details` 로 phone/website 보강. 실패 시 Geocoding API 폴백, 그것도 실패 시 null. c) `data` 빌드: geo 우선 (`formatted_address`, lat/lng, place_id, business_status, rating, rating_count, phone, website), 나머지는 LLM 값. d) `RestaurantService.upsert(data)` → restId. e) `evaluation` 정규화 (Map→JSON, String→JSON 리터럴) 후 `linkVideoRestaurant(videoDbId, restId, foods, evaluationJson, guests)`. f) `VectorService.buildChunks(name, restData, videoTitle)` → 한 줄로 합쳐진 단일 chunk → `saveRestaurantVectors` (Embed batch, INSERT VECTOR). 실패 시 warn 만. g) `count++` 로그. 8. **종료**: `updateVideoStatus(done, null, rawResponse)`. `processPending` 호출자는 총합>0 이면 `cache.flush()`. 9. **상태 전이**: `pending → processing(transcript 도착) → done` (LLM 결과 0/N) | `error` (예외) | `skip`(운영 수동, #269). 10. **Geocoding 한국 주소 region 파싱**: `parseRegionFromAddress` 가 토큰 단위로 "대한민국|특별/광역/도|구/군/시" 추출, 결과는 `한국|서울|강남구` 형식. 해외 식당은 LLM 이 직접 region 을 지정하므로 보조적 용도. ## 9. 엣지케이스 & 에러 처리 - **OCI 인증 미설정**: PostConstruct 가 warn 후 chatClient/embedClient null → 호출 시 `IllegalStateException` → `processExtract` 가 try/catch 없이 호출 스택을 상위(`processVideo`)로 전파, 상태 `error`. - **LLM JSON 파싱 완전 실패**: `parseJson` throw → `extractRestaurants` catch → `ExtractionResult(empty, "")`. `processExtract` 는 0건 종결 + `done`. - **트레일링 콤마/마크다운**: 자동 sanitize. - **잘린 array (`maxTokens` 도달)**: 부분 복구 후 사용; 잘린 항목은 폐기. - **식당 이름 누락**: skip (저장 안 함). - **Geocoding 모두 실패**: 식당은 `latitude/longitude=null` 로 저장 → 지도 노출 제외, 검색은 가능. - **evaluation 형식 다양성**: Map/String 모두 처리, null 그대로 통과. - **transcript blank**: `processVideo` 가 self-check 후 `done` 반환 (recall=0 허용). - **Vector 저장 실패**: warn 만 (식당은 이미 저장됨, 추후 `rebuild-vectors` SSE 로 복구 — #269). - **OCI Embed 96 한도**: 자동 분할 호출. - **Google API rate limit (`OVER_QUERY_LIMIT`)**: status != OK 면 null 반환 → 좌표 없이 저장. - **place details 실패**: phone/website 누락만, 좌표는 유지. - **temperature=0.0** 이지만 모델 비결정성 일부 잔존 → 같은 영상 재실행 시 결과 약간 다를 수 있음 (멱등 보장은 `upsert` 키 = google_place_id/name+address 조합에 의존, #268). - **안전 기본값**: 외부 I/O 실패 시 전 항목 폐기 대신 부분 저장 (좌표 없는 식당이라도 유지) — 운영자가 어드민에서 수동 보정 가능. ## 10. 테스트 계획 - **단위 — ExtractorService** - transcript 절단 임계 (7999 → 그대로 / 8001 → head+중략+tail). - LLM 응답 케이스: 정상 array / 단일 object / `[]` / 깨진 JSON → 각각 정상 List / 단건 List / 빈 List / 빈 List + log. - **단위 — OciGenAiService.parseJson** - ` ```json [...] ``` `, 트레일링 콤마, 잘린 array, 완전 비-JSON → 시나리오별 검증. - **단위 — GeocodingService** - Places OK + details OK → 전 필드 채움. - Places ZERO_RESULTS → Geocoding 폴백 호출 검증 (WireMock). - 둘 다 실패 → null. - `parseRegionFromAddress`: 서울특별시/경기도/광역시/특별자치시/외국 주소 → 각각 기대 region 또는 null. - **단위 — PipelineService.processExtract** - 식당 N개 mock 추출 → upsert N회, linkVideoRestaurant N회, saveRestaurantVectors N회 호출 검증. - vector save 예외 → 식당은 저장, warn 로그. - name=null 항목은 skip (count 미증가). - **통합 (Spring + WireMock)**: Google Maps 모킹 + OCI mock → `processPending(3)` 실행 후 videos.status, video_restaurants, restaurant_vectors 행수 검증. - **드라이런**: prod 호출 비용 차단을 위해 `app.oci.*` 미설정 시 chat/embed 가 즉시 throw → `processVideo` 가 status=`error` 마킹하고 다음 영상으로 진행 (`processPending` 루프). - **인수조건 매핑**: AC1↔ExtractorService 단위, AC2↔processExtract 통합, AC3↔parseJson 단위, AC4↔GeocodingService 단위, AC5↔evaluation 정규화 단위, AC6↔processVideo 단위. ## 11. 리스크 & 대안 검토 - **OCI GenAI 단일 벤더 잠금**: 대안 OpenAI/Anthropic. 트레이드오프: OCI 는 동일 테넌시 내 IAM 통합/내한권 결제. → **ADR 후보** (`adr/0002-llm-provider.md`). - **transcript 절단 (선택)**: 8000자 hard cut. 대안: 청크 + map-reduce 요약 (지연/비용↑) 또는 더 큰 context 모델. 현재 영상 평균 < 8000자라 단순 cut 채택. - **Geocoding Places vs Geocoding 폴백 순서**: Places 가 phone/rating 까지 주므로 1순위. 대안: 카카오/네이버 로컬 API (한국 정확도↑) — 향후 옵션. - **벡터 chunk 1개/식당**: 검색 정확도 vs 비용 트레이드오프. 대안: 메뉴별 분할 chunk → 임베딩 수 N배, FETCH 시 중복 제거 필요. 현재 토픽이 좁아 단일 chunk 유지. - **temperature=0.0**: 재현성↑. 대안: 약간 ↑ 시 다양한 메뉴 추출 가능 — 일관성 우선. - **evaluation JSON 강제**: DB CHECK 제약을 만족시키는 가장 단순한 방법 (JSON 리터럴 wrap). 향후 정형화(`{summary, rating, ...}`) 이전 가능. - **부분 실패 허용**: 식당 일부만 저장되는 시나리오 수용 → 운영자 검토 비용. 대안: 전부 임시 영역 → 검토 후 swap (구현 복잡). ## 12. 미해결 질문 (Open Questions) - `cuisine_type` 표준 목록 위반 비율이 얼마나 되는가? 사전 검증(`CuisineTypes.isValid`) 후 자동 폴백을 LLM 단계에서 적용할지. - transcript 8000자 cut 대신 슬라이딩 윈도우 multi-pass 요약 도입 여부 (비용/정확도 검토). - Geocoding 결과 중 `business_status=CLOSED_*` 인 식당의 처리 정책 (자동 제외 vs 표시). - 영상에 동일 식당이 중복 언급될 때 upsert 키와 link 중복 방지 (현재 `RestaurantService.upsert` 키 정책에 의존). - Embed cosine 임계(`maxDistance`)는 #271 에서 0.57 — 학습 데이터 누적 후 재조정 필요. - 다국어 영상 (예: 일본 식당) 의 region 파싱 강건성 (현재 한국 주소 패턴 위주).