From 9c2dc9f43a8204a9c9870afc4eabc6acd6bc1cde Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 14:01:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(search):=20#293=20=EA=B2=80=EC=83=89/?= =?UTF-8?q?=EB=B2=A1=ED=84=B0=20=EA=B2=B0=ED=95=A8=207=EA=B1=B4=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchController: q 빈값 가드 (HTTP 400) — '%%' LIKE 응답 폭발 차단 - SearchService: - keywordSearch: LIKE 와일드카드 escape (%, _, \\) - hybrid 모드: semantic 결과에도 attachChannels 호출 (이전: keyword만) - ObjectMapper/TypeReference static 재사용 (캐시 hit 경로 GC 압박 완화) - 알 수 없는 mode → warn 로그 + keyword fallback (이전: silent) - maxDistance를 @Value("${app.search.max-distance:0.57}")로 외부화 - SearchMapper.xml: LIKE 절에 ESCAPE '\\' 추가 - VectorService.searchSimilar: embeddings/first list null/empty 가드 (NPE 방지) - application.yml: app.search.max-distance (env SEARCH_MAX_DISTANCE) 추가 후속 분리: batch insert + 테스트 (별도 후속 이슈) Refs: #293 --- .../tasteby/controller/SearchController.java | 9 +++++- .../com/tasteby/service/SearchService.java | 28 +++++++++++++++---- .../com/tasteby/service/VectorService.java | 9 ++++-- .../src/main/resources/application.yml | 5 ++++ .../resources/mybatis/mapper/SearchMapper.xml | 13 +++++---- 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/backend-java/src/main/java/com/tasteby/controller/SearchController.java b/backend-java/src/main/java/com/tasteby/controller/SearchController.java index e8fd48a..7e13426 100644 --- a/backend-java/src/main/java/com/tasteby/controller/SearchController.java +++ b/backend-java/src/main/java/com/tasteby/controller/SearchController.java @@ -2,7 +2,9 @@ package com.tasteby.controller; import com.tasteby.domain.Restaurant; import com.tasteby.service.SearchService; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.util.List; @@ -21,7 +23,12 @@ public class SearchController { @RequestParam String q, @RequestParam(defaultValue = "keyword") String mode, @RequestParam(defaultValue = "20") int limit) { + // #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단 + if (q == null || q.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다"); + } if (limit > 100) limit = 100; - return searchService.search(q, mode, limit); + if (limit < 1) limit = 1; + return searchService.search(q.trim(), mode, limit); } } diff --git a/backend-java/src/main/java/com/tasteby/service/SearchService.java b/backend-java/src/main/java/com/tasteby/service/SearchService.java index 93777eb..425d378 100644 --- a/backend-java/src/main/java/com/tasteby/service/SearchService.java +++ b/backend-java/src/main/java/com/tasteby/service/SearchService.java @@ -1,9 +1,12 @@ package com.tasteby.service; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.tasteby.domain.Restaurant; import com.tasteby.mapper.SearchMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.*; @@ -12,12 +15,17 @@ import java.util.*; public class SearchService { private static final Logger log = LoggerFactory.getLogger(SearchService.class); + private static final ObjectMapper JSON = new ObjectMapper(); + private static final TypeReference> LIST_TYPE = new TypeReference<>() {}; private final SearchMapper searchMapper; private final RestaurantService restaurantService; private final VectorService vectorService; private final CacheService cache; + @Value("${app.search.max-distance:0.57}") + private double maxDistance; + public SearchService(SearchMapper searchMapper, RestaurantService restaurantService, VectorService vectorService, @@ -33,8 +41,8 @@ public class SearchService { String cached = cache.getRaw(key); if (cached != null) { try { - var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference>() {}); + // #293 — ObjectMapper 재사용 (필드 static) + return JSON.readValue(cached, LIST_TYPE); } catch (Exception ignored) {} } @@ -44,13 +52,20 @@ public class SearchService { case "hybrid" -> { var kw = keywordSearch(q, limit); var sem = semanticSearch(q, limit); + // #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락) + if (!sem.isEmpty()) attachChannels(sem); Set seen = new HashSet<>(); var merged = new ArrayList(); for (var r : kw) { if (seen.add(r.getId())) merged.add(r); } for (var r : sem) { if (seen.add(r.getId())) merged.add(r); } result = merged.size() > limit ? merged.subList(0, limit) : merged; } - default -> result = keywordSearch(q, limit); + case "keyword" -> result = keywordSearch(q, limit); + default -> { + // #293 — 알 수 없는 mode는 silent fallback 대신 경고 로그 + log.warn("Unknown search mode '{}', falling back to keyword", mode); + result = keywordSearch(q, limit); + } } cache.set(key, result); @@ -58,7 +73,10 @@ public class SearchService { } private List keywordSearch(String q, int limit) { - String pattern = "%" + q + "%"; + // #293 — LIKE 와일드카드 escape: 사용자 입력의 %, _, \ 를 리터럴로 처리. + // SQL에서는 ESCAPE '\\' 절을 사용 (SearchMapper.xml). + String escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + String pattern = "%" + escaped + "%"; List results = searchMapper.keywordSearch(pattern, limit); if (!results.isEmpty()) { attachChannels(results); @@ -68,7 +86,7 @@ public class SearchService { private List semanticSearch(String q, int limit) { try { - var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57); + var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance); if (similar.isEmpty()) return List.of(); Set seen = new LinkedHashSet<>(); diff --git a/backend-java/src/main/java/com/tasteby/service/VectorService.java b/backend-java/src/main/java/com/tasteby/service/VectorService.java index e738a2a..7ff3c0e 100644 --- a/backend-java/src/main/java/com/tasteby/service/VectorService.java +++ b/backend-java/src/main/java/com/tasteby/service/VectorService.java @@ -27,12 +27,15 @@ public class VectorService { */ public List> searchSimilar(String query, int topK, double maxDistance) { List> embeddings = genAi.embedTexts(List.of(query)); - if (embeddings.isEmpty()) return List.of(); + // #293 — embeddings 빈/null 가드 (NPE/IndexOutOfBoundsException 방지) + if (embeddings == null || embeddings.isEmpty()) return List.of(); + List first = embeddings.getFirst(); + if (first == null || first.isEmpty()) return List.of(); // Convert to float array for Oracle VECTOR type - float[] queryVec = new float[embeddings.getFirst().size()]; + float[] queryVec = new float[first.size()]; for (int i = 0; i < queryVec.length; i++) { - queryVec[i] = embeddings.getFirst().get(i).floatValue(); + queryVec[i] = first.get(i).floatValue(); } String sql = """ diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 01d5d38..b997d83 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -59,6 +59,11 @@ app: cache: ttl-seconds: 600 + search: + # #293 — 벡터 검색 cosine distance 임계값 (0.0=완전일치, 1.0=직교). + # 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값. + max-distance: ${SEARCH_MAX_DISTANCE:0.57} + daemon: # 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로 # dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다. diff --git a/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml b/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml index b373868..67f76b5 100644 --- a/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml @@ -30,12 +30,13 @@ JOIN video_restaurants vr ON vr.restaurant_id = r.id JOIN videos v ON v.id = vr.video_id WHERE r.latitude IS NOT NULL - AND (UPPER(r.name) LIKE UPPER(#{query}) - OR UPPER(r.address) LIKE UPPER(#{query}) - OR UPPER(r.region) LIKE UPPER(#{query}) - OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) - OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) - OR UPPER(v.title) LIKE UPPER(#{query})) + + AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\' + OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\' + OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\' + OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\' + OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\' + OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\') FETCH FIRST #{limit} ROWS ONLY