Files
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

248 lines
21 KiB
Markdown

<!-- 기능 설계서. 작성: [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 파싱 강건성 (현재 한국 주소 패턴 위주).