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