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 29598047..fd6d51bc 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 @@ -2,10 +2,13 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.core.types.dsl.StringPath; +import java.util.Arrays; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.stream.Collectors; public class QueryUtils { @@ -21,8 +24,42 @@ public static BooleanExpression ifIdIsNotNullAndGreaterThanZero(Function relevanceScore(StringPath column, String booleanQuery) { return Expressions.numberTemplate(Double.class, - "function('match_against', {0}, {1})", column, booleanQuery).gt(0); + "function('match_against', {0}, {1})", column, booleanQuery); + } + + /** + * "제목 매칭 우선" 정렬용 가중치 점수 = 제목 일치율 × 1,000,000 + 전체 일치율. + * 제목에서 맞으면 점수가 압도적으로 커져, 내용에서만 맞은 결과보다 항상 위로 정렬된다. + * (BOOLEAN MODE 점수는 짧은 레시피 텍스트 기준 한 자릿수 수준이라 1e6 가중치면 계층이 뒤집히지 않는다.) + */ + public static NumberExpression titlePriorityScore(StringPath titleTokens, StringPath allTokens, String booleanQuery) { + // 제목 계층은 AND(모든 검색어 필수, +토큰)로 평가 → 제목에 검색어를 "다 포함"한 글만 상위로. + // "간장 국수" 검색 시 제목에 국수만 3번 반복한 초계국수(간장 없음)는 제목 점수 0 이 되어 밀린다. + // 전체(allTokens)는 OR 유지 → recall 보존(일부만 맞아도 결과엔 남음). + return relevanceScore(titleTokens, toRequiredForm(booleanQuery)).multiply(1_000_000.0) + .add(relevanceScore(allTokens, booleanQuery)); + } + + /** + * OR BOOLEAN 쿼리("간장 국수")를 AND 필수형("+간장 +국수")으로 변환. + * FULLTEXT 최소 토큰 길이(2) 미만인 1글자 토큰은 인덱싱이 안 돼 필수(+)로 걸면 매칭이 깨지므로 그대로 둔다. + */ + public static String toRequiredForm(String orBooleanQuery) { + if (orBooleanQuery == null || orBooleanQuery.isBlank()) { + return orBooleanQuery; + } + return Arrays.stream(orBooleanQuery.trim().split("\\s+")) + .map(token -> token.length() >= 2 ? "+" + token : token) + .collect(Collectors.joining(" ")); } /** 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 84dc9ebc..71ddf50b 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 @@ -74,23 +74,26 @@ public RecipesResponse findRecipesByKeywordOrderBy(User user, String keyword, lo private List findByKeywordOrderByRecipeScrapCnt(SearchQuery query, long lastRecipeId, int size) { + Double lastRelevance = recipeRepository.findRelevanceScoreByRecipeId(query, lastRecipeId); long recipeScrapCnt = recipeScrapService.countByRecipeId(lastRecipeId); - return recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(query, lastRecipeId, recipeScrapCnt, size); + return recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(query, lastRecipeId, lastRelevance, recipeScrapCnt, size); } private List findByKeywordOrderByRecipeViewCnt(SearchQuery query, long lastRecipeId, int size) { + Double lastRelevance = recipeRepository.findRelevanceScoreByRecipeId(query, lastRecipeId); long recipeViewCnt = recipeViewService.countByRecipeId(lastRecipeId); - return recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(query, lastRecipeId, recipeViewCnt, size); + return recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(query, lastRecipeId, lastRelevance, recipeViewCnt, size); } private List findByKeywordOrderByCreatedAt(SearchQuery query, long lastRecipeId, int size) { + Double lastRelevance = recipeRepository.findRelevanceScoreByRecipeId(query, lastRecipeId); Recipe recipe = recipeRepository.findById(lastRecipeId).orElse(null); - return recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(query, lastRecipeId, recipe != null ? recipe.getCreatedAt() : null, size); + return recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(query, lastRecipeId, lastRelevance, recipe != null ? recipe.getCreatedAt() : null, size); } @Transactional(readOnly = true) 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 5f4df9ea..9f4cc3c5 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 @@ -36,32 +36,37 @@ public SearchTokensBackfillRunner(PlatformTransactionManager transactionManager) public void run(ApplicationArguments args) { log.info("SearchTokens backfill started"); - backfill("Recipe", "recipeId", new String[]{"recipeNm", "introduction"}, NORI_TOKENIZE); + backfill("Recipe", "recipeId", "searchTokens", 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); + backfill("RecipeIngredient", "recipeIngredientId", "searchTokens", new String[]{"ingredientName"}, SIMPLE_NORMALIZE); + backfill("BlogRecipe", "blogRecipeId", "searchTokens", new String[]{"title", "description"}, NORI_TOKENIZE); + backfill("YoutubeRecipe", "youtubeRecipeId", "searchTokens", new String[]{"title", "description"}, NORI_TOKENIZE); + + // 제목 우선 정렬용 titleSearchTokens (제목만 토큰화). 재료(RecipeIngredient)는 제목 개념이 없어 제외. + backfill("Recipe", "recipeId", "titleSearchTokens", new String[]{"recipeNm"}, NORI_TOKENIZE); + backfill("BlogRecipe", "blogRecipeId", "titleSearchTokens", new String[]{"title"}, NORI_TOKENIZE); + backfill("YoutubeRecipe", "youtubeRecipeId", "titleSearchTokens", new String[]{"title"}, NORI_TOKENIZE); log.info("SearchTokens backfill done"); } - private void backfill(String table, String pk, String[] sourceColumns, Function tokenizer) { + private void backfill(String table, String pk, String targetColumn, String[] sourceColumns, Function tokenizer) { - log.info("[{}] backfill started", table); + log.info("[{}.{}] backfill started", table, targetColumn); long lastId = 0L; long total = 0L; while (true) { long startId = lastId; - BatchResult r = transactionTemplate.execute(status -> processBatch(table, pk, sourceColumns, startId, tokenizer)); + BatchResult r = transactionTemplate.execute(status -> processBatch(table, pk, targetColumn, sourceColumns, startId, tokenizer)); if (r == null || r.processed == 0) break; lastId = r.lastId; total += r.updated; - log.info("[{}] progress lastId={} updatedSoFar={}", table, lastId, total); + log.info("[{}.{}] progress lastId={} updatedSoFar={}", table, targetColumn, lastId, total); } - log.info("[{}] backfill done. updated={}", table, total); + log.info("[{}.{}] backfill done. updated={}", table, targetColumn, total); } - private BatchResult processBatch(String table, String pk, String[] sourceColumns, long startId, Function tokenizer) { + private BatchResult processBatch(String table, String pk, String targetColumn, String[] sourceColumns, long startId, Function tokenizer) { String columnList = String.join(", ", sourceColumns); String selectSql = "SELECT " + pk + ", " + columnList + @@ -78,7 +83,7 @@ private BatchResult processBatch(String table, String pk, String[] sourceColumns if (rows.isEmpty()) return new BatchResult(0, 0, startId); - String updateSql = "UPDATE " + table + " SET searchTokens = :tokens WHERE " + pk + " = :id"; + String updateSql = "UPDATE " + table + " SET " + targetColumn + " = :tokens WHERE " + pk + " = :id"; int updated = 0; long lastId = startId; 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 721ffa88..de310ad2 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 @@ -7,7 +7,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -36,6 +40,10 @@ public BlogRecipeClientSearchService(BlogRecipeRepository blogRecipeRepository, this.blogRecipeThumbnailCrawlingService = blogRecipeThumbnailCrawlingService; } + // REQUIRES_NEW: 호출한 검색 트랜잭션과 분리된 새 트랜잭션에서 저장하고, 이 메서드가 반환되는 시점에 + // 커밋된다. 호출 측(BlogRecipeService)이 잡은 키워드 락은 이 커밋 이후에 풀리므로, + // 다음 요청은 여기서 커밋된 blogUrl 을 findByBlogUrlIn 으로 보고 걸러낸다(중복 방지). + @Transactional(propagation = Propagation.REQUIRES_NEW) @CircuitBreaker(name = "recipe-blog-search", fallbackMethod = "fallback") public void searchNaverBlogRecipes(String keyword) { @@ -60,11 +68,16 @@ public void fallback(String keyword, Throwable e) { private List createBlogRecipes(List blogRecipes) { - List blogUrls = blogRecipes.stream().map(BlogRecipe::getBlogUrl).collect(Collectors.toList()); + // 네이버 응답이 한 배치 안에서 같은 blogUrl 을 중복으로 내려주는 경우가 있어, 먼저 blogUrl 기준으로 distinct 처리한다. + List distinctBlogRecipes = new ArrayList<>(blogRecipes.stream() + .collect(Collectors.toMap(BlogRecipe::getBlogUrl, Function.identity(), (o1, o2) -> o1, LinkedHashMap::new)) + .values()); + + List blogUrls = distinctBlogRecipes.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)); - return blogRecipeRepository.saveAll(blogRecipes.stream() + return blogRecipeRepository.saveAll(distinctBlogRecipes.stream() .filter(blogRecipe -> !existBlogRecipeMapByBlogUrl.containsKey(blogRecipe.getBlogUrl())) .collect(Collectors.toList())); } 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 2d74eb6e..a36cc5e3 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 @@ -10,10 +10,12 @@ import com.recipe.app.src.recipe.domain.blog.BlogScrap; import com.recipe.app.src.recipe.infra.blog.BlogRecipeRepository; import com.recipe.app.src.user.domain.User; +import com.google.common.util.concurrent.Striped; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.concurrent.locks.Lock; @Service public class BlogRecipeService { @@ -28,6 +30,11 @@ public class BlogRecipeService { private static final int MIN_RECIPE_CNT = 10; + // 같은 키워드를 여러 요청이 동시에 처음 검색할 때, 각자 findByBlogUrlIn(빈 결과) → 각자 saveAll 로 + // 같은 blogUrl 이 중복 저장되는 레이스를 막는다. 단일 인스턴스(EC2 1대) 전제의 JVM 내부 락. + // 고정 64개 stripe 라 키워드가 늘어도 락 객체가 무한정 쌓이지 않는다. + private final Striped keywordLocks = Striped.lock(64); + public BlogRecipeService(BlogRecipeRepository blogRecipeRepository, BlogScrapService blogScrapService, BlogViewService blogViewService, BadWordFiltering badWordFiltering, BlogRecipeClientSearchService blogRecipeClientSearchService, SearchKeywordService searchKeywordService) { @@ -52,7 +59,15 @@ public RecipesResponse findBlogRecipesByKeyword(User user, String keyword, long long totalCnt = blogRecipeRepository.countByKeyword(query); if (totalCnt < MIN_RECIPE_CNT) { - blogRecipeClientSearchService.searchNaverBlogRecipes(keyword); + // 락을 잡은 한 요청만 네이버에서 채우고, 그 트랜잭션(REQUIRES_NEW)이 커밋된 뒤 락을 푼다. + // 뒤이어 락을 잡는 요청은 findByBlogUrlIn 에서 앞 요청이 커밋한 행을 보고 걸러내므로 중복이 안 쌓인다. + Lock lock = keywordLocks.get(keyword); + lock.lock(); + try { + blogRecipeClientSearchService.searchNaverBlogRecipes(keyword); + } finally { + lock.unlock(); + } } List blogRecipes = findByKeywordOrderBy(query, lastBlogRecipeId, size, sort); @@ -77,23 +92,26 @@ private List findByKeywordOrderBy(SearchQuery query, long lastBlogRe private List findByKeywordOrderByBlogScrapCnt(SearchQuery query, long lastBlogRecipeId, int size) { + Double lastRelevance = blogRecipeRepository.findRelevanceScoreByBlogRecipeId(query, lastBlogRecipeId); long lastBlogScrapCnt = blogScrapService.countByBlogRecipeId(lastBlogRecipeId); - return blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(query, lastBlogRecipeId, lastBlogScrapCnt, size); + return blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(query, lastBlogRecipeId, lastRelevance, lastBlogScrapCnt, size); } private List findByKeywordOrderByBlogViewCnt(SearchQuery query, long lastBlogRecipeId, int size) { + Double lastRelevance = blogRecipeRepository.findRelevanceScoreByBlogRecipeId(query, lastBlogRecipeId); long lastBlogViewCnt = blogViewService.countByBlogRecipeId(lastBlogRecipeId); - return blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(query, lastBlogRecipeId, lastBlogViewCnt, size); + return blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(query, lastBlogRecipeId, lastRelevance, lastBlogViewCnt, size); } private List findByKeywordOrderByPublishedAt(SearchQuery query, long lastBlogRecipeId, int size) { + Double lastRelevance = blogRecipeRepository.findRelevanceScoreByBlogRecipeId(query, lastBlogRecipeId); BlogRecipe blogRecipe = blogRecipeRepository.findById(lastBlogRecipeId).orElse(null); - return blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(query, lastBlogRecipeId, blogRecipe == null ? null : blogRecipe.getPublishedAt(), size); + return blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(query, lastBlogRecipeId, lastRelevance, blogRecipe == null ? null : blogRecipe.getPublishedAt(), size); } @Transactional(readOnly = true) 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 7e856862..b3cc10f1 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 @@ -2,33 +2,63 @@ import com.recipe.app.src.recipe.domain.blog.BlogRecipe; import com.recipe.app.src.recipe.infra.blog.BlogRecipeRepository; +import lombok.extern.slf4j.Slf4j; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import java.net.URL; import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +@Slf4j @Service public class BlogRecipeThumbnailCrawlingService { + // 썸네일 크롤링은 URL 당 외부 HTTP 를 최대 2회 호출한다. 순차로 하면 매우 느려서 병렬로 처리한다. + private static final int CRAWL_POOL_SIZE = 32; + // 첫 검색이 썸네일 크롤링 때문에 지나치게 오래 블로킹되지 않도록 전체 대기 상한을 둔다. + private static final long CRAWL_TIMEOUT_SECONDS = 6L; + // 각 페이지 fetch 타임아웃(ms). Jsoup connect 의 기본값(30s)은 느린 URL 하나가 스레드를 오래 점유하므로 줄인다. + private static final int FETCH_TIMEOUT_MS = 3000; + private final BlogRecipeRepository blogRecipeRepository; public BlogRecipeThumbnailCrawlingService(BlogRecipeRepository blogRecipeRepository) { this.blogRecipeRepository = blogRecipeRepository; } - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) + /** + * 신규 저장된 블로그 레시피들의 썸네일을 크롤링해 채운다. + * 첫 검색에서도 썸네일이 보이도록 동기로 처리하되, 병렬 + 전체 타임아웃으로 대기 시간을 제한한다. + * 타임아웃 내에 못 끝낸 소수는 썸네일이 빈 채로 남는다(크롤링 실패도 빈 문자열). + */ public void saveThumbnails(List blogRecipes) { - for (BlogRecipe blogRecipe : blogRecipes) { - blogRecipe.changeThumbnail(getBlogThumbnailUrl(blogRecipe.getBlogUrl())); + if (blogRecipes == null || blogRecipes.isEmpty()) { + return; + } + + ExecutorService pool = Executors.newFixedThreadPool(Math.min(CRAWL_POOL_SIZE, blogRecipes.size())); + try { + List> tasks = blogRecipes.stream() + .map(blogRecipe -> (Callable) () -> { + blogRecipe.changeThumbnail(getBlogThumbnailUrl(blogRecipe.getBlogUrl())); + return null; + }) + .collect(Collectors.toList()); + pool.invokeAll(tasks, CRAWL_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("blog thumbnail crawling interrupted"); + } finally { + pool.shutdownNow(); } blogRecipeRepository.saveAll(blogRecipes); @@ -50,13 +80,13 @@ private String getNaverBlogThumbnailUrl(String blogUrl) { try { URL url = new URL(blogUrl); - Document doc = Jsoup.parse(url, 5000); + Document doc = Jsoup.parse(url, FETCH_TIMEOUT_MS); Elements iframes = doc.select("iframe#mainFrame"); String src = iframes.attr("src"); String url2 = "http://blog.naver.com" + src; - Document doc2 = Jsoup.connect(url2).get(); + Document doc2 = Jsoup.connect(url2).timeout(FETCH_TIMEOUT_MS).get(); return doc2.select("meta[property=og:image]").get(0).attr("content"); } catch (Exception e) { @@ -68,7 +98,7 @@ private String getTistoryBlogThumbnailUrl(String blogUrl) { try { - Document doc = Jsoup.connect(blogUrl).get(); + Document doc = Jsoup.connect(blogUrl).timeout(FETCH_TIMEOUT_MS).get(); Elements imageLinks = doc.getElementsByTag("img"); String thumbnailUrl = null; 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 8a848e54..e157c5e4 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 @@ -75,23 +75,26 @@ private List findByKeywordOrderBy(SearchQuery query, long lastYou private List findByKeywordOrderByYoutubeScrapCnt(SearchQuery query, long lastYoutubeRecipeId, int size) { + Double lastRelevance = youtubeRecipeRepository.findRelevanceScoreByYoutubeRecipeId(query, lastYoutubeRecipeId); long youtubeScrapCnt = youtubeScrapService.countByYoutubeRecipeId(lastYoutubeRecipeId); - return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(query, lastYoutubeRecipeId, youtubeScrapCnt, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(query, lastYoutubeRecipeId, lastRelevance, youtubeScrapCnt, size); } private List findByKeywordOrderByYoutubeViewCnt(SearchQuery query, long lastYoutubeRecipeId, int size) { + Double lastRelevance = youtubeRecipeRepository.findRelevanceScoreByYoutubeRecipeId(query, lastYoutubeRecipeId); long youtubeViewCnt = youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId); - return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(query, lastYoutubeRecipeId, youtubeViewCnt, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(query, lastYoutubeRecipeId, lastRelevance, youtubeViewCnt, size); } private List findByKeywordOrderByPostDate(SearchQuery query, long lastYoutubeRecipeId, int size) { + Double lastRelevance = youtubeRecipeRepository.findRelevanceScoreByYoutubeRecipeId(query, lastYoutubeRecipeId); YoutubeRecipe youtubeRecipe = youtubeRecipeRepository.findById(lastYoutubeRecipeId).orElse(null); - return youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(query, lastYoutubeRecipeId, youtubeRecipe != null ? youtubeRecipe.getPostDate() : null, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(query, lastYoutubeRecipeId, lastRelevance, youtubeRecipe != null ? youtubeRecipe.getPostDate() : null, size); } @Transactional(readOnly = true) 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 3b539791..ea9d2075 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 @@ -77,6 +77,10 @@ public class Recipe extends BaseEntity { @Column(name = "searchTokens", columnDefinition = "TEXT") private String searchTokens; + // 제목(recipeNm)만 토큰화. 검색 정렬에서 "제목 매칭 우선" 계층에 사용. + @Column(name = "titleSearchTokens", columnDefinition = "TEXT") + private String titleSearchTokens; + @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) List ingredients = new ArrayList<>(); @@ -175,5 +179,6 @@ public long calculateIngredientMatchRate(Set normalizedFridgeNames) { private void refreshSearchTokens() { String source = (recipeNm != null ? recipeNm : "") + " " + (introduction != null ? introduction : ""); this.searchTokens = KoreanTokenizer.tokenize(source); + this.titleSearchTokens = KoreanTokenizer.tokenize(recipeNm != null ? recipeNm : ""); } } 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 31b13c89..4b67551d 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 @@ -58,6 +58,10 @@ public class BlogRecipe extends BaseEntity { @Column(name = "searchTokens", columnDefinition = "TEXT") private String searchTokens; + // 제목(title)만 토큰화. 검색 정렬에서 "제목 매칭 우선" 계층에 사용. + @Column(name = "titleSearchTokens", columnDefinition = "TEXT") + private String titleSearchTokens; + @Builder public BlogRecipe(Long blogRecipeId, String blogUrl, String blogThumbnailImgUrl, String title, String description, LocalDate publishedAt, String blogName, long scrapCnt, long viewCnt) { @@ -99,5 +103,6 @@ public void changeThumbnail(String blogThumbnailUrl) { private void refreshSearchTokens() { String source = (title != null ? title : "") + " " + (description != null ? description : ""); this.searchTokens = KoreanTokenizer.tokenize(source); + this.titleSearchTokens = KoreanTokenizer.tokenize(title != null ? title : ""); } } 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 3a5a7078..7995f6f1 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 @@ -58,6 +58,10 @@ public class YoutubeRecipe extends BaseEntity { @Column(name = "searchTokens", columnDefinition = "TEXT") private String searchTokens; + // 제목(title)만 토큰화. 검색 정렬에서 "제목 매칭 우선" 계층에 사용. + @Column(name = "titleSearchTokens", columnDefinition = "TEXT") + private String titleSearchTokens; + @Builder public YoutubeRecipe(Long youtubeRecipeId, String title, String description, String thumbnailImgUrl, LocalDate postDate, String channelName, String youtubeId, long scrapCnt, long viewCnt) { @@ -95,5 +99,6 @@ public void plusViewCnt() { private void refreshSearchTokens() { String source = (title != null ? title : "") + " " + (description != null ? description : ""); this.searchTokens = KoreanTokenizer.tokenize(source); + this.titleSearchTokens = KoreanTokenizer.tokenize(title != null ? title : ""); } } 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 83b3c843..aad7a7e0 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 @@ -14,11 +14,15 @@ public interface RecipeCustomRepository { Long countByKeyword(SearchQuery query); - List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, LocalDateTime createdAt, int size); + // 아래 정렬들은 모두 "검색어 일치율(relevance) 대분류 → 각 정렬키 소분류 → recipeId" 순. + // lastRelevance 는 커서(직전 페이지 마지막 레시피)의 현재 검색어 기준 점수. findRelevanceScoreByRecipeId 로 구해 넘긴다. + Double findRelevanceScoreByRecipeId(SearchQuery query, Long recipeId); - List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, long recipeScrapCnt, int size); + List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, LocalDateTime createdAt, int size); - List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, long recipeViewCnt, int size); + List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, long recipeScrapCnt, int size); + + List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, 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 c970178d..bf199404 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 @@ -13,11 +13,15 @@ import java.util.stream.Collectors; import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; 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.common.utils.QueryUtils.relevanceScore; +import static com.recipe.app.src.common.utils.QueryUtils.titlePriorityScore; 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; @@ -53,59 +57,106 @@ public Long countByKeyword(SearchQuery query) { } @Override - public List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, LocalDateTime lastCreatedAt, int size) { + public List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, LocalDateTime lastCreatedAt, int size) { return queryFactory .selectFrom(recipe) .where( recipe.hiddenYn.eq("N"), keywordMatch(query), - ifIdIsNotNullAndGreaterThanZero((recipeId, createdAt) -> recipe.createdAt.lt(createdAt) - .or(recipe.createdAt.eq(createdAt) - .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastCreatedAt) + relevanceCursor(query, lastRecipeId, lastRelevance, + () -> recipe.createdAt.lt(lastCreatedAt), () -> recipe.createdAt.eq(lastCreatedAt)) ) - .orderBy(recipe.createdAt.desc(), recipe.recipeId.desc()) + .orderBy(relevanceOrder(query, recipe.createdAt.desc())) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, long lastRecipeScrapCnt, int size) { + public Double findRelevanceScoreByRecipeId(SearchQuery query, Long recipeId) { + + // 커서로 쓸 "직전 페이지 마지막 레시피"의 현재 검색어 기준 점수를 구한다. (최신순의 createdAt findById 대응) + // 점수 개념이 없는 1글자(ExactToken)/빈 쿼리는 null → 첫 페이지처럼 keyset 조건 없이 처리된다. + if (recipeId == null || recipeId <= 0 || !(query instanceof SearchQuery.BooleanQuery b)) { + return null; + } + + return queryFactory + .select(titlePriorityScore(recipe.titleSearchTokens, recipe.searchTokens, b.query())) + .from(recipe) + .where(recipe.recipeId.eq(recipeId)) + .fetchOne(); + } + + @Override + public List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, long lastRecipeScrapCnt, int size) { return queryFactory .selectFrom(recipe) .where( recipe.hiddenYn.eq("N"), keywordMatch(query), - ifIdIsNotNullAndGreaterThanZero((recipeId, recipeScrapCnt) -> recipe.scrapCnt.lt(recipeScrapCnt) - .or(recipe.scrapCnt.eq(recipeScrapCnt) - .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastRecipeScrapCnt) + relevanceCursor(query, lastRecipeId, lastRelevance, + () -> recipe.scrapCnt.lt(lastRecipeScrapCnt), () -> recipe.scrapCnt.eq(lastRecipeScrapCnt)) ) - .orderBy(recipe.scrapCnt.desc(), recipe.recipeId.desc()) + .orderBy(relevanceOrder(query, recipe.scrapCnt.desc())) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, long lastRecipeViewCnt, int size) { + public List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, Double lastRelevance, long lastRecipeViewCnt, int size) { return queryFactory .selectFrom(recipe) .where( recipe.hiddenYn.eq("N"), keywordMatch(query), - ifIdIsNotNullAndGreaterThanZero((recipeId, recipeViewCnt) -> recipe.viewCnt.lt(recipeViewCnt) - .or(recipe.viewCnt.eq(recipeViewCnt) - .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastRecipeViewCnt) + relevanceCursor(query, lastRecipeId, lastRelevance, + () -> recipe.viewCnt.lt(lastRecipeViewCnt), () -> recipe.viewCnt.eq(lastRecipeViewCnt)) ) - .orderBy(recipe.viewCnt.desc(), recipe.recipeId.desc()) + .orderBy(relevanceOrder(query, recipe.viewCnt.desc())) .limit(size) .fetch(); } + /** + * "검색어 일치율(relevance) 대분류 → 소분류(secondary) → recipeId" 정렬의 keyset 커서. + * BooleanQuery(2글자+)면 relevance 를 맨 앞에, 그 외(1글자 ExactToken 등)엔 relevance 계층 없이 기존 순수 keyset. + * secondary 는 lastCreatedAt 이 null 일 수 있어 지연 평가(Supplier)로 받는다. lastRecipeId<=0(첫 페이지)이면 커서 없음. + */ + private BooleanExpression relevanceCursor(SearchQuery query, Long lastRecipeId, Double lastRelevance, + java.util.function.Supplier secondaryLt, + java.util.function.Supplier secondaryEq) { + + if (lastRecipeId == null || lastRecipeId <= 0) { + return null; + } + + BooleanExpression idLt = recipe.recipeId.lt(lastRecipeId); + + if (query instanceof SearchQuery.BooleanQuery b && lastRelevance != null) { + NumberExpression score = titlePriorityScore(recipe.titleSearchTokens, recipe.searchTokens, b.query()); + return score.lt(lastRelevance) + .or(score.eq(lastRelevance).and(secondaryLt.get())) + .or(score.eq(lastRelevance).and(secondaryEq.get()).and(idLt)); + } + + return secondaryLt.get().or(secondaryEq.get().and(idLt)); + } + + /** + * BooleanQuery 면 relevance DESC 를 맨 앞에 둔 정렬키, 그 외엔 기존 (secondary, recipeId) 정렬키를 반환. + */ + private OrderSpecifier[] relevanceOrder(SearchQuery query, OrderSpecifier secondary) { + + if (query instanceof SearchQuery.BooleanQuery b) { + return new OrderSpecifier[]{titlePriorityScore(recipe.titleSearchTokens, recipe.searchTokens, b.query()).desc(), secondary, recipe.recipeId.desc()}; + } + + return new OrderSpecifier[]{secondary, recipe.recipeId.desc()}; + } + private BooleanExpression keywordMatch(SearchQuery query) { return matchSearchQuery(recipe.searchTokens, query) .or(JPAExpressions.selectOne() 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 64214172..966c6a29 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 @@ -13,11 +13,14 @@ public interface BlogRecipeCustomRepository { List findByKeywordLimit(String keyword, int size); - List findByKeywordLimitOrderByPublishedAtDesc(SearchQuery query, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size); + // 아래 정렬들은 모두 "검색어 일치율(relevance) 대분류 → 각 정렬키 소분류 → blogRecipeId" 순. + Double findRelevanceScoreByBlogRecipeId(SearchQuery query, Long blogRecipeId); - List findByKeywordLimitOrderByBlogScrapCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogScrapCnt, int size); + List findByKeywordLimitOrderByPublishedAtDesc(SearchQuery query, Long lastBlogRecipeId, Double lastRelevance, LocalDate lastBlogRecipePublishedAt, int size); - List findByKeywordLimitOrderByBlogViewCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogViewCnt, int size); + List findByKeywordLimitOrderByBlogScrapCntDesc(SearchQuery query, Long lastBlogRecipeId, Double lastRelevance, long lastBlogScrapCnt, int size); + + List findByKeywordLimitOrderByBlogViewCntDesc(SearchQuery query, Long lastBlogRecipeId, Double lastRelevance, 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 02fb2637..2c148b59 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 @@ -3,15 +3,21 @@ import com.recipe.app.src.common.infra.BaseRepositoryImpl; import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; import jakarta.persistence.EntityManager; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.function.Supplier; 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.common.utils.QueryUtils.relevanceScore; +import static com.recipe.app.src.common.utils.QueryUtils.titlePriorityScore; import static com.recipe.app.src.recipe.domain.blog.QBlogRecipe.blogRecipe; import static com.recipe.app.src.recipe.domain.blog.QBlogScrap.blogScrap; @@ -46,56 +52,93 @@ public List findByKeywordLimit(String keyword, int size) { } @Override - public List findByKeywordLimitOrderByPublishedAtDesc(SearchQuery query, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size) { + public Double findRelevanceScoreByBlogRecipeId(SearchQuery query, Long blogRecipeId) { + + if (blogRecipeId == null || blogRecipeId <= 0 || !(query instanceof SearchQuery.BooleanQuery b)) { + return null; + } + + return queryFactory + .select(titlePriorityScore(blogRecipe.titleSearchTokens, blogRecipe.searchTokens, b.query())) + .from(blogRecipe) + .where(blogRecipe.blogRecipeId.eq(blogRecipeId)) + .fetchOne(); + } + + @Override + public List findByKeywordLimitOrderByPublishedAtDesc(SearchQuery query, Long lastBlogRecipeId, Double lastRelevance, LocalDate lastBlogRecipePublishedAt, int size) { return queryFactory .selectFrom(blogRecipe) .where( matchSearchQuery(blogRecipe.searchTokens, query), - ifIdIsNotNullAndGreaterThanZero((blogRecipeId, publishedAt) -> blogRecipe.publishedAt.lt(publishedAt) - .or(blogRecipe.publishedAt.eq(publishedAt) - .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), - lastBlogRecipeId, lastBlogRecipePublishedAt) + relevanceCursor(query, lastBlogRecipeId, lastRelevance, + () -> blogRecipe.publishedAt.lt(lastBlogRecipePublishedAt), () -> blogRecipe.publishedAt.eq(lastBlogRecipePublishedAt)) ) - .orderBy(blogRecipe.publishedAt.desc(), blogRecipe.blogRecipeId.desc()) + .orderBy(relevanceOrder(query, blogRecipe.publishedAt.desc())) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByBlogScrapCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogScrapCnt, int size) { + public List findByKeywordLimitOrderByBlogScrapCntDesc(SearchQuery query, Long lastBlogRecipeId, Double lastRelevance, long lastBlogScrapCnt, int size) { return queryFactory .selectFrom(blogRecipe) .where( matchSearchQuery(blogRecipe.searchTokens, query), - ifIdIsNotNullAndGreaterThanZero((blogRecipeId, blogScrapCnt) -> blogRecipe.scrapCnt.lt(blogScrapCnt) - .or(blogRecipe.scrapCnt.eq(blogScrapCnt) - .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), - lastBlogRecipeId, lastBlogScrapCnt) + relevanceCursor(query, lastBlogRecipeId, lastRelevance, + () -> blogRecipe.scrapCnt.lt(lastBlogScrapCnt), () -> blogRecipe.scrapCnt.eq(lastBlogScrapCnt)) ) - .orderBy(blogRecipe.scrapCnt.desc(), blogRecipe.blogRecipeId.desc()) + .orderBy(relevanceOrder(query, blogRecipe.scrapCnt.desc())) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByBlogViewCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogViewCnt, int size) { + public List findByKeywordLimitOrderByBlogViewCntDesc(SearchQuery query, Long lastBlogRecipeId, Double lastRelevance, long lastBlogViewCnt, int size) { return queryFactory .selectFrom(blogRecipe) .where( matchSearchQuery(blogRecipe.searchTokens, query), - ifIdIsNotNullAndGreaterThanZero((blogRecipeId, blogViewCnt) -> blogRecipe.viewCnt.lt(blogViewCnt) - .or(blogRecipe.viewCnt.eq(blogViewCnt) - .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), - lastBlogRecipeId, lastBlogViewCnt) + relevanceCursor(query, lastBlogRecipeId, lastRelevance, + () -> blogRecipe.viewCnt.lt(lastBlogViewCnt), () -> blogRecipe.viewCnt.eq(lastBlogViewCnt)) ) - .orderBy(blogRecipe.viewCnt.desc(), blogRecipe.blogRecipeId.desc()) + .orderBy(relevanceOrder(query, blogRecipe.viewCnt.desc())) .limit(size) .fetch(); } + // "검색어 일치율 대분류 → 소분류(secondary) → blogRecipeId" keyset 커서. (recipe 와 동일 패턴) + private BooleanExpression relevanceCursor(SearchQuery query, Long lastBlogRecipeId, Double lastRelevance, + Supplier secondaryLt, Supplier secondaryEq) { + + if (lastBlogRecipeId == null || lastBlogRecipeId <= 0) { + return null; + } + + BooleanExpression idLt = blogRecipe.blogRecipeId.lt(lastBlogRecipeId); + + if (query instanceof SearchQuery.BooleanQuery b && lastRelevance != null) { + NumberExpression score = titlePriorityScore(blogRecipe.titleSearchTokens, blogRecipe.searchTokens, b.query()); + return score.lt(lastRelevance) + .or(score.eq(lastRelevance).and(secondaryLt.get())) + .or(score.eq(lastRelevance).and(secondaryEq.get()).and(idLt)); + } + + return secondaryLt.get().or(secondaryEq.get().and(idLt)); + } + + private OrderSpecifier[] relevanceOrder(SearchQuery query, OrderSpecifier secondary) { + + if (query instanceof SearchQuery.BooleanQuery b) { + return new OrderSpecifier[]{titlePriorityScore(blogRecipe.titleSearchTokens, blogRecipe.searchTokens, b.query()).desc(), secondary, blogRecipe.blogRecipeId.desc()}; + } + + return new OrderSpecifier[]{secondary, blogRecipe.blogRecipeId.desc()}; + } + @Override public List findUserScrapBlogRecipesLimit(Long userId, Long lastBlogRecipeId, LocalDateTime lastScrapCreatedAt, int size) { 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 2797e41d..acefbf0a 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 @@ -13,11 +13,14 @@ public interface YoutubeRecipeCustomRepository { List findByKeywordLimit(String keyword, int size); - List findByKeywordLimitOrderByPostDateDesc(SearchQuery query, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size); + // 아래 정렬들은 모두 "검색어 일치율(relevance) 대분류 → 각 정렬키 소분류 → youtubeRecipeId" 순. + Double findRelevanceScoreByYoutubeRecipeId(SearchQuery query, Long youtubeRecipeId); - List findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long youtubeScrapCnt, int size); + List findByKeywordLimitOrderByPostDateDesc(SearchQuery query, Long lastYoutubeRecipeId, Double lastRelevance, LocalDate lastYoutubeRecipePostDate, int size); - List findByKeywordLimitOrderByYoutubeViewCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long youtubeViewCnt, int size); + List findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchQuery query, Long lastYoutubeRecipeId, Double lastRelevance, long youtubeScrapCnt, int size); + + List findByKeywordLimitOrderByYoutubeViewCntDesc(SearchQuery query, Long lastYoutubeRecipeId, Double lastRelevance, 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 1dee14c4..48e013af 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 @@ -3,15 +3,21 @@ 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 com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; import jakarta.persistence.EntityManager; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.function.Supplier; 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.common.utils.QueryUtils.relevanceScore; +import static com.recipe.app.src.common.utils.QueryUtils.titlePriorityScore; import static com.recipe.app.src.recipe.domain.youtube.QYoutubeRecipe.youtubeRecipe; import static com.recipe.app.src.recipe.domain.youtube.QYoutubeScrap.youtubeScrap; @@ -46,56 +52,93 @@ public List findByKeywordLimit(String keyword, int size) { } @Override - public List findByKeywordLimitOrderByPostDateDesc(SearchQuery query, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size) { + public Double findRelevanceScoreByYoutubeRecipeId(SearchQuery query, Long youtubeRecipeId) { + + if (youtubeRecipeId == null || youtubeRecipeId <= 0 || !(query instanceof SearchQuery.BooleanQuery b)) { + return null; + } + + return queryFactory + .select(titlePriorityScore(youtubeRecipe.titleSearchTokens, youtubeRecipe.searchTokens, b.query())) + .from(youtubeRecipe) + .where(youtubeRecipe.youtubeRecipeId.eq(youtubeRecipeId)) + .fetchOne(); + } + + @Override + public List findByKeywordLimitOrderByPostDateDesc(SearchQuery query, Long lastYoutubeRecipeId, Double lastRelevance, LocalDate lastYoutubeRecipePostDate, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( matchSearchQuery(youtubeRecipe.searchTokens, query), - ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, postDate) -> youtubeRecipe.postDate.lt(postDate) - .or(youtubeRecipe.postDate.eq(postDate) - .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), - lastYoutubeRecipeId, lastYoutubeRecipePostDate) + relevanceCursor(query, lastYoutubeRecipeId, lastRelevance, + () -> youtubeRecipe.postDate.lt(lastYoutubeRecipePostDate), () -> youtubeRecipe.postDate.eq(lastYoutubeRecipePostDate)) ) - .orderBy(youtubeRecipe.postDate.desc(), youtubeRecipe.youtubeRecipeId.desc()) + .orderBy(relevanceOrder(query, youtubeRecipe.postDate.desc())) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long lastYoutubeScrapCnt, int size) { + public List findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchQuery query, Long lastYoutubeRecipeId, Double lastRelevance, long lastYoutubeScrapCnt, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( matchSearchQuery(youtubeRecipe.searchTokens, query), - ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, youtubeScrapCnt) -> youtubeRecipe.scrapCnt.lt(youtubeScrapCnt) - .or(youtubeRecipe.scrapCnt.eq(youtubeScrapCnt) - .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), - lastYoutubeRecipeId, lastYoutubeScrapCnt) + relevanceCursor(query, lastYoutubeRecipeId, lastRelevance, + () -> youtubeRecipe.scrapCnt.lt(lastYoutubeScrapCnt), () -> youtubeRecipe.scrapCnt.eq(lastYoutubeScrapCnt)) ) - .orderBy(youtubeRecipe.scrapCnt.desc(), youtubeRecipe.youtubeRecipeId.desc()) + .orderBy(relevanceOrder(query, youtubeRecipe.scrapCnt.desc())) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByYoutubeViewCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long lastYoutubeViewCnt, int size) { + public List findByKeywordLimitOrderByYoutubeViewCntDesc(SearchQuery query, Long lastYoutubeRecipeId, Double lastRelevance, long lastYoutubeViewCnt, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( matchSearchQuery(youtubeRecipe.searchTokens, query), - ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, youtubeViewCnt) -> youtubeRecipe.viewCnt.lt(youtubeViewCnt) - .or(youtubeRecipe.viewCnt.eq(youtubeViewCnt) - .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), - lastYoutubeRecipeId, lastYoutubeViewCnt) + relevanceCursor(query, lastYoutubeRecipeId, lastRelevance, + () -> youtubeRecipe.viewCnt.lt(lastYoutubeViewCnt), () -> youtubeRecipe.viewCnt.eq(lastYoutubeViewCnt)) ) - .orderBy(youtubeRecipe.viewCnt.desc(), youtubeRecipe.youtubeRecipeId.desc()) + .orderBy(relevanceOrder(query, youtubeRecipe.viewCnt.desc())) .limit(size) .fetch(); } + // "검색어 일치율 대분류 → 소분류(secondary) → youtubeRecipeId" keyset 커서. (recipe 와 동일 패턴) + private BooleanExpression relevanceCursor(SearchQuery query, Long lastYoutubeRecipeId, Double lastRelevance, + Supplier secondaryLt, Supplier secondaryEq) { + + if (lastYoutubeRecipeId == null || lastYoutubeRecipeId <= 0) { + return null; + } + + BooleanExpression idLt = youtubeRecipe.youtubeRecipeId.lt(lastYoutubeRecipeId); + + if (query instanceof SearchQuery.BooleanQuery b && lastRelevance != null) { + NumberExpression score = titlePriorityScore(youtubeRecipe.titleSearchTokens, youtubeRecipe.searchTokens, b.query()); + return score.lt(lastRelevance) + .or(score.eq(lastRelevance).and(secondaryLt.get())) + .or(score.eq(lastRelevance).and(secondaryEq.get()).and(idLt)); + } + + return secondaryLt.get().or(secondaryEq.get().and(idLt)); + } + + private OrderSpecifier[] relevanceOrder(SearchQuery query, OrderSpecifier secondary) { + + if (query instanceof SearchQuery.BooleanQuery b) { + return new OrderSpecifier[]{titlePriorityScore(youtubeRecipe.titleSearchTokens, youtubeRecipe.searchTokens, b.query()).desc(), secondary, youtubeRecipe.youtubeRecipeId.desc()}; + } + + return new OrderSpecifier[]{secondary, youtubeRecipe.youtubeRecipeId.desc()}; + } + @Override public List findUserScrapYoutubeRecipesLimit(Long userId, Long lastYoutubeRecipeId, LocalDateTime lastScrapCreatedAt, int 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 e709c36f..dbfca9f0 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 @@ -72,7 +72,7 @@ class RecipeSearchServiceTest extends Specification { .build(), ] - recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(_, lastRecipeId, 0, size) >> recipes + recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(_, lastRecipeId, _, 0, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -146,7 +146,7 @@ class RecipeSearchServiceTest extends Specification { .build(), ] - recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(_, lastRecipeId, 0, size) >> recipes + recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(_, lastRecipeId, _, 0, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -222,7 +222,7 @@ class RecipeSearchServiceTest extends Specification { recipeRepository.findById(lastRecipeId) >> Optional.empty() - recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, lastRecipeId, null, size) >> recipes + recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, lastRecipeId, _, null, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -342,7 +342,7 @@ class RecipeSearchServiceTest extends Specification { recipeRepository.countByKeyword(_) >> 1 recipeRepository.findById(_) >> Optional.empty() - recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, _, _, _) >> [recipe] + recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, _, _, _, _) >> [recipe] userService.findByUserIds(_) >> [] recipeScrapService.findByRecipeIds(_) >> [] @@ -688,7 +688,7 @@ class RecipeSearchServiceTest extends Specification { result.totalCnt == 0 result.recipes.isEmpty() 0 * recipeRepository.countByKeyword(_) - 0 * recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, _, _, _) + 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 ca55d790..7ecc0c84 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 @@ -65,7 +65,7 @@ class BlogRecipeServiceTest extends Specification { .build(), ] - blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(_, lastBlogRecipeId, 0, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(_, lastBlogRecipeId, _, 0, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() @@ -135,7 +135,7 @@ class BlogRecipeServiceTest extends Specification { .build(), ] - blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(_, lastBlogRecipeId, 0, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(_, lastBlogRecipeId, _, 0, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() @@ -205,7 +205,7 @@ class BlogRecipeServiceTest extends Specification { blogRecipeRepository.findById(lastBlogRecipeId) >> Optional.empty() - blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(_, 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 bccbcb4c..e30586d3 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 @@ -66,7 +66,7 @@ class YoutubeRecipeServiceTest extends Specification { .build() ] - youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(_, lastYoutubeRecipeId, 0, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(_, lastYoutubeRecipeId, _, 0, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() @@ -136,7 +136,7 @@ class YoutubeRecipeServiceTest extends Specification { .build() ] - youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(_, lastYoutubeRecipeId, 0, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(_, lastYoutubeRecipeId, _, 0, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() @@ -208,7 +208,7 @@ class YoutubeRecipeServiceTest extends Specification { youtubeRecipeRepository.findById(lastYoutubeRecipeId) >> Optional.empty() - youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(_, lastYoutubeRecipeId, null, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(_, lastYoutubeRecipeId, _, null, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() 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 a7623f4e..a9a27b5d 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 @@ -303,14 +303,14 @@ class RecipeCustomRepositoryTest extends Specification { .isHidden(false) .build(), Recipe.builder() - .recipeNm("제목") - .introduction("테스트설명") + .recipeNm("테스트제목") + .introduction("설명") .level(RecipeLevel.NORMAL) .userId(users.get(0).userId) .isHidden(false) .build(), Recipe.builder() - .recipeNm("제목") + .recipeNm("테스트제목") .introduction("설명") .level(RecipeLevel.NORMAL) .userId(users.get(0).userId) @@ -333,8 +333,9 @@ class RecipeCustomRepositoryTest extends Specification { committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } + // 세 레시피 모두 "테스트" 를 동일하게 포함 → 일치율 균일 → 소분류(최신순)만으로 순서 결정 when: - List response = recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(SearchKeywordNormalizer.normalize("테스트"), 0L, recipes.createdAt.max().plusMinutes(1), 3); + List response = recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(SearchKeywordNormalizer.normalize("테스트"), 0L, null, recipes.createdAt.max().plusMinutes(1), 3); then: response.size() == 3 @@ -343,6 +344,73 @@ class RecipeCustomRepositoryTest extends Specification { response.get(2).recipeId == recipes.get(0).recipeId } + def "검색어로 레시피 목록 조회 - 일치율이 소분류(스크랩순)보다 우선한다"() { + + given: + Recipe both = Recipe.builder() + .recipeNm("간장국수") + .introduction("간장 국수 만드는 법") // 간장+국수 → 높은 일치율 + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .scrapCnt(0L) // 스크랩은 0 + .build() + Recipe onlyOne = Recipe.builder() + .recipeNm("비빔국수") + .introduction("새콤한 국수 요리") // 국수만 → 낮은 일치율 + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .scrapCnt(100L) // 스크랩은 많음 + .build() + + committedTx.executeWithoutResult { status -> recipeRepository.saveAll([onlyOne, both]) } + + SearchQuery query = SearchKeywordNormalizer.normalize("간장 국수") + + when: "스크랩순 정렬" + List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(query, 0L, null, 0, 10) + + then: "스크랩이 0이어도 일치율 높은(간장+국수) 레시피가 스크랩 100짜리보다 위로 온다" + query instanceof SearchQuery.BooleanQuery + response.size() == 2 + response.get(0).recipeId == both.recipeId + response.get(1).recipeId == onlyOne.recipeId + } + + def "검색어로 레시피 목록 조회 - 제목 매칭이 내용 매칭보다 우선한다"() { + + given: + Recipe titleMatch = Recipe.builder() + .recipeNm("간장국수") // 제목에서 매칭 + .introduction("맛있는 레시피") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .scrapCnt(0L) // 스크랩은 0 + .build() + Recipe descMatch = Recipe.builder() + .recipeNm("볶음밥") + .introduction("간장 국수 만드는 재료") // 내용에서만 매칭 + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .scrapCnt(100L) // 스크랩은 많음 + .build() + + committedTx.executeWithoutResult { status -> recipeRepository.saveAll([descMatch, titleMatch]) } + + SearchQuery query = SearchKeywordNormalizer.normalize("간장 국수") + + when: "스크랩순 정렬" + List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(query, 0L, null, 0, 10) + + then: "제목에서 맞은 레시피가 스크랩 0이어도, 내용에서만 맞은 스크랩 100짜리보다 위로 온다" + response.size() == 2 + response.get(0).recipeId == titleMatch.recipeId + response.get(1).recipeId == descMatch.recipeId + } + def "검색어로 레시피 목록 조회 - 스크랩 수 많은 순 정렬"() { given: @@ -365,7 +433,7 @@ class RecipeCustomRepositoryTest extends Specification { .scrapCnt(0L) .build(), Recipe.builder() - .recipeNm("제목") + .recipeNm("테스트제목") .introduction("설명") .level(RecipeLevel.NORMAL) .userId(users.get(0).userId) @@ -389,8 +457,11 @@ class RecipeCustomRepositoryTest extends Specification { committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } + // 세 레시피 모두 "테스트" 를 동일하게 포함 → 일치율 균일 → 소분류(스크랩순)만으로 순서 결정 when: - List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), recipes.get(2).recipeId, 2, 3); + SearchQuery query = SearchKeywordNormalizer.normalize("테스트") + Double lastRelevance = recipeRepository.findRelevanceScoreByRecipeId(query, recipes.get(2).recipeId) + List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(query, recipes.get(2).recipeId, lastRelevance, 2, 3); then: response.size() == 2 @@ -419,7 +490,7 @@ class RecipeCustomRepositoryTest extends Specification { .viewCnt(0L) .build(), Recipe.builder() - .recipeNm("제목") + .recipeNm("테스트제목") .introduction("설명") .level(RecipeLevel.NORMAL) .userId(users.get(0).userId) @@ -443,8 +514,11 @@ class RecipeCustomRepositoryTest extends Specification { committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } + // 세 레시피 모두 "테스트" 를 동일하게 포함 → 일치율 균일 → 소분류(조회순)만으로 순서 결정 when: - List response = recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), recipes.get(2).recipeId, 2, 3); + SearchQuery query = SearchKeywordNormalizer.normalize("테스트") + Double lastRelevance = recipeRepository.findRelevanceScoreByRecipeId(query, recipes.get(2).recipeId) + List response = recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(query, recipes.get(2).recipeId, lastRelevance, 2, 3); then: response.size() == 2 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 3d3f41e8..6e404742 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 @@ -105,8 +105,8 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build(), BlogRecipe.builder() - .title("제목2") - .description("테스트설명2") + .title("테스트제목2") + .description("설명2") .publishedAt(LocalDate.of(2024, 1, 5)) .blogUrl("http://naver.com") .blogThumbnailImgUrl("http://test.jpg") @@ -122,7 +122,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .build(), BlogRecipe.builder() .title("테스트제목3") - .description("테스트설명3") + .description("설명3") .publishedAt(LocalDate.of(2024, 1, 3)) .blogUrl("http://naver.com") .blogThumbnailImgUrl("http://test.jpg") @@ -133,7 +133,9 @@ class BlogRecipeCustomRepositoryTest extends Specification { when: BlogRecipe lastBlogRecipe = blogRecipes.get(1); - List response = blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(SearchKeywordNormalizer.normalize("테스트"), lastBlogRecipe.getBlogRecipeId(), lastBlogRecipe.getPublishedAt(), 3); + def query = SearchKeywordNormalizer.normalize("테스트") + Double lastRel = blogRecipeRepository.findRelevanceScoreByBlogRecipeId(query, lastBlogRecipe.getBlogRecipeId()) + List response = blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(query, lastBlogRecipe.getBlogRecipeId(), lastRel, lastBlogRecipe.getPublishedAt(), 3); then: response.size() == 2 @@ -171,8 +173,8 @@ class BlogRecipeCustomRepositoryTest extends Specification { .scrapCnt(0L) .build(), BlogRecipe.builder() - .title("제목2") - .description("테스트설명2") + .title("테스트제목2") + .description("설명2") .publishedAt(LocalDate.of(2024, 1, 5)) .blogUrl("http://naver.com") .blogThumbnailImgUrl("http://test.jpg") @@ -190,7 +192,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .build(), BlogRecipe.builder() .title("테스트제목3") - .description("테스트설명3") + .description("설명3") .publishedAt(LocalDate.of(2024, 1, 3)) .blogUrl("http://naver.com") .blogThumbnailImgUrl("http://test.jpg") @@ -201,7 +203,9 @@ class BlogRecipeCustomRepositoryTest extends Specification { committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: - List response = blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), blogRecipes.get(3).blogRecipeId, 3, 3); + def query = SearchKeywordNormalizer.normalize("테스트") + Double lastRel = blogRecipeRepository.findRelevanceScoreByBlogRecipeId(query, blogRecipes.get(3).blogRecipeId) + List response = blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(query, blogRecipes.get(3).blogRecipeId, lastRel, 3, 3); then: response.size() == 2 @@ -235,8 +239,8 @@ class BlogRecipeCustomRepositoryTest extends Specification { .viewCnt(2L) .build(), BlogRecipe.builder() - .title("제목2") - .description("테스트설명2") + .title("테스트제목2") + .description("설명2") .publishedAt(LocalDate.of(2024, 1, 5)) .blogUrl("http://naver.com") .blogThumbnailImgUrl("http://test.jpg") @@ -254,7 +258,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .build(), BlogRecipe.builder() .title("테스트제목3") - .description("테스트설명3") + .description("설명3") .publishedAt(LocalDate.of(2024, 1, 3)) .blogUrl("http://naver.com") .blogThumbnailImgUrl("http://test.jpg") @@ -265,7 +269,9 @@ class BlogRecipeCustomRepositoryTest extends Specification { committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: - List response = blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), blogRecipes.get(3).blogRecipeId, 2, 3); + def query = SearchKeywordNormalizer.normalize("테스트") + Double lastRel = blogRecipeRepository.findRelevanceScoreByBlogRecipeId(query, blogRecipes.get(3).blogRecipeId) + List response = blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(query, blogRecipes.get(3).blogRecipeId, lastRel, 2, 3); then: response.size() == 2 @@ -292,8 +298,8 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build(), BlogRecipe.builder() - .title("제목2") - .description("테스트설명2") + .title("테스트제목2") + .description("설명2") .publishedAt(LocalDate.of(2024, 1, 5)) .blogUrl("http://naver.com") .blogThumbnailImgUrl("http://test.jpg") @@ -309,7 +315,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .build(), BlogRecipe.builder() .title("테스트제목3") - .description("테스트설명3") + .description("설명3") .publishedAt(LocalDate.of(2024, 1, 3)) .blogUrl("http://naver.com") .blogThumbnailImgUrl("http://test.jpg") 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 29707ea1..1888146d 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 @@ -83,8 +83,8 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), YoutubeRecipe.builder() - .title("제목") - .description("테스트설명") + .title("테스트제목") + .description("설명") .postDate(LocalDate.of(2024, 1, 1)) .channelName("테스트") .youtubeId("youtube4") @@ -129,8 +129,8 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), YoutubeRecipe.builder() - .title("제목") - .description("테스트설명") + .title("테스트제목") + .description("설명") .postDate(LocalDate.of(2024, 1, 1)) .channelName("테스트") .youtubeId("youtube4") @@ -141,7 +141,9 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { when: YoutubeRecipe lastYoutubeRecipe = youtubeRecipes.get(1); - List response = youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(SearchKeywordNormalizer.normalize("테스트"), lastYoutubeRecipe.youtubeRecipeId, lastYoutubeRecipe.postDate, 3); + def query = SearchKeywordNormalizer.normalize("테스트") + Double lastRel = youtubeRecipeRepository.findRelevanceScoreByYoutubeRecipeId(query, lastYoutubeRecipe.youtubeRecipeId) + List response = youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(query, lastYoutubeRecipe.youtubeRecipeId, lastRel, lastYoutubeRecipe.postDate, 3); then: response.size() == 2 @@ -167,7 +169,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { List youtubeRecipes = [ YoutubeRecipe.builder() .title("테스트제목") - .description("테스트설명") + .description("설명") .postDate(LocalDate.of(2024, 1, 3)) .channelName("테스트") .youtubeId("youtube1") @@ -176,7 +178,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .build(), YoutubeRecipe.builder() .title("테스트제목") - .description("테스트") + .description("설명") .postDate(LocalDate.of(2024, 1, 5)) .channelName("테스트") .youtubeId("youtube2") @@ -193,8 +195,8 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .scrapCnt(1L) .build(), YoutubeRecipe.builder() - .title("제목") - .description("테스트설명") + .title("테스트제목") + .description("설명") .postDate(LocalDate.of(2024, 1, 1)) .channelName("테스트") .youtubeId("youtube4") @@ -205,7 +207,9 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: - List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), youtubeRecipes.get(3).youtubeRecipeId, 2, 3); + def query = SearchKeywordNormalizer.normalize("테스트") + Double lastRel = youtubeRecipeRepository.findRelevanceScoreByYoutubeRecipeId(query, youtubeRecipes.get(3).youtubeRecipeId) + List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(query, youtubeRecipes.get(3).youtubeRecipeId, lastRel, 2, 3); then: response.size() == 2 @@ -231,7 +235,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { List youtubeRecipes = [ YoutubeRecipe.builder() .title("테스트제목") - .description("테스트설명") + .description("설명") .postDate(LocalDate.of(2024, 1, 3)) .channelName("테스트") .youtubeId("youtube1") @@ -240,7 +244,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .build(), YoutubeRecipe.builder() .title("테스트제목") - .description("테스트") + .description("설명") .postDate(LocalDate.of(2024, 1, 5)) .channelName("테스트") .youtubeId("youtube2") @@ -257,8 +261,8 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .viewCnt(1L) .build(), YoutubeRecipe.builder() - .title("제목") - .description("테스트설명") + .title("테스트제목") + .description("설명") .postDate(LocalDate.of(2024, 1, 1)) .channelName("테스트") .youtubeId("youtube4") @@ -269,7 +273,9 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: - List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), youtubeRecipes.get(3).youtubeRecipeId, 2, 3) + def query = SearchKeywordNormalizer.normalize("테스트") + Double lastRel = youtubeRecipeRepository.findRelevanceScoreByYoutubeRecipeId(query, youtubeRecipes.get(3).youtubeRecipeId) + List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(query, youtubeRecipes.get(3).youtubeRecipeId, lastRel, 2, 3) then: response.size() == 2 @@ -312,8 +318,8 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), YoutubeRecipe.builder() - .title("제목") - .description("테스트설명") + .title("테스트제목") + .description("설명") .postDate(LocalDate.of(2024, 1, 1)) .channelName("테스트") .youtubeId("youtube4")