Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/main/java/com/recipe/app/src/common/utils/QueryUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -21,8 +24,42 @@ public static BooleanExpression ifIdIsNotNullAndGreaterThanZero(Function<Long, B
* MySQL FULLTEXT BOOLEAN MODE 매칭 조건. SearchKeywordNormalizer 가 만든 BOOLEAN 모드 쿼리 문자열을 받는다.
*/
public static BooleanExpression matchAgainst(StringPath column, String booleanQuery) {
return relevanceScore(column, booleanQuery).gt(0);
}

/**
* MySQL FULLTEXT BOOLEAN MODE 의 relevance 점수(매칭 토큰 수 기반)를 그대로 반환한다.
* WHERE 필터(matchAgainst)와 ORDER BY(정확도순) 양쪽에서 재사용한다.
*/
public static NumberExpression<Double> 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<Double> 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(" "));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,23 +74,26 @@ public RecipesResponse findRecipesByKeywordOrderBy(User user, String keyword, lo

private List<Recipe> 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<Recipe> 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<Recipe> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> tokenizer) {
private void backfill(String table, String pk, String targetColumn, String[] sourceColumns, Function<String, String> 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<String, String> tokenizer) {
private BatchResult processBatch(String table, String pk, String targetColumn, String[] sourceColumns, long startId, Function<String, String> tokenizer) {

String columnList = String.join(", ", sourceColumns);
String selectSql = "SELECT " + pk + ", " + columnList +
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {

Expand All @@ -60,11 +68,16 @@ public void fallback(String keyword, Throwable e) {

private List<BlogRecipe> createBlogRecipes(List<BlogRecipe> blogRecipes) {

List<String> blogUrls = blogRecipes.stream().map(BlogRecipe::getBlogUrl).collect(Collectors.toList());
// 네이버 응답이 한 배치 안에서 같은 blogUrl 을 중복으로 내려주는 경우가 있어, 먼저 blogUrl 기준으로 distinct 처리한다.
List<BlogRecipe> distinctBlogRecipes = new ArrayList<>(blogRecipes.stream()
.collect(Collectors.toMap(BlogRecipe::getBlogUrl, Function.identity(), (o1, o2) -> o1, LinkedHashMap::new))
.values());

List<String> blogUrls = distinctBlogRecipes.stream().map(BlogRecipe::getBlogUrl).collect(Collectors.toList());
List<BlogRecipe> existBlogRecipes = blogRecipeRepository.findByBlogUrlIn(blogUrls);
Map<String, BlogRecipe> 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()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Lock> keywordLocks = Striped.lock(64);

public BlogRecipeService(BlogRecipeRepository blogRecipeRepository, BlogScrapService blogScrapService, BlogViewService blogViewService,
BadWordFiltering badWordFiltering, BlogRecipeClientSearchService blogRecipeClientSearchService,
SearchKeywordService searchKeywordService) {
Expand All @@ -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<BlogRecipe> blogRecipes = findByKeywordOrderBy(query, lastBlogRecipeId, size, sort);
Expand All @@ -77,23 +92,26 @@ private List<BlogRecipe> findByKeywordOrderBy(SearchQuery query, long lastBlogRe

private List<BlogRecipe> 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<BlogRecipe> 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<BlogRecipe> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlogRecipe> 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<Callable<Void>> tasks = blogRecipes.stream()
.map(blogRecipe -> (Callable<Void>) () -> {
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);
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
Loading
Loading