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
..

설계서: 백엔드 - 검색/벡터 추천 (#271)

상태: Approved 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #271 · 관련 ADR: 없음 · 구현 파일: backend-java/src/main/java/com/tasteby/service/SearchService.java, backend-java/src/main/java/com/tasteby/service/VectorService.java, backend-java/src/main/java/com/tasteby/controller/SearchController.java · 테스트: TBD (현재 없음)

1. 목적 (Why)

사용자가 식당명/메뉴/지역 키워드로 빠르게 후보를 찾고, 의미 기반 추천(예: "혼술하기 좋은 이자카야")도 받을 수 있도록 키워드 SQL + Oracle 23ai VECTOR 코사인 거리 검색을 동일 엔드포인트로 제공한다.

2. 범위 (Scope)

  • 포함:
    • GET /api/search?q=&mode=&limit= 단일 엔드포인트.
    • 모드: keyword(기본, LIKE), semantic(벡터), hybrid(키워드 + 벡터 union).
    • 결과 캐싱 (CacheService, key = search:q=..:m=..:l=..).
    • 채널명 부착(attachChannels) — 검색 결과에 어떤 채널들이 다뤘는지 표시.
    • 벡터 인덱스 운영용 API: 추출 파이프라인에서 호출되는 saveRestaurantVectors, 검색용 searchSimilar.
  • 제외 (out of scope):
    • 식당 상세/지도 노출 — #268/#278.
    • 벡터 재생성 SSE (rebuild-vectors) — #269 (TODO).
    • 사용자별 개인화 추천/로그.
    • 채널 마스터 데이터 — #273.
    • 임베딩 모델 학습/튜닝.

3. 인수조건 (Acceptance Criteria)

  • GET /api/search?q=족발&mode=keyword&limit=20restaurants.name/foods_mentioned/...%족발% LIKE 매칭된 식당을 최대 limit(상한 100)개 반환하고, 각 결과의 channels 배열에 출연 채널명이 채워진다.
  • mode=semantic 호출 시 OCI Embed 로 쿼리 임베딩 → VECTOR_DISTANCE(... COSINE)maxDistance ≤ 0.57 인 chunk 상위 max(30, limit*3) 개를 가져와, restaurant_id 중복 제거 후 좌표 있는 식당만 limit 개 반환한다.
  • mode=hybrid 는 키워드 결과 우선 + 의미 결과를 뒤에 union 하며, 동일 식당 중복 제거 후 limit 로 컷한다.
  • 동일 (q, mode, limit) 두 번째 호출은 Redis 캐시에서 즉시 반환 (DB/OCI 호출 0회).
  • semantic 호출 중 OCI 실패 시 keyword 결과로 자동 폴백하며 500 을 던지지 않는다.
  • VectorService.saveRestaurantVectors 가 chunks 리스트를 96개 단위 배치 임베딩 후 한 INSERT/chunk 로 Oracle VECTOR 컬럼에 저장한다.

4. 컨텍스트 & 제약

  • 의존성:
    • Oracle 23ai VECTOR 타입 + VECTOR_DISTANCE(..., COSINE) 함수.
    • OciGenAiService.embedTexts (Cohere/embed-v4 등, 96 배치).
    • RestaurantService.findById — semantic 결과 1차 행 조회.
    • CacheService (Redis) — 직렬화는 Jackson ObjectMapper(local 인스턴스).
    • SearchMapper.keywordSearch, SearchMapper.findChannelsByRestaurantIds.
  • 제약:
    • limit ≤ 100 (Controller 가드).
    • 임베딩 비용 → 동일 쿼리 캐시 hit 시 0 호출, miss 시 1 호출.
    • VECTOR 컬럼 바인딩은 NamedParameterJdbcTemplate + float[] 직접 바인딩 (MyBatis 미지원이라 JDBC 사용).
    • hybrid mode 는 union 후 limit 만 적용 — 가중치 랭킹은 미구현 (단순 keyword 우선).
  • 가정:
    • 검색 빈도는 식당 추출보다 훨씬 잦지만 임베딩 호출은 캐시로 대부분 흡수된다.
    • 식당 1건당 vector chunk 1개 (VectorService.buildChunks) — 좌표 없는 식당은 semantic 결과에서 자동 제거.
    • cosine distance 임계 0.57 은 운영 관측치 기반 (조정 가능).

5. 아키텍처 개요

  • 모듈/파일:
    • controller/SearchController.java — REST 엔드포인트, limit clamp.
    • service/SearchService.java — 모드 분기, 캐시, 채널 부착.
    • service/VectorService.java — 임베딩 + Oracle VECTOR 검색/저장 (JDBC).
    • mapper/SearchMapper.java (+ XML) — keywordSearch, findChannelsByRestaurantIds.
    • 협력: OciGenAiService (#270), RestaurantService (#268), CacheService (#276).
  • I/O ↔ 순수 로직 경계:
    • I/O: Oracle (LIKE + VECTOR), Redis 캐시, OCI Embed.
    • 순수 로직: 모드 분기, 중복 제거(LinkedHashSet), keyword 우선 union, buildChunks 텍스트 합성, 좌표 필터(r.getLatitude() != null).
                                ┌────────────────────────┐
GET /api/search?q=&mode=&limit= │ SearchController       │
────────────────────────────────▶ search(q, mode, limit) │
                                │ limit = min(limit,100) │
                                └──────────┬─────────────┘
                                           │
                                           ▼
                          ┌──────────────────────────────────┐
                          │ SearchService.search             │
                          │   cache.get("search:q=..:m=..")  │ hit ──▶ return List<Restaurant>
                          └──────────┬───────────────────────┘ miss
              ┌────────────┬─────────┴────────────┬─────────────┐
              ▼            ▼                      ▼             ▼
        keywordSearch   semanticSearch        hybrid:           cache.set
              │              │                kw + sem union          │
              ▼              ▼                                         │
      SearchMapper      VectorService.searchSimilar                     │
      LIKE %q%          ├── OciGenAiService.embedTexts(query)           │
      attachChannels    │   → float[] qvec                              │
                        ├── jdbc.query VECTOR_DISTANCE(... COSINE)      │
                        │   ≤0.57, ORDER BY dist FETCH FIRST k          │
                        └── RestaurantService.findById(rid) [coord!=null]
                                                                       │
                                                                       ▼
                                                            ◀────────────── Response

6. 데이터 모델

  • 입력:
    • 쿼리 파라미터: q: string (필수), mode ∈ {keyword, semantic, hybrid}(기본 keyword), limit: int (기본 20, 상한 100).
  • 출력: List<Restaurant> — id, name, address, region, latitude, longitude, cuisineType, priceRange, phone, website, googlePlaceId, businessStatus, rating, ratingCount, updatedAt, channels: string[] (검색 결과에만 채움), foodsMentioned (옵션).
  • 벡터 인덱스 저장 (restaurant_vectors):
    • id 32자 UUID(hex upper), restaurant_id FK, chunk_text CLOB, embedding VECTOR.
    • chunk 본문 예시:
      식당: <name>
      지역: <region>
      음식 종류: <cuisine_type>
      메뉴: a, b, c
      평가: <evaluation>
      가격대: <price>
      영상: <video_title>
      
  • 캐시 키: search:q=<q>:m=<mode>:l=<limit> (CacheService.makeKey). 값은 List<Restaurant> Jackson JSON.
  • 검증 규칙:
    • q 가 비어 있으면 Controller 가드(현재 미존재) — 빈 문자열은 %% LIKE 로 모든 식당 매칭되므로 클라이언트에서 빈 쿼리 차단 권장. (Open Question 참조)
    • limit > 100 → 100 으로 clamp.
    • semantic 결과는 latitude != null 인 식당만 — 지도 노출 가능한 후보로 한정.

7. 함수 명세 (Function Specs)

함수 책임(1줄) 시그니처(잠정) 입력 출력 에러/실패 복잡?
SearchController.search REST 엔드포인트 + limit clamp List<Restaurant> search(q, mode, limit) q, mode, limit List 없음 단순
SearchService.search 모드 분기 + 캐싱 List<Restaurant> search(q, mode, limit) 동일 동일 캐시 직렬화 catch 복잡
SearchService.keywordSearch (private) LIKE 검색 + 채널 부착 (q, limit) %q% pattern List 빈 결과 빈 list 단순
SearchService.semanticSearch (private) 벡터 검색 + 식당 조회 (q, limit) q, limit List catch → keyword 폴백 복잡
SearchService.attachChannels (private) restaurant_id ↔ 채널명 부착 (restaurants) List void (mutate) 매핑 누락 시 빈 list 단순
VectorService.searchSimilar OCI embed + Oracle VECTOR 질의 (query, topK, maxDistance) text, k, dist List<Map> (restaurant_id, chunk_text, distance) embed 실패 throw 복잡
VectorService.saveRestaurantVectors chunk 배열 임베딩 + INSERT (restaurantId, chunks) rid, chunks void chunk 별 update 예외 throw 복잡
VectorService.buildChunks (static) 식당 데이터 → 임베딩 텍스트 (name, data, videoTitle) name + Map + title List<String>(1) 없음 단순

복잡 표시 함수는 외부 I/O + 폴백 또는 비-MyBatis 경로(JDBC + VECTOR 바인딩). 별도 fn-*.md 우선순위: searchSimilar, semanticSearch.

8. 흐름 / 알고리즘

  1. 요청 진입: Controller 가 limit > 100 이면 100 으로 clamp → SearchService.search(q, mode, limit) 호출.
  2. 캐시 조회: key=search:q=<q>:m=<mode>:l=<limit>cache.getRaw hit 시 Jackson 역직렬화 후 즉시 반환. 직렬화 예외는 무시하고 본 로직 진행.
  3. 모드 분기:
    • keyword (default): SearchMapper.keywordSearch("%q%", limit) → 결과에 attachChannels 적용.
    • semantic: VectorService.searchSimilar(q, max(30, limit*3), 0.57) → 결과의 restaurant_idLinkedHashSet 으로 중복 제거 → restaurantService.findById(rid) 로 행 조회, latitude != null 인 것만 limit 개까지 누적.
    • hybrid: keyword 결과 + semantic 결과를 순서대로 union (HashSet seen 으로 중복 제거), limit 초과시 subList(0, limit). (현재 채널 부착은 keyword 결과에만 적용됨.)
  4. 벡터 검색 내부 (searchSimilar): a) OciGenAiService.embedTexts([query])List<List<Double>> → 첫 임베딩을 float[] 로 변환. b) SQL:
    SELECT rv.restaurant_id, rv.chunk_text,
           VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
    FROM restaurant_vectors rv
    WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist
    ORDER BY dist
    FETCH FIRST :k ROWS ONLY
    
    :qvec/:qvec2 동일 배열 두 번 바인딩 (SELECT 와 WHERE 에 각각 사용). c) RowMapper: RESTAURANT_ID, CHUNK_TEXT(CLOB → JsonUtil.readClob), DIST.
  5. 캐시 저장: 최종 결과를 cache.set(key, result) (Jackson JSON 직렬화, CacheService 가 TTL 관리 — #276).
  6. 벡터 저장 (saveRestaurantVectors): chunks 빈 리스트면 즉시 종료. embedTexts(chunks) 후 chunks.size 만큼 반복하며 각 row 에 새 UUID + 변환된 float[] + chunk_text 를 INSERT (단건 update). 예외는 호출자(PipelineService) 에서 warn 처리.
  7. 채널 부착: SearchMapper.findChannelsByRestaurantIds(ids) → row 의 restaurant_id(소문자 또는 RESTAURANT_ID 대문자) 두 키 모두 지원 → Map<restId, List<channelName>> 구성 → 각 Restaurant 의 channels 필드 set (없으면 빈 리스트).

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

  • 빈 쿼리: q="" → keyword 는 모든 식당 매칭(LIKE %%), semantic 은 의미 약함. 현재 Controller 가드 없음 → Open Questions 참조.
  • 특수문자/와일드카드: q 에 %/_ 가 있으면 LIKE 부작용. 현재 escape 미적용 (Open Question).
  • OCI Embed 미설정: IllegalStateExceptionsemanticSearch catch → keyword 폴백, 사용자에겐 200 응답.
  • 임베딩 빈 결과: searchSimilar 가 빈 list 반환 → semantic 결과 0 → keywordSearch 폴백은 발생하지 않음 (현재 코드: semantic 0이면 빈 결과 반환 가능). hybrid 모드는 키워드 결과로 채워짐.
  • 좌표 없는 식당: semantic 결과에서 자동 제외 (지도 무관 검색 시 누락 가능 — 의도).
  • 캐시 직렬화 실패: getRaw 후 mapper.readValue 예외는 무시 후 DB 재조회.
  • VECTOR 거리 임계 미달: WHERE dist <= 0.57 로 0건 가능 → 빈 list. 임계 낮추거나 키워드 사용 권장.
  • 채널 매핑 row 키 대소문자 차이: row.getOrDefault 로 두 케이스 모두 처리 — Oracle 컬럼 대문자/lowerKeys 미적용 시 대비.
  • CLOB chunk_text: JsonUtil.readClob 으로 안전 변환.
  • DB 연결 실패: jdbc.query 예외 → SearchService 의 semanticSearch catch → keyword 폴백.
  • 부분 결과: hybrid 에서 keyword 만 결과가 있고 semantic 이 throw 시, keyword 결과만 반환되도록 catch 위치는 semanticSearch 내부 → 그대로 빈 리스트가 hybrid union 의 절반으로 사용됨 (장애 격리).
  • 안전 기본값: 모든 외부 실패는 keyword 결과 또는 빈 list 로 수렴; 500 응답을 피한다.

10. 테스트 계획

  • 단위 — SearchService
    • 캐시 hit → mapper/vector 미호출, 결과 동일.
    • 캐시 miss + keyword 모드 → keywordSearch 1회, attachChannels 호출.
    • semantic 모드 → vector 결과 K*3, dedup, 좌표 없는 식당 제외 검증.
    • hybrid 중복 제거 순서 (kw 우선) 검증.
    • vector 예외 → keyword 폴백.
  • 단위 — VectorService
    • buildChunks 입력 누락 필드 (region/cuisine/foods 등) 가 출력에서 자연스럽게 생략.
    • saveRestaurantVectors empty chunks → no-op.
  • 통합 (Spring + Testcontainers Oracle 또는 in-memory mock)
    • restaurant_vectors 에 샘플 데이터 삽입 → searchSimilar("족발", 10, 0.57) 거리 정렬 검증.
    • keywordSearch LIKE 매칭 + 채널 부착.
  • 계약 테스트
    • GET /api/search?q=&limit=200 → limit clamp 100.
    • mode 오타 → default(keyword).
  • 드라이런/모킹: OCI Embed → 고정 vector 반환 stub. Oracle VECTOR 함수 모킹 어려움 → Testcontainers 23ai (free profile) 사용 권장.
  • 인수조건 매핑: AC1↔keyword 통합, AC2↔searchSimilar 통합, AC3↔hybrid 단위, AC4↔캐시 단위, AC5↔폴백 단위, AC6↔saveRestaurantVectors 통합.

11. 리스크 & 대안 검토

  • Oracle 23ai VECTOR (선택): DB 내장이라 별도 인프라 불필요. 대안: pgvector, Pinecone, Qdrant — 운영 부담↑. → 트레이드오프: 23ai 라이선스/리전 의존.
  • NamedParameterJdbcTemplate 직접 사용: MyBatis 가 VECTOR 직렬화 미지원 → JDBC 가 가장 단순. 대안: TypeHandler 작성 (구현 비용). 현 단계에서 단일 메서드라 직접 JDBC 유지.
  • 단일 chunk/식당: 비용 절감 + 단순. 대안: 메뉴/리뷰/장르 분리 chunk → recall↑, 비용/저장↑. 후속 ADR 후보.
  • hybrid union 단순 합치기: 가중치 랭킹 부재 → semantic 일치 식당이 뒤에 묻힘. 대안: RRF (Reciprocal Rank Fusion) 또는 distance/score 정규화 후 정렬.
  • maxDistance=0.57 하드코딩: 운영 관측치 변동 시 코드 수정 필요. 대안: 환경변수/설정으로 빼기.
  • 캐시 무효화: 식당/링크 변경 시 CacheService.flush() 전체 플러시 (현재 정책) → 콜드스타트 비용. 대안: 키 prefix 별 무효화.
  • 빈 쿼리 가드 부재: 운영 사고 시 모든 식당 반환 → 응답 크기 폭발. 트레이드오프: 가드 추가 (저비용).

12. 미해결 질문 (Open Questions)

  • 빈 쿼리/공백 쿼리는 400 반환할지, 인기 식당 fallback 으로 응답할지.
  • LIKE 와일드카드(%/_) escape 정책.
  • hybrid 모드 랭킹 알고리즘 (RRF 도입 여부, semantic 가중치).
  • semantic 모드에서 좌표 없는 식당도 노출할지 (검색 결과 vs 지도 마커 분리).
  • 임계 maxDistance=0.57 의 모니터링/튜닝 방법 (사용자 클릭률 로그 필요).
  • restaurant_vectors 중복(같은 식당 여러 chunk) 정책 — 현재 saveRestaurantVectors 가 추가만 함, 재추출 시 누적될 가능성.
  • 검색 결과에 foods_mentioned/거리/평점 동시 노출 방식 (#278 와 합의 필요).