[InConcert] 스크래핑 비동기 처리(1)

2025. 1. 10. 14:13·Projects

1. Selenium을 선택한 이유

웹 스크래핑을 할 때 대표적으로 Selenium과 Jsoup이 사용된다.

이번 프로젝트에서는 PlayDB에서 공연 정보를 스크래핑하는 과정에서 Selenium을 선택했다.

 

Selenium vs Jsoup 비교
  장점 단점
Selenium - 동적 콘텐츠(JavaScript 렌더링) 처리 가능
- 로그인, 버튼 클릭 등 브라우저 자동화 가능
- 실행 속도 느림 (브라우저 직접 실행)
- CPU/메모리 사용량 높음
Jsoup - 빠르고 가벼움
- HTML 문서 파싱 및 데이터 추출이 간편
- JavaScript로 생성된 콘텐츠는 크롤링 불가
- 브라우저 상호작용 불가능

 

PlayDB에서 공연 목록과 상세 정보는 JavaScript를 통해 동적으로 로드된다. 

즉, 단순한 정적 HTML을 가져오는 Jsoup만으로는 데이터를 스크래핑할 수 없고, 실제 브라우저에서 페이지를 실행한 후 데이터를 가져와야 하는 상황이었다.

 

그래서 `JavaScript가 실행된 후 데이터를 추출할 수 있는 Selenium을 선택`하게 되었다.

 


2. 스크래핑 비동기 처리로 성능 최적화

문제 상황

기존에는 Selenium을 이용해 웹 스크래핑을 수행한 후 데이터를 가져와서 렌더링하는 방식이었다.

그러나 스크래핑이 완료될 때까지 홈 화면이 뜨지 않는 문제가 발생했다.

즉, 공연 정보를 가져오는 작업이 끝날 때까지 사용자는 빈 화면을 보게 되는 문제가 있었다.

 

문제 원인

`crawlIfNecessary()` 메서드가 동기적으로 실행되면서, 스크래핑이 끝나야만 홈 화면이 렌더링 됐기 때문

 

▶ 기존 코드

- 기존 HomeController (스크래핑 동기 실행)

@GetMapping("/home")
    public String home(Model model) {
        // 스크래핑이 끝날 때까지 대기
        crawlingService.crawlIfNecessary();

        // 기존의 게시글 로드
        List<PostDto> infoPosts = homeService.getAllCategoryPosts("info");
        List<PostDto> reviewPosts = homeService.getAllCategoryPosts("review");
        List<PostDto> matchPosts = homeService.getAllCategoryPosts("match");
        List<PostDto> transferPosts = homeService.getAllCategoryPosts("transfer");
        List<PostDto> popularPosts = homeService.findLatestPostsByPostCategory();

        model.addAttribute("infoPosts", infoPosts);
        model.addAttribute("reviewPosts", reviewPosts);
        model.addAttribute("matchPosts", matchPosts);
        model.addAttribute("transferPosts", transferPosts);
        model.addAttribute("popularPosts", popularPosts);

        return "home";
    }

 

- 기존 PerformanceService (스크래핑 동기 실행)

@Transactional
    public void crawlIfNecessary() {
        Performance lastCrawl = performanceRepository.findTopByOrderByIdDesc();
        LocalDateTime now = LocalDateTime.now();

        // 스크래핑이 수행되지 않았으면 실행
        if (lastCrawl == null) {
            performCrawling();
        }
        // 마지막으로 스크래핑한 지 24시간이 지났을 때 다시 실행
        else if(ChronoUnit.HOURS.between(lastCrawl.getUpdatedAt(), now) >= 24){
            performanceRepository.delete(lastCrawl);    // 이전 크롤링 지우기
            infoRepository.afterCrawling();
            performCrawling();
        }
    }

 

해결 방법

1. 비동기 처리(`@Async` 적용)

- 메인 스레드가 블로킹되지 않도록 백그라운드에서 실행

- 스크래핑이 진행되는 동안 사용자는 홈 화면을 먼저 볼 수 있음

 

2. 병렬 처리(`CompletableFuture` 적용)

- 각 카테고리(뮤지컬, 콘서트, 연극, 기타)의 스크래핑을 동시에 실행

- 전체 실행 시간을 최적화

 

3. self-injection으로 @Async 동작하게 하기

- @Async 는 Spring AOP 프록시를 거쳐야 적용된다. 같은 클래스 내부에서 this.method() 로 호출하면 프록시를 우회해 어노테이션이 무력화된다.
 - 자기 자신을 @Lazy 로 주입받아 self.method() 형태로 호출하면, 프록시 경유 호출이 되어 @Async 가 정상 동작한다.

 

▶ 개선 코드

- 수정된 HomeController (스크래핑 호출 제거, 진행 상태만 노출)

@GetMapping("/home")
public String home(Model model) {
  // 스크래핑 호출 제거. 정기 스케줄(매일 새벽 4시)에서만 실행되도록 분리.

  // 기존의 게시글 로드
  List<PostDTO> popularPosts = homeService.getPopularPosts();
  List<PostDTO> infoPosts = homeService.getAllPostDTOsByCategoryTitle("info");
  List<PostDTO> reviewPosts = homeService.getAllPostDTOsByCategoryTitle("review");
  List<PostDTO> matchPosts = homeService.getAllPostDTOsByCategoryTitle("match");
  List<PostDTO> transferPosts = homeService.getAllPostDTOsByCategoryTitle("transfer");

  model.addAttribute("popularPosts", popularPosts);
  model.addAttribute("infoPosts", infoPosts);
  model.addAttribute("reviewPosts", reviewPosts);
  model.addAttribute("matchPosts", matchPosts);
  model.addAttribute("transferPosts", transferPosts);
  model.addAttribute("isCrawling", performanceService.isCrawling()); // 진행 상태만 화면에 전달
  return "home";
}

 

- 수정된 PerformanceService (비동기 + 병렬 실행)

@Service
@Slf4j
public class PerformanceService {
  private final PerformanceRepository performanceRepository;
  private final PostCategoryRepository postCategoryRepository;
  private final InfoRepository infoRepository;
  private final UserService userService;
  private final ScrapingLoggingService scrapingLoggingService;

  // 자기 자신을 프록시로 주입 → 내부에서도 AOP 인터셉터를 거치게 함
  private final PerformanceService self;

  public PerformanceService(
          PerformanceRepository performanceRepository,
          PostCategoryRepository postCategoryRepository,
          InfoRepository infoRepository,
          UserService userService,
          ScrapingLoggingService scrapingLoggingService,
          @Lazy PerformanceService self) {
      this.performanceRepository = performanceRepository;
      this.postCategoryRepository = postCategoryRepository;
      this.infoRepository = infoRepository;
      this.userService = userService;
      this.scrapingLoggingService = scrapingLoggingService;
      this.self = self;
	}
    
  private volatile boolean isCrawling = false;
  private final Object crawlingLock = new Object();

  public boolean isCrawling() {
      return isCrawling;
  }

  @Async
  public void startCrawlingAsync() {
      // 중복 실행 방지
      synchronized (crawlingLock) {
          if (isCrawling) {
              log.info("스크래핑이 이미 진행 중입니다. 중복 실행 방지.");
              return;
          }
          isCrawling = true;
      }

      try {
          // 카테고리별 병렬 처리
          List<CompletableFuture<Void>> futures = new ArrayList<>();
          for (int type = 1; type <= 4; type++) {
              int finalType = type;
              futures.add(CompletableFuture.runAsync(() ->
                      scrapingLoggingService.measureScrapingPerformance(() ->
                              crawlPerformances(String.valueOf(finalType)))));
          }
          CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
      } finally {
          synchronized (crawlingLock) {
              isCrawling = false;
          }
      }
  }

  // 매일 새벽 4시 정기 실행
  @Scheduled(cron = "0 0 4 * * ?")
  @Transactional
  public void scheduleCrawling() {
      performanceRepository.deleteAll();
      self.startCrawlingAsync();   // ← this 가 아닌 self 로 호출 (프록시 경유)
  }

  // crawlPerformances(...) 등 그 외 로직은 동일
}

 

- AsyncConfig (스레드 풀 명시적 구성)

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
  @Override
  public Executor getAsyncExecutor() {
      ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
      executor.setCorePoolSize(5);
      executor.setMaxPoolSize(10);
      executor.setQueueCapacity(25);
      executor.initialize();
      return executor;
  }

  @Override
  public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
      return new SimpleAsyncUncaughtExceptionHandler();
  }
}
  1. `@Async` 애노테이션: `startCrawlingAsync()` 를 별도 스레드에서 실행해, 호출자(스케줄러)가 블로킹되지 않도록 한다.
  2. self-injection (@Lazy PerformanceService self): 같은 클래스 내부 호출(this.startCrawlingAsync())은 Spring AOP 프록시를 우회하여 @Async 가 무력화된다. 이를 피하기 위해 자기 자신을 프록시로 주입받아 `self.startCrawlingAsync()` 형태로 호출한다. `@Lazy` 는 자기 주입 시 발생하는 순환 의존성을 풀기 위함이다.
  3. 중복 실행 방지: `synchronized` 블록과 `volatile boolean` 변수를 사용하여 여러 요청이 동시에 들어와도 스크래핑이 중복 실행되지 않도록 한다. 이미 스크래핑이 진행 중이면 로그만 남기고 반환한다.
  4. CompletableFuture.runAsync(): 4개의 스크래핑 작업을 별도의 스레드 풀에서 병렬로 실행한다.
  5. 병렬 처리: 4개 카테고리(1:뮤지컬, 2:콘서트, 3:연극, 4:기타)에 대해 각각 `CompletableFuture`를 생성한다.모든 카테고리를 동시에 병렬로 처리하여 전체 실행 시간을 단축한다.
  6. CompletableFuture.allOf().join(): 생성된 모든 CompletableFuture 작업(4개 카테고리)이 완료될 때까지 대기한다. 이를 통해 모든 카테고리의 스크래핑이 완료되었을 때 finally 블록이 실행되어 `isCrawling` 변수를 `false`로 변경한다.
  7. 예외 처리: `try-finally` 블록을 사용하여 스크래핑 중 예외가 발생하더라도 반드시 `isCrawling` 상태를 `false`로 복원한다. 이렇게 함으로써 한 번의 스크래핑 실패가 시스템 전체에 영향을 미치지 않도록 한다.

 


3. 결론

기존에는 스크래핑이 끝나야 홈 화면이 뜨는 문제가 있었다.
스케줄 분리(@Scheduled) + 비동기 처리(@Async) + 병렬 처리(CompletableFuture) + 중복 실행 방지를 적용하여
스크래핑 작업을 백그라운드에서 실행하고, 사용자에게 즉시 홈 화면을 띄울 수 있도록 최적화했다.

'Projects' 카테고리의 다른 글

[Poppy] Redisson 기반 분산 락을 활용한 예약 시스템 동시성 제어  (0) 2025.02.26
[InConcert] SSE+Redis를 활용한 실시간 알림  (0) 2025.01.22
[InConcert] LazyInitializationException 해결  (0) 2025.01.19
[InConcert] 스크래핑 비동기 처리(2)  (0) 2025.01.15
'Projects' 카테고리의 다른 글
  • [Poppy] Redisson 기반 분산 락을 활용한 예약 시스템 동시성 제어
  • [InConcert] SSE+Redis를 활용한 실시간 알림
  • [InConcert] LazyInitializationException 해결
  • [InConcert] 스크래핑 비동기 처리(2)
oniwon
oniwon
  • oniwon
    눙눙의 개발일지
    oniwon
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Java
      • Spring
      • CS
      • Infra
      • Projects
      • F-Lab
      • Airflow
  • 인기 글

  • 블로그 메뉴

    • 홈
    • 개인정보처리방침 (Privacy Policy)
    • 블로그 소개 (About)
  • 최근 글

  • 태그

    자바 다형성
    에프랩
    병렬 처리
    Flab 멘토링
    Flab멘토링
    dns
    DAG
    JVM
    Flab 3주차 후기
    SSE
    에프랩멘토링
    자바 내부 동작
    비동기 처리
    동시성
    Flab후기
    redis
    Flab커넥터스
    inconcert
    Flab 추천인코드
    Flab추천인코드
    https
    F-Lab
    tls
    Java
    Airflow
    자바백엔드
    FLAB
    커넥터스2385
    자바면접
    네트워크
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.1
oniwon
[InConcert] 스크래핑 비동기 처리(1)
상단으로

티스토리툴바