Files
tasteby/docs/design/270-backend-extract-pipeline
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
..

설계서: 백엔드 - 영상→식당 추출 파이프라인 (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<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 프롬프트 출력 = 파이프라인 입력):
    [{
      "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 결과):
    {
      "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 파싱 강건성 (현재 한국 주소 패턴 위주).