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 (백로그)
21 KiB
21 KiB
설계서: 백엔드 - 영상→식당 추출 파이프라인 (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.
- LLM 프롬프트 정의(
- 제외 (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_vectorsVECTOR 컬럼). - 내부:
YouTubeService(transcript),RestaurantService.upsert/linkVideoRestaurant,VectorService.saveRestaurantVectors,VideoService.updateVideoFields,CacheService.flush. - 유틸:
CuisineTypes.CUISINE_LIST_TEXT,JsonUtil.toJson.
- OCI Generative AI Inference SDK (
- 제약:
- 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-cuisineSSE)에 의존.
- OCI Chat
- 가정:
- 영상 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-cuisineSSE 로 사후 보정. transcript_textis 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. 흐름 / 알고리즘
- transcript 확보 (단건 daemon 경로):
processVideo→updateVideoStatus(processing)→YouTubeService.getTranscript(videoId, "auto"). null/blank →done마킹 후 0 반환. - transcript 컨텍스트 정리:
ExtractorService.extractRestaurants입장에서 길이>8000 이면head(0,7000) + "...(중략)..." + tail(len-1000)으로 가운데를 잘라낸다. - 프롬프트 합성:
customPrompt ?: EXTRACT_PROMPT에{title},{transcript}단순 치환.CUISINE_LIST_TEXT는 컴파일 타임에 포맷됨. - LLM 호출:
OciGenAiService.chat(prompt, 8192)—GenericChatRequest(temperature=0.0)으로UserMessage(TextContent)전송. 응답에서GenericChatResponse.choices[0].message.content[0].text추출 후 trim. - JSON 복구:
parseJson절차```(json)? ... ```제거., (?=[}\]])트레일링 콤마 제거.mapper.readValue1차 시도.- 실패 +
[로 시작하면 인덱스 스캔으로 객체 단위 점진 파싱 → 최대한 많이 복구. 마지막에도 0건이면RuntimeException.
- 결과 정규화: List → 그대로, Map → 단건 List 로 감쌈, 그 외 → 빈 List + raw 반환.
- 식당 단위 후처리 (processExtract for-each):
a)
name == nullskip. 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++로그. - 종료:
updateVideoStatus(done, null, rawResponse).processPending호출자는 총합>0 이면cache.flush(). - 상태 전이:
pending → processing(transcript 도착) → done(LLM 결과 0/N) |error(예외) |skip(운영 수동, #269). - Geocoding 한국 주소 region 파싱:
parseRegionFromAddress가 토큰 단위로 "대한민국|특별/광역/도|구/군/시" 추출, 결과는한국|서울|강남구형식. 해외 식당은 LLM 이 직접 region 을 지정하므로 보조적 용도.
9. 엣지케이스 & 에러 처리
- OCI 인증 미설정: PostConstruct 가 warn 후 chatClient/embedClient null → 호출 시
IllegalStateException→processExtract가 try/catch 없이 호출 스택을 상위(processVideo)로 전파, 상태error. - LLM JSON 파싱 완전 실패:
parseJsonthrow →extractRestaurantscatch →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-vectorsSSE 로 복구 — #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 파싱 강건성 (현재 한국 주소 패턴 위주).