diff --git a/src/main/java/com/recipe/app/src/common/config/AsyncConfig.java b/src/main/java/com/recipe/app/src/common/config/AsyncConfig.java index b56421ce..f1a4018d 100644 --- a/src/main/java/com/recipe/app/src/common/config/AsyncConfig.java +++ b/src/main/java/com/recipe/app/src/common/config/AsyncConfig.java @@ -3,11 +3,13 @@ import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @EnableAsync +@EnableScheduling @Configuration public class AsyncConfig implements AsyncConfigurer { 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 b3cc10f1..bf7a363f 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 @@ -7,8 +7,10 @@ import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import java.io.IOException; import java.net.URL; import java.util.List; import java.util.concurrent.Callable; @@ -21,12 +23,19 @@ @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 하나가 스레드를 오래 점유하므로 줄인다. + // 첫 검색 경로: 응답을 오래 막지 않도록 병렬 처리하되, 네이버 rate-limit 을 피하려 동시성은 과하지 않게. + // (개별 크롤이 ~0.2~0.5초라 12 동시성이면 50건도 수 초 내, 6초 예산 안에서 처리) + private static final int FIRST_SEARCH_POOL_SIZE = 12; + private static final long FIRST_SEARCH_TIMEOUT_SECONDS = 6L; + // 백필(스케줄러/수동) 경로: 배경 작업이라 네이버 부담을 줄이려 병렬 작게 + 대기 넉넉히. + private static final int BACKFILL_POOL_SIZE = 8; + private static final long BACKFILL_TIMEOUT_SECONDS = 120L; + + // 각 페이지 fetch 타임아웃(ms). Jsoup 기본(30s)은 느린 URL 하나가 스레드를 오래 점유하므로 줄인다. private static final int FETCH_TIMEOUT_MS = 3000; + // 타깃 재시도: "페이지 fetch 실패(네트워크/타임아웃)" 에만 재시도. og:image 부재 등은 재시도해도 소용없어 즉시 포기. + private static final int MAX_FETCH_ATTEMPTS = 3; + private static final long RETRY_BACKOFF_MS = 700L; private final BlogRecipeRepository blogRecipeRepository; @@ -35,25 +44,48 @@ public BlogRecipeThumbnailCrawlingService(BlogRecipeRepository blogRecipeReposit } /** - * 신규 저장된 블로그 레시피들의 썸네일을 크롤링해 채운다. + * 신규 저장된 블로그 레시피들의 썸네일을 크롤링해 채운다(첫 검색 경로). * 첫 검색에서도 썸네일이 보이도록 동기로 처리하되, 병렬 + 전체 타임아웃으로 대기 시간을 제한한다. - * 타임아웃 내에 못 끝낸 소수는 썸네일이 빈 채로 남는다(크롤링 실패도 빈 문자열). */ public void saveThumbnails(List blogRecipes) { + crawlAndSave(blogRecipes, FIRST_SEARCH_POOL_SIZE, FIRST_SEARCH_TIMEOUT_SECONDS); + } + + /** + * 썸네일이 비어 있는(크롤 실패로 미채움) 블로그 레시피를 한 번에 limit 개까지 다시 크롤링해 채운다. + * (스케줄러 백필 경로. 여러 회차에 걸쳐 백로그를 소진한다.) + * + * @return 처리 대상 건수 + */ + public int retryEmptyThumbnails(int limit) { + + List targets = blogRecipeRepository.findEmptyThumbnails(PageRequest.of(0, limit)); + + if (targets.isEmpty()) { + return 0; + } + + log.info("blog thumbnail retry start - targets={}", targets.size()); + crawlAndSave(targets, BACKFILL_POOL_SIZE, BACKFILL_TIMEOUT_SECONDS); + log.info("blog thumbnail retry done - targets={}", targets.size()); + return targets.size(); + } + + private void crawlAndSave(List blogRecipes, int poolSize, long timeoutSeconds) { if (blogRecipes == null || blogRecipes.isEmpty()) { return; } - ExecutorService pool = Executors.newFixedThreadPool(Math.min(CRAWL_POOL_SIZE, blogRecipes.size())); + ExecutorService pool = Executors.newFixedThreadPool(Math.min(poolSize, blogRecipes.size())); try { List> tasks = blogRecipes.stream() .map(blogRecipe -> (Callable) () -> { - blogRecipe.changeThumbnail(getBlogThumbnailUrl(blogRecipe.getBlogUrl())); + blogRecipe.changeThumbnail(crawlThumbnail(blogRecipe.getBlogUrl())); return null; }) .collect(Collectors.toList()); - pool.invokeAll(tasks, CRAWL_TIMEOUT_SECONDS, TimeUnit.SECONDS); + pool.invokeAll(tasks, timeoutSeconds, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("blog thumbnail crawling interrupted"); @@ -64,55 +96,68 @@ public void saveThumbnails(List blogRecipes) { blogRecipeRepository.saveAll(blogRecipes); } - public String getBlogThumbnailUrl(String blogUrl) { + /** + * 타깃 재시도 크롤: 페이지 fetch 실패(IOException/타임아웃)에만 최대 {@link #MAX_FETCH_ATTEMPTS}회 재시도한다. + * 페이지는 왔는데 og:image 가 없는 경우(영구적 상황)는 빈 문자열을 그대로 반환하고 재시도하지 않는다. + */ + public String crawlThumbnail(String blogUrl) { + + for (int attempt = 1; attempt <= MAX_FETCH_ATTEMPTS; attempt++) { + try { + return fetchThumbnail(blogUrl); + } catch (IOException e) { + if (attempt == MAX_FETCH_ATTEMPTS) { + log.debug("thumbnail fetch failed after {} attempts: {} ({})", attempt, blogUrl, e.getMessage()); + return ""; + } + sleepQuietly(RETRY_BACKOFF_MS); + } + } + return ""; + } + + private String fetchThumbnail(String blogUrl) throws IOException { if (blogUrl.contains("naver")) { - return getNaverBlogThumbnailUrl(blogUrl); + return fetchNaverThumbnail(blogUrl); } else if (blogUrl.contains("tistory")) { - return getTistoryBlogThumbnailUrl(blogUrl); - } else { - return ""; + return fetchTistoryThumbnail(blogUrl); } + return ""; } - private String getNaverBlogThumbnailUrl(String blogUrl) { - - try { - - URL url = new URL(blogUrl); - Document doc = Jsoup.parse(url, FETCH_TIMEOUT_MS); - - Elements iframes = doc.select("iframe#mainFrame"); - String src = iframes.attr("src"); + private String fetchNaverThumbnail(String blogUrl) throws IOException { - String url2 = "http://blog.naver.com" + src; - Document doc2 = Jsoup.connect(url2).timeout(FETCH_TIMEOUT_MS).get(); + Document doc = Jsoup.parse(new URL(blogUrl), FETCH_TIMEOUT_MS); - return doc2.select("meta[property=og:image]").get(0).attr("content"); - } catch (Exception e) { - return ""; + String src = doc.select("iframe#mainFrame").attr("src"); + if (src.isBlank()) { + return ""; // mainFrame 이 없으면 구조상 추출 불가 → 재시도 무의미 } + + Document doc2 = Jsoup.connect("http://blog.naver.com" + src).timeout(FETCH_TIMEOUT_MS).get(); + Elements og = doc2.select("meta[property=og:image]"); + return og.isEmpty() ? "" : og.get(0).attr("content"); } - private String getTistoryBlogThumbnailUrl(String blogUrl) { + private String fetchTistoryThumbnail(String blogUrl) throws IOException { - try { + Document doc = Jsoup.connect(blogUrl).timeout(FETCH_TIMEOUT_MS).get(); - Document doc = Jsoup.connect(blogUrl).timeout(FETCH_TIMEOUT_MS).get(); - - Elements imageLinks = doc.getElementsByTag("img"); - String thumbnailUrl = null; - for (Element image : imageLinks) { - String temp = image.attr("src"); - if (!temp.contains("admin")) { - thumbnailUrl = temp; - break; - } + for (Element image : doc.getElementsByTag("img")) { + String src = image.attr("src"); + if (!src.contains("admin")) { + return src; } + } + return ""; + } - return thumbnailUrl; - } catch (Exception e) { - return ""; + private void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } } } diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogThumbnailRetryScheduler.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogThumbnailRetryScheduler.java new file mode 100644 index 00000000..fe1b3e60 --- /dev/null +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogThumbnailRetryScheduler.java @@ -0,0 +1,33 @@ +package com.recipe.app.src.recipe.application.blog; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 썸네일이 비어 있는(크롤 실패로 미채움) 블로그 레시피를 주기적으로 재크롤한다. + * + * - 시간 제한 없이 빈 것 전체 대상(백로그 포함). 한 번에 BATCH_SIZE 개씩 처리하며 여러 회에 걸쳐 소진. + * - 대부분은 일시 실패라 다음 회차에 채워지고, 영구 실패(삭제/비공개 등 소수)만 매번 재시도된다. + * - 6시간마다 실행. 대량 외부 크롤이라 동시성은 서비스에서 낮게(8) 잡아 네이버 부담을 줄인다. + * - 단일 인스턴스 전제(로컬 포함 항상 동작). + */ +@Slf4j +@Component +public class BlogThumbnailRetryScheduler { + + private static final int BATCH_SIZE = 300; + + private final BlogRecipeThumbnailCrawlingService blogRecipeThumbnailCrawlingService; + + public BlogThumbnailRetryScheduler(BlogRecipeThumbnailCrawlingService blogRecipeThumbnailCrawlingService) { + this.blogRecipeThumbnailCrawlingService = blogRecipeThumbnailCrawlingService; + } + + // 6시간마다 (00/06/12/18시) + @Scheduled(cron = "0 0 0/6 * * *") + public void retryEmptyThumbnails() { + int processed = blogRecipeThumbnailCrawlingService.retryEmptyThumbnails(BATCH_SIZE); + log.info("scheduled blog thumbnail retry processed={}", processed); + } +} diff --git a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepository.java index 11ab8f01..d57d2a32 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepository.java @@ -1,7 +1,9 @@ package com.recipe.app.src.recipe.infra.blog; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -10,4 +12,8 @@ public interface BlogRecipeRepository extends JpaRepository, BlogRecipeCustomRepository { List findByBlogUrlIn(List blogUrls); + + // 썸네일이 비어 있는(크롤 실패로 미채움) 블로그 레시피 — 스케줄러가 배치로 재크롤(백로그 포함) + @Query("SELECT b FROM BlogRecipe b WHERE b.blogThumbnailImgUrl IS NULL OR b.blogThumbnailImgUrl = '' ORDER BY b.blogRecipeId DESC") + List findEmptyThumbnails(Pageable pageable); }