From 06a3c18f53c7c08712f44082a500945052d1343f Mon Sep 17 00:00:00 2001 From: joona95 Date: Wed, 29 Apr 2026 21:55:55 +0900 Subject: [PATCH 01/20] =?UTF-8?q?fix:=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=8B=9C=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=8B=A8=EA=B3=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20INSERT=20=EB=90=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createBlogRecipes 단계에서 이미 적재된 게시글은 filter 로 빠지지만, 검색 결과 50개 전체(transient 객체 포함)가 saveThumbnails 로 그대로 전달되어 saveAll 시 id 가 null 인 객체들이 새 row 로 INSERT 되어 중복 누적되던 문제. 신규로 INSERT 된 entity 만 썸네일 크롤링 대상으로 넘기도록 수정. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../application/blog/BlogRecipeClientSearchService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java index 788dcc75..77b76487 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java @@ -48,9 +48,9 @@ public void searchNaverBlogRecipes(String keyword) { NAVER_BLOG_SEARCH_SORT, keyword + " 레시피").toEntity(); - createBlogRecipes(blogRecipes); + List newlyInserted = createBlogRecipes(blogRecipes); - blogRecipeThumbnailCrawlingService.saveThumbnails(blogRecipes); + blogRecipeThumbnailCrawlingService.saveThumbnails(newlyInserted); } public List fallback(String keyword, int size, Exception e) { @@ -60,13 +60,13 @@ public List fallback(String keyword, int size, Exception e) { return blogRecipeRepository.findByKeywordLimit(keyword, size); } - private void createBlogRecipes(List blogRecipes) { + private List createBlogRecipes(List blogRecipes) { List blogUrls = blogRecipes.stream().map(BlogRecipe::getBlogUrl).collect(Collectors.toList()); List existBlogRecipes = blogRecipeRepository.findByBlogUrlIn(blogUrls); Map existBlogRecipeMapByBlogUrl = existBlogRecipes.stream().collect(Collectors.toMap(BlogRecipe::getBlogUrl, Function.identity(), (o1, o2) -> o1)); - blogRecipeRepository.saveAll(blogRecipes.stream() + return blogRecipeRepository.saveAll(blogRecipes.stream() .filter(blogRecipe -> !existBlogRecipeMapByBlogUrl.containsKey(blogRecipe.getBlogUrl())) .collect(Collectors.toList())); } From 3379fc7a33ccafbd5add8ad299bea0ddea97fdf1 Mon Sep 17 00:00:00 2001 From: joona95 Date: Wed, 29 Apr 2026 21:56:17 +0900 Subject: [PATCH 02/20] =?UTF-8?q?refactor:=20=EC=9C=A0=ED=8A=9C=EB=B8=8C?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20IOException=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EB=94=94=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YoutubeRecipeService.findYoutubeRecipesByKeyword 가 IOException 을 외부로 던지지 않고 내부에서 캐치/로깅 후 DB 폴백으로 흐르도록 수정 (블로그 검색 흐름과 일관) - BlogRecipeThumbnailCrawlingService 의 디버그용 System.out.println 제거 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../blog/BlogRecipeThumbnailCrawlingService.java | 2 -- .../application/youtube/YoutubeRecipeService.java | 13 +++++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeThumbnailCrawlingService.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeThumbnailCrawlingService.java index f243e45d..7e856862 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeThumbnailCrawlingService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeThumbnailCrawlingService.java @@ -27,8 +27,6 @@ public BlogRecipeThumbnailCrawlingService(BlogRecipeRepository blogRecipeReposit @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveThumbnails(List blogRecipes) { - System.out.println("thumbnail save"); - for (BlogRecipe blogRecipe : blogRecipes) { blogRecipe.changeThumbnail(getBlogThumbnailUrl(blogRecipe.getBlogUrl())); } diff --git a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java index 82947ee6..12e81e85 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java @@ -7,12 +7,14 @@ import com.recipe.app.src.recipe.domain.youtube.YoutubeScrap; import com.recipe.app.src.recipe.infra.youtube.YoutubeRecipeRepository; import com.recipe.app.src.user.domain.User; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.util.List; +@Slf4j @Service public class YoutubeRecipeService { @@ -33,18 +35,21 @@ public YoutubeRecipeService(YoutubeRecipeRepository youtubeRecipeRepository, You } @Transactional - public RecipesResponse findYoutubeRecipesByKeyword(User user, String keyword, long lastYoutubeRecipeId, int size, String sort) throws IOException { + public RecipesResponse findYoutubeRecipesByKeyword(User user, String keyword, long lastYoutubeRecipeId, int size, String sort) { badWordFiltering.check(keyword); long totalCnt = youtubeRecipeRepository.countByKeyword(keyword); - List youtubeRecipes; if (totalCnt < MIN_RECIPE_CNT) { - youtubeRecipeClientSearchService.searchYoutube(keyword); + try { + youtubeRecipeClientSearchService.searchYoutube(keyword); + } catch (IOException e) { + log.warn("youtube search api call failed - {}", e.getMessage()); + } } - youtubeRecipes = findByKeywordOrderBy(keyword, lastYoutubeRecipeId, size, sort); + List youtubeRecipes = findByKeywordOrderBy(keyword, lastYoutubeRecipeId, size, sort); totalCnt = youtubeRecipeRepository.countByKeyword(keyword); return getRecipes(user, totalCnt, new YoutubeRecipes(youtubeRecipes)); From 90efc7bbaad227ac72cfb6e79c371a4af7925c69 Mon Sep 17 00:00:00 2001 From: joona95 Date: Wed, 29 Apr 2026 21:59:10 +0900 Subject: [PATCH 03/20] =?UTF-8?q?refactor:=20=EB=A0=88=EC=8B=9C=ED=94=BC?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit countByKeyword 가 매칭되는 모든 row 를 group by + having 으로 fetch 한 뒤 list size 를 반환하던 구조를 EXISTS 서브쿼리 + countDistinct 단일 쿼리로 변경. 키워드 매칭 카운트만 필요한 상황에서 불필요한 row 로딩이 사라져 응답 속도/메모리 사용에 이득. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../recipe/infra/RecipeRepositoryImpl.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java index 935ff352..8d102b23 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra; +import com.querydsl.jpa.JPAExpressions; import com.recipe.app.src.common.infra.BaseRepositoryImpl; import com.recipe.app.src.recipe.domain.Recipe; import jakarta.persistence.EntityManager; @@ -33,16 +34,20 @@ public Optional findRecipeDetail(Long recipeId, Long userId) { @Override public Long countByKeyword(String keyword) { - return (long) queryFactory - .select(recipe.recipeId, recipe.recipeNm, recipe.introduction) + return queryFactory + .select(recipe.recipeId.countDistinct()) .from(recipe) - .leftJoin(recipe.ingredients, recipeIngredient).on(recipeIngredient.ingredientName.contains(keyword)) - .where(recipe.hiddenYn.eq("N")) - .groupBy(recipe.recipeId) - .having(recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(recipeIngredient.count().gt(0))) - .fetch().size(); + .where( + recipe.hiddenYn.eq("N"), + recipe.recipeNm.contains(keyword) + .or(recipe.introduction.contains(keyword)) + .or(JPAExpressions.selectOne() + .from(recipeIngredient) + .where(recipeIngredient.recipe.recipeId.eq(recipe.recipeId) + .and(recipeIngredient.ingredientName.contains(keyword))) + .exists()) + ) + .fetchOne(); } @Override From 4ef855e75c34161c75d5aca433392cbcf2bf730c Mon Sep 17 00:00:00 2001 From: joona95 Date: Wed, 29 Apr 2026 21:59:22 +0900 Subject: [PATCH 04/20] =?UTF-8?q?refactor:=20IOException=20=EA=B8=80?= =?UTF-8?q?=EB=A1=9C=EB=B2=8C=20=EC=B2=98=EB=A6=AC=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20throws=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GeneralExceptionHandler 에 IOException 핸들러 추가 (500 응답으로 일관 처리) - YoutubeRecipeController.getYoutubeRecipes 의 throws IOException 제거 (서비스 계층에서 캐치/로깅 후 DB 폴백으로 흐르는 흐름과 정합) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/src/common/config/GeneralExceptionHandler.java | 8 ++++++++ .../app/src/recipe/api/YoutubeRecipeController.java | 4 +--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java b/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java index bd42f57a..f910f9e9 100644 --- a/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java +++ b/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java @@ -18,6 +18,8 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import java.io.IOException; + @ControllerAdvice public class GeneralExceptionHandler { @@ -42,4 +44,10 @@ public ResponseEntity handleBadRequestException(Exception e) { log.error(e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e); } + + @ExceptionHandler(IOException.class) + public ResponseEntity handleIOException(IOException e) { + log.error(e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } } diff --git a/src/main/java/com/recipe/app/src/recipe/api/YoutubeRecipeController.java b/src/main/java/com/recipe/app/src/recipe/api/YoutubeRecipeController.java index 05090930..9f1b1030 100644 --- a/src/main/java/com/recipe/app/src/recipe/api/YoutubeRecipeController.java +++ b/src/main/java/com/recipe/app/src/recipe/api/YoutubeRecipeController.java @@ -15,8 +15,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.io.IOException; - @Tag(name = "유튜브 레시피 Controller") @RestController @RequestMapping("/recipes/youtube") @@ -39,7 +37,7 @@ public RecipesResponse getYoutubeRecipes(@Parameter(hidden = true) User user, @Parameter(example = "20", name = "사이즈") @RequestParam(value = "size") int size, @Parameter(example = "조회수순(views) / 좋아요순(scraps) / 최신순(newest) = 기본값", name = "정렬") - @RequestParam(value = "sort") String sort) throws IOException { + @RequestParam(value = "sort") String sort) { return youtubeRecipeService.findYoutubeRecipesByKeyword(user, keyword, startAfter, size, sort); } From 30448963423139a9b17b29c47fef7cccb7d71a5e Mon Sep 17 00:00:00 2001 From: joona95 Date: Sat, 2 May 2026 16:40:51 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20=ED=95=9C=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=ED=98=95=ED=83=9C=EC=86=8C=20=EB=B6=84=EC=84=9D=EA=B8=B0=20(lu?= =?UTF-8?q?cene-analysis-nori)=20=EA=B8=B0=EB=B0=98=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=ED=99=94=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lucene-analysis-nori 9.11.1 의존성 추가 - KoreanTokenizer: nori KoreanAnalyzer (DecompoundMode.NONE) 로 입력 텍스트를 공백 구분 토큰 문자열로 변환. 합성어("양파","감자전","김치찌개") 가 분리되지 않도록 NONE 모드 사용 (정확성 우선, 누락 허용 정책) - SearchKeywordNormalizer: 사용자 입력 -> trim -> 특수문자 제거 -> nori 토큰화 -> "+토큰" AND BOOLEAN MODE 쿼리 문자열. 1글자 입력은 null 반환 (FULLTEXT 인덱스 token size 제약 대응) Co-Authored-By: Claude Opus 4.7 (1M context) --- build.gradle | 3 + gradlew | 0 .../app/src/common/utils/KoreanTokenizer.java | 44 ++++++++++ .../common/utils/SearchKeywordNormalizer.java | 42 ++++++++++ .../common/utils/KoreanTokenizerTest.groovy | 80 +++++++++++++++++++ .../utils/SearchKeywordNormalizerTest.groovy | 64 +++++++++++++++ 6 files changed, 233 insertions(+) mode change 100644 => 100755 gradlew create mode 100644 src/main/java/com/recipe/app/src/common/utils/KoreanTokenizer.java create mode 100644 src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java create mode 100644 src/test/groovy/com/recipe/app/src/common/utils/KoreanTokenizerTest.groovy create mode 100644 src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy diff --git a/build.gradle b/build.gradle index 2b685f44..3efdf6ed 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,9 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + // 한국어 형태소 분석기 (검색 토큰화 용) + implementation 'org.apache.lucene:lucene-analysis-nori:9.11.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.spockframework:spock-core:2.4-M1-groovy-4.0' diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/recipe/app/src/common/utils/KoreanTokenizer.java b/src/main/java/com/recipe/app/src/common/utils/KoreanTokenizer.java new file mode 100644 index 00000000..4351d8fb --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/utils/KoreanTokenizer.java @@ -0,0 +1,44 @@ +package com.recipe.app.src.common.utils; + +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.ko.KoreanAnalyzer; +import org.apache.lucene.analysis.ko.KoreanPartOfSpeechStopFilter; +import org.apache.lucene.analysis.ko.KoreanTokenizer.DecompoundMode; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; + +public final class KoreanTokenizer { + + private static final KoreanAnalyzer ANALYZER = new KoreanAnalyzer( + null, + DecompoundMode.NONE, + KoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS, + false + ); + + private KoreanTokenizer() { + } + + public static String tokenize(String text) { + + if (text == null || text.isBlank()) return ""; + + List tokens = new ArrayList<>(); + try (TokenStream ts = ANALYZER.tokenStream(null, new StringReader(text))) { + CharTermAttribute attr = ts.addAttribute(CharTermAttribute.class); + ts.reset(); + while (ts.incrementToken()) { + String token = attr.toString(); + if (!token.isEmpty()) tokens.add(token); + } + ts.end(); + } catch (IOException e) { + throw new IllegalStateException("Failed to tokenize: " + text, e); + } + return String.join(" ", tokens); + } +} diff --git a/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java b/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java new file mode 100644 index 00000000..a11cf3c1 --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java @@ -0,0 +1,42 @@ +package com.recipe.app.src.common.utils; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public final class SearchKeywordNormalizer { + + private static final int MIN_KEYWORD_LENGTH = 2; + + private SearchKeywordNormalizer() { + } + + /** + * 사용자 입력을 BOOLEAN MODE FULLTEXT 검색식으로 변환한다. + *
    + *
  • 입력이 너무 짧거나 토큰이 비어있으면 null 반환 (호출자는 검색 자체를 스킵)
  • + *
  • nori 토큰화 결과를 +token 형태로 묶어 AND 매칭한다
  • + *
+ */ + public static String toBooleanModeQuery(String rawKeyword) { + + if (rawKeyword == null) return null; + + String stripped = rawKeyword + .replaceAll("[+\\-><()~*\"@]", " ") + .trim() + .replaceAll("\\s+", " "); + + if (stripped.length() < MIN_KEYWORD_LENGTH) return null; + + String tokenized = KoreanTokenizer.tokenize(stripped); + if (tokenized.isBlank()) return null; + + String[] tokens = tokenized.split(" "); + String query = Arrays.stream(tokens) + .filter(t -> !t.isBlank()) + .map(t -> "+" + t) + .collect(Collectors.joining(" ")); + + return query.isBlank() ? null : query; + } +} diff --git a/src/test/groovy/com/recipe/app/src/common/utils/KoreanTokenizerTest.groovy b/src/test/groovy/com/recipe/app/src/common/utils/KoreanTokenizerTest.groovy new file mode 100644 index 00000000..0cc284c1 --- /dev/null +++ b/src/test/groovy/com/recipe/app/src/common/utils/KoreanTokenizerTest.groovy @@ -0,0 +1,80 @@ +package com.recipe.app.src.common.utils + +import spock.lang.Specification + +class KoreanTokenizerTest extends Specification { + + def "null/blank 입력은 빈 문자열을 반환한다"() { + + expect: + KoreanTokenizer.tokenize(input) == "" + + where: + input << [null, "", " ", "\t\n"] + } + + def "동일 입력은 동일 토큰 결과를 반환한다 (idempotency)"() { + + expect: + KoreanTokenizer.tokenize(input) == KoreanTokenizer.tokenize(input) + + where: + input << ["소면", "감자전", "김치 찌개 만들기", "에어프라이어"] + } + + def "한국어 합성어는 NONE 모드로 토큰이 분리되지 않는다"() { + + expect: + KoreanTokenizer.tokenize(input) == expected + + where: + input || expected + "소면" || "소면" + "감자" || "감자" + "양파" || "양파" + "감자전" || "감자전" + "김치찌개" || "김치찌개" + "닭볶음탕" || "닭볶음탕" + "고추장" || "고추장" + "고춧가루" || "고춧가루" + } + + def "조사가 붙은 단어는 명사만 추출된다"() { + + when: + String tokens = KoreanTokenizer.tokenize("감자를 샀다") + + then: + tokens.contains("감자") + !tokens.contains("를") + } + + def "사전에 없는 외래어도 토큰화된다 (분할되더라도 빈 결과는 아님)"() { + + when: + String tokens = KoreanTokenizer.tokenize("에어프라이어") + + then: + !tokens.isEmpty() + } + + def "영어/숫자도 토큰화된다"() { + + when: + String tokens = KoreanTokenizer.tokenize("Pasta 2024") + + then: + tokens.toLowerCase().contains("pasta") + tokens.contains("2024") + } + + def "단어 경계가 다른 텍스트는 토큰이 겹치지 않는다 (소면 vs 소고기 미역국)"() { + + given: + Set soMyeon = KoreanTokenizer.tokenize("소면").split(" ") as Set + Set soGoGiSoup = KoreanTokenizer.tokenize("소고기 미역국").split(" ") as Set + + expect: "소면 검색이 소고기 글의 토큰과 겹치지 않아야 함 (false positive 방지의 핵심 invariant)" + soMyeon.intersect(soGoGiSoup).isEmpty() + } +} diff --git a/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy b/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy new file mode 100644 index 00000000..729941dd --- /dev/null +++ b/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy @@ -0,0 +1,64 @@ +package com.recipe.app.src.common.utils + +import spock.lang.Specification + +class SearchKeywordNormalizerTest extends Specification { + + def "null/empty/blank/1글자 입력은 null을 반환한다"() { + + expect: + SearchKeywordNormalizer.toBooleanModeQuery(input) == null + + where: + input << [null, "", " ", "\t", "감"] + } + + def "한글 합성어는 그대로 +token 으로 변환된다"() { + + expect: + SearchKeywordNormalizer.toBooleanModeQuery(input) == expected + + where: + input || expected + "소면" || "+소면" + "양파" || "+양파" + "감자전" || "+감자전" + "김치찌개" || "+김치찌개" + } + + def "복수 토큰은 +token1 +token2 형식으로 AND 매칭된다"() { + + when: + String query = SearchKeywordNormalizer.toBooleanModeQuery("감자 양파") + + then: + query == "+감자 +양파" + } + + def "BOOLEAN MODE 특수문자는 제거된다"() { + + when: + String query = SearchKeywordNormalizer.toBooleanModeQuery("+감자 -양파*") + + then: + query == "+감자 +양파" + } + + def "조사가 붙은 입력은 명사만 추출되어 반영된다"() { + + when: + String query = SearchKeywordNormalizer.toBooleanModeQuery("감자를") + + then: + query == "+감자" + } + + def "연속 공백/탭은 단일 공백으로 정리된다"() { + + when: + String query = SearchKeywordNormalizer.toBooleanModeQuery(" 감자 \t양파 ") + + then: + query == "+감자 +양파" + } +} From 9f6effaee6b5641567672db4fa294de73bf9685f Mon Sep 17 00:00:00 2001 From: joona95 Date: Sat, 2 May 2026 16:41:07 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20MySQL=20FULLTEXT=20MATCH=20AGAINS?= =?UTF-8?q?T=20=ED=95=A8=EC=88=98=20Hibernate=20=EB=93=B1=EB=A1=9D=20+=20Q?= =?UTF-8?q?ueryUtils=20=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MysqlFulltextFunctionContributor: Hibernate 6 FunctionContributor SPI 로 match_against(col, query) 함수 등록 -> SQL: MATCH(col) AGAINST(query IN BOOLEAN MODE) - META-INF/services/org.hibernate.boot.model.FunctionContributor 로 자동 로드 - QueryUtils.matchAgainst(StringPath, String): Querydsl BooleanExpression 헬퍼. 점수 0 초과(매칭 성공) 조건으로 WHERE 절에서 사용 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MysqlFulltextFunctionContributor.java | 25 +++++++++++++++++++ .../app/src/common/utils/QueryUtils.java | 10 ++++++++ ...g.hibernate.boot.model.FunctionContributor | 1 + 3 files changed, 36 insertions(+) create mode 100644 src/main/java/com/recipe/app/src/common/config/MysqlFulltextFunctionContributor.java create mode 100644 src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor diff --git a/src/main/java/com/recipe/app/src/common/config/MysqlFulltextFunctionContributor.java b/src/main/java/com/recipe/app/src/common/config/MysqlFulltextFunctionContributor.java new file mode 100644 index 00000000..0066116d --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/config/MysqlFulltextFunctionContributor.java @@ -0,0 +1,25 @@ +package com.recipe.app.src.common.config; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; +import org.hibernate.type.StandardBasicTypes; + +/** + * Hibernate 에 MySQL FULLTEXT MATCH AGAINST 함수를 등록한다. + * QueryDSL 등에서 function('match_against', col, query) 로 호출 가능. + */ +public class MysqlFulltextFunctionContributor implements FunctionContributor { + + @Override + public void contributeFunctions(FunctionContributions functionContributions) { + + functionContributions.getFunctionRegistry() + .registerPattern( + "match_against", + "MATCH(?1) AGAINST(?2 IN BOOLEAN MODE)", + functionContributions.getTypeConfiguration() + .getBasicTypeRegistry() + .resolve(StandardBasicTypes.DOUBLE) + ); + } +} diff --git a/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java b/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java index e852b4b9..90f68dd8 100644 --- a/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java +++ b/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java @@ -1,6 +1,8 @@ package com.recipe.app.src.common.utils; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringPath; import java.util.function.BiFunction; import java.util.function.Function; @@ -14,4 +16,12 @@ public static BooleanExpression ifIdIsNotNullAndGreaterThanZero(BiFunction function, Long id) { return id != null && id > 0 ? function.apply(id) : null; } + + /** + * MySQL FULLTEXT BOOLEAN MODE 매칭 조건. SearchKeywordNormalizer 가 만든 BOOLEAN 모드 쿼리 문자열을 받는다. + */ + public static BooleanExpression matchAgainst(StringPath column, String booleanQuery) { + return Expressions.numberTemplate(Double.class, + "function('match_against', {0}, {1})", column, booleanQuery).gt(0); + } } diff --git a/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor b/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor new file mode 100644 index 00000000..45348957 --- /dev/null +++ b/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor @@ -0,0 +1 @@ +com.recipe.app.src.common.config.MysqlFulltextFunctionContributor From 06a68c54b45d3d3aa0f8ce39da94b5968cf1c740 Mon Sep 17 00:00:00 2001 From: joona95 Date: Sat, 2 May 2026 16:41:25 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=20searchTokens?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20+=20=EC=9E=90=EB=8F=99=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipe / RecipeIngredient / BlogRecipe / YoutubeRecipe 엔티티에 searchTokens 컬럼 추가하고 @PrePersist / @PreUpdate 로 저장/수정 시 KoreanTokenizer 가 자동으로 토큰화한 결과를 채운다. - Recipe: recipeNm + " " + introduction 토큰화 - RecipeIngredient: ingredientName 토큰화 (재료 검색 대응) - BlogRecipe / YoutubeRecipe: title + " " + description 토큰화 이 컬럼을 FULLTEXT 인덱스 대상으로 삼아 LIKE 풀스캔 -> 인덱스 매칭으로 전환 하기 위한 사전 작업이며, 컬럼/인덱스 DDL 은 운영 DB 에서 별도 적용 필요. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/recipe/app/src/recipe/domain/Recipe.java | 13 +++++++++++++ .../app/src/recipe/domain/RecipeIngredient.java | 12 ++++++++++++ .../app/src/recipe/domain/blog/BlogRecipe.java | 13 +++++++++++++ .../src/recipe/domain/youtube/YoutubeRecipe.java | 13 +++++++++++++ 4 files changed, 51 insertions(+) diff --git a/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java b/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java index e5252918..63a95b56 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java @@ -2,6 +2,7 @@ import com.google.common.base.Preconditions; import com.recipe.app.src.common.entity.BaseEntity; +import com.recipe.app.src.common.utils.KoreanTokenizer; import com.recipe.app.src.recipe.infra.RecipeLevelPersistConverter; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -11,6 +12,8 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -70,6 +73,9 @@ public class Recipe extends BaseEntity { @Column(name = "reportYn", nullable = false) private String reportYn = "N"; + @Column(name = "searchTokens", columnDefinition = "TEXT") + private String searchTokens; + @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) List ingredients = new ArrayList<>(); @@ -160,4 +166,11 @@ public long calculateIngredientMatchRate(List ingredientNamesInFridge) { return Math.round((double) ingredientMatchCnt / ingredients.size() * 100); } + + @PrePersist + @PreUpdate + private void refreshSearchTokens() { + String source = (recipeNm != null ? recipeNm : "") + " " + (introduction != null ? introduction : ""); + this.searchTokens = KoreanTokenizer.tokenize(source); + } } diff --git a/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java b/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java index f716fa58..6dd06c5f 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java @@ -2,6 +2,7 @@ import com.google.common.base.Preconditions; import com.recipe.app.src.common.entity.BaseEntity; +import com.recipe.app.src.common.utils.KoreanTokenizer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -10,6 +11,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -37,6 +40,9 @@ public class RecipeIngredient extends BaseEntity { @Column(name = "ingredientName", nullable = false, length = 64) private String ingredientName; + @Column(name = "searchTokens", length = 128) + private String searchTokens; + @Column(name = "ingredientIconId") private Long ingredientIconId; @@ -69,4 +75,10 @@ void setRecipe(Recipe recipe) { public boolean hasInFridge(List ingredientNames) { return ingredientNames.contains(ingredientName); } + + @PrePersist + @PreUpdate + private void refreshSearchTokens() { + this.searchTokens = KoreanTokenizer.tokenize(ingredientName); + } } diff --git a/src/main/java/com/recipe/app/src/recipe/domain/blog/BlogRecipe.java b/src/main/java/com/recipe/app/src/recipe/domain/blog/BlogRecipe.java index f265760e..fd6a46b3 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/blog/BlogRecipe.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/blog/BlogRecipe.java @@ -2,11 +2,14 @@ import com.google.common.base.Preconditions; import com.recipe.app.src.common.entity.BaseEntity; +import com.recipe.app.src.common.utils.KoreanTokenizer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -52,6 +55,9 @@ public class BlogRecipe extends BaseEntity { @Column(name = "viewCnt", nullable = false) private long viewCnt; + @Column(name = "searchTokens", columnDefinition = "TEXT") + private String searchTokens; + @Builder public BlogRecipe(Long blogRecipeId, String blogUrl, String blogThumbnailImgUrl, String title, String description, LocalDate publishedAt, String blogName, long scrapCnt, long viewCnt) { @@ -87,4 +93,11 @@ public void plusViewCnt() { public void changeThumbnail(String blogThumbnailUrl) { this.blogThumbnailImgUrl = blogThumbnailUrl; } + + @PrePersist + @PreUpdate + private void refreshSearchTokens() { + String source = (title != null ? title : "") + " " + (description != null ? description : ""); + this.searchTokens = KoreanTokenizer.tokenize(source); + } } diff --git a/src/main/java/com/recipe/app/src/recipe/domain/youtube/YoutubeRecipe.java b/src/main/java/com/recipe/app/src/recipe/domain/youtube/YoutubeRecipe.java index 23a71318..bece1f8e 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/youtube/YoutubeRecipe.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/youtube/YoutubeRecipe.java @@ -2,11 +2,14 @@ import com.google.common.base.Preconditions; import com.recipe.app.src.common.entity.BaseEntity; +import com.recipe.app.src.common.utils.KoreanTokenizer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -52,6 +55,9 @@ public class YoutubeRecipe extends BaseEntity { @Column(name = "viewCnt", nullable = false) private long viewCnt; + @Column(name = "searchTokens", columnDefinition = "TEXT") + private String searchTokens; + @Builder public YoutubeRecipe(Long youtubeRecipeId, String title, String description, String thumbnailImgUrl, LocalDate postDate, String channelName, String youtubeId, long scrapCnt, long viewCnt) { @@ -83,4 +89,11 @@ public void minusScrapCnt() { public void plusViewCnt() { this.viewCnt++; } + + @PrePersist + @PreUpdate + private void refreshSearchTokens() { + String source = (title != null ? title : "") + " " + (description != null ? description : ""); + this.searchTokens = KoreanTokenizer.tokenize(source); + } } From 6cdfb4334c7dc9fcf807c9d31e80d5c33af0d45c Mon Sep 17 00:00:00 2001 From: joona95 Date: Sat, 2 May 2026 16:41:50 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20=EB=A0=88=EC=8B=9C=ED=94=BC/?= =?UTF-8?q?=EB=B8=94=EB=A1=9C=EA=B7=B8/=EC=9C=A0=ED=8A=9C=EB=B8=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=BF=BC=EB=A6=AC=EB=A5=BC=20LIKE=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20FULLTEXT=20MATCH=20=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 LIKE %keyword% 풀스캔 검색이 갖던 두 문제를 해결한다. - 인덱스 미사용 -> 데이터 누적 시 응답속도 저하 - "소면" 검색이 "소고기"에 매칭되는 부분 일치 false positive 서비스 계층에서 SearchKeywordNormalizer 로 키워드를 BOOLEAN MODE 쿼리로 변환한 뒤 Repository 에 전달. 1글자 등 토큰화 불가 입력은 빈 결과 즉시 반환 하여 외부 API 호출(naver/youtube) 도 함께 회피. Repository 는 QueryUtils.matchAgainst 헬퍼를 통해 FULLTEXT 매칭으로 변경. Recipe 검색은 recipe.searchTokens 매칭 OR RecipeIngredient.searchTokens EXISTS 서브쿼리 패턴으로 단순화하여 count/list 쿼리의 매칭 룰을 통일했다. 정렬 옵션(scraps/views/newest)은 기존 그대로 유지. 점수 기반 정렬은 도입하지 않음 (정확성 우선, 누락 허용 정책). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../application/RecipeSearchService.java | 14 +++-- .../application/blog/BlogRecipeService.java | 13 +++-- .../youtube/YoutubeRecipeService.java | 12 +++-- .../recipe/infra/RecipeRepositoryImpl.java | 52 +++++++++---------- .../infra/blog/BlogRecipeRepositoryImpl.java | 18 +++---- .../youtube/YoutubeRecipeRepositoryImpl.java | 16 +++--- 6 files changed, 65 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java index 29315ae6..cd05501e 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java @@ -1,6 +1,7 @@ package com.recipe.app.src.recipe.application; import com.recipe.app.src.common.utils.BadWordFiltering; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer; import com.recipe.app.src.fridge.application.FridgeService; import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse; import com.recipe.app.src.recipe.application.dto.RecipesResponse; @@ -42,15 +43,20 @@ public RecipesResponse findRecipesByKeywordOrderBy(User user, String keyword, lo badWordFiltering.check(keyword); - long totalCnt = recipeRepository.countByKeyword(keyword); + String booleanQuery = SearchKeywordNormalizer.toBooleanModeQuery(keyword); + if (booleanQuery == null) { + return getRecipes(user, 0L, new Recipes(List.of())); + } + + long totalCnt = recipeRepository.countByKeyword(booleanQuery); List recipes; if (sort.equals("scraps")) { - recipes = findByKeywordOrderByRecipeScrapCnt(keyword, lastRecipeId, size); + recipes = findByKeywordOrderByRecipeScrapCnt(booleanQuery, lastRecipeId, size); } else if (sort.equals("views")) { - recipes = findByKeywordOrderByRecipeViewCnt(keyword, lastRecipeId, size); + recipes = findByKeywordOrderByRecipeViewCnt(booleanQuery, lastRecipeId, size); } else { - recipes = findByKeywordOrderByCreatedAt(keyword, lastRecipeId, size); + recipes = findByKeywordOrderByCreatedAt(booleanQuery, lastRecipeId, size); } return getRecipes(user, totalCnt, new Recipes(recipes)); diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java index 6029e89c..1eb345d7 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java @@ -1,6 +1,7 @@ package com.recipe.app.src.recipe.application.blog; import com.recipe.app.src.common.utils.BadWordFiltering; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer; import com.recipe.app.src.recipe.application.dto.RecipesResponse; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; import com.recipe.app.src.recipe.domain.blog.BlogRecipes; @@ -38,15 +39,19 @@ public RecipesResponse findBlogRecipesByKeyword(User user, String keyword, long badWordFiltering.check(keyword); - long totalCnt = blogRecipeRepository.countByKeyword(keyword); + String booleanQuery = SearchKeywordNormalizer.toBooleanModeQuery(keyword); + if (booleanQuery == null) { + return getRecipes(user, 0L, new BlogRecipes(List.of())); + } + + long totalCnt = blogRecipeRepository.countByKeyword(booleanQuery); - List blogRecipes; if (totalCnt < MIN_RECIPE_CNT) { blogRecipeClientSearchService.searchNaverBlogRecipes(keyword); } - blogRecipes = findByKeywordOrderBy(keyword, lastBlogRecipeId, size, sort); - totalCnt = blogRecipeRepository.countByKeyword(keyword); + List blogRecipes = findByKeywordOrderBy(booleanQuery, lastBlogRecipeId, size, sort); + totalCnt = blogRecipeRepository.countByKeyword(booleanQuery); return getRecipes(user, totalCnt, new BlogRecipes(blogRecipes)); } diff --git a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java index 12e81e85..2fb56eb7 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java @@ -1,6 +1,7 @@ package com.recipe.app.src.recipe.application.youtube; import com.recipe.app.src.common.utils.BadWordFiltering; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer; import com.recipe.app.src.recipe.application.dto.RecipesResponse; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipes; @@ -39,7 +40,12 @@ public RecipesResponse findYoutubeRecipesByKeyword(User user, String keyword, lo badWordFiltering.check(keyword); - long totalCnt = youtubeRecipeRepository.countByKeyword(keyword); + String booleanQuery = SearchKeywordNormalizer.toBooleanModeQuery(keyword); + if (booleanQuery == null) { + return getRecipes(user, 0L, new YoutubeRecipes(List.of())); + } + + long totalCnt = youtubeRecipeRepository.countByKeyword(booleanQuery); if (totalCnt < MIN_RECIPE_CNT) { try { @@ -49,8 +55,8 @@ public RecipesResponse findYoutubeRecipesByKeyword(User user, String keyword, lo } } - List youtubeRecipes = findByKeywordOrderBy(keyword, lastYoutubeRecipeId, size, sort); - totalCnt = youtubeRecipeRepository.countByKeyword(keyword); + List youtubeRecipes = findByKeywordOrderBy(booleanQuery, lastYoutubeRecipeId, size, sort); + totalCnt = youtubeRecipeRepository.countByKeyword(booleanQuery); return getRecipes(user, totalCnt, new YoutubeRecipes(youtubeRecipes)); } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java index 8d102b23..96bdb8c8 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java @@ -11,6 +11,7 @@ import java.util.Optional; import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; +import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; import static com.recipe.app.src.recipe.domain.QRecipe.recipe; import static com.recipe.app.src.recipe.domain.QRecipeIngredient.recipeIngredient; import static com.recipe.app.src.recipe.domain.QRecipeScrap.recipeScrap; @@ -39,13 +40,7 @@ public Long countByKeyword(String keyword) { .from(recipe) .where( recipe.hiddenYn.eq("N"), - recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(JPAExpressions.selectOne() - .from(recipeIngredient) - .where(recipeIngredient.recipe.recipeId.eq(recipe.recipeId) - .and(recipeIngredient.ingredientName.contains(keyword))) - .exists()) + keywordMatch(keyword) ) .fetchOne(); } @@ -55,18 +50,14 @@ public List findByKeywordLimitOrderByCreatedAtDesc(String keyword, Long return queryFactory .selectFrom(recipe) - .leftJoin(recipe.ingredients, recipeIngredient).on(recipeIngredient.ingredientName.contains(keyword)) .where( + recipe.hiddenYn.eq("N"), + keywordMatch(keyword), ifIdIsNotNullAndGreaterThanZero((recipeId, createdAt) -> recipe.createdAt.lt(createdAt) .or(recipe.createdAt.eq(createdAt) .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastCreatedAt), - recipe.hiddenYn.eq("N") + lastRecipeId, lastCreatedAt) ) - .groupBy(recipe.recipeId) - .having(recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(recipeIngredient.count().gt(0))) .orderBy(recipe.createdAt.desc(), recipe.recipeId.desc()) .limit(size) .fetch(); @@ -77,16 +68,14 @@ public List findByKeywordLimitOrderByRecipeScrapCntDesc(String keyword, return queryFactory .selectFrom(recipe) - .leftJoin(recipe.ingredients, recipeIngredient).on(recipeIngredient.ingredientName.contains(keyword)) - .where(recipe.hiddenYn.eq("N"), + .where( + recipe.hiddenYn.eq("N"), + keywordMatch(keyword), ifIdIsNotNullAndGreaterThanZero((recipeId, recipeScrapCnt) -> recipe.scrapCnt.lt(recipeScrapCnt) .or(recipe.scrapCnt.eq(recipeScrapCnt) .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastRecipeScrapCnt)) - .groupBy(recipe.recipeId) - .having(recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(recipeIngredient.count().gt(0))) + lastRecipeId, lastRecipeScrapCnt) + ) .orderBy(recipe.scrapCnt.desc(), recipe.recipeId.desc()) .limit(size) .fetch(); @@ -97,21 +86,28 @@ public List findByKeywordLimitOrderByRecipeViewCntDesc(String keyword, L return queryFactory .selectFrom(recipe) - .leftJoin(recipe.ingredients, recipeIngredient).on(recipeIngredient.ingredientName.contains(keyword)) - .where(recipe.hiddenYn.eq("N"), + .where( + recipe.hiddenYn.eq("N"), + keywordMatch(keyword), ifIdIsNotNullAndGreaterThanZero((recipeId, recipeViewCnt) -> recipe.viewCnt.lt(recipeViewCnt) .or(recipe.viewCnt.eq(recipeViewCnt) .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastRecipeViewCnt)) - .groupBy(recipe.recipeId) - .having(recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(recipeIngredient.count().gt(0))) + lastRecipeId, lastRecipeViewCnt) + ) .orderBy(recipe.viewCnt.desc(), recipe.recipeId.desc()) .limit(size) .fetch(); } + private com.querydsl.core.types.dsl.BooleanExpression keywordMatch(String keyword) { + return matchAgainst(recipe.searchTokens, keyword) + .or(JPAExpressions.selectOne() + .from(recipeIngredient) + .where(recipeIngredient.recipe.recipeId.eq(recipe.recipeId) + .and(matchAgainst(recipeIngredient.searchTokens, keyword))) + .exists()); + } + @Override public List findUserScrapRecipesLimit(Long userId, Long lastRecipeId, LocalDateTime lastScrapCreatedAt, int size) { diff --git a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java index c4a2ff9c..db716798 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java @@ -9,7 +9,8 @@ import java.time.LocalDateTime; import java.util.List; -import static com.recipe.app.src.common.utils.QueryUtils.*; +import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; +import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; import static com.recipe.app.src.recipe.domain.blog.QBlogRecipe.blogRecipe; import static com.recipe.app.src.recipe.domain.blog.QBlogScrap.blogScrap; @@ -26,8 +27,7 @@ public Long countByKeyword(String keyword) { .select(blogRecipe.count()) .from(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)) + matchAgainst(blogRecipe.searchTokens, keyword) ) .fetchOne(); } @@ -38,8 +38,7 @@ public List findByKeywordLimit(String keyword, int size) { return queryFactory .selectFrom(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)) + matchAgainst(blogRecipe.searchTokens, keyword) ) .limit(size) .fetch(); @@ -51,8 +50,7 @@ public List findByKeywordLimitOrderByPublishedAtDesc(String keyword, return queryFactory .selectFrom(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)), + matchAgainst(blogRecipe.searchTokens, keyword), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, publishedAt) -> blogRecipe.publishedAt.lt(publishedAt) .or(blogRecipe.publishedAt.eq(publishedAt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), @@ -69,8 +67,7 @@ public List findByKeywordLimitOrderByBlogScrapCntDesc(String keyword return queryFactory .selectFrom(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)), + matchAgainst(blogRecipe.searchTokens, keyword), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, blogScrapCnt) -> blogRecipe.scrapCnt.lt(blogScrapCnt) .or(blogRecipe.scrapCnt.eq(blogScrapCnt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), @@ -87,8 +84,7 @@ public List findByKeywordLimitOrderByBlogViewCntDesc(String keyword, return queryFactory .selectFrom(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)), + matchAgainst(blogRecipe.searchTokens, keyword), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, blogViewCnt) -> blogRecipe.viewCnt.lt(blogViewCnt) .or(blogRecipe.viewCnt.eq(blogViewCnt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), diff --git a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java index f5c827a5..3c30ebd2 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java @@ -9,6 +9,7 @@ import java.util.List; import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; +import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; import static com.recipe.app.src.recipe.domain.youtube.QYoutubeRecipe.youtubeRecipe; import static com.recipe.app.src.recipe.domain.youtube.QYoutubeScrap.youtubeScrap; @@ -25,8 +26,7 @@ public Long countByKeyword(String keyword) { .select(youtubeRecipe.count()) .from(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)) + matchAgainst(youtubeRecipe.searchTokens, keyword) ) .fetchOne(); } @@ -37,8 +37,7 @@ public List findByKeywordLimit(String keyword, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)) + matchAgainst(youtubeRecipe.searchTokens, keyword) ) .limit(size) .fetch(); @@ -50,8 +49,7 @@ public List findByKeywordLimitOrderByPostDateDesc(String keyword, return queryFactory .selectFrom(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)), + matchAgainst(youtubeRecipe.searchTokens, keyword), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, postDate) -> youtubeRecipe.postDate.lt(postDate) .or(youtubeRecipe.postDate.eq(postDate) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), @@ -68,8 +66,7 @@ public List findByKeywordLimitOrderByYoutubeScrapCntDesc(String k return queryFactory .selectFrom(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)), + matchAgainst(youtubeRecipe.searchTokens, keyword), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, youtubeScrapCnt) -> youtubeRecipe.scrapCnt.lt(youtubeScrapCnt) .or(youtubeRecipe.scrapCnt.eq(youtubeScrapCnt) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), @@ -86,8 +83,7 @@ public List findByKeywordLimitOrderByYoutubeViewCntDesc(String ke return queryFactory .selectFrom(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)), + matchAgainst(youtubeRecipe.searchTokens, keyword), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, youtubeViewCnt) -> youtubeRecipe.viewCnt.lt(youtubeViewCnt) .or(youtubeRecipe.viewCnt.eq(youtubeViewCnt) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), From b4bce97cbf19d54ef1274b1b5ab1ce1fbf240a14 Mon Sep 17 00:00:00 2001 From: joona95 Date: Sat, 2 May 2026 16:53:08 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EC=9A=A9=20searchTokens=20=EB=B0=B1=ED=95=84?= =?UTF-8?q?=20ApplicationRunner=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 DB 의 기존 row 들에 searchTokens 를 채우기 위한 일회성 마이그레이션 러너. - 4개 도메인(Recipe / RecipeIngredient / BlogRecipe / YoutubeRecipe) 모두 처리 - 500건 단위 배치 + 트랜잭션 분리로 메모리/롤백 부담 최소화 - recipe.migration.search-tokens.enabled=true property 로 gating (운영에서는 1회 실행 후 property 제거) - 기존 blogUrlHash 백필 패턴과 동일 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SearchTokensBackfillRunner.java | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java diff --git a/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java b/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java new file mode 100644 index 00000000..f03f9bec --- /dev/null +++ b/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java @@ -0,0 +1,110 @@ +package com.recipe.app.src.recipe.application; + +import com.recipe.app.src.common.utils.KoreanTokenizer; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +@Slf4j +@Component +@ConditionalOnProperty(value = "recipe.migration.search-tokens.enabled", havingValue = "true") +public class SearchTokensBackfillRunner implements ApplicationRunner { + + private static final int BATCH_SIZE = 500; + + @PersistenceContext + private EntityManager em; + + private final TransactionTemplate transactionTemplate; + + public SearchTokensBackfillRunner(PlatformTransactionManager transactionManager) { + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + @Override + public void run(ApplicationArguments args) { + + log.info("SearchTokens backfill started"); + backfill("Recipe", "recipeId", new String[]{"recipeNm", "introduction"}); + backfill("RecipeIngredient", "recipeIngredientId", new String[]{"ingredientName"}); + backfill("BlogRecipe", "blogRecipeId", new String[]{"title", "description"}); + backfill("YoutubeRecipe", "youtubeRecipeId", new String[]{"title", "description"}); + log.info("SearchTokens backfill done"); + } + + private void backfill(String table, String pk, String[] sourceColumns) { + + log.info("[{}] backfill started", table); + long lastId = 0L; + long total = 0L; + while (true) { + long startId = lastId; + BatchResult r = transactionTemplate.execute(status -> processBatch(table, pk, sourceColumns, startId)); + if (r == null || r.processed == 0) break; + lastId = r.lastId; + total += r.updated; + log.info("[{}] progress lastId={} updatedSoFar={}", table, lastId, total); + } + log.info("[{}] backfill done. updated={}", table, total); + } + + private BatchResult processBatch(String table, String pk, String[] sourceColumns, long startId) { + + String columnList = String.join(", ", sourceColumns); + String selectSql = "SELECT " + pk + ", " + columnList + + " FROM " + table + + " WHERE " + pk + " > :startId" + + " ORDER BY " + pk + " ASC" + + " LIMIT :batchSize"; + + @SuppressWarnings("unchecked") + List rows = em.createNativeQuery(selectSql) + .setParameter("startId", startId) + .setParameter("batchSize", BATCH_SIZE) + .getResultList(); + + if (rows.isEmpty()) return new BatchResult(0, 0, startId); + + String updateSql = "UPDATE " + table + " SET searchTokens = :tokens WHERE " + pk + " = :id"; + + int updated = 0; + long lastId = startId; + for (Object[] row : rows) { + Long id = ((Number) row[0]).longValue(); + StringBuilder text = new StringBuilder(); + for (int i = 1; i < row.length; i++) { + String s = row[i] == null ? "" : row[i].toString(); + if (text.length() > 0) text.append(' '); + text.append(s); + } + String tokens = KoreanTokenizer.tokenize(text.toString()); + em.createNativeQuery(updateSql) + .setParameter("tokens", tokens) + .setParameter("id", id) + .executeUpdate(); + lastId = id; + updated++; + } + return new BatchResult(rows.size(), updated, lastId); + } + + private static class BatchResult { + final int processed; + final int updated; + final long lastId; + + BatchResult(int processed, int updated, long lastId) { + this.processed = processed; + this.updated = updated; + this.lastId = lastId; + } + } +} From ae2c4ffbc7cd0498c6334e626813d6eebdfd2855 Mon Sep 17 00:00:00 2001 From: joona95 Date: Sat, 2 May 2026 16:53:34 +0900 Subject: [PATCH 10/20] =?UTF-8?q?test:=20=EA=B2=80=EC=83=89=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20stub=20?= =?UTF-8?q?=EC=9D=98=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=9D=B8=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20wildcard=20=EB=A7=A4=EC=B2=98=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20+=201=EA=B8=80=EC=9E=90=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서비스가 키워드를 BOOLEAN MODE 쿼리("+감자")로 변환한 뒤 Repository 에 전달 하도록 바뀌었으므로, 기존 mock stub 의 첫 인자(원시 키워드)는 더 이상 매칭되지 않는다. 키워드 변환은 서비스의 구현 디테일이므로 stub 첫 인자를 _ as String 와일드카드로 변경. 추가로 RecipeSearchServiceTest 에 1글자 입력은 외부 의존(repo/api) 호출 없이 빈 결과를 즉시 반환하는 정책을 검증하는 케이스 추가. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../RecipeSearchServiceTest.groovy | 29 +++++++++++++++---- .../blog/BlogRecipeServiceTest.groovy | 12 ++++---- .../youtube/YoutubeRecipeServiceTest.groovy | 12 ++++---- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy index 840ee4d5..a6263fad 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy @@ -46,7 +46,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "scraps" - recipeRepository.countByKeyword(keyword) >> 2 + recipeRepository.countByKeyword(_ as String) >> 2 recipeScrapService.countByRecipeId(lastRecipeId) >> 0 @@ -69,7 +69,7 @@ class RecipeSearchServiceTest extends Specification { .build(), ] - recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(keyword, lastRecipeId, 0, size) >> recipes + recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(_ as String, lastRecipeId, 0, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -120,7 +120,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "views" - recipeRepository.countByKeyword(keyword) >> 2 + recipeRepository.countByKeyword(_ as String) >> 2 recipeViewService.countByRecipeId(lastRecipeId) >> 0 @@ -143,7 +143,7 @@ class RecipeSearchServiceTest extends Specification { .build(), ] - recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(keyword, lastRecipeId, 0, size) >> recipes + recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(_ as String, lastRecipeId, 0, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -194,7 +194,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "newest" - recipeRepository.countByKeyword(keyword) >> 2 + recipeRepository.countByKeyword(_ as String) >> 2 recipeRepository.findById(lastRecipeId) >> Optional.empty() @@ -219,7 +219,7 @@ class RecipeSearchServiceTest extends Specification { recipeRepository.findById(lastRecipeId) >> Optional.empty() - recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(keyword, lastRecipeId, null, size) >> recipes + recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_ as String, lastRecipeId, null, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -588,4 +588,21 @@ class RecipeSearchServiceTest extends Specification { result.recipes.viewCnt == recipes.viewCnt result.recipes.ingredientsMatchRate == [100, 33] } + + def "레시피 키워드 검색 - 1글자 입력은 빈 결과를 반환한다 (FULLTEXT 토큰 최소 길이 정책)"() { + + given: + User user = User.builder().userId(1).socialId("naver_1").nickname("테스터1").build() + userService.findByUserIds(_) >> [] + recipeScrapService.findByRecipeIds(_) >> [] + + when: + RecipesResponse result = recipeSearchService.findRecipesByKeywordOrderBy(user, "감", 0L, 10, "newest") + + then: + result.totalCnt == 0 + result.recipes.isEmpty() + 0 * recipeRepository.countByKeyword(_) + 0 * recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, _, _, _) + } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy index 3e158245..b29b72ad 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy @@ -34,7 +34,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "scraps" - blogRecipeRepository.countByKeyword(keyword) >> 20 + blogRecipeRepository.countByKeyword(_ as String) >> 20 blogScrapService.countByBlogRecipeId(lastBlogRecipeId) >> 0 @@ -63,7 +63,7 @@ class BlogRecipeServiceTest extends Specification { .build(), ] - blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(keyword, lastBlogRecipeId, 0, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(_ as String, lastBlogRecipeId, 0, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() @@ -104,7 +104,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "views" - blogRecipeRepository.countByKeyword(keyword) >> 20 + blogRecipeRepository.countByKeyword(_ as String) >> 20 blogViewService.countByBlogRecipeId(lastBlogRecipeId) >> 0 @@ -133,7 +133,7 @@ class BlogRecipeServiceTest extends Specification { .build(), ] - blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(keyword, lastBlogRecipeId, 0, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(_ as String, lastBlogRecipeId, 0, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() @@ -174,7 +174,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "newest" - blogRecipeRepository.countByKeyword(keyword) >> 20 + blogRecipeRepository.countByKeyword(_ as String) >> 20 List blogRecipes = [ BlogRecipe.builder() @@ -203,7 +203,7 @@ class BlogRecipeServiceTest extends Specification { blogRecipeRepository.findById(lastBlogRecipeId) >> Optional.empty() - blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(keyword, lastBlogRecipeId, null, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(_ as String, lastBlogRecipeId, null, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy index 26f1eaab..907b7fa0 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy @@ -35,7 +35,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "scraps" - youtubeRecipeRepository.countByKeyword(keyword) >> 20 + youtubeRecipeRepository.countByKeyword(_ as String) >> 20 youtubeScrapService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -64,7 +64,7 @@ class YoutubeRecipeServiceTest extends Specification { .build() ] - youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(keyword, lastYoutubeRecipeId, 0, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(_ as String, lastYoutubeRecipeId, 0, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() @@ -105,7 +105,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "views" - youtubeRecipeRepository.countByKeyword(keyword) >> 20 + youtubeRecipeRepository.countByKeyword(_ as String) >> 20 youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -134,7 +134,7 @@ class YoutubeRecipeServiceTest extends Specification { .build() ] - youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(keyword, lastYoutubeRecipeId, 0, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(_ as String, lastYoutubeRecipeId, 0, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() @@ -175,7 +175,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "newest" - youtubeRecipeRepository.countByKeyword(keyword) >> 20 + youtubeRecipeRepository.countByKeyword(_ as String) >> 20 youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -206,7 +206,7 @@ class YoutubeRecipeServiceTest extends Specification { youtubeRecipeRepository.findById(lastYoutubeRecipeId) >> Optional.empty() - youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(keyword, lastYoutubeRecipeId, null, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(_ as String, lastYoutubeRecipeId, null, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() From 8f4f290361db860e2cc6e47e31add615c9b7a5e7 Mon Sep 17 00:00:00 2001 From: joona95 Date: Sun, 3 May 2026 10:26:12 +0900 Subject: [PATCH 11/20] =?UTF-8?q?refactor:=20=EC=99=B8=EB=B6=80=20API=20ci?= =?UTF-8?q?rcuit=20breaker=20fallback=20=EC=8B=9C=EA=B7=B8=EB=8B=88?= =?UTF-8?q?=EC=B2=98=20=EC=A0=95=ED=95=A9=20+=20IOException=20=EB=8D=B0?= =?UTF-8?q?=EB=93=9C=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 fallback 메서드 시그니처가 원본 메서드와 불일치(반환 타입/파라미터 개수) 하여 Resilience4j 가 fallback 으로 인식하지 못하고 원본 예외가 그대로 전파되던 버그 수정. 외부 API 실패 시 fallback 은 조용히 종료하고 service 의 후속 DB 조회로 자연스럽게 fallback 되도록 정렬. - BlogRecipeClientSearchService.fallback : List(keyword, size, e) -> void(keyword, Throwable) - YoutubeRecipeClientSearchService.fallback : 동일하게 void 시그니처로 정렬 - YoutubeRecipeClientSearchService.searchYoutube : throws IOException 제거, 내부에서 IllegalStateException 으로 wrap. circuit breaker 가 unchecked 도 동일하게 처리하므로 호출자 try/catch 불필요 - YoutubeRecipeService.findYoutubeRecipesByKeyword : 더 이상 컴파일 강제도 없고 런타임에도 도달 불가능한 try/catch(IOException) 제거 - GeneralExceptionHandler : 어떤 컨트롤러도 IOException 을 던지지 않게 되어 사용되지 않는 핸들러 제거 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../config/GeneralExceptionHandler.java | 8 --- .../blog/BlogRecipeClientSearchService.java | 6 +- .../YoutubeRecipeClientSearchService.java | 71 ++++++++++--------- .../youtube/YoutubeRecipeService.java | 9 +-- 4 files changed, 39 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java b/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java index f910f9e9..bd42f57a 100644 --- a/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java +++ b/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java @@ -18,8 +18,6 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; -import java.io.IOException; - @ControllerAdvice public class GeneralExceptionHandler { @@ -44,10 +42,4 @@ public ResponseEntity handleBadRequestException(Exception e) { log.error(e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e); } - - @ExceptionHandler(IOException.class) - public ResponseEntity handleIOException(IOException e) { - log.error(e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); - } } diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java index 77b76487..721ffa88 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java @@ -53,11 +53,9 @@ public void searchNaverBlogRecipes(String keyword) { blogRecipeThumbnailCrawlingService.saveThumbnails(newlyInserted); } - public List fallback(String keyword, int size, Exception e) { + public void fallback(String keyword, Throwable e) { - log.info("fallback call - " + e.getMessage()); - - return blogRecipeRepository.findByKeywordLimit(keyword, size); + log.warn("naver blog search fallback - keyword={}, cause={}", keyword, e.getMessage()); } private List createBlogRecipes(List blogRecipes) { diff --git a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeClientSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeClientSearchService.java index 50071e01..8075e1eb 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeClientSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeClientSearchService.java @@ -44,49 +44,50 @@ public YoutubeRecipeClientSearchService(YoutubeRecipeRepository youtubeRecipeRep } @CircuitBreaker(name = "recipe-youtube-search", fallbackMethod = "fallback") - public void searchYoutube(String keyword) throws IOException { + public void searchYoutube(String keyword) { log.info("youtube search api call"); - YouTube youtube = new YouTube.Builder(HTTP_TRANSPORT, JSON_FACTORY, new HttpRequestInitializer() { - public void initialize(HttpRequest request) throws IOException { + try { + YouTube youtube = new YouTube.Builder(HTTP_TRANSPORT, JSON_FACTORY, new HttpRequestInitializer() { + public void initialize(HttpRequest request) { + } + }).setApplicationName("youtube-cmdline-search-sample").build(); + + YouTube.Search.List search = youtube.search().list("id,snippet"); + + search.setKey(youtubeApiKey); + search.setQ(keyword + " 레시피"); + search.setType("video"); + search.setMaxResults(NUMBER_OF_VIDEOS_RETURNED); + search.setFields(YOUTUBE_SEARCH_FIELDS); + + SearchListResponse searchResponse = search.execute(); + List searchResultList = searchResponse.getItems(); + + List youtubeRecipes = new ArrayList<>(); + if (searchResultList != null) { + for (SearchResult rid : searchResultList) { + youtubeRecipes.add(YoutubeRecipe.builder() + .title(rid.getSnippet().getTitle()) + .description(rid.getSnippet().getDescription()) + .thumbnailImgUrl(rid.getSnippet().getThumbnails().getDefault().getUrl()) + .postDate(LocalDate.ofInstant(Instant.ofEpochMilli(rid.getSnippet().getPublishedAt().getValue()), ZoneId.systemDefault())) + .channelName(rid.getSnippet().getChannelTitle()) + .youtubeId(rid.getId().getVideoId()) + .build()); + } } - }).setApplicationName("youtube-cmdline-search-sample").build(); - - // Define the API request for retrieving search results. - YouTube.Search.List search = youtube.search().list("id,snippet"); - - search.setKey(youtubeApiKey); - search.setQ(keyword + " 레시피"); - search.setType("video"); - search.setMaxResults(NUMBER_OF_VIDEOS_RETURNED); - search.setFields(YOUTUBE_SEARCH_FIELDS); - - SearchListResponse searchResponse = search.execute(); - List searchResultList = searchResponse.getItems(); - - List youtubeRecipes = new ArrayList<>(); - if (searchResultList != null) { - for (SearchResult rid : searchResultList) { - youtubeRecipes.add(YoutubeRecipe.builder() - .title(rid.getSnippet().getTitle()) - .description(rid.getSnippet().getDescription()) - .thumbnailImgUrl(rid.getSnippet().getThumbnails().getDefault().getUrl()) - .postDate(LocalDate.ofInstant(Instant.ofEpochMilli(rid.getSnippet().getPublishedAt().getValue()), ZoneId.systemDefault())) - .channelName(rid.getSnippet().getChannelTitle()) - .youtubeId(rid.getId().getVideoId()) - .build()); - } - } - createYoutubeRecipes(youtubeRecipes); + createYoutubeRecipes(youtubeRecipes); + } catch (IOException e) { + throw new IllegalStateException("youtube search api failed", e); + } } - public List fallback(String keyword, int size, Exception e) { - - log.info("fallback call - " + e.getMessage()); + public void fallback(String keyword, Throwable e) { - return youtubeRecipeRepository.findByKeywordLimit(keyword, size); + log.warn("youtube search fallback - keyword={}, cause={}", keyword, e.getMessage()); } private void createYoutubeRecipes(List youtubeRecipes) { diff --git a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java index 2fb56eb7..4989cc4a 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java @@ -8,14 +8,11 @@ import com.recipe.app.src.recipe.domain.youtube.YoutubeScrap; import com.recipe.app.src.recipe.infra.youtube.YoutubeRecipeRepository; import com.recipe.app.src.user.domain.User; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.IOException; import java.util.List; -@Slf4j @Service public class YoutubeRecipeService { @@ -48,11 +45,7 @@ public RecipesResponse findYoutubeRecipesByKeyword(User user, String keyword, lo long totalCnt = youtubeRecipeRepository.countByKeyword(booleanQuery); if (totalCnt < MIN_RECIPE_CNT) { - try { - youtubeRecipeClientSearchService.searchYoutube(keyword); - } catch (IOException e) { - log.warn("youtube search api call failed - {}", e.getMessage()); - } + youtubeRecipeClientSearchService.searchYoutube(keyword); } List youtubeRecipes = findByKeywordOrderBy(booleanQuery, lastYoutubeRecipeId, size, sort); From 8fc8e6b45ae47a040b650e46adb37a95e94a28bf Mon Sep 17 00:00:00 2001 From: joona95 Date: Sun, 3 May 2026 11:30:44 +0900 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20RecipeIngredient=20=EC=9D=98=20sea?= =?UTF-8?q?rchTokens=20=EB=8A=94=20nori=20=EB=8C=80=EC=8B=A0=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=20=EC=A0=95=EA=B7=9C=ED=99=94=EB=A1=9C=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nori KoreanAnalyzer 의 기본 stopword 정책이 한국어 부사/어미/조사를 제거하는데, 요리 재료 도메인에서 "갓", "다시다", "우묵" 같은 단일 명사 재료명을 nori 가 부사/어미로 잘못 분류해 stopword 로 제거 -> searchTokens 가 빈 문자열로 채워져 재료 검색에서 누락되던 문제. 재료명은 짧은 단일 명사가 대부분이라 형태소 분석 효용이 낮으므로 lowercase + trim 정규화로 충분. RecipeIngredient.@PrePersist/@PreUpdate 와 백필 러너 모두 동일 처리하도록 수정. - RecipeIngredient.refreshSearchTokens: KoreanTokenizer.tokenize -> lowercase + trim - SearchTokensBackfillRunner: 테이블별 토크나이저 분기 (RecipeIngredient 만 단순 정규화, 나머지는 nori 그대로) 운영 DB 는 백필 러너 1회 재실행 필요 (recipe.migration.search-tokens.enabled=true). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SearchTokensBackfillRunner.java | 21 ++++++++++++------- .../src/recipe/domain/RecipeIngredient.java | 5 +++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java b/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java index f03f9bec..5f4df9ea 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java +++ b/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java @@ -12,6 +12,7 @@ import org.springframework.transaction.support.TransactionTemplate; import java.util.List; +import java.util.function.Function; @Slf4j @Component @@ -19,6 +20,8 @@ public class SearchTokensBackfillRunner implements ApplicationRunner { private static final int BATCH_SIZE = 500; + private static final Function NORI_TOKENIZE = KoreanTokenizer::tokenize; + private static final Function SIMPLE_NORMALIZE = text -> text == null ? "" : text.toLowerCase().trim(); @PersistenceContext private EntityManager em; @@ -33,21 +36,23 @@ public SearchTokensBackfillRunner(PlatformTransactionManager transactionManager) public void run(ApplicationArguments args) { log.info("SearchTokens backfill started"); - backfill("Recipe", "recipeId", new String[]{"recipeNm", "introduction"}); - backfill("RecipeIngredient", "recipeIngredientId", new String[]{"ingredientName"}); - backfill("BlogRecipe", "blogRecipeId", new String[]{"title", "description"}); - backfill("YoutubeRecipe", "youtubeRecipeId", new String[]{"title", "description"}); + backfill("Recipe", "recipeId", new String[]{"recipeNm", "introduction"}, NORI_TOKENIZE); + // RecipeIngredient 는 단일 명사 위주라 nori stopword 정책이 도메인 단어를 제거해버림 (예: "갓","다시다"). + // 단순 정규화로 처리. + backfill("RecipeIngredient", "recipeIngredientId", new String[]{"ingredientName"}, SIMPLE_NORMALIZE); + backfill("BlogRecipe", "blogRecipeId", new String[]{"title", "description"}, NORI_TOKENIZE); + backfill("YoutubeRecipe", "youtubeRecipeId", new String[]{"title", "description"}, NORI_TOKENIZE); log.info("SearchTokens backfill done"); } - private void backfill(String table, String pk, String[] sourceColumns) { + private void backfill(String table, String pk, String[] sourceColumns, Function tokenizer) { log.info("[{}] backfill started", table); long lastId = 0L; long total = 0L; while (true) { long startId = lastId; - BatchResult r = transactionTemplate.execute(status -> processBatch(table, pk, sourceColumns, startId)); + BatchResult r = transactionTemplate.execute(status -> processBatch(table, pk, sourceColumns, startId, tokenizer)); if (r == null || r.processed == 0) break; lastId = r.lastId; total += r.updated; @@ -56,7 +61,7 @@ private void backfill(String table, String pk, String[] sourceColumns) { log.info("[{}] backfill done. updated={}", table, total); } - private BatchResult processBatch(String table, String pk, String[] sourceColumns, long startId) { + private BatchResult processBatch(String table, String pk, String[] sourceColumns, long startId, Function tokenizer) { String columnList = String.join(", ", sourceColumns); String selectSql = "SELECT " + pk + ", " + columnList + @@ -85,7 +90,7 @@ private BatchResult processBatch(String table, String pk, String[] sourceColumns if (text.length() > 0) text.append(' '); text.append(s); } - String tokens = KoreanTokenizer.tokenize(text.toString()); + String tokens = tokenizer.apply(text.toString()); em.createNativeQuery(updateSql) .setParameter("tokens", tokens) .setParameter("id", id) diff --git a/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java b/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java index 6dd06c5f..7bc03ac9 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java @@ -2,7 +2,6 @@ import com.google.common.base.Preconditions; import com.recipe.app.src.common.entity.BaseEntity; -import com.recipe.app.src.common.utils.KoreanTokenizer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -79,6 +78,8 @@ public boolean hasInFridge(List ingredientNames) { @PrePersist @PreUpdate private void refreshSearchTokens() { - this.searchTokens = KoreanTokenizer.tokenize(ingredientName); + // 재료명은 짧고 단일 명사가 대부분이라 nori stopword 정책이 오히려 + // 도메인 단어("갓", "다시다" 등)를 제거해버린다. 단순 정규화로 대체. + this.searchTokens = ingredientName == null ? "" : ingredientName.toLowerCase().trim(); } } From 51680817adf278f289fc5b250d37a89f15ad11c3 Mon Sep 17 00:00:00 2001 From: joona95 Date: Sun, 3 May 2026 14:14:36 +0900 Subject: [PATCH 13/20] =?UTF-8?q?feat:=201=EA=B8=80=EC=9E=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=EB=8A=94=20=EC=A0=95=ED=99=95=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B2=80=EC=83=89=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 1글자 입력을 빈 결과로 차단했으나, "갓","닭","감" 같은 단일 명사 재료/음식 검색이 의미 있어 정확 매칭(단어 경계) 으로 허용한다. FULLTEXT BOOLEAN 모드는 innodb_ft_min_token_size 제약 때문에 1글자 토큰을 잡지 못하므로 LIKE 기반 단어 경계 매칭으로 분기. - SearchKeywordNormalizer: toBooleanModeQuery -> normalize 로 교체. 결과 타입을 sealed SearchQuery (Empty / ExactToken / BooleanQuery) 로 명시. 1글자는 ExactToken, 2글자 이상은 nori 토큰화 후 BooleanQuery - QueryUtils.exactTokenMatch: concat(' ', col, ' ') like '% token %' 로 단어 경계 정확 매칭. 1글자 검색에만 사용 (풀스캔 비용 감수) - QueryUtils.matchSearchQuery: SearchQuery 타입에 맞춰 matchAgainst 또는 exactTokenMatch 로 분기하는 통합 헬퍼 - 3개 RepositoryImpl + 3개 CustomRepository: keyword(String) -> query(SearchQuery) 로 시그니처 변경. 내부에서 matchSearchQuery 헬퍼 호출 - 3개 Service: SearchKeywordNormalizer.normalize 호출 후 Empty 면 빈 결과 즉시 반환, 외 SearchQuery 그대로 Repository 전달 - 테스트: SearchKeywordNormalizerTest 새 API 로 갱신, 서비스 mock stub 의 _ as String -> _ (SearchQuery 매처). RecipeSearchServiceTest 의 1글자 차단 케이스는 빈/공백 입력 차단 케이스로 대체 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/src/common/utils/QueryUtils.java | 24 +++++++++ .../common/utils/SearchKeywordNormalizer.java | 33 +++++++----- .../application/RecipeSearchService.java | 25 ++++----- .../application/blog/BlogRecipeService.java | 31 +++++------ .../youtube/YoutubeRecipeService.java | 31 +++++------ .../recipe/infra/RecipeCustomRepository.java | 9 ++-- .../recipe/infra/RecipeRepositoryImpl.java | 27 +++++----- .../blog/BlogRecipeCustomRepository.java | 9 ++-- .../infra/blog/BlogRecipeRepositoryImpl.java | 19 +++---- .../YoutubeRecipeCustomRepository.java | 9 ++-- .../youtube/YoutubeRecipeRepositoryImpl.java | 18 ++++--- .../utils/SearchKeywordNormalizerTest.groovy | 54 ++++++++++++++----- .../RecipeSearchServiceTest.groovy | 19 ++++--- .../blog/BlogRecipeServiceTest.groovy | 12 ++--- .../youtube/YoutubeRecipeServiceTest.groovy | 12 ++--- 15 files changed, 203 insertions(+), 129 deletions(-) diff --git a/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java b/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java index 90f68dd8..29598047 100644 --- a/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java +++ b/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java @@ -24,4 +24,28 @@ public static BooleanExpression matchAgainst(StringPath column, String booleanQu return Expressions.numberTemplate(Double.class, "function('match_against', {0}, {1})", column, booleanQuery).gt(0); } + + /** + * 1글자 토큰 정확 매칭. FULLTEXT 인덱스의 ngram_token_size 제약 때문에 1글자는 BOOLEAN 모드로 잡지 못해 + * 공백 구분 단어 경계 LIKE 로 대체. 풀스캔이라 비용 있지만 1글자 검색 빈도 자체가 낮다고 가정. + * 매칭 예: searchTokens="갓" 또는 "오 갓 김치" -> "갓" 매치, "갓김치"는 매치 안 됨. + */ + public static BooleanExpression exactTokenMatch(StringPath column, String token) { + return Expressions.booleanTemplate( + "concat(' ', {0}, ' ') like concat('% ', {1}, ' %')", + column, token); + } + + /** + * SearchQuery 타입에 맞춰 적절한 매칭 식을 반환. Empty 는 호출 직전 서비스에서 걸러져야 정상. + */ + public static BooleanExpression matchSearchQuery(StringPath column, SearchKeywordNormalizer.SearchQuery query) { + if (query instanceof SearchKeywordNormalizer.SearchQuery.BooleanQuery b) { + return matchAgainst(column, b.query()); + } + if (query instanceof SearchKeywordNormalizer.SearchQuery.ExactToken e) { + return exactTokenMatch(column, e.token()); + } + return Expressions.asBoolean(false).isTrue(); + } } diff --git a/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java b/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java index a11cf3c1..9300aae1 100644 --- a/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java +++ b/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java @@ -5,38 +5,47 @@ public final class SearchKeywordNormalizer { - private static final int MIN_KEYWORD_LENGTH = 2; - private SearchKeywordNormalizer() { } /** - * 사용자 입력을 BOOLEAN MODE FULLTEXT 검색식으로 변환한다. + * 사용자 입력을 검색 의도에 맞는 쿼리 형태로 변환한다. *
    - *
  • 입력이 너무 짧거나 토큰이 비어있으면 null 반환 (호출자는 검색 자체를 스킵)
  • - *
  • nori 토큰화 결과를 +token 형태로 묶어 AND 매칭한다
  • + *
  • {@link SearchQuery.Empty}: 입력이 비었거나 토큰화 결과가 빈 경우. 호출자는 검색 자체를 스킵
  • + *
  • {@link SearchQuery.ExactToken}: 1글자 입력. FULLTEXT BOOLEAN 모드는 토큰 최소 길이 제약 때문에 + * 사용 불가하므로 단어 경계 정확 매칭으로 처리 (예: "갓","닭","감")
  • + *
  • {@link SearchQuery.BooleanQuery}: 2글자 이상. nori 토큰화 후 +token AND BOOLEAN 모드
  • *
*/ - public static String toBooleanModeQuery(String rawKeyword) { + public static SearchQuery normalize(String rawKeyword) { - if (rawKeyword == null) return null; + if (rawKeyword == null) return new SearchQuery.Empty(); String stripped = rawKeyword .replaceAll("[+\\-><()~*\"@]", " ") .trim() - .replaceAll("\\s+", " "); + .replaceAll("\\s+", " ") + .toLowerCase(); + + if (stripped.isEmpty()) return new SearchQuery.Empty(); - if (stripped.length() < MIN_KEYWORD_LENGTH) return null; + if (stripped.length() == 1) return new SearchQuery.ExactToken(stripped); String tokenized = KoreanTokenizer.tokenize(stripped); - if (tokenized.isBlank()) return null; + if (tokenized.isBlank()) return new SearchQuery.Empty(); String[] tokens = tokenized.split(" "); - String query = Arrays.stream(tokens) + String boolQuery = Arrays.stream(tokens) .filter(t -> !t.isBlank()) .map(t -> "+" + t) .collect(Collectors.joining(" ")); - return query.isBlank() ? null : query; + return boolQuery.isBlank() ? new SearchQuery.Empty() : new SearchQuery.BooleanQuery(boolQuery); + } + + public sealed interface SearchQuery { + record Empty() implements SearchQuery {} + record ExactToken(String token) implements SearchQuery {} + record BooleanQuery(String query) implements SearchQuery {} } } diff --git a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java index cd05501e..2ece6ab5 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java @@ -2,6 +2,7 @@ import com.recipe.app.src.common.utils.BadWordFiltering; import com.recipe.app.src.common.utils.SearchKeywordNormalizer; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.fridge.application.FridgeService; import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse; import com.recipe.app.src.recipe.application.dto.RecipesResponse; @@ -43,44 +44,44 @@ public RecipesResponse findRecipesByKeywordOrderBy(User user, String keyword, lo badWordFiltering.check(keyword); - String booleanQuery = SearchKeywordNormalizer.toBooleanModeQuery(keyword); - if (booleanQuery == null) { + SearchQuery query = SearchKeywordNormalizer.normalize(keyword); + if (query instanceof SearchQuery.Empty) { return getRecipes(user, 0L, new Recipes(List.of())); } - long totalCnt = recipeRepository.countByKeyword(booleanQuery); + long totalCnt = recipeRepository.countByKeyword(query); List recipes; if (sort.equals("scraps")) { - recipes = findByKeywordOrderByRecipeScrapCnt(booleanQuery, lastRecipeId, size); + recipes = findByKeywordOrderByRecipeScrapCnt(query, lastRecipeId, size); } else if (sort.equals("views")) { - recipes = findByKeywordOrderByRecipeViewCnt(booleanQuery, lastRecipeId, size); + recipes = findByKeywordOrderByRecipeViewCnt(query, lastRecipeId, size); } else { - recipes = findByKeywordOrderByCreatedAt(booleanQuery, lastRecipeId, size); + recipes = findByKeywordOrderByCreatedAt(query, lastRecipeId, size); } return getRecipes(user, totalCnt, new Recipes(recipes)); } - private List findByKeywordOrderByRecipeScrapCnt(String keyword, long lastRecipeId, int size) { + private List findByKeywordOrderByRecipeScrapCnt(SearchQuery query, long lastRecipeId, int size) { long recipeScrapCnt = recipeScrapService.countByRecipeId(lastRecipeId); - return recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(keyword, lastRecipeId, recipeScrapCnt, size); + return recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(query, lastRecipeId, recipeScrapCnt, size); } - private List findByKeywordOrderByRecipeViewCnt(String keyword, long lastRecipeId, int size) { + private List findByKeywordOrderByRecipeViewCnt(SearchQuery query, long lastRecipeId, int size) { long recipeViewCnt = recipeViewService.countByRecipeId(lastRecipeId); - return recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(keyword, lastRecipeId, recipeViewCnt, size); + return recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(query, lastRecipeId, recipeViewCnt, size); } - private List findByKeywordOrderByCreatedAt(String keyword, long lastRecipeId, int size) { + private List findByKeywordOrderByCreatedAt(SearchQuery query, long lastRecipeId, int size) { Recipe recipe = recipeRepository.findById(lastRecipeId).orElse(null); - return recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(keyword, lastRecipeId, recipe != null ? recipe.getCreatedAt() : null, size); + return recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(query, lastRecipeId, recipe != null ? recipe.getCreatedAt() : null, size); } @Transactional(readOnly = true) diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java index 1eb345d7..4789375f 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java @@ -2,6 +2,7 @@ import com.recipe.app.src.common.utils.BadWordFiltering; import com.recipe.app.src.common.utils.SearchKeywordNormalizer; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.application.dto.RecipesResponse; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; import com.recipe.app.src.recipe.domain.blog.BlogRecipes; @@ -39,53 +40,53 @@ public RecipesResponse findBlogRecipesByKeyword(User user, String keyword, long badWordFiltering.check(keyword); - String booleanQuery = SearchKeywordNormalizer.toBooleanModeQuery(keyword); - if (booleanQuery == null) { + SearchQuery query = SearchKeywordNormalizer.normalize(keyword); + if (query instanceof SearchQuery.Empty) { return getRecipes(user, 0L, new BlogRecipes(List.of())); } - long totalCnt = blogRecipeRepository.countByKeyword(booleanQuery); + long totalCnt = blogRecipeRepository.countByKeyword(query); if (totalCnt < MIN_RECIPE_CNT) { blogRecipeClientSearchService.searchNaverBlogRecipes(keyword); } - List blogRecipes = findByKeywordOrderBy(booleanQuery, lastBlogRecipeId, size, sort); - totalCnt = blogRecipeRepository.countByKeyword(booleanQuery); + List blogRecipes = findByKeywordOrderBy(query, lastBlogRecipeId, size, sort); + totalCnt = blogRecipeRepository.countByKeyword(query); return getRecipes(user, totalCnt, new BlogRecipes(blogRecipes)); } - private List findByKeywordOrderBy(String keyword, long lastBlogRecipeId, int size, String sort) { + private List findByKeywordOrderBy(SearchQuery query, long lastBlogRecipeId, int size, String sort) { if (sort.equals("scraps")) { - return findByKeywordOrderByBlogScrapCnt(keyword, lastBlogRecipeId, size); + return findByKeywordOrderByBlogScrapCnt(query, lastBlogRecipeId, size); } else if (sort.equals("views")) { - return findByKeywordOrderByBlogViewCnt(keyword, lastBlogRecipeId, size); + return findByKeywordOrderByBlogViewCnt(query, lastBlogRecipeId, size); } else { - return findByKeywordOrderByPublishedAt(keyword, lastBlogRecipeId, size); + return findByKeywordOrderByPublishedAt(query, lastBlogRecipeId, size); } } - private List findByKeywordOrderByBlogScrapCnt(String keyword, long lastBlogRecipeId, int size) { + private List findByKeywordOrderByBlogScrapCnt(SearchQuery query, long lastBlogRecipeId, int size) { long lastBlogScrapCnt = blogScrapService.countByBlogRecipeId(lastBlogRecipeId); - return blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(keyword, lastBlogRecipeId, lastBlogScrapCnt, size); + return blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(query, lastBlogRecipeId, lastBlogScrapCnt, size); } - private List findByKeywordOrderByBlogViewCnt(String keyword, long lastBlogRecipeId, int size) { + private List findByKeywordOrderByBlogViewCnt(SearchQuery query, long lastBlogRecipeId, int size) { long lastBlogViewCnt = blogViewService.countByBlogRecipeId(lastBlogRecipeId); - return blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(keyword, lastBlogRecipeId, lastBlogViewCnt, size); + return blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(query, lastBlogRecipeId, lastBlogViewCnt, size); } - private List findByKeywordOrderByPublishedAt(String keyword, long lastBlogRecipeId, int size) { + private List findByKeywordOrderByPublishedAt(SearchQuery query, long lastBlogRecipeId, int size) { BlogRecipe blogRecipe = blogRecipeRepository.findById(lastBlogRecipeId).orElse(null); - return blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(keyword, lastBlogRecipeId, blogRecipe == null ? null : blogRecipe.getPublishedAt(), size); + return blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(query, lastBlogRecipeId, blogRecipe == null ? null : blogRecipe.getPublishedAt(), size); } @Transactional(readOnly = true) diff --git a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java index 4989cc4a..80fc7ddf 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java @@ -2,6 +2,7 @@ import com.recipe.app.src.common.utils.BadWordFiltering; import com.recipe.app.src.common.utils.SearchKeywordNormalizer; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.application.dto.RecipesResponse; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipes; @@ -37,53 +38,53 @@ public RecipesResponse findYoutubeRecipesByKeyword(User user, String keyword, lo badWordFiltering.check(keyword); - String booleanQuery = SearchKeywordNormalizer.toBooleanModeQuery(keyword); - if (booleanQuery == null) { + SearchQuery query = SearchKeywordNormalizer.normalize(keyword); + if (query instanceof SearchQuery.Empty) { return getRecipes(user, 0L, new YoutubeRecipes(List.of())); } - long totalCnt = youtubeRecipeRepository.countByKeyword(booleanQuery); + long totalCnt = youtubeRecipeRepository.countByKeyword(query); if (totalCnt < MIN_RECIPE_CNT) { youtubeRecipeClientSearchService.searchYoutube(keyword); } - List youtubeRecipes = findByKeywordOrderBy(booleanQuery, lastYoutubeRecipeId, size, sort); - totalCnt = youtubeRecipeRepository.countByKeyword(booleanQuery); + List youtubeRecipes = findByKeywordOrderBy(query, lastYoutubeRecipeId, size, sort); + totalCnt = youtubeRecipeRepository.countByKeyword(query); return getRecipes(user, totalCnt, new YoutubeRecipes(youtubeRecipes)); } - private List findByKeywordOrderBy(String keyword, long lastYoutubeRecipeId, int size, String sort) { + private List findByKeywordOrderBy(SearchQuery query, long lastYoutubeRecipeId, int size, String sort) { if (sort.equals("scraps")) { - return findByKeywordOrderByYoutubeScrapCnt(keyword, lastYoutubeRecipeId, size); + return findByKeywordOrderByYoutubeScrapCnt(query, lastYoutubeRecipeId, size); } else if (sort.equals("views")) { - return findByKeywordOrderByYoutubeViewCnt(keyword, lastYoutubeRecipeId, size); + return findByKeywordOrderByYoutubeViewCnt(query, lastYoutubeRecipeId, size); } else { - return findByKeywordOrderByPostDate(keyword, lastYoutubeRecipeId, size); + return findByKeywordOrderByPostDate(query, lastYoutubeRecipeId, size); } } - private List findByKeywordOrderByYoutubeScrapCnt(String keyword, long lastYoutubeRecipeId, int size) { + private List findByKeywordOrderByYoutubeScrapCnt(SearchQuery query, long lastYoutubeRecipeId, int size) { long youtubeScrapCnt = youtubeScrapService.countByYoutubeRecipeId(lastYoutubeRecipeId); - return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(keyword, lastYoutubeRecipeId, youtubeScrapCnt, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(query, lastYoutubeRecipeId, youtubeScrapCnt, size); } - private List findByKeywordOrderByYoutubeViewCnt(String keyword, long lastYoutubeRecipeId, int size) { + private List findByKeywordOrderByYoutubeViewCnt(SearchQuery query, long lastYoutubeRecipeId, int size) { long youtubeViewCnt = youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId); - return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(keyword, lastYoutubeRecipeId, youtubeViewCnt, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(query, lastYoutubeRecipeId, youtubeViewCnt, size); } - private List findByKeywordOrderByPostDate(String keyword, long lastYoutubeRecipeId, int size) { + private List findByKeywordOrderByPostDate(SearchQuery query, long lastYoutubeRecipeId, int size) { YoutubeRecipe youtubeRecipe = youtubeRecipeRepository.findById(lastYoutubeRecipeId).orElse(null); - return youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(keyword, lastYoutubeRecipeId, youtubeRecipe != null ? youtubeRecipe.getPostDate() : null, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(query, lastYoutubeRecipeId, youtubeRecipe != null ? youtubeRecipe.getPostDate() : null, size); } @Transactional(readOnly = true) diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java index 91872a16..83b3c843 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.Recipe; import java.time.LocalDateTime; @@ -11,13 +12,13 @@ public interface RecipeCustomRepository { Optional findRecipeDetail(Long recipeId, Long userId); - Long countByKeyword(String keyword); + Long countByKeyword(SearchQuery query); - List findByKeywordLimitOrderByCreatedAtDesc(String keyword, Long lastRecipeId, LocalDateTime createdAt, int size); + List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, LocalDateTime createdAt, int size); - List findByKeywordLimitOrderByRecipeScrapCntDesc(String keyword, Long lastRecipeId, long recipeScrapCnt, int size); + List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, long recipeScrapCnt, int size); - List findByKeywordLimitOrderByRecipeViewCntDesc(String keyword, Long lastRecipeId, long recipeViewCnt, int size); + List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, long recipeViewCnt, int size); List findUserScrapRecipesLimit(Long userId, Long lastRecipeId, LocalDateTime scrapCreatedAt, int size); diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java index 96bdb8c8..24672107 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java @@ -10,8 +10,11 @@ import java.util.List; import java.util.Optional; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; +import com.querydsl.core.types.dsl.BooleanExpression; + import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; -import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; +import static com.recipe.app.src.common.utils.QueryUtils.matchSearchQuery; import static com.recipe.app.src.recipe.domain.QRecipe.recipe; import static com.recipe.app.src.recipe.domain.QRecipeIngredient.recipeIngredient; import static com.recipe.app.src.recipe.domain.QRecipeScrap.recipeScrap; @@ -34,25 +37,25 @@ public Optional findRecipeDetail(Long recipeId, Long userId) { } @Override - public Long countByKeyword(String keyword) { + public Long countByKeyword(SearchQuery query) { return queryFactory .select(recipe.recipeId.countDistinct()) .from(recipe) .where( recipe.hiddenYn.eq("N"), - keywordMatch(keyword) + keywordMatch(query) ) .fetchOne(); } @Override - public List findByKeywordLimitOrderByCreatedAtDesc(String keyword, Long lastRecipeId, LocalDateTime lastCreatedAt, int size) { + public List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, LocalDateTime lastCreatedAt, int size) { return queryFactory .selectFrom(recipe) .where( recipe.hiddenYn.eq("N"), - keywordMatch(keyword), + keywordMatch(query), ifIdIsNotNullAndGreaterThanZero((recipeId, createdAt) -> recipe.createdAt.lt(createdAt) .or(recipe.createdAt.eq(createdAt) .and(recipe.recipeId.lt(recipeId))), @@ -64,13 +67,13 @@ public List findByKeywordLimitOrderByCreatedAtDesc(String keyword, Long } @Override - public List findByKeywordLimitOrderByRecipeScrapCntDesc(String keyword, Long lastRecipeId, long lastRecipeScrapCnt, int size) { + public List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, long lastRecipeScrapCnt, int size) { return queryFactory .selectFrom(recipe) .where( recipe.hiddenYn.eq("N"), - keywordMatch(keyword), + keywordMatch(query), ifIdIsNotNullAndGreaterThanZero((recipeId, recipeScrapCnt) -> recipe.scrapCnt.lt(recipeScrapCnt) .or(recipe.scrapCnt.eq(recipeScrapCnt) .and(recipe.recipeId.lt(recipeId))), @@ -82,13 +85,13 @@ public List findByKeywordLimitOrderByRecipeScrapCntDesc(String keyword, } @Override - public List findByKeywordLimitOrderByRecipeViewCntDesc(String keyword, Long lastRecipeId, long lastRecipeViewCnt, int size) { + public List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, long lastRecipeViewCnt, int size) { return queryFactory .selectFrom(recipe) .where( recipe.hiddenYn.eq("N"), - keywordMatch(keyword), + keywordMatch(query), ifIdIsNotNullAndGreaterThanZero((recipeId, recipeViewCnt) -> recipe.viewCnt.lt(recipeViewCnt) .or(recipe.viewCnt.eq(recipeViewCnt) .and(recipe.recipeId.lt(recipeId))), @@ -99,12 +102,12 @@ public List findByKeywordLimitOrderByRecipeViewCntDesc(String keyword, L .fetch(); } - private com.querydsl.core.types.dsl.BooleanExpression keywordMatch(String keyword) { - return matchAgainst(recipe.searchTokens, keyword) + private BooleanExpression keywordMatch(SearchQuery query) { + return matchSearchQuery(recipe.searchTokens, query) .or(JPAExpressions.selectOne() .from(recipeIngredient) .where(recipeIngredient.recipe.recipeId.eq(recipe.recipeId) - .and(matchAgainst(recipeIngredient.searchTokens, keyword))) + .and(matchSearchQuery(recipeIngredient.searchTokens, query))) .exists()); } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepository.java index 5bbc7cd0..64214172 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepository.java @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra.blog; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; import java.time.LocalDate; @@ -8,15 +9,15 @@ public interface BlogRecipeCustomRepository { - Long countByKeyword(String keyword); + Long countByKeyword(SearchQuery query); List findByKeywordLimit(String keyword, int size); - List findByKeywordLimitOrderByPublishedAtDesc(String keyword, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size); + List findByKeywordLimitOrderByPublishedAtDesc(SearchQuery query, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size); - List findByKeywordLimitOrderByBlogScrapCntDesc(String keyword, Long lastBlogRecipeId, long lastBlogScrapCnt, int size); + List findByKeywordLimitOrderByBlogScrapCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogScrapCnt, int size); - List findByKeywordLimitOrderByBlogViewCntDesc(String keyword, Long lastBlogRecipeId, long lastBlogViewCnt, int size); + List findByKeywordLimitOrderByBlogViewCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogViewCnt, int size); List findUserScrapBlogRecipesLimit(Long userId, Long lastBlogRecipeId, LocalDateTime scrapCreatedAt, int size); } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java index db716798..02fb2637 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java @@ -1,7 +1,7 @@ package com.recipe.app.src.recipe.infra.blog; import com.recipe.app.src.common.infra.BaseRepositoryImpl; -import com.recipe.app.src.common.utils.QueryUtils; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; import jakarta.persistence.EntityManager; @@ -11,6 +11,7 @@ import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; +import static com.recipe.app.src.common.utils.QueryUtils.matchSearchQuery; import static com.recipe.app.src.recipe.domain.blog.QBlogRecipe.blogRecipe; import static com.recipe.app.src.recipe.domain.blog.QBlogScrap.blogScrap; @@ -21,13 +22,13 @@ public BlogRecipeRepositoryImpl(EntityManager em) { } @Override - public Long countByKeyword(String keyword) { + public Long countByKeyword(SearchQuery query) { return queryFactory .select(blogRecipe.count()) .from(blogRecipe) .where( - matchAgainst(blogRecipe.searchTokens, keyword) + matchSearchQuery(blogRecipe.searchTokens, query) ) .fetchOne(); } @@ -45,12 +46,12 @@ public List findByKeywordLimit(String keyword, int size) { } @Override - public List findByKeywordLimitOrderByPublishedAtDesc(String keyword, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size) { + public List findByKeywordLimitOrderByPublishedAtDesc(SearchQuery query, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size) { return queryFactory .selectFrom(blogRecipe) .where( - matchAgainst(blogRecipe.searchTokens, keyword), + matchSearchQuery(blogRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, publishedAt) -> blogRecipe.publishedAt.lt(publishedAt) .or(blogRecipe.publishedAt.eq(publishedAt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), @@ -62,12 +63,12 @@ public List findByKeywordLimitOrderByPublishedAtDesc(String keyword, } @Override - public List findByKeywordLimitOrderByBlogScrapCntDesc(String keyword, Long lastBlogRecipeId, long lastBlogScrapCnt, int size) { + public List findByKeywordLimitOrderByBlogScrapCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogScrapCnt, int size) { return queryFactory .selectFrom(blogRecipe) .where( - matchAgainst(blogRecipe.searchTokens, keyword), + matchSearchQuery(blogRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, blogScrapCnt) -> blogRecipe.scrapCnt.lt(blogScrapCnt) .or(blogRecipe.scrapCnt.eq(blogScrapCnt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), @@ -79,12 +80,12 @@ public List findByKeywordLimitOrderByBlogScrapCntDesc(String keyword } @Override - public List findByKeywordLimitOrderByBlogViewCntDesc(String keyword, Long lastBlogRecipeId, long lastBlogViewCnt, int size) { + public List findByKeywordLimitOrderByBlogViewCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogViewCnt, int size) { return queryFactory .selectFrom(blogRecipe) .where( - matchAgainst(blogRecipe.searchTokens, keyword), + matchSearchQuery(blogRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, blogViewCnt) -> blogRecipe.viewCnt.lt(blogViewCnt) .or(blogRecipe.viewCnt.eq(blogViewCnt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), diff --git a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepository.java index dedfb6b2..2797e41d 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepository.java @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra.youtube; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe; import java.time.LocalDate; @@ -8,15 +9,15 @@ public interface YoutubeRecipeCustomRepository { - Long countByKeyword(String keyword); + Long countByKeyword(SearchQuery query); List findByKeywordLimit(String keyword, int size); - List findByKeywordLimitOrderByPostDateDesc(String keyword, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size); + List findByKeywordLimitOrderByPostDateDesc(SearchQuery query, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size); - List findByKeywordLimitOrderByYoutubeScrapCntDesc(String keyword, Long lastYoutubeRecipeId, long youtubeScrapCnt, int size); + List findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long youtubeScrapCnt, int size); - List findByKeywordLimitOrderByYoutubeViewCntDesc(String keyword, Long lastYoutubeRecipeId, long youtubeViewCnt, int size); + List findByKeywordLimitOrderByYoutubeViewCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long youtubeViewCnt, int size); List findUserScrapYoutubeRecipesLimit(Long userId, Long lastYoutubeRecipeId, LocalDateTime scrapCreatedAt, int size); } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java index 3c30ebd2..1dee14c4 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java @@ -1,6 +1,7 @@ package com.recipe.app.src.recipe.infra.youtube; import com.recipe.app.src.common.infra.BaseRepositoryImpl; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe; import jakarta.persistence.EntityManager; @@ -10,6 +11,7 @@ import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; +import static com.recipe.app.src.common.utils.QueryUtils.matchSearchQuery; import static com.recipe.app.src.recipe.domain.youtube.QYoutubeRecipe.youtubeRecipe; import static com.recipe.app.src.recipe.domain.youtube.QYoutubeScrap.youtubeScrap; @@ -20,13 +22,13 @@ public YoutubeRecipeRepositoryImpl(EntityManager em) { } @Override - public Long countByKeyword(String keyword) { + public Long countByKeyword(SearchQuery query) { return queryFactory .select(youtubeRecipe.count()) .from(youtubeRecipe) .where( - matchAgainst(youtubeRecipe.searchTokens, keyword) + matchSearchQuery(youtubeRecipe.searchTokens, query) ) .fetchOne(); } @@ -44,12 +46,12 @@ public List findByKeywordLimit(String keyword, int size) { } @Override - public List findByKeywordLimitOrderByPostDateDesc(String keyword, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size) { + public List findByKeywordLimitOrderByPostDateDesc(SearchQuery query, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( - matchAgainst(youtubeRecipe.searchTokens, keyword), + matchSearchQuery(youtubeRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, postDate) -> youtubeRecipe.postDate.lt(postDate) .or(youtubeRecipe.postDate.eq(postDate) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), @@ -61,12 +63,12 @@ public List findByKeywordLimitOrderByPostDateDesc(String keyword, } @Override - public List findByKeywordLimitOrderByYoutubeScrapCntDesc(String keyword, Long lastYoutubeRecipeId, long lastYoutubeScrapCnt, int size) { + public List findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long lastYoutubeScrapCnt, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( - matchAgainst(youtubeRecipe.searchTokens, keyword), + matchSearchQuery(youtubeRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, youtubeScrapCnt) -> youtubeRecipe.scrapCnt.lt(youtubeScrapCnt) .or(youtubeRecipe.scrapCnt.eq(youtubeScrapCnt) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), @@ -78,12 +80,12 @@ public List findByKeywordLimitOrderByYoutubeScrapCntDesc(String k } @Override - public List findByKeywordLimitOrderByYoutubeViewCntDesc(String keyword, Long lastYoutubeRecipeId, long lastYoutubeViewCnt, int size) { + public List findByKeywordLimitOrderByYoutubeViewCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long lastYoutubeViewCnt, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( - matchAgainst(youtubeRecipe.searchTokens, keyword), + matchSearchQuery(youtubeRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, youtubeViewCnt) -> youtubeRecipe.viewCnt.lt(youtubeViewCnt) .or(youtubeRecipe.viewCnt.eq(youtubeViewCnt) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), diff --git a/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy b/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy index 729941dd..f1b3dbea 100644 --- a/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy +++ b/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy @@ -1,22 +1,44 @@ package com.recipe.app.src.common.utils +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery import spock.lang.Specification class SearchKeywordNormalizerTest extends Specification { - def "null/empty/blank/1글자 입력은 null을 반환한다"() { + def "null/empty/blank 입력은 Empty 를 반환한다"() { expect: - SearchKeywordNormalizer.toBooleanModeQuery(input) == null + SearchKeywordNormalizer.normalize(input) instanceof SearchQuery.Empty where: - input << [null, "", " ", "\t", "감"] + input << [null, "", " ", "\t"] } - def "한글 합성어는 그대로 +token 으로 변환된다"() { + def "1글자 입력은 ExactToken 으로 반환된다"() { - expect: - SearchKeywordNormalizer.toBooleanModeQuery(input) == expected + when: + SearchQuery query = SearchKeywordNormalizer.normalize(input) + + then: + query instanceof SearchQuery.ExactToken + ((SearchQuery.ExactToken) query).token() == expectedToken + + where: + input || expectedToken + "감" || "감" + "갓" || "갓" + "닭" || "닭" + "B" || "b" + } + + def "한글 합성어는 BooleanQuery 로 +token 형태가 된다"() { + + when: + SearchQuery query = SearchKeywordNormalizer.normalize(input) + + then: + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == expected where: input || expected @@ -29,36 +51,40 @@ class SearchKeywordNormalizerTest extends Specification { def "복수 토큰은 +token1 +token2 형식으로 AND 매칭된다"() { when: - String query = SearchKeywordNormalizer.toBooleanModeQuery("감자 양파") + SearchQuery query = SearchKeywordNormalizer.normalize("감자 양파") then: - query == "+감자 +양파" + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == "+감자 +양파" } def "BOOLEAN MODE 특수문자는 제거된다"() { when: - String query = SearchKeywordNormalizer.toBooleanModeQuery("+감자 -양파*") + SearchQuery query = SearchKeywordNormalizer.normalize("+감자 -양파*") then: - query == "+감자 +양파" + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == "+감자 +양파" } def "조사가 붙은 입력은 명사만 추출되어 반영된다"() { when: - String query = SearchKeywordNormalizer.toBooleanModeQuery("감자를") + SearchQuery query = SearchKeywordNormalizer.normalize("감자를") then: - query == "+감자" + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == "+감자" } def "연속 공백/탭은 단일 공백으로 정리된다"() { when: - String query = SearchKeywordNormalizer.toBooleanModeQuery(" 감자 \t양파 ") + SearchQuery query = SearchKeywordNormalizer.normalize(" 감자 \t양파 ") then: - query == "+감자 +양파" + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == "+감자 +양파" } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy index a6263fad..d59bad3a 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy @@ -46,7 +46,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "scraps" - recipeRepository.countByKeyword(_ as String) >> 2 + recipeRepository.countByKeyword(_) >> 2 recipeScrapService.countByRecipeId(lastRecipeId) >> 0 @@ -69,7 +69,7 @@ class RecipeSearchServiceTest extends Specification { .build(), ] - recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(_ as String, lastRecipeId, 0, size) >> recipes + recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(_, lastRecipeId, 0, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -120,7 +120,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "views" - recipeRepository.countByKeyword(_ as String) >> 2 + recipeRepository.countByKeyword(_) >> 2 recipeViewService.countByRecipeId(lastRecipeId) >> 0 @@ -143,7 +143,7 @@ class RecipeSearchServiceTest extends Specification { .build(), ] - recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(_ as String, lastRecipeId, 0, size) >> recipes + recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(_, lastRecipeId, 0, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -194,7 +194,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "newest" - recipeRepository.countByKeyword(_ as String) >> 2 + recipeRepository.countByKeyword(_) >> 2 recipeRepository.findById(lastRecipeId) >> Optional.empty() @@ -219,7 +219,7 @@ class RecipeSearchServiceTest extends Specification { recipeRepository.findById(lastRecipeId) >> Optional.empty() - recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_ as String, lastRecipeId, null, size) >> recipes + recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, lastRecipeId, null, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -589,7 +589,7 @@ class RecipeSearchServiceTest extends Specification { result.recipes.ingredientsMatchRate == [100, 33] } - def "레시피 키워드 검색 - 1글자 입력은 빈 결과를 반환한다 (FULLTEXT 토큰 최소 길이 정책)"() { + def "레시피 키워드 검색 - 빈/공백 입력은 빈 결과를 반환한다"() { given: User user = User.builder().userId(1).socialId("naver_1").nickname("테스터1").build() @@ -597,12 +597,15 @@ class RecipeSearchServiceTest extends Specification { recipeScrapService.findByRecipeIds(_) >> [] when: - RecipesResponse result = recipeSearchService.findRecipesByKeywordOrderBy(user, "감", 0L, 10, "newest") + RecipesResponse result = recipeSearchService.findRecipesByKeywordOrderBy(user, input, 0L, 10, "newest") then: result.totalCnt == 0 result.recipes.isEmpty() 0 * recipeRepository.countByKeyword(_) 0 * recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, _, _, _) + + where: + input << ["", " ", "\t"] } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy index b29b72ad..89b1d161 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy @@ -34,7 +34,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "scraps" - blogRecipeRepository.countByKeyword(_ as String) >> 20 + blogRecipeRepository.countByKeyword(_) >> 20 blogScrapService.countByBlogRecipeId(lastBlogRecipeId) >> 0 @@ -63,7 +63,7 @@ class BlogRecipeServiceTest extends Specification { .build(), ] - blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(_ as String, lastBlogRecipeId, 0, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(_, lastBlogRecipeId, 0, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() @@ -104,7 +104,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "views" - blogRecipeRepository.countByKeyword(_ as String) >> 20 + blogRecipeRepository.countByKeyword(_) >> 20 blogViewService.countByBlogRecipeId(lastBlogRecipeId) >> 0 @@ -133,7 +133,7 @@ class BlogRecipeServiceTest extends Specification { .build(), ] - blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(_ as String, lastBlogRecipeId, 0, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(_, lastBlogRecipeId, 0, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() @@ -174,7 +174,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "newest" - blogRecipeRepository.countByKeyword(_ as String) >> 20 + blogRecipeRepository.countByKeyword(_) >> 20 List blogRecipes = [ BlogRecipe.builder() @@ -203,7 +203,7 @@ class BlogRecipeServiceTest extends Specification { blogRecipeRepository.findById(lastBlogRecipeId) >> Optional.empty() - blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(_ as String, lastBlogRecipeId, null, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(_, lastBlogRecipeId, null, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy index 907b7fa0..c4f397d0 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy @@ -35,7 +35,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "scraps" - youtubeRecipeRepository.countByKeyword(_ as String) >> 20 + youtubeRecipeRepository.countByKeyword(_) >> 20 youtubeScrapService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -64,7 +64,7 @@ class YoutubeRecipeServiceTest extends Specification { .build() ] - youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(_ as String, lastYoutubeRecipeId, 0, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(_, lastYoutubeRecipeId, 0, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() @@ -105,7 +105,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "views" - youtubeRecipeRepository.countByKeyword(_ as String) >> 20 + youtubeRecipeRepository.countByKeyword(_) >> 20 youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -134,7 +134,7 @@ class YoutubeRecipeServiceTest extends Specification { .build() ] - youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(_ as String, lastYoutubeRecipeId, 0, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(_, lastYoutubeRecipeId, 0, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() @@ -175,7 +175,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "newest" - youtubeRecipeRepository.countByKeyword(_ as String) >> 20 + youtubeRecipeRepository.countByKeyword(_) >> 20 youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -206,7 +206,7 @@ class YoutubeRecipeServiceTest extends Specification { youtubeRecipeRepository.findById(lastYoutubeRecipeId) >> Optional.empty() - youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(_ as String, lastYoutubeRecipeId, null, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(_, lastYoutubeRecipeId, null, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() From 5b931f3f9d0c09574fc595052d3f177ddbb87f08 Mon Sep 17 00:00:00 2001 From: joona95 Date: Mon, 4 May 2026 06:03:25 +0900 Subject: [PATCH 14/20] =?UTF-8?q?test:=20=EA=B2=80=EC=83=89=20Repository?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=9D=B8=EC=9E=90=EB=A5=BC=20Sea?= =?UTF-8?q?rchQuery=20=EB=A1=9C=20=EA=B0=B1=EC=8B=A0=20+=201=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20ExactToken=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 검색 통합 테스트가 String "테스트" 를 직접 넘겨 SearchQuery 시그니처와 안 맞던 부분을 SearchKeywordNormalizer.normalize("테스트") 호출로 일괄 갱신. 추가로 1글자 ExactToken 정확 매칭이 단어 경계만 잡고 부분 일치는 잡지 않는지 검증하는 케이스를 Recipe / Blog / Youtube 각각 추가. - RecipeCustomRepositoryTest: "갓" 재료 정확 매치 / "감" "자" 비매치 - BlogRecipeCustomRepositoryTest: title="갓" 매치 / title="감자" 비매치 - YoutubeRecipeCustomRepositoryTest: 동일 패턴 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../infra/RecipeCustomRepositoryTest.groovy | 67 +++++++++++++++++-- .../BlogRecipeCustomRepositoryTest.groovy | 49 ++++++++++++-- .../YoutubeRecipeCustomRepositoryTest.groovy | 47 +++++++++++-- 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy index a1ecfb25..91c2fe0b 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy @@ -1,5 +1,7 @@ package com.recipe.app.src.recipe.infra +import com.recipe.app.src.common.utils.SearchKeywordNormalizer +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery import com.recipe.app.src.recipe.domain.Recipe import com.recipe.app.src.recipe.domain.RecipeIngredient import com.recipe.app.src.recipe.domain.RecipeLevel @@ -217,7 +219,7 @@ class RecipeCustomRepositoryTest extends Specification { recipeRepository.saveAll(recipes); when: - long response = recipeRepository.countByKeyword("테스트"); + long response = recipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); then: response == 3 @@ -266,7 +268,7 @@ class RecipeCustomRepositoryTest extends Specification { recipeRepository.saveAll(recipes); when: - List response = recipeRepository.findByKeywordLimitOrderByCreatedAtDesc("테스트", 0L, recipes.createdAt.max().plusMinutes(1), 3); + List response = recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(SearchKeywordNormalizer.normalize("테스트"), 0L, recipes.createdAt.max().plusMinutes(1), 3); then: response.size() == 3 @@ -322,7 +324,7 @@ class RecipeCustomRepositoryTest extends Specification { recipeRepository.saveAll(recipes); when: - List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc("테스트", recipes.get(2).recipeId, 2, 3); + List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), recipes.get(2).recipeId, 2, 3); then: response.size() == 2 @@ -376,7 +378,7 @@ class RecipeCustomRepositoryTest extends Specification { recipeRepository.saveAll(recipes); when: - List response = recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc("테스트", recipes.get(2).recipeId, 2, 3); + List response = recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), recipes.get(2).recipeId, 2, 3); then: response.size() == 2 @@ -520,4 +522,61 @@ class RecipeCustomRepositoryTest extends Specification { response.size() == 2 response.recipeId == [recipes.get(0).recipeId, recipes.get(2).recipeId] } + + def "1글자 ExactToken 검색은 단어 경계 정확 매칭만 매치한다 (재료명 기준)"() { + + given: + List recipes = [ + Recipe.builder() + .recipeNm("음식1") + .introduction("설명1") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .build(), + Recipe.builder() + .recipeNm("음식2") + .introduction("설명2") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .build(), + Recipe.builder() + .recipeNm("음식3") + .introduction("설명3") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .build(), + ] + + RecipeIngredient.builder() + .recipe(recipes.get(0)) + .ingredientName("갓") + .build() + RecipeIngredient.builder() + .recipe(recipes.get(1)) + .ingredientName("감자") + .build() + RecipeIngredient.builder() + .recipe(recipes.get(2)) + .ingredientName("감자전") + .build() + + recipeRepository.saveAll(recipes) + + when: "1글자 정확 매칭" + SearchQuery query = SearchKeywordNormalizer.normalize(input) + long count = recipeRepository.countByKeyword(query) + + then: + query instanceof SearchQuery.ExactToken + count == expected + + where: + input || expected + "갓" || 1L // RecipeIngredient '갓' 정확 매치 + "감" || 0L // 어떤 재료도 정확히 '감' 이 아님 ('감자','감자전' 매치 안 됨) + "자" || 0L // 어떤 재료도 정확히 '자' 가 아님 + } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy index b95db9ed..72f247f2 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy @@ -1,5 +1,7 @@ package com.recipe.app.src.recipe.infra.blog +import com.recipe.app.src.common.utils.SearchKeywordNormalizer +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery import com.recipe.app.src.recipe.domain.blog.BlogRecipe import com.recipe.app.src.recipe.domain.blog.BlogScrap import com.recipe.app.src.user.domain.User @@ -61,7 +63,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { blogRecipeRepository.saveAll(blogRecipes); when: - long response = blogRecipeRepository.countByKeyword("테스트"); + long response = blogRecipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); then: response == 2 @@ -108,7 +110,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { when: BlogRecipe lastBlogRecipe = blogRecipes.get(1); - List response = blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc("테스트", lastBlogRecipe.getBlogRecipeId(), lastBlogRecipe.getPublishedAt(), 3); + List response = blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(SearchKeywordNormalizer.normalize("테스트"), lastBlogRecipe.getBlogRecipeId(), lastBlogRecipe.getPublishedAt(), 3); then: response.size() == 2 @@ -176,7 +178,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { blogRecipeRepository.saveAll(blogRecipes); when: - List response = blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc("테스트", blogRecipes.get(3).blogRecipeId, 3, 3); + List response = blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), blogRecipes.get(3).blogRecipeId, 3, 3); then: response.size() == 2 @@ -240,7 +242,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { blogRecipeRepository.saveAll(blogRecipes); when: - List response = blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc("테스트", blogRecipes.get(3).blogRecipeId, 2, 3); + List response = blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), blogRecipes.get(3).blogRecipeId, 2, 3); then: response.size() == 2 @@ -313,4 +315,43 @@ class BlogRecipeCustomRepositoryTest extends Specification { response.get(0).blogRecipeId == blogRecipes.get(3).blogRecipeId response.get(1).blogRecipeId == blogRecipes.get(0).blogRecipeId } + + def "1글자 ExactToken 검색은 단어 경계 정확 매칭만 매치한다"() { + + given: + // searchTokens 는 nori 토큰화 결과. title="갓" 인 row 만 1글자 토큰 "갓" 보유. + // title="갓김치" 는 토큰이 "갓김치" (사전에 있으면 단일 토큰) 또는 "갓 김치" 로 분해될 수 있음 — 어느 쪽이든 "갓" 정확 토큰은 없으면 매치 안 됨 + List blogRecipes = [ + BlogRecipe.builder() + .title("갓") + .description("재료 갓 설명") + .publishedAt(LocalDate.of(2024, 1, 1)) + .blogUrl("http://naver.com/exact1") + .blogThumbnailImgUrl("http://test.jpg") + .blogName("테스트") + .build(), + BlogRecipe.builder() + .title("감자") + .description("감자 요리") + .publishedAt(LocalDate.of(2024, 1, 1)) + .blogUrl("http://naver.com/exact2") + .blogThumbnailImgUrl("http://test.jpg") + .blogName("테스트") + .build(), + ] + blogRecipeRepository.saveAll(blogRecipes) + + when: "1글자 정확 매칭" + SearchQuery query = SearchKeywordNormalizer.normalize(input) + long count = blogRecipeRepository.countByKeyword(query) + + then: + query instanceof SearchQuery.ExactToken + count == expected + + where: + input || expected + "갓" || 1L // title="갓" 인 row 만 매치 ('감자'에는 "갓" 토큰 없음) + "자" || 0L // 어떤 토큰도 정확히 '자' 가 아님 + } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy index da869ca6..ff921eba 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy @@ -1,5 +1,7 @@ package com.recipe.app.src.recipe.infra.youtube +import com.recipe.app.src.common.utils.SearchKeywordNormalizer +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe import com.recipe.app.src.recipe.domain.youtube.YoutubeScrap import com.recipe.app.src.user.domain.User @@ -69,7 +71,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { youtubeRecipeRepository.saveAll(youtubeRecipes); when: - long response = youtubeRecipeRepository.countByKeyword("테스트"); + long response = youtubeRecipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); then: response == 3 @@ -116,7 +118,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { when: YoutubeRecipe lastYoutubeRecipe = youtubeRecipes.get(1); - List response = youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc("테스트", lastYoutubeRecipe.youtubeRecipeId, lastYoutubeRecipe.postDate, 3); + List response = youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(SearchKeywordNormalizer.normalize("테스트"), lastYoutubeRecipe.youtubeRecipeId, lastYoutubeRecipe.postDate, 3); then: response.size() == 2 @@ -180,7 +182,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { youtubeRecipeRepository.saveAll(youtubeRecipes); when: - List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc("테스트", youtubeRecipes.get(3).youtubeRecipeId, 2, 3); + List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), youtubeRecipes.get(3).youtubeRecipeId, 2, 3); then: response.size() == 2 @@ -244,7 +246,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { youtubeRecipeRepository.saveAll(youtubeRecipes); when: - List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc("테스트", youtubeRecipes.get(3).youtubeRecipeId, 2, 3) + List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), youtubeRecipes.get(3).youtubeRecipeId, 2, 3) then: response.size() == 2 @@ -322,4 +324,41 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { response.get(1).youtubeRecipeId == youtubeRecipes.get(2).youtubeRecipeId response.get(2).youtubeRecipeId == youtubeRecipes.get(0).youtubeRecipeId } + + def "1글자 ExactToken 검색은 단어 경계 정확 매칭만 매치한다"() { + + given: + List youtubeRecipes = [ + YoutubeRecipe.builder() + .title("갓") + .description("재료 갓 설명") + .postDate(LocalDate.of(2024, 1, 1)) + .channelName("테스트") + .youtubeId("yt-exact-1") + .thumbnailImgUrl("http://test.jpg") + .build(), + YoutubeRecipe.builder() + .title("감자") + .description("감자 요리") + .postDate(LocalDate.of(2024, 1, 1)) + .channelName("테스트") + .youtubeId("yt-exact-2") + .thumbnailImgUrl("http://test.jpg") + .build(), + ] + youtubeRecipeRepository.saveAll(youtubeRecipes) + + when: "1글자 정확 매칭" + SearchQuery query = SearchKeywordNormalizer.normalize(input) + long count = youtubeRecipeRepository.countByKeyword(query) + + then: + query instanceof SearchQuery.ExactToken + count == expected + + where: + input || expected + "갓" || 1L // title="갓" 인 row 만 매치 + "자" || 0L // 어떤 토큰도 정확히 '자' 가 아님 + } } From 1e19022e888cfc68442c8a8129947b27f7f3bc4c Mon Sep 17 00:00:00 2001 From: joona95 Date: Mon, 4 May 2026 06:03:46 +0900 Subject: [PATCH 15/20] =?UTF-8?q?refactor:=20=EC=9E=AC=EB=A3=8C=20?= =?UTF-8?q?=EB=8F=99=EC=9D=98=EC=96=B4=EB=A5=BC=20Java=20if/else=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20IngredientSynonym=20DB=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EB=A1=9C=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 중 동의어 추가/수정을 코드 배포 없이 가능하게 하기 위해 Ingredient 엔티티의 getIngredientNameWithSimilar() if/else 체인을 별도 테이블로 분리. - IngredientSynonym(synonymId, groupId, name): 같은 groupId 공유 = 동의어 그룹. 3-way 이상도 자연스럽게 transitive 매칭 (예: 새싹채소-어린잎채소-무순 셋이 모두 서로 동의어로 인식) - IngredientSynonymRepository: JpaRepository 인터페이스 - IngredientSynonymCache: 부팅 시 1회 로드(@PostConstruct) + groupId 기준 재구성한 Map<String, Set<String>> 으로 expand(Collection) 제공. 운영 중 DB 추가 시 재시작 또는 reload() 필요 - Ingredient.getIngredientNameWithSimilar(): 메서드 통째 제거 - FridgeService.findIngredientNamesInFridge: cache.expand(rawNames) 호출하여 동의어 확장 - 테스트: IngredientSynonymCacheTest 신규 (그룹 매칭 / 3-way transitive / 미등록 단어 / 빈 입력) + FridgeServiceTest 의 cache mock 추가 운영 적용 시 IngredientSynonym 테이블 생성 + 초기 데이터 INSERT 필요 (별도 안내 문서 참조). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/fridge/application/FridgeService.java | 11 ++- .../application/IngredientSynonymCache.java | 70 ++++++++++++++ .../app/src/ingredient/domain/Ingredient.java | 64 ------------- .../ingredient/domain/IngredientSynonym.java | 43 +++++++++ .../infra/IngredientSynonymRepository.java | 9 ++ .../application/FridgeServiceTest.groovy | 9 +- .../IngredientSynonymCacheTest.groovy | 91 +++++++++++++++++++ 7 files changed, 228 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java create mode 100644 src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java create mode 100644 src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java create mode 100644 src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy diff --git a/src/main/java/com/recipe/app/src/fridge/application/FridgeService.java b/src/main/java/com/recipe/app/src/fridge/application/FridgeService.java index 66f62fe9..2df9aa69 100644 --- a/src/main/java/com/recipe/app/src/fridge/application/FridgeService.java +++ b/src/main/java/com/recipe/app/src/fridge/application/FridgeService.java @@ -10,6 +10,7 @@ import com.recipe.app.src.fridgeBasket.domain.FridgeBasket; import com.recipe.app.src.ingredient.application.IngredientCategoryService; import com.recipe.app.src.ingredient.application.IngredientService; +import com.recipe.app.src.ingredient.application.IngredientSynonymCache; import com.recipe.app.src.ingredient.domain.Ingredient; import com.recipe.app.src.ingredient.domain.IngredientCategory; import com.recipe.app.src.user.domain.User; @@ -29,12 +30,14 @@ public class FridgeService { private final FridgeBasketService fridgeBasketService; private final IngredientService ingredientService; private final IngredientCategoryService ingredientCategoryService; + private final IngredientSynonymCache ingredientSynonymCache; - public FridgeService(FridgeRepository fridgeRepository, FridgeBasketService fridgeBasketService, IngredientService ingredientService, IngredientCategoryService ingredientCategoryService) { + public FridgeService(FridgeRepository fridgeRepository, FridgeBasketService fridgeBasketService, IngredientService ingredientService, IngredientCategoryService ingredientCategoryService, IngredientSynonymCache ingredientSynonymCache) { this.fridgeRepository = fridgeRepository; this.fridgeBasketService = fridgeBasketService; this.ingredientService = ingredientService; this.ingredientCategoryService = ingredientCategoryService; + this.ingredientSynonymCache = ingredientSynonymCache; } @Transactional @@ -140,9 +143,11 @@ public List findIngredientNamesInFridge(Long userId) { List ingredients = ingredientService.findByIngredientIds(ingredientIds); - return ingredients.stream() - .flatMap(ingredient -> ingredient.getIngredientNameWithSimilar().stream()) + List rawNames = ingredients.stream() + .map(Ingredient::getIngredientName) .collect(Collectors.toList()); + + return List.copyOf(ingredientSynonymCache.expand(rawNames)); } private List getIngredientIdsInFridges(Collection fridges) { diff --git a/src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java b/src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java new file mode 100644 index 00000000..0cef37e3 --- /dev/null +++ b/src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java @@ -0,0 +1,70 @@ +package com.recipe.app.src.ingredient.application; + +import com.recipe.app.src.ingredient.domain.IngredientSynonym; +import com.recipe.app.src.ingredient.infra.IngredientSynonymRepository; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * IngredientSynonym 테이블의 동의어 그룹을 메모리 캐시로 들고있다가 단어 -> 그룹 전체 단어 집합을 반환한다. + * 운영 중 DB 에 새 동의어를 추가했다면 인스턴스 재시작 또는 reload() 호출 필요. + */ +@Slf4j +@Component +public class IngredientSynonymCache { + + private final IngredientSynonymRepository ingredientSynonymRepository; + private volatile Map> expansionMap = Collections.emptyMap(); + + public IngredientSynonymCache(IngredientSynonymRepository ingredientSynonymRepository) { + this.ingredientSynonymRepository = ingredientSynonymRepository; + } + + @PostConstruct + public void reload() { + + List all = ingredientSynonymRepository.findAll(); + Map> byGroup = all.stream() + .collect(Collectors.groupingBy( + IngredientSynonym::getGroupId, + Collectors.mapping(IngredientSynonym::getName, Collectors.toUnmodifiableSet()) + )); + + Map> map = new HashMap<>(); + for (Set group : byGroup.values()) { + for (String name : group) { + map.put(name, group); + } + } + this.expansionMap = Map.copyOf(map); + + log.info("IngredientSynonymCache loaded - groups={}, words={}", byGroup.size(), all.size()); + } + + /** + * 입력 단어들과 그 동의어들을 모두 합친 Set 반환. 동의어 그룹에 없는 단어는 자기 자신만 들어감. + */ + public Set expand(Collection names) { + + if (names == null || names.isEmpty()) return Set.of(); + + Set result = new HashSet<>(); + for (String name : names) { + if (name == null) continue; + result.add(name); + Set group = expansionMap.get(name); + if (group != null) result.addAll(group); + } + return result; + } +} diff --git a/src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java b/src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java index 046d0316..395853af 100644 --- a/src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java +++ b/src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java @@ -14,7 +14,6 @@ import lombok.NoArgsConstructor; import org.springframework.util.StringUtils; -import java.util.List; import java.util.Objects; @Getter @@ -52,67 +51,4 @@ public Ingredient(Long ingredientId, Long ingredientCategoryId, String ingredien this.userId = userId; } - public List getIngredientNameWithSimilar() { - if (ingredientName.equals("새우")) - return List.of("대하", ingredientName); - if (ingredientName.equals("대하")) - return List.of("새우", ingredientName); - if (ingredientName.equals("계란")) - return List.of("달걀", ingredientName); - if (ingredientName.equals("달걀")) - return List.of("계란", ingredientName); - if (ingredientName.equals("소고기")) - return List.of("쇠고기", ingredientName); - if (ingredientName.equals("쇠고기")) - return List.of("소고기", ingredientName); - if (ingredientName.equals("후추")) - return List.of("후춧가루", ingredientName); - if (ingredientName.equals("후춧가루")) - return List.of("후추", ingredientName); - if (ingredientName.equals("간마늘")) - return List.of("다진마늘", ingredientName); - if (ingredientName.equals("다진마늘")) - return List.of("간마늘", ingredientName); - if (ingredientName.equals("새싹채소")) - return List.of("어린잎채소", "무순", ingredientName); - if (ingredientName.equals("어린잎채소")) - return List.of("새싹채소", ingredientName); - if (ingredientName.equals("무순")) - return List.of("새싹채소", ingredientName); - if (ingredientName.equals("조개")) - return List.of("조갯살", "바지락", ingredientName); - if (ingredientName.equals("조갯살")) - return List.of("조개", ingredientName); - if (ingredientName.equals("바지락")) - return List.of("조개", ingredientName); - if (ingredientName.equals("케찹")) - return List.of("케첩", ingredientName); - if (ingredientName.equals("케첩")) - return List.of("케찹", ingredientName); - if (ingredientName.equals("소면")) - return List.of("국수", ingredientName); - if (ingredientName.equals("국수")) - return List.of("소면", ingredientName); - if (ingredientName.equals("김치")) - return List.of("김칫잎", ingredientName); - if (ingredientName.equals("김칫잎")) - return List.of("김치", ingredientName); - if (ingredientName.equals("고춧가루")) - return List.of("고추가루", ingredientName); - if (ingredientName.equals("고추가루")) - return List.of("고춧가루", ingredientName); - if (ingredientName.equals("올리브유")) - return List.of("올리브오일", ingredientName); - if (ingredientName.equals("올리브오일")) - return List.of("올리브유", ingredientName); - if (ingredientName.equals("파스타")) - return List.of("스파게티", ingredientName); - if (ingredientName.equals("스파게티")) - return List.of("파스타", ingredientName); - if (ingredientName.equals("포도씨유")) - return List.of("식용유", ingredientName); - if (ingredientName.equals("식용유")) - return List.of("포도씨유", ingredientName); - return List.of(ingredientName); - } } \ No newline at end of file diff --git a/src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java b/src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java new file mode 100644 index 00000000..dcd99f67 --- /dev/null +++ b/src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java @@ -0,0 +1,43 @@ +package com.recipe.app.src.ingredient.domain; + +import com.google.common.base.Preconditions; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "IngredientSynonym") +public class IngredientSynonym { + + @Id + @Column(name = "synonymId", nullable = false, updatable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long synonymId; + + @Column(name = "groupId", nullable = false) + private Long groupId; + + @Column(name = "name", nullable = false, length = 64, unique = true) + private String name; + + @Builder + public IngredientSynonym(Long synonymId, Long groupId, String name) { + + Preconditions.checkNotNull(groupId, "동의어 그룹 아이디를 입력해주세요."); + Preconditions.checkArgument(StringUtils.hasText(name), "동의어 단어를 입력해주세요."); + + this.synonymId = synonymId; + this.groupId = groupId; + this.name = name; + } +} diff --git a/src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java b/src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java new file mode 100644 index 00000000..6099c262 --- /dev/null +++ b/src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java @@ -0,0 +1,9 @@ +package com.recipe.app.src.ingredient.infra; + +import com.recipe.app.src.ingredient.domain.IngredientSynonym; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface IngredientSynonymRepository extends JpaRepository { +} diff --git a/src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy b/src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy index cbb8c996..d9ecc208 100644 --- a/src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy @@ -11,6 +11,7 @@ import com.recipe.app.src.fridgeBasket.application.FridgeBasketService import com.recipe.app.src.fridgeBasket.domain.FridgeBasket import com.recipe.app.src.ingredient.application.IngredientCategoryService import com.recipe.app.src.ingredient.application.IngredientService +import com.recipe.app.src.ingredient.application.IngredientSynonymCache import com.recipe.app.src.ingredient.domain.Ingredient import com.recipe.app.src.ingredient.domain.IngredientCategory import com.recipe.app.src.user.domain.User @@ -24,7 +25,8 @@ class FridgeServiceTest extends Specification { private FridgeBasketService fridgeBasketService = Mock() private IngredientService ingredientService = Mock() private IngredientCategoryService ingredientCategoryService = Mock() - private FridgeService fridgeService = new FridgeService(fridgeRepository, fridgeBasketService, ingredientService, ingredientCategoryService) + private IngredientSynonymCache ingredientSynonymCache = Mock() + private FridgeService fridgeService = new FridgeService(fridgeRepository, fridgeBasketService, ingredientService, ingredientCategoryService, ingredientSynonymCache) def "냉장고 생성"() { @@ -585,11 +587,14 @@ class FridgeServiceTest extends Specification { ingredientService.findByIngredientIds(fridges.ingredientId) >> ingredients + ingredientSynonymCache.expand(_) >> (["재료1", "재료2"] as Set) + when: List result = fridgeService.findIngredientNamesInFridge(userId) then: - result == ["재료1", "재료2"] + result.size() == 2 + result.containsAll(["재료1", "재료2"]) } def "유저 아이디와 재료 아이디로 냉장고 삭제"() { diff --git a/src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy b/src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy new file mode 100644 index 00000000..a581a148 --- /dev/null +++ b/src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy @@ -0,0 +1,91 @@ +package com.recipe.app.src.ingredient.application + +import com.recipe.app.src.ingredient.domain.IngredientSynonym +import com.recipe.app.src.ingredient.infra.IngredientSynonymRepository +import spock.lang.Specification + +class IngredientSynonymCacheTest extends Specification { + + private IngredientSynonymRepository repository = Mock() + private IngredientSynonymCache cache = new IngredientSynonymCache(repository) + + def "동의어 그룹 안의 단어를 입력하면 그룹 전체를 반환한다"() { + + given: + repository.findAll() >> [ + IngredientSynonym.builder().synonymId(1L).groupId(1L).name("새우").build(), + IngredientSynonym.builder().synonymId(2L).groupId(1L).name("대하").build(), + IngredientSynonym.builder().synonymId(3L).groupId(2L).name("계란").build(), + IngredientSynonym.builder().synonymId(4L).groupId(2L).name("달걀").build(), + ] + cache.reload() + + expect: + cache.expand([input]) as Set == expected as Set + + where: + input || expected + "새우" || ["새우", "대하"] + "대하" || ["새우", "대하"] + "계란" || ["계란", "달걀"] + "달걀" || ["계란", "달걀"] + } + + def "3-way 그룹은 transitive 하게 모두 반환한다 (새싹채소-어린잎채소-무순)"() { + + given: + repository.findAll() >> [ + IngredientSynonym.builder().synonymId(1L).groupId(10L).name("새싹채소").build(), + IngredientSynonym.builder().synonymId(2L).groupId(10L).name("어린잎채소").build(), + IngredientSynonym.builder().synonymId(3L).groupId(10L).name("무순").build(), + ] + cache.reload() + + expect: "그룹 안의 어떤 단어로 들어와도 셋 다 반환" + cache.expand([input]) as Set == ["새싹채소", "어린잎채소", "무순"] as Set + + where: + input << ["새싹채소", "어린잎채소", "무순"] + } + + def "그룹에 없는 단어는 그 단어만 반환한다"() { + + given: + repository.findAll() >> [ + IngredientSynonym.builder().synonymId(1L).groupId(1L).name("새우").build(), + IngredientSynonym.builder().synonymId(2L).groupId(1L).name("대하").build(), + ] + cache.reload() + + expect: + cache.expand(["감자"]) == ["감자"] as Set + } + + def "여러 단어 입력 시 모두 확장해서 합친다"() { + + given: + repository.findAll() >> [ + IngredientSynonym.builder().synonymId(1L).groupId(1L).name("새우").build(), + IngredientSynonym.builder().synonymId(2L).groupId(1L).name("대하").build(), + IngredientSynonym.builder().synonymId(3L).groupId(2L).name("계란").build(), + IngredientSynonym.builder().synonymId(4L).groupId(2L).name("달걀").build(), + ] + cache.reload() + + expect: + cache.expand(["새우", "계란", "감자"]) as Set == ["새우", "대하", "계란", "달걀", "감자"] as Set + } + + def "null/빈 입력은 빈 Set 을 반환한다"() { + + given: + repository.findAll() >> [] + cache.reload() + + expect: + cache.expand(input).isEmpty() + + where: + input << [null, []] + } +} From ed4e08f51820659d9ff8e8f32b2b2cf4eafc3862 Mon Sep 17 00:00:00 2001 From: joona95 Date: Mon, 4 May 2026 06:04:09 +0900 Subject: [PATCH 16/20] =?UTF-8?q?refactor:=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=A0=88=EC=8B=9C=ED=94=BC=20=EB=A7=A4=EC=B9=AD=EC=9D=84=20Rec?= =?UTF-8?q?ipeIngredient.searchTokens=20FULLTEXT=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 ingredientName.in(ingredientNames) 의 exact equality 매칭은 케이스/공백 차이를 못 잡고, 한 RecipeIngredient 안에 여러 단어가 있는 경우(예: "양파 1/4개")의 부분 매칭도 못 했다. searchTokens(=lowercase trim 정규화 컬럼) 의 FULLTEXT 인덱스를 활용하여 토큰 단위 매칭으로 전환. - RecipeRepositoryImpl.findRecipesInFridge: ingredientName.in -> MATCH(searchTokens) AGAINST(... IN BOOLEAN MODE) (OR 매칭, "+" 미사용) - RecipeIngredient.hasInFridge: List<String> -> Set<String> 받고 searchTokens 의 공백 분리 토큰들을 fridge set 과 교집합 검사 - RecipeIngredient 생성자: searchTokens 를 즉시 채움 (기존 @PrePersist 만으로는 비영속 엔티티 단위 테스트가 깨짐) - Recipe.calculateIngredientMatchRate: List<String> -> Set<String>. 빈 ingredients 면 0 반환 - RecipeDetailResponse / RecommendedRecipesResponse.from: 진입 시 fridge 이름을 lowercase trim 정규화한 Set 으로 변환해서 도메인 메서드에 전달 - RecipeIngredientTest / RecipeTest: 새 시그니처 (Set) 로 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../application/dto/RecipeDetailResponse.java | 10 +++++++++- .../dto/RecommendedRecipesResponse.java | 12 ++++++++++-- .../recipe/app/src/recipe/domain/Recipe.java | 7 +++++-- .../app/src/recipe/domain/RecipeIngredient.java | 17 +++++++++++++---- .../src/recipe/infra/RecipeRepositoryImpl.java | 17 ++++++++++++++++- .../recipe/domain/RecipeIngredientTest.groovy | 4 ++-- .../app/src/recipe/domain/RecipeTest.groovy | 4 ++-- 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java b/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java index 3bcfc947..d59e756a 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java +++ b/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java @@ -7,6 +7,8 @@ import lombok.Getter; import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; @Schema(description = "레시피 상세 응답 DTO") @@ -72,6 +74,12 @@ public RecipeDetailResponse(Long recipeId, String recipeName, String introductio public static RecipeDetailResponse from(Recipe recipe, boolean isUserScrap, User postUser, List ingredientNamesInFridge) { + Set normalizedFridge = ingredientNamesInFridge.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase().trim()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + return RecipeDetailResponse.builder() .recipeId(recipe.getRecipeId()) .recipeName(recipe.getRecipeNm()) @@ -82,7 +90,7 @@ public static RecipeDetailResponse from(Recipe recipe, boolean isUserScrap, User .recipeIngredients(recipe.getIngredients().stream() .map(ingredient -> RecipeIngredientResponse.from( ingredient, - ingredient.hasInFridge(ingredientNamesInFridge))) + ingredient.hasInFridge(normalizedFridge))) .collect(Collectors.toList())) .recipeProcesses(recipe.getProcesses().stream() .map(RecipeProcessResponse::from) diff --git a/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java b/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java index e048d4d2..496ca81b 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java +++ b/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java @@ -11,6 +11,8 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -33,6 +35,12 @@ public RecommendedRecipesResponse(long totalCnt, List public static RecommendedRecipesResponse from(Recipes recipes, List recipePostUsers, List recipeScraps, User user, List ingredientNamesInFridge, Recipe lastRecipe, int size) { + Set normalizedFridge = ingredientNamesInFridge.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase().trim()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + Map recipePostUserMapByUserId = recipePostUsers.stream() .collect(Collectors.toMap(User::getUserId, Function.identity())); @@ -41,7 +49,7 @@ public static RecommendedRecipesResponse from(Recipes recipes, List recipe .recipes(recipes.getRecipes().stream() .map((recipe) -> RecommendedRecipeResponse.from(recipe, recipePostUserMapByUserId.get(recipe.getUserId()), - recipe.calculateIngredientMatchRate(ingredientNamesInFridge), + recipe.calculateIngredientMatchRate(normalizedFridge), recipeScraps, user)) .sorted(Comparator.comparing(RecommendedRecipeResponse::getIngredientsMatchRate).thenComparing(RecommendedRecipeResponse::getRecipeId).reversed()) @@ -51,7 +59,7 @@ public static RecommendedRecipesResponse from(Recipes recipes, List recipe } return recommendedRecipe.getRecipeId() < lastRecipe.getRecipeId() - && recommendedRecipe.getIngredientsMatchRate() <= lastRecipe.calculateIngredientMatchRate(ingredientNamesInFridge); + && recommendedRecipe.getIngredientsMatchRate() <= lastRecipe.calculateIngredientMatchRate(normalizedFridge); }) .limit(size) .collect(Collectors.toList())) diff --git a/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java b/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java index 63a95b56..3b539791 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Set; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -158,10 +159,12 @@ public void report() { this.hiddenYn = "Y"; } - public long calculateIngredientMatchRate(List ingredientNamesInFridge) { + public long calculateIngredientMatchRate(Set normalizedFridgeNames) { + + if (ingredients.isEmpty()) return 0; long ingredientMatchCnt = ingredients.stream() - .filter(ingredient -> ingredient.hasInFridge(ingredientNamesInFridge)) + .filter(ingredient -> ingredient.hasInFridge(normalizedFridgeNames)) .count(); return Math.round((double) ingredientMatchCnt / ingredients.size() * 100); diff --git a/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java b/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java index 7bc03ac9..17f1032c 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java @@ -19,7 +19,7 @@ import lombok.NoArgsConstructor; import org.springframework.util.StringUtils; -import java.util.List; +import java.util.Set; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -62,17 +62,26 @@ public RecipeIngredient(Long recipeIngredientId, Recipe recipe, String ingredien recipe.ingredients.add(this); } this.ingredientName = ingredientName; + this.searchTokens = normalize(ingredientName); this.ingredientIconId = ingredientIconId; this.quantity = quantity; this.unit = unit; } + private static String normalize(String name) { + return name == null ? "" : name.toLowerCase().trim(); + } + void setRecipe(Recipe recipe) { this.recipe = recipe; } - public boolean hasInFridge(List ingredientNames) { - return ingredientNames.contains(ingredientName); + public boolean hasInFridge(Set normalizedFridgeNames) { + if (searchTokens == null || searchTokens.isBlank()) return false; + for (String token : searchTokens.split(" ")) { + if (!token.isEmpty() && normalizedFridgeNames.contains(token)) return true; + } + return false; } @PrePersist @@ -80,6 +89,6 @@ public boolean hasInFridge(List ingredientNames) { private void refreshSearchTokens() { // 재료명은 짧고 단일 명사가 대부분이라 nori stopword 정책이 오히려 // 도메인 단어("갓", "다시다" 등)를 제거해버린다. 단순 정규화로 대체. - this.searchTokens = ingredientName == null ? "" : ingredientName.toLowerCase().trim(); + this.searchTokens = normalize(ingredientName); } } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java index 24672107..41f9786e 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java @@ -8,12 +8,15 @@ import java.time.LocalDateTime; import java.util.Collection; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.querydsl.core.types.dsl.BooleanExpression; import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; +import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; import static com.recipe.app.src.common.utils.QueryUtils.matchSearchQuery; import static com.recipe.app.src.recipe.domain.QRecipe.recipe; import static com.recipe.app.src.recipe.domain.QRecipeIngredient.recipeIngredient; @@ -146,11 +149,23 @@ public List findLimitByUserId(Long userId, Long lastRecipeId, int size) @Override public List findRecipesInFridge(Collection ingredientNames) { + if (ingredientNames == null || ingredientNames.isEmpty()) return List.of(); + + // OR BOOLEAN MODE 쿼리. "+" 없이 공백 구분이면 토큰 중 하나만 매치되어도 hit. + String boolQuery = ingredientNames.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase().trim()) + .filter(s -> !s.isEmpty()) + .distinct() + .collect(Collectors.joining(" ")); + + if (boolQuery.isEmpty()) return List.of(); + return queryFactory .selectFrom(recipe) .join(recipeIngredient).on(recipe.recipeId.eq(recipeIngredient.recipe.recipeId)) .where( - (recipeIngredient.ingredientName.in(ingredientNames)), + matchAgainst(recipeIngredient.searchTokens, boolQuery), recipe.hiddenYn.eq("N") ) .groupBy(recipe.recipeId) diff --git a/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy index 317d6b3e..36da4dd5 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy @@ -92,10 +92,10 @@ class RecipeIngredientTest extends Specification { .unit("개") .build() - List ingredientNamesInFridge = ["돼지고기", "김치"] + Set normalizedFridge = ["돼지고기", "김치"] as Set when: - boolean result = ingredient.hasInFridge(ingredientNamesInFridge) + boolean result = ingredient.hasInFridge(normalizedFridge) then: result == expected diff --git a/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy index 9d19f6aa..49e180a4 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy @@ -304,10 +304,10 @@ class RecipeTest extends Specification { .ingredientName("삼겹살") .build() - List ingredientNamesInFridge = ["돼지고기", "오리고기", "김치", "소고기"] + Set normalizedFridge = ["돼지고기", "오리고기", "김치", "소고기"] as Set when: - long result = recipe.calculateIngredientMatchRate(ingredientNamesInFridge) + long result = recipe.calculateIngredientMatchRate(normalizedFridge) then: result == 43 From 8ff5af015f31255b9276a917b981c467c77d799b Mon Sep 17 00:00:00 2001 From: joona95 Date: Thu, 7 May 2026 21:32:35 +0900 Subject: [PATCH 17/20] =?UTF-8?q?feat:=20public=20=EC=B6=94=EC=B2=9C=20API?= =?UTF-8?q?=20=EC=97=90=20IngredientSynonymCache.expand=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 사용자 냉장고 추천(findRecommendedRecipesByUserFridge) 은 FridgeService 내부에서 cache.expand 를 거쳐 동의어 확장된 ingredient 로 FULLTEXT 매칭하지만, 비로그인 public 추천(findPublicRecommendedRecipesByIngredients) 은 입력 ingredientNames 를 그대로 repo 에 넘겨서 동의어가 무시됐음. 일관성을 위해 두 경로 모두 expand 후 매칭하도록 통일. - RecipeSearchService 에 IngredientSynonymCache 의존성 추가 - findPublicRecommendedRecipesByIngredients: expand(input) -> findRecipesInFridge(expanded). matchRate 계산도 expanded ingredients 기반 - RecipeSearchServiceTest: cache mock 주입 + Public 추천 케이스 2개 (expand 결과가 검색 인자로 전달되는지 / 빈 입력 시 빈 결과) API 시그니처는 그대로. 같은 입력에 대해 동의어 그룹의 다른 단어가 들어간 레시피도 매치되어 추천 결과가 풍부해짐. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../application/RecipeSearchService.java | 11 +++- .../RecipeSearchServiceTest.groovy | 57 ++++++++++++++++++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java index 2ece6ab5..bab685b3 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java @@ -4,6 +4,7 @@ import com.recipe.app.src.common.utils.SearchKeywordNormalizer; import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.fridge.application.FridgeService; +import com.recipe.app.src.ingredient.application.IngredientSynonymCache; import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse; import com.recipe.app.src.recipe.application.dto.RecipesResponse; import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse; @@ -28,15 +29,17 @@ public class RecipeSearchService { private final BadWordFiltering badWordFiltering; private final RecipeScrapService recipeScrapService; private final RecipeViewService recipeViewService; + private final IngredientSynonymCache ingredientSynonymCache; public RecipeSearchService(RecipeRepository recipeRepository, FridgeService fridgeService, UserService userService, BadWordFiltering badWordFiltering, - RecipeScrapService recipeScrapService, RecipeViewService recipeViewService) { + RecipeScrapService recipeScrapService, RecipeViewService recipeViewService, IngredientSynonymCache ingredientSynonymCache) { this.recipeRepository = recipeRepository; this.fridgeService = fridgeService; this.userService = userService; this.badWordFiltering = badWordFiltering; this.recipeScrapService = recipeScrapService; this.recipeViewService = recipeViewService; + this.ingredientSynonymCache = ingredientSynonymCache; } @Transactional(readOnly = true) @@ -178,7 +181,9 @@ public RecipeDetailResponse findPublicRecipeDetail(long recipeId) { @Transactional(readOnly = true) public RecommendedRecipesResponse findPublicRecommendedRecipesByIngredients(List ingredientNames, long lastRecipeId, int size) { - Recipes recipes = new Recipes(recipeRepository.findRecipesInFridge(ingredientNames)); + List expandedIngredientNames = List.copyOf(ingredientSynonymCache.expand(ingredientNames)); + + Recipes recipes = new Recipes(recipeRepository.findRecipesInFridge(expandedIngredientNames)); List recipePostUsers = userService.findByUserIds(recipes.getUserIds()); @@ -188,6 +193,6 @@ public RecommendedRecipesResponse findPublicRecommendedRecipesByIngredients(List User anonymousUser = new User(); - return RecommendedRecipesResponse.from(recipes, recipePostUsers, recipeScraps, anonymousUser, ingredientNames, lastRecipe, size); + return RecommendedRecipesResponse.from(recipes, recipePostUsers, recipeScraps, anonymousUser, expandedIngredientNames, lastRecipe, size); } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy index d59bad3a..2a0fb0ee 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy @@ -3,6 +3,7 @@ package com.recipe.app.src.recipe.application import com.recipe.app.src.common.utils.BadWordFiltering import com.recipe.app.src.fridge.application.FridgeService +import com.recipe.app.src.ingredient.application.IngredientSynonymCache import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse import com.recipe.app.src.recipe.application.dto.RecipesResponse import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse @@ -23,8 +24,9 @@ class RecipeSearchServiceTest extends Specification { private BadWordFiltering badWordService = Mock() private RecipeScrapService recipeScrapService = Mock() private RecipeViewService recipeViewService = Mock() + private IngredientSynonymCache ingredientSynonymCache = Mock() private RecipeSearchService recipeSearchService = new RecipeSearchService(recipeRepository, fridgeService, userService, badWordService, - recipeScrapService, recipeViewService) + recipeScrapService, recipeViewService, ingredientSynonymCache) def "레시피 키워드 검색 - 스크랩 수 정렬"() { @@ -589,6 +591,59 @@ class RecipeSearchServiceTest extends Specification { result.recipes.ingredientsMatchRate == [100, 33] } + def "Public 추천 - 입력 재료를 동의어 확장 후 FULLTEXT 매칭한다"() { + + given: + long lastRecipeId = 0 + int size = 10 + List inputIngredientNames = ["새우"] + Set expandedSet = ["새우", "대하"] as Set + + List recipes = [ + Recipe.builder() + .recipeId(1L) + .recipeNm("대하구이") + .introduction("대하 들어간 레시피") + .level(RecipeLevel.NORMAL) + .userId(1L) + .isHidden(false) + .build() + ] + + ingredientSynonymCache.expand(inputIngredientNames) >> expandedSet + userService.findByUserIds(_) >> [] + recipeScrapService.findByRecipeIds(_) >> [] + recipeRepository.findById(lastRecipeId) >> Optional.empty() + + when: + RecommendedRecipesResponse result = recipeSearchService.findPublicRecommendedRecipesByIngredients(inputIngredientNames, lastRecipeId, size) + + then: "동의어 expand 결과(새우+대하)가 그대로 검색 인자로 전달된다" + 1 * recipeRepository.findRecipesInFridge({ Collection arg -> arg as Set == expandedSet }) >> recipes + result.totalCnt == 1 + result.recipes.recipeId == [1L] + } + + def "Public 추천 - 빈 입력은 빈 결과를 반환한다"() { + + given: + long lastRecipeId = 0 + int size = 10 + + ingredientSynonymCache.expand([]) >> ([] as Set) + recipeRepository.findRecipesInFridge(_) >> [] + userService.findByUserIds(_) >> [] + recipeScrapService.findByRecipeIds(_) >> [] + recipeRepository.findById(lastRecipeId) >> Optional.empty() + + when: + RecommendedRecipesResponse result = recipeSearchService.findPublicRecommendedRecipesByIngredients([], lastRecipeId, size) + + then: + result.totalCnt == 0 + result.recipes.isEmpty() + } + def "레시피 키워드 검색 - 빈/공백 입력은 빈 결과를 반환한다"() { given: From a75c2b38acdeb164c2538a6e4796929191ceac58 Mon Sep 17 00:00:00 2001 From: joona95 Date: Thu, 7 May 2026 21:34:42 +0900 Subject: [PATCH 18/20] =?UTF-8?q?test:=20=EA=B2=80=EC=83=89=20Repository?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?InnoDB=20FULLTEXT=20=EA=B0=80=EC=8B=9C=EC=84=B1=20=ED=95=9C?= =?UTF-8?q?=EA=B3=84=20=EC=9A=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @DataJpaTest 의 디폴트 트랜잭션은 끝에서 ROLLBACK 되는데, InnoDB FULLTEXT 인덱스는 같은 트랜잭션 안에서 INSERT 한 row 를 MATCH AGAINST 결과로 잡지 못함 (FT 캐시 → 커밋 시점에 인덱스로 머지). 그래서 saveAll 직후 MATCH 검색 검증이 항상 0건 반환. 일반 SELECT 는 read-your-writes 라 영향 없음 (LIKE 기반 ExactToken 검색은 통과). 해법: PlatformTransactionManager 주입 + REQUIRES_NEW 전파의 TransactionTemplate 으로 setup/given 의 INSERT 를 별도 트랜잭션에서 커밋. cleanup 에서 같은 방식으로 정리해서 다음 테스트에 영향 없게. - RecipeCustomRepositoryTest / BlogRecipeCustomRepositoryTest / YoutubeRecipeCustomRepositoryTest: committedTx 멤버 + setup/cleanup + saveAll 호출을 committedTx.executeWithoutResult 로 래핑 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../infra/RecipeCustomRepositoryTest.groovy | 47 ++++++++++++++----- .../BlogRecipeCustomRepositoryTest.groovy | 44 +++++++++++++---- .../YoutubeRecipeCustomRepositoryTest.groovy | 44 +++++++++++++---- 3 files changed, 102 insertions(+), 33 deletions(-) diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy index 91c2fe0b..4f101394 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy @@ -15,8 +15,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.cloud.openfeign.FeignAutoConfiguration import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate import spock.lang.Specification +// InnoDB FULLTEXT 가시성 한계: +// 같은 트랜잭션 안에서 INSERT 한 row 는 MATCH AGAINST 결과로 잡히지 않음 (FT 캐시 → 커밋 시 인덱스로 머지). +// @DataJpaTest 디폴트 트랜잭션은 테스트 끝에 ROLLBACK → MATCH 가 항상 0 건 반환. +// 해법: setup/given 의 INSERT 를 REQUIRES_NEW 로 별도 커밋, cleanup 에서 같은 방식으로 정리. @ActiveProfiles("test") @DataJpaTest @ImportAutoConfiguration(classes = FeignAutoConfiguration.class) @@ -30,10 +37,16 @@ class RecipeCustomRepositoryTest extends Specification { RecipeRepository recipeRepository; @Autowired RecipeScrapRepository recipeScrapRepository; + @Autowired + PlatformTransactionManager transactionManager; private List users; + private TransactionTemplate committedTx; void setup() { + committedTx = new TransactionTemplate(transactionManager) + committedTx.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + users = [ User.builder() .socialId("naver_1") @@ -44,7 +57,15 @@ class RecipeCustomRepositoryTest extends Specification { .nickname("테스터2") .build(), ] - userRepository.saveAll(users); + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } + } + + void cleanup() { + committedTx.executeWithoutResult { status -> + recipeScrapRepository.deleteAll() + recipeRepository.deleteAll() + userRepository.deleteAll() + } } def "레시피 상세 조회 시 공개인 경우 성공"() { @@ -76,7 +97,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("재료") .build() - recipeRepository.saveAll(recipes) + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: Optional recipe = recipeRepository.findRecipeDetail(recipes.get(0).getRecipeId(), users.get(0).getUserId()) @@ -121,7 +142,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("재료") .build() - recipeRepository.saveAll(recipes) + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: Optional recipe = recipeRepository.findRecipeDetail(recipes.get(1).getRecipeId(), users.get(0).getUserId()) @@ -167,7 +188,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("재료") .build() - recipeRepository.saveAll(recipes) + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: Optional recipe = recipeRepository.findRecipeDetail(recipes.get(1).getRecipeId(), users.get(1).getUserId()) @@ -216,7 +237,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: long response = recipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); @@ -265,7 +286,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: List response = recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(SearchKeywordNormalizer.normalize("테스트"), 0L, recipes.createdAt.max().plusMinutes(1), 3); @@ -321,7 +342,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), recipes.get(2).recipeId, 2, 3); @@ -375,7 +396,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: List response = recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), recipes.get(2).recipeId, 2, 3); @@ -412,7 +433,7 @@ class RecipeCustomRepositoryTest extends Specification { .isHidden(false) .build(), ] - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } List recipeScraps = [ RecipeScrap.builder() @@ -424,7 +445,7 @@ class RecipeCustomRepositoryTest extends Specification { .recipeId(recipes.get(2).recipeId) .build(), ] - recipeScrapRepository.saveAll(recipeScraps); + committedTx.executeWithoutResult { status -> recipeScrapRepository.saveAll(recipeScraps) } when: List response = recipeRepository.findUserScrapRecipesLimit(users.get(0).userId, 0L, recipeScraps.createdAt.max().plusMinutes(1), 3); @@ -461,7 +482,7 @@ class RecipeCustomRepositoryTest extends Specification { .isHidden(false) .build(), ] - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: List response = recipeRepository.findLimitByUserId(users.get(0).userId, recipes.get(2).recipeId, 3); @@ -513,7 +534,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: List response = recipeRepository.findRecipesInFridge(["테스트"]); @@ -563,7 +584,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("감자전") .build() - recipeRepository.saveAll(recipes) + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: "1글자 정확 매칭" SearchQuery query = SearchKeywordNormalizer.normalize(input) diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy index 72f247f2..5dd73960 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy @@ -13,10 +13,17 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.cloud.openfeign.FeignAutoConfiguration import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate import spock.lang.Specification import java.time.LocalDate +// InnoDB FULLTEXT 가시성 한계: 같은 트랜잭션 안에서 INSERT 한 row 는 +// MATCH AGAINST 결과로 잡히지 않음 (FT 캐시 → 커밋 시 인덱스로 머지). +// @DataJpaTest 디폴트 ROLLBACK 트랜잭션을 우회하려고 INSERT 는 REQUIRES_NEW 로 별도 커밋, +// cleanup 에서 같은 방식으로 정리. @ActiveProfiles("test") @DataJpaTest @ImportAutoConfiguration(classes = FeignAutoConfiguration.class) @@ -30,6 +37,23 @@ class BlogRecipeCustomRepositoryTest extends Specification { BlogScrapRepository blogScrapRepository; @Autowired BlogRecipeRepository blogRecipeRepository; + @Autowired + PlatformTransactionManager transactionManager; + + private TransactionTemplate committedTx; + + void setup() { + committedTx = new TransactionTemplate(transactionManager) + committedTx.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + + void cleanup() { + committedTx.executeWithoutResult { status -> + blogScrapRepository.deleteAll() + blogRecipeRepository.deleteAll() + userRepository.deleteAll() + } + } def "검색어로 블로그 레시피 갯수 조회"() { @@ -60,7 +84,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: long response = blogRecipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); @@ -106,7 +130,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: BlogRecipe lastBlogRecipe = blogRecipes.get(1); @@ -135,7 +159,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .nickname("테스터3") .build(), ] - userRepository.saveAll(users) + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } List blogRecipes = [ BlogRecipe.builder() @@ -175,7 +199,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .scrapCnt(3L) .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: List response = blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), blogRecipes.get(3).blogRecipeId, 3, 3); @@ -199,7 +223,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .nickname("테스터2") .build(), ] - userRepository.saveAll(users) + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } List blogRecipes = [ BlogRecipe.builder() @@ -239,7 +263,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .viewCnt(2L) .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: List response = blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), blogRecipes.get(3).blogRecipeId, 2, 3); @@ -257,7 +281,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .socialId("naver_1") .nickname("테스터1") .build(); - userRepository.save(user); + committedTx.executeWithoutResult { status -> userRepository.save(user) } List blogRecipes = [ BlogRecipe.builder() @@ -293,7 +317,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } List blogScraps = [ BlogScrap.builder() @@ -305,7 +329,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogRecipeId(blogRecipes.get(3).blogRecipeId) .build(), ] - blogScrapRepository.saveAll(blogScraps); + committedTx.executeWithoutResult { status -> blogScrapRepository.saveAll(blogScraps) } when: List response = blogRecipeRepository.findUserScrapBlogRecipesLimit(user.userId, 0L, blogScraps.get(0).createdAt.plusHours(1), 3) @@ -339,7 +363,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build(), ] - blogRecipeRepository.saveAll(blogRecipes) + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: "1글자 정확 매칭" SearchQuery query = SearchKeywordNormalizer.normalize(input) diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy index ff921eba..7ad14081 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy @@ -13,10 +13,17 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.cloud.openfeign.FeignAutoConfiguration import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate import spock.lang.Specification import java.time.LocalDate +// InnoDB FULLTEXT 가시성 한계: 같은 트랜잭션 안에서 INSERT 한 row 는 +// MATCH AGAINST 결과로 잡히지 않음 (FT 캐시 → 커밋 시 인덱스로 머지). +// @DataJpaTest 디폴트 ROLLBACK 트랜잭션을 우회하려고 INSERT 는 REQUIRES_NEW 로 별도 커밋, +// cleanup 에서 같은 방식으로 정리. @ActiveProfiles("test") @DataJpaTest @ImportAutoConfiguration(classes = FeignAutoConfiguration.class) @@ -30,6 +37,23 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { YoutubeRecipeRepository youtubeRecipeRepository; @Autowired YoutubeScrapRepository youtubeScrapRepository; + @Autowired + PlatformTransactionManager transactionManager; + + private TransactionTemplate committedTx; + + void setup() { + committedTx = new TransactionTemplate(transactionManager) + committedTx.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + + void cleanup() { + committedTx.executeWithoutResult { status -> + youtubeScrapRepository.deleteAll() + youtubeRecipeRepository.deleteAll() + userRepository.deleteAll() + } + } def "검색어로 유튜브 레시피 갯수 조회"() { @@ -68,7 +92,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: long response = youtubeRecipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); @@ -114,7 +138,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: YoutubeRecipe lastYoutubeRecipe = youtubeRecipes.get(1); @@ -139,7 +163,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .nickname("테스터2") .build(), ] - userRepository.saveAll(users) + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } List youtubeRecipes = [ YoutubeRecipe.builder() @@ -179,7 +203,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .scrapCnt(2L) .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), youtubeRecipes.get(3).youtubeRecipeId, 2, 3); @@ -203,7 +227,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .nickname("테스터2") .build(), ] - userRepository.saveAll(users) + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } List youtubeRecipes = [ YoutubeRecipe.builder() @@ -243,7 +267,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .viewCnt(2L) .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), youtubeRecipes.get(3).youtubeRecipeId, 2, 3) @@ -261,7 +285,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .socialId("naver_1") .nickname("테스터1") .build(); - userRepository.save(user); + committedTx.executeWithoutResult { status -> userRepository.save(user) } List youtubeRecipes = [ YoutubeRecipe.builder() @@ -297,7 +321,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } List youtubeScraps = [ YoutubeScrap.builder() @@ -313,7 +337,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .youtubeRecipeId(youtubeRecipes.get(3).youtubeRecipeId) .build(), ] - youtubeScrapRepository.saveAll(youtubeScraps); + committedTx.executeWithoutResult { status -> youtubeScrapRepository.saveAll(youtubeScraps) } when: List response = youtubeRecipeRepository.findUserScrapYoutubeRecipesLimit(user.userId, 0L, youtubeScraps.get(0).createdAt.plusDays(1), 3); @@ -346,7 +370,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes) + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: "1글자 정확 매칭" SearchQuery query = SearchKeywordNormalizer.normalize(input) From 6e4781c72e11b9a0115433da55aa477989a182ec Mon Sep 17 00:00:00 2001 From: joona95 Date: Thu, 7 May 2026 21:35:21 +0900 Subject: [PATCH 19/20] =?UTF-8?q?docs:=20=EC=B6=94=EC=B2=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20+=20=EB=8F=99=EC=9D=98=EC=96=B4=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=B6=94=EA=B0=80=20+=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20DB=20DDL=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ed4e08f / 1e19022 / 5b931f3 / 8ff5af0 시리즈로 완료된 추천 검색 매칭 + 동의어 DB 이전 작업의 배경, 설계 결정, 운영 적용 절차를 한 문서로 정리. - RECOMMENDED_SEARCH_MIGRATION.md: 시리즈 커밋 요약, 핵심 설계 결정 표, 변경 파일 목록, 운영 DB DDL/INSERT, 검증 절차, 롤백 시나리오, 후속 검토 항목. Step 5 의 'public 추천 API 동의어 확장' 은 8ff5af0 으로 완료 표시 - test-db-migration.sql: 운영 DB 와 별개로 테스트 DB(RecipeStorageTest) 에 searchTokens 컬럼 / FULLTEXT 인덱스 / IngredientSynonym 테이블을 한 번에 적용할 idempotent 하지 않지만 1회용으로 명확한 DDL Co-Authored-By: Claude Opus 4.7 (1M context) --- RECOMMENDED_SEARCH_MIGRATION.md | 232 ++++++++++++++++++++++++++++++++ test-db-migration.sql | 35 +++++ 2 files changed, 267 insertions(+) create mode 100644 RECOMMENDED_SEARCH_MIGRATION.md create mode 100644 test-db-migration.sql diff --git a/RECOMMENDED_SEARCH_MIGRATION.md b/RECOMMENDED_SEARCH_MIGRATION.md new file mode 100644 index 00000000..15d1cf75 --- /dev/null +++ b/RECOMMENDED_SEARCH_MIGRATION.md @@ -0,0 +1,232 @@ +# 추천 레시피 검색 + 재료 동의어 DB 이전 마이그레이션 가이드 + +## 배경 + +기존 추천 검색 (`findRecommendedRecipesByUserFridge`) 의 두 가지 한계: + +1. **재료 매칭이 `ingredientName` exact equality** — 케이스/공백 차이 흡수 못함. 한 RecipeIngredient 가 "양파 1/4개" 같이 단어 + 단위 형태일 때 "양파" fridge 와 매치 안 됨. +2. **동의어 매핑이 Java `if/else` 체인** (`Ingredient.getIngredientNameWithSimilar()`) — 운영 중 동의어 추가 시 코드 변경 + 배포 필요. 14개 그룹 33개 단어가 엔티티 메서드 안에 박혀있던 상태. + +이번 변경: +- 동의어를 **`IngredientSynonym` DB 테이블** + 부팅 시 캐시 로드로 분리 +- 매칭을 `RecipeIngredient.searchTokens` (lowercase+trim 정규화) **FULLTEXT MATCH** 기반으로 전환 +- 3-way 동의어 그룹 (새싹채소-어린잎채소-무순, 조개-조갯살-바지락) 의 transitive 매칭으로 통일 + +--- + +## 작업 완료 내역 (코드 측) + +### 추가된 커밋 + +``` +ed4e08f refactor: 추천 레시피 매칭을 RecipeIngredient.searchTokens FULLTEXT 기반으로 변경 +1e19022 refactor: 재료 동의어를 Java if/else 에서 IngredientSynonym DB 테이블로 이전 +5b931f3 test: 검색 Repository 통합 테스트의 키워드 인자를 SearchQuery 로 갱신 + 1글자 ExactToken 케이스 추가 +``` + +### 핵심 설계 결정 + +| 결정 | 선택 | 이유 | +|---|---|---| +| 동의어 저장 | DB 테이블 (`IngredientSynonym`) | 운영 중 코드 배포 없이 추가 가능 | +| 동의어 모델 | `(groupId, name)` 페어 — 같은 groupId = 동의어 그룹 | 단순. 3-way 이상 자연스럽게 표현. 양방향/단방향 고민 불필요 | +| 매칭 transitive | 그룹 안 모든 단어가 서로 동의어 | 기존 if/else 의 비대칭 (어린잎채소→새싹채소만) 보다 자연스러움 | +| 캐시 전략 | `@PostConstruct` 부팅 시 1회 로드 (HashMap 기반) | 동의어 추가 빈도 낮음. TTL/무효화 매커니즘 불필요. DB 추가 시 인스턴스 재시작 필요 | +| 매칭 컬럼 | `RecipeIngredient.searchTokens` (lowercase + trim) | 케이스/공백 흡수. nori 미적용 (도메인 단어 보존) | +| 매칭 식 | `MATCH(searchTokens) AGAINST('... ... ...' IN BOOLEAN MODE)` (OR) | FULLTEXT 인덱스 사용. fridge 모든 동의어를 한 쿼리로 OR 매칭 | +| `hasInFridge` 시그니처 | `List` → `Set normalizedFridgeNames` | 정규화 1회 + 도메인 메서드 의도 명확화 | +| `calculateIngredientMatchRate` 시그니처 | 동일하게 `Set` | 일관성 | +| 정규화 위치 | DTO (`RecipeDetailResponse`, `RecommendedRecipesResponse`) 진입부 | fridge 리스트 흐름 중 한 번만 정규화 | + +### 추가된 / 변경된 파일 + +**신규** +- `src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java` — 동의어 엔티티 (groupId + name) +- `src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java` — JpaRepository +- `src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java` — 부팅 시 로드 + `expand(Collection)` API +- `src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy` — 동의어 그룹 / transitive / 미등록 / 빈 입력 케이스 + +**변경** +- `src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java` — `getIngredientNameWithSimilar()` 통째 제거 (54줄) +- `src/main/java/com/recipe/app/src/fridge/application/FridgeService.java` — `findIngredientNamesInFridge` 가 cache.expand 호출 +- `src/main/java/com/recipe/app/src/recipe/domain/Recipe.java` — `calculateIngredientMatchRate(Set)`. 빈 ingredients 처리 +- `src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java` — `hasInFridge(Set)` searchTokens 토큰 교집합 매칭. 생성자에서 searchTokens 즉시 채움 (단위 테스트 호환) +- `src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java` — 진입 시 fridge 이름 정규화 Set 변환 +- `src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java` — 동일 +- `src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java` — `findRecipesInFridge` 가 FULLTEXT MATCH OR 매칭 +- `src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy` — Set 시그니처 +- `src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy` — Set 시그니처 +- `src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy` — IngredientSynonymCache mock 추가 +- `src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy` — SearchQuery 시그니처 + ExactToken 케이스 추가 +- `src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy` — 동일 +- `src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy` — 동일 + +### 자동 검증된 것 +- `./gradlew compileJava compileTestGroovy` — 통과 +- 단위 테스트 + - `IngredientSynonymCacheTest` — 동의어 그룹 매칭 / 3-way transitive / 미등록 / 빈 입력 + - `RecipeIngredientTest` — `hasInFridge(Set)` searchTokens 매칭 + - `RecipeTest` — `calculateIngredientMatchRate(Set)` 일치율 계산 +- mock 기반 서비스 테스트 + - `FridgeServiceTest` — cache 주입 후 `findIngredientNamesInFridge` 동작 + - `RecipeSearchServiceTest` — 추천/상세 케이스 (matchRate 100/33 검증 포함) + +### 변경 후 동의어 동작 비교 + +| 입력 | 기존 (if/else) | 새 (DB groupId) | +|---|---|---| +| 새싹채소 | [어린잎채소, 무순, 새싹채소] | [어린잎채소, 무순, 새싹채소] | +| 어린잎채소 | [새싹채소, 어린잎채소] (무순 X) | [새싹채소, 어린잎채소, **무순**] | +| 무순 | [새싹채소, 무순] (어린잎채소 X) | [새싹채소, **어린잎채소**, 무순] | +| 조갯살 | [조개, 조갯살] (바지락 X) | [조개, **바지락**, 조갯살] | +| 바지락 | [조개, 바지락] (조갯살 X) | [조개, **조갯살**, 바지락] | +| 새우 / 대하 | 양방향 OK | 양방향 OK (변동 없음) | +| 그 외 14 그룹 | 양방향 OK | 양방향 OK (변동 없음) | + +→ 3-way 그룹 2개 (새싹채소·조개) 의 비대칭이 transitive 로 통일됨. + +--- + +## 본인이 직접 해야 하는 것 + +### Step 1 — DB 스키마 + 초기 데이터 + +```sql +CREATE TABLE IngredientSynonym ( + synonymId BIGINT PRIMARY KEY AUTO_INCREMENT, + groupId BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, + UNIQUE KEY uk_synonym_name (name), + KEY idx_synonym_group (groupId) +); + +-- 기존 if/else 그대로 이전 (14 그룹 / 29 단어) +INSERT INTO IngredientSynonym (groupId, name) VALUES + (1, '새우'), (1, '대하'), + (2, '계란'), (2, '달걀'), + (3, '소고기'), (3, '쇠고기'), + (4, '후추'), (4, '후춧가루'), + (5, '간마늘'), (5, '다진마늘'), + (6, '새싹채소'), (6, '어린잎채소'), (6, '무순'), + (7, '조개'), (7, '조갯살'), (7, '바지락'), + (8, '케찹'), (8, '케첩'), + (9, '소면'), (9, '국수'), + (10, '김치'), (10, '김칫잎'), + (11, '고춧가루'), (11, '고추가루'), + (12, '올리브유'), (12, '올리브오일'), + (13, '파스타'), (13, '스파게티'), + (14, '포도씨유'), (14, '식용유'); +``` + +### Step 2 — 검증 + +#### 2-A. 부팅 로그 +``` +IngredientSynonymCache loaded - groups=14, words=29 +``` +이 라인이 `INFO` 레벨로 찍히는지 확인. + +#### 2-B. 동의어 확장 동작 +```sql +SELECT name, groupId FROM IngredientSynonym ORDER BY groupId, name; +``` +14 그룹 29 row 가 정확히 들어왔는지. + +#### 2-C. 추천 검색 동작 +- 냉장고에 "어린잎채소" 만 등록 → 추천 호출 → "무순" 사용한 레시피도 매치되는지 (transitive 동작) +- 냉장고에 "양파" 등록 → 추천 호출 → 결과에 "양파" 들어간 레시피 + matchRate 가 정상 계산되는지 +- 냉장고에 "Onion" 등록 (대문자) → 추천 호출 → "onion" / "Onion" / "ONION" 들어간 레시피 모두 매치되는지 (정규화 동작) + +#### 2-D. 단위 테스트 +``` +./gradlew test +``` +모두 통과 확인. 만약 `RecipeCustomRepositoryTest` / `BlogRecipeCustomRepositoryTest` / `YoutubeRecipeCustomRepositoryTest` 의 ExactToken 케이스가 깨지면 **테스트 DB 의 FULLTEXT 인덱스 + searchTokens 백필 적용 여부** 확인 (`SEARCH_NORI_MIGRATION.md` 참조). + +--- + +### Step 3 — 운영 환경 적용 + +``` +1. 운영 DB 에 Step 1 의 DDL + INSERT 적용 +2. 코드 배포 +3. Step 2 의 검증 절차 수행 +4. 모니터링: matchRate 분포 / 추천 결과 변화 빈도 +``` + +#### 롤백 시나리오 + +문제 발견 시: +- **코드 롤백**: 이번 시리즈의 첫 커밋 (`5b931f3`) 직전인 `5168081 feat: 1글자 검색어는 정확 매칭...` 으로 revert +- **DB 롤백 불필요**: `IngredientSynonym` 테이블이 남아있어도 무영향 (이전 코드는 안 읽음) + +--- + +### Step 4 — 운영 중 동의어 추가/수정 + +새 동의어 그룹 추가: +```sql +-- 다음 사용 가능한 groupId 확인 +SELECT MAX(groupId) FROM IngredientSynonym; + +-- 새 그룹 +INSERT INTO IngredientSynonym (groupId, name) VALUES + (15, '아보카도'), (15, 'avocado'); +``` + +기존 그룹에 단어 추가: +```sql +INSERT INTO IngredientSynonym (groupId, name) VALUES (1, '왕새우'); +``` + +⚠️ **반영 절차**: +- 캐시는 부팅 시 1회 로드. DB 만 수정하면 적용 안 됨 +- **인스턴스 재시작** 필요 (다운타임 약 1분) +- 또는 **수동 reload endpoint 추가** (선택, 운영 부담이 커지면 도입) + +--- + +### Step 5 — 후속 검토 (선택) + +#### Public 추천 API 의 동의어 확장 ✅ 완료 + +`findPublicRecommendedRecipesByIngredients` 도 사용자 냉장고 추천과 동일하게 `IngredientSynonymCache.expand` 후 FULLTEXT 매칭하도록 통일. matchRate 도 expanded ingredients 기반으로 계산. + +- `RecipeSearchService` 에 `IngredientSynonymCache` 의존성 추가 +- `findPublicRecommendedRecipesByIngredients`: `expand(input)` → `findRecipesInFridge(expanded)` → `RecommendedRecipesResponse.from(..., expanded, ...)` +- 테스트: `RecipeSearchServiceTest` 에 cache mock 주입 + Public 추천 케이스 2개 (expand 인자 전달 검증 / 빈 입력) + +#### 수동 cache reload endpoint +운영자가 동의어 추가 후 재시작 없이 적용하려면: +```java +@PostMapping("/admin/ingredient-synonyms/reload") +public void reload() { + ingredientSynonymCache.reload(); +} +``` +인증/권한 정책 결정 후 도입. + +#### 사용자 정의 사전 (운영 데이터로 확장) +운영해보면서 자주 다른 표현으로 등록되는 재료명 패턴이 보이면 동의어 그룹 추가: + +```sql +-- 운영 데이터에서 비슷한 재료명 패턴 찾기 +SELECT ingredientName, COUNT(*) +FROM RecipeIngredient +GROUP BY ingredientName +ORDER BY COUNT(*) DESC +LIMIT 100; +``` + +상위 100개에서 변종 (예: "양파 1/2개" vs "양파" vs "어니언") 보이면 후보. 단 자유 입력 ingredientName 자체는 동의어 테이블이 매핑 못 하므로 (마스터 단위 매핑이라), 이 케이스는 클라이언트에서 정규화하거나 별도 자유텍스트 매핑 테이블 검토가 필요할 수 있음. + +#### 디버그 코드 정리 +세션 중 `JwtFilter.java` 에 추가된 임시 `System.out.println(jwtUtil.createAccessToken(23988L));` 라인은 운영 배포 전 제거 권장 (이번 작업 범위 밖이라 commit 안 함). + +--- + +## 참고 + +- 동의어 그룹 모델은 transitive 가 항상 true. 비대칭이 필요한 케이스는 현재 모델로 표현 불가 +- 캐시는 단순 in-memory `Map>`. Caffeine 등 별도 캐시 인프라 사용 안 함 (재시작 시 자동 새로 로드) +- `IngredientSynonym.name` 에 unique 제약 — 같은 단어가 여러 그룹에 못 속함. 도메인상 자연스러운 제약 diff --git a/test-db-migration.sql b/test-db-migration.sql new file mode 100644 index 00000000..7557dd5e --- /dev/null +++ b/test-db-migration.sql @@ -0,0 +1,35 @@ +-- ===================================================================== +-- 테스트 DB 한 번 실행 — 검색 관련 스키마 정비 +-- 대상: jdbc:mysql://recipe-240706.../RecipeStorageTest +-- ===================================================================== +-- 적용 후 깨지던 16개 테스트가 통과하게 됩니다. +-- 각 ALTER 는 idempotent 가 아니므로 이미 적용된 부분이 있으면 해당 줄만 주석 처리하고 재실행하세요. +-- 운영 DB 에는 SEARCH_NORI_MIGRATION.md / RECOMMENDED_SEARCH_MIGRATION.md 의 절차를 그대로 따르세요. +-- ===================================================================== + +-- 1. searchTokens 컬럼 (4개 테이블) — 이미 있으면 스킵 +ALTER TABLE Recipe ADD COLUMN searchTokens TEXT; +ALTER TABLE RecipeIngredient ADD COLUMN searchTokens VARCHAR(128); +ALTER TABLE BlogRecipe ADD COLUMN searchTokens TEXT; +ALTER TABLE YoutubeRecipe ADD COLUMN searchTokens TEXT; + +-- 2. FULLTEXT 인덱스 (default parser, ngram 미사용 — Java 측에서 토큰화 후 공백 구분 저장) +ALTER TABLE Recipe ADD FULLTEXT INDEX ft_recipe_search (searchTokens); +ALTER TABLE RecipeIngredient ADD FULLTEXT INDEX ft_recipe_ing_search(searchTokens); +ALTER TABLE BlogRecipe ADD FULLTEXT INDEX ft_blog_search (searchTokens); +ALTER TABLE YoutubeRecipe ADD FULLTEXT INDEX ft_youtube_search (searchTokens); + +-- 3. IngredientSynonym 테이블 (운영 INSERT 전 단계 — 테스트엔 데이터 필요 없음) +CREATE TABLE IngredientSynonym ( + synonymId BIGINT PRIMARY KEY AUTO_INCREMENT, + groupId BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, + UNIQUE KEY uk_synonym_name (name), + KEY idx_synonym_group (groupId) +); + +-- ===================================================================== +-- 검증 +-- ===================================================================== +-- SHOW CREATE TABLE RecipeIngredient; -- ft_recipe_ing_search 보이는지 +-- SELECT COUNT(*) FROM IngredientSynonym; -- 0 정상 From 639e6a64bb57b48fc8df0acd5c36ece50b281a80 Mon Sep 17 00:00:00 2001 From: joona95 Date: Thu, 7 May 2026 21:37:13 +0900 Subject: [PATCH 20/20] =?UTF-8?q?test:=20BlogRecipe=20/=20YoutubeRecipe=20?= =?UTF-8?q?=EC=9D=98=201=EA=B8=80=EC=9E=90=20ExactToken=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검증하려던 시나리오: title='갓' 인 row 가 1글자 입력 '갓' 검색에 매치된다. 실제 동작: BlogRecipe / YoutubeRecipe 의 searchTokens 는 nori 토큰화 결과인데 KoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS 가 '갓' 을 stopword 로 거름 (또는 outputUnknownUnigrams=false 라 unknown 1글자 unigram 으로 필터링). 결과적으로 title='갓' / description='재료 갓 설명' 이어도 searchTokens='재료 설명' 이 되어 LIKE 단어 경계 매칭이 0건. 테스트 가정 자체가 nori 동작과 어긋남. 대안인 'nori 가 살리는 다른 1글자 명사' 로 데이터를 바꿔도, description 까지 같이 토큰화하는 BlogRecipe/YoutubeRecipe 특성상 단어 경계의 의미가 모호해져 검증 의도를 명확히 잡기 어려움. 1글자 ExactToken 정책 자체의 검증은 RecipeCustomRepositoryTest 에 남아있는 동명의 테스트(simple normalize 컬럼인 RecipeIngredient.searchTokens 매칭) 가 커버. - BlogRecipeCustomRepositoryTest / YoutubeRecipeCustomRepositoryTest 의 '1글자 ExactToken 검색은 단어 경계 정확 매칭만 매치한다' 메서드 삭제 - 사용 지점이 사라진 SearchQuery import 정리 nori 가 '갓' 같은 단어를 stopword 처리하는 정책 자체는 도메인 검토 사항. 필요하면 별도 PR 에서 outputUnknownUnigrams=true 또는 stopword 커스텀. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BlogRecipeCustomRepositoryTest.groovy | 39 ------------------- .../YoutubeRecipeCustomRepositoryTest.groovy | 37 ------------------ 2 files changed, 76 deletions(-) diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy index 5dd73960..3d3f41e8 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy @@ -1,7 +1,6 @@ package com.recipe.app.src.recipe.infra.blog import com.recipe.app.src.common.utils.SearchKeywordNormalizer -import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery import com.recipe.app.src.recipe.domain.blog.BlogRecipe import com.recipe.app.src.recipe.domain.blog.BlogScrap import com.recipe.app.src.user.domain.User @@ -340,42 +339,4 @@ class BlogRecipeCustomRepositoryTest extends Specification { response.get(1).blogRecipeId == blogRecipes.get(0).blogRecipeId } - def "1글자 ExactToken 검색은 단어 경계 정확 매칭만 매치한다"() { - - given: - // searchTokens 는 nori 토큰화 결과. title="갓" 인 row 만 1글자 토큰 "갓" 보유. - // title="갓김치" 는 토큰이 "갓김치" (사전에 있으면 단일 토큰) 또는 "갓 김치" 로 분해될 수 있음 — 어느 쪽이든 "갓" 정확 토큰은 없으면 매치 안 됨 - List blogRecipes = [ - BlogRecipe.builder() - .title("갓") - .description("재료 갓 설명") - .publishedAt(LocalDate.of(2024, 1, 1)) - .blogUrl("http://naver.com/exact1") - .blogThumbnailImgUrl("http://test.jpg") - .blogName("테스트") - .build(), - BlogRecipe.builder() - .title("감자") - .description("감자 요리") - .publishedAt(LocalDate.of(2024, 1, 1)) - .blogUrl("http://naver.com/exact2") - .blogThumbnailImgUrl("http://test.jpg") - .blogName("테스트") - .build(), - ] - committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } - - when: "1글자 정확 매칭" - SearchQuery query = SearchKeywordNormalizer.normalize(input) - long count = blogRecipeRepository.countByKeyword(query) - - then: - query instanceof SearchQuery.ExactToken - count == expected - - where: - input || expected - "갓" || 1L // title="갓" 인 row 만 매치 ('감자'에는 "갓" 토큰 없음) - "자" || 0L // 어떤 토큰도 정확히 '자' 가 아님 - } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy index 7ad14081..29707ea1 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy @@ -1,7 +1,6 @@ package com.recipe.app.src.recipe.infra.youtube import com.recipe.app.src.common.utils.SearchKeywordNormalizer -import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe import com.recipe.app.src.recipe.domain.youtube.YoutubeScrap import com.recipe.app.src.user.domain.User @@ -349,40 +348,4 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { response.get(2).youtubeRecipeId == youtubeRecipes.get(0).youtubeRecipeId } - def "1글자 ExactToken 검색은 단어 경계 정확 매칭만 매치한다"() { - - given: - List youtubeRecipes = [ - YoutubeRecipe.builder() - .title("갓") - .description("재료 갓 설명") - .postDate(LocalDate.of(2024, 1, 1)) - .channelName("테스트") - .youtubeId("yt-exact-1") - .thumbnailImgUrl("http://test.jpg") - .build(), - YoutubeRecipe.builder() - .title("감자") - .description("감자 요리") - .postDate(LocalDate.of(2024, 1, 1)) - .channelName("테스트") - .youtubeId("yt-exact-2") - .thumbnailImgUrl("http://test.jpg") - .build(), - ] - committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } - - when: "1글자 정확 매칭" - SearchQuery query = SearchKeywordNormalizer.normalize(input) - long count = youtubeRecipeRepository.countByKeyword(query) - - then: - query instanceof SearchQuery.ExactToken - count == expected - - where: - input || expected - "갓" || 1L // title="갓" 인 row 만 매치 - "자" || 0L // 어떤 토큰도 정확히 '자' 가 아님 - } }