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.parseJsonjson 코드 블록, 트레일링 콤마, 잘린 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 → 추출 파이프라인 전체 실패 (processVideoerror 로 마킹).
    • 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 + 다단계 복구 경로. parseJsonprocessExtract 는 동작 다이어그램 별도 작성 권장.

8. 흐름 / 알고리즘

  1. transcript 확보 (단건 daemon 경로): processVideoupdateVideoStatus(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 → 호출 시 IllegalStateExceptionprocessExtract 가 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 파싱 강건성 (현재 한국 주소 패턴 위주).