Files
tasteby/docs/design/322-restaurant-llm-verify/README.md
joungmin d2e78b0363 feat(verify): #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김
DB 마이그레이션 (운영 ATP에 사전 실행 완료):
- restaurants.hidden NUMBER(1) DEFAULT 0 NOT NULL
- restaurants.hidden_reason VARCHAR2(120)
- restaurants.verified_at TIMESTAMP
- idx_restaurants_hidden 인덱스

코드:
- Restaurant 도메인에 hidden/hiddenReason/verifiedAt 필드 추가
- RestaurantMapper.xml resultMap 갱신 + findAll에 hidden=0 조건 (includeHidden=true 시 제외)
- RestaurantMapper에 updateVerification/clearHidden/findUnverified/countUnverified 추가
- RestaurantService.findAll() includeHidden 오버로드 + 검증 헬퍼 메서드
- RestaurantVerifyService 신규 (verify, verifyAsync, verifyAll, buildPrompt, parseVerifyResponse)
  - LLM 응답이 JSON 아닐 때 안전 기본값(valid=true) → hidden 유지
  - 백필은 식당당 200ms sleep으로 LLM rate 보호
- PipelineService.processExtract 끝에 verifyAsync(restId) 호출 (신규 등록 자동 검증)
- AdminRestaurantController 신규 — requireAdmin 필수:
  - GET  /api/admin/restaurants/verify/pending
  - POST /api/admin/restaurants/verify/all?batchSize=10
  - POST /api/admin/restaurants/{id}/verify
  - PATCH /api/admin/restaurants/{id}/hidden {hidden, reason}

프롬프트:
- 식당명, 주소, 지역, cuisine, foods를 OCI GenAI로 보내 valid/is_franchise/reason 판정
- 보수적 가이드 (모호하면 valid=true)

설계서: docs/design/322-restaurant-llm-verify/README.md (Approved 대기)

Refs: #322
2026-06-15 13:04:23 +09:00

10 KiB

설계서: LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (#322)

상태: Draft 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #322 · 관련 ADR: 없음 · 구현 파일: backend-java/src/main/java/com/tasteby/service/RestaurantVerifyService.java(신규), backend-java/src/main/java/com/tasteby/domain/Restaurant.java(필드 3개 추가), backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml(컬럼 매핑), backend-java/src/main/java/com/tasteby/service/RestaurantService.java(필터링), DB 마이그레이션 SQL · 테스트: 단위 테스트 신규 (검증 결과 파싱, hidden 필터링)

1. 목적 (Why)

LLM 추출 과정에서 (a) 식당이 아닌 비식별자(영상 제목, 사람 이름, 일반 명사 등)가 식당으로 잘못 등록되거나 (b) 흔한 프랜차이즈(스타벅스, 맥도날드 등)가 큐레이션 의도와 무관하게 등록되어 사용자 경험을 저해. LLM 2차 검증으로 자동 숨김 처리하고 어드민에서 수동 복구 가능하게 한다.

2. 범위 (Scope)

  • 포함
    • restaurants 테이블 컬럼 추가: hidden NUMBER(1) DEFAULT 0, hidden_reason VARCHAR2(120), verified_at TIMESTAMP.
    • 신규 RestaurantVerifyService: 단건 검증 + 배치 백필 검증.
    • PipelineService.processExtract 흐름 끝에 검증 호출(신규 등록 자동 검증).
    • 어드민 API: 일괄 재검증 트리거 + 개별 hidden 토글.
    • 프론트: 공개 API 응답에서 hidden=true 제외(어드민 응답에는 포함).
  • 제외 (out of scope)
    • 이미지 인식, 메뉴 검증.
    • 프랜차이즈 매칭 전용 DB/지식베이스(이번엔 LLM 단발 판정).
    • 어드민 UI 대량 작업(필요 시 후속 이슈).

3. 인수조건 (Acceptance Criteria)

  • restaurants 테이블에 hidden/hidden_reason/verified_at 3개 컬럼이 존재한다.
  • 신규 식당 등록 후 60초 이내 verified_at이 설정된다.
  • GET /api/restaurants 응답에 hidden=1 식당은 포함되지 않는다.
  • GET /api/admin/restaurants?include_hidden=true 는 hidden을 포함하고 hidden_reason을 노출한다.
  • 어드민 PATCH /api/admin/restaurants/{id}/hidden {hidden:false} 토글이 정상 동작한다.
  • 어드민 POST /api/admin/restaurants/verify-all 호출 시 미검증 식당 전체를 백필(rate-limit 적용).
  • LLM 호출 실패 시 식당은 hidden=0(공개) 유지(안전한 기본값) + 로그.
  • 단위 테스트로 LLM 응답 파싱 + 필터링 로직 통과.

4. 컨텍스트 & 제약

  • 의존성: 기존 OciGenAiService.chat(prompt, maxTokens) 재사용.
  • DB: Oracle 23ai. DDL은 ALTER TABLE 마이그레이션.
  • LLM 비용: 검증은 한 식당당 1회 단발(짧은 프롬프트). 500개 백필 시 약 500 호출.
  • 봇/quota 제약 없음(OCI GenAI는 내부 호출).
  • 기존 데이터: 약 500건 식당 → 백필 1회 필요. 신규 영상 처리 흐름에 자동 통합.
  • 가정: LLM 판정 정확도 85-95%. 실수 시 어드민에서 수동 복구.

5. 아키텍처 개요

PipelineService.processExtract
   │  (기존 흐름)
   ▼
RestaurantService.upsert
   │
   ▼
RestaurantVerifyService.verifyAsync(restaurantId)
   │  (비동기 — 사용자 응답 차단 안 함)
   ▼
OciGenAiService.chat(prompt, maxTokens=100)
   │
   ▼
parseVerifyResponse → { valid: bool, isFranchise: bool, reason: string }
   │
   ▼
RestaurantMapper.updateVerification(id, hidden, hiddenReason, verifiedAt)
   │
   ▼ (공개 조회 시)
RestaurantService.list(...) → WHERE hidden = 0
RestaurantController.adminList(includeHidden=true) → 전체 + hidden_reason 노출

I/O ↔ 순수 로직 경계: parseVerifyResponse는 순수 함수(LLM 응답 문자열 → 객체). 외부 I/O(LLM 호출, DB write)는 서비스 메서드.

6. 데이터 모델

DB 마이그레이션

ALTER TABLE restaurants ADD (
  hidden NUMBER(1) DEFAULT 0 NOT NULL,
  hidden_reason VARCHAR2(120),
  verified_at TIMESTAMP
);
CREATE INDEX idx_restaurants_hidden ON restaurants(hidden);

Restaurant 도메인 추가 필드

필드 타입 의미
hidden Boolean true면 공개 조회에서 제외
hiddenReason String "not_restaurant" / "franchise" / "manual" / null
verifiedAt Instant 마지막 검증 시각, null이면 미검증

LLM 응답 스키마

{
  "valid": true,
  "is_franchise": false,
  "reason": "한식 전문점, 로컬"
}

7. 함수 명세

함수 책임(1줄) 시그니처(잠정) 입력 출력 에러/실패 복잡?
RestaurantVerifyService.verify(id) 단건 검증 + DB 반영 void verify(String restaurantId) restaurantId side-effect LLM/DB 예외 → 로그 후 hidden 유지(공개) 복잡
RestaurantVerifyService.verifyAsync(id) 비동기 트리거 void verifyAsync(String restaurantId) id - thread pool 만원 → 다음 cron 처리 단순
RestaurantVerifyService.verifyAll(limit) 백필(rate-limit 적용) int verifyAll(int batchSize) batch 처리된 개수 LLM rate limit → sleep 복잡
RestaurantVerifyService.buildPrompt(r) 프롬프트 생성 String buildPrompt(Restaurant) r 프롬프트 문자열 - 단순
RestaurantVerifyService.parseVerifyResponse(s) LLM 응답 → DTO VerifyResult parse(String) LLM raw DTO 파싱 실패 → valid=true 기본값(안전) 복잡
RestaurantMapper.updateVerification(id, hidden, reason, ts) DB 갱신 int update(...) 4 args 업데이트 행 수 DB 예외 단순
RestaurantService.list() (수정) 공개 조회 hidden=0 필터 WHERE hidden = 0 추가 - - - 단순
AdminRestaurantController.toggleHidden(id) (신규) 어드민 수동 토글 PATCH /api/admin/restaurants/{id}/hidden id, body success requireAdmin 단순
AdminRestaurantController.verifyAll() (신규) 백필 트리거 POST /api/admin/restaurants/verify-all - 처리 개수 requireAdmin 단순

복잡 함수는 각각 fn-verify.md, fn-verify-all.md, fn-parse-verify-response.md 후속 분리 가능(현재 후속 이슈로).

8. 흐름 / 알고리즘

신규 등록 검증

  1. PipelineService.processExtract 완료 시 restaurantId 획득.
  2. RestaurantVerifyService.verifyAsync(restaurantId) 호출(@Async).
  3. 별도 스레드에서 verify(id) 실행:
    • 식당 조회 → buildPromptOciGenAiService.chatparseVerifyResponse
    • valid=false 또는 is_franchise=true면 hidden=1, reason 설정
    • RestaurantMapper.updateVerification 호출
  4. 캐시 무효화는 검증 결과가 hidden=1일 때만(공개 목록 변경).

백필

  1. 어드민 POST /api/admin/restaurants/verify-all 호출.
  2. verifyAll(batchSize=10):
    • WHERE verified_at IS NULL 인 식당 10개 조회 → 순차 검증
    • 식당당 200ms sleep(LLM rate limit 보호)
    • 끝까지 반복(do { ... } while (count == 10))
  3. 전체 카운트 반환.

프롬프트

당신은 식당 데이터 큐레이터다. 다음 식당이 (1) 실제 운영 식당인지, (2) 흔한 프랜차이즈인지 판정하라.

식당명: {name}
주소: {address}
지역: {region}
음식 분류: {cuisineType}
언급된 음식: {foodsMentioned}

응답 형식(JSON만, 다른 텍스트 없이):
{"valid": true|false, "is_franchise": true|false, "reason": "20자 이내"}

가이드:
- valid=false: 식당 이름이 사람 이름, 영상 제목 일부, 일반 명사("점심", "맛집"), 영문 prefix("name:", "title:") 등 분명히 식당이 아닌 경우.
- is_franchise=true: 스타벅스, 맥도날드, 버거킹, 김밥천국, 본죽 등 전국 50개 이상 매장의 흔한 체인.
- 판단이 모호하면 valid=true, is_franchise=false (보수적).

9. 엣지케이스 & 에러 처리

  • LLM 응답이 JSON 아님: parseVerifyResponse가 JSON 파싱 실패 → valid=true, is_franchise=false 기본값(안전).
  • LLM 호출 실패(timeout/quota): 로그 후 verified_at 미설정 → 다음 백필에서 재시도.
  • LLM이 false negative(잘못된 식당을 정상이라 판정): 어드민 수동 토글로 보완.
  • LLM이 false positive(정상 식당을 잘못/프랜차이즈로 판정): 어드민 수동 hidden=false 토글.
  • 동시성: verifyAsync가 같은 ID 두 번 호출돼도 idempotent(같은 결과로 update).
  • 레이트 리밋: 백필에서 식당당 200ms sleep + 단건 검증은 별 신경 안 씀(빈도 낮음).

10. 테스트 계획

  • 단위:
    • parseVerifyResponse: 정상 JSON / 파손 JSON / 빈 문자열 / 마크다운 코드블록 포함 케이스.
    • buildPrompt: 모든 필드 채워진 경우 / 일부 null 케이스.
  • 통합 (수동 또는 후속):
    • 프랜차이즈 식당 1건 시드 → verifyAll → hidden=1 확인.
    • 정상 식당 1건 시드 → verifyAll → hidden=0 확인.
  • 회귀: 기존 GET /api/restaurants 응답 구조 변경 없음(필드만 추가, 옵션).

11. 리스크 & 대안 검토

  • 선택: 단발 LLM 판정 + 어드민 수동 보완.
  • 대안 A: 프랜차이즈 DB 자체 구축(스타벅스/맥도날드 등 화이트리스트 매칭) — 정확도↑이지만 운영 부담↑, 신규 프랜차이즈 누락 위험.
  • 대안 B: 추출 단계(OciGenAiService.parseJson)에서 한 번에 판정 — 비용↓이지만 추출 로직 비대해짐.
  • 대안 C: 이중 검증(LLM A + LLM B 일치 시만 hidden) — 정확도↑↑이지만 비용 2배.
  • 트레이드오프: 단발 판정은 비용·복잡도 낮으나 false positive 가능. 어드민 토글로 보완 가능하므로 수용.

12. 미해결 질문

  • 백필 1회 트리거 후 주기적 재검증 필요한가(예: 폐업 식당 자동 hidden)? — 후속.
  • LLM 비용 모니터링 — 별도 이슈로 분리 권고.
  • 프랜차이즈 판정 임계값 — 사용자 의견 수렴 필요. 현재 가이드는 "전국 50개 이상".
  • 어드민 UI에서 일괄 작업(체크박스 + 일괄 hidden 토글) — 별도 이슈.