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

# 설계서: 백엔드 - 영상→식당 추출 파이프라인 (LLM+Geocoding) (#270)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [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<Map>` 으로 정규화한다.
- [ ] `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<Map<String,Object>>
                                 ▼
            ┌──────────── 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<Map>, rawResponse}` | catch 후 빈 결과 + log | **복잡** |
| `PipelineService.processVideo` | 단건 영상 end-to-end | `int processVideo(Map<String,Object>)` | 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<List<Double>> 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 파싱 강건성 (현재 한국 주소 패턴 위주).
