Spiaminto
Selenium Java 의 java.util.concurrent.TimeoutException 문제와 Playwright 본문
Selenium Java 의 java.util.concurrent.TimeoutException 문제와 Playwright
spiaminto 2024. 9. 25. 15:46웹 스크래핑 도구로 Selenium Java 를 사용하던 도중 발생한 문제와 Playwright 로의 라이브러리 변경에 대해 기록하려 한다.
0. 상황
이 당시 Selenium 4.19.1 을 사용하여 아래와 같이 스크래핑 하고 있었다.
public class WebDriverUtil {
public static WebDriver getChromeDriver() {
ChromeOptions chromeOptions = new ChromeOptions();
chromeOptions.addArguments(
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
"--lang=ko",
"--disable-images",
"--blink-settings=imagesEnabled=false"
);
chromeOptions.setPageLoadStrategy(PageLoadStrategy.NONE);
return new ChromeDriver(defaultChromeService, chromeOptions);
}
}
위의 유틸리티 클래스를 이용하여 드라이버를 만든 뒤
protected Element openViewPageAndParse(String executeUrl) {
Element result;
try {
Thread.sleep(500);
webDriver.get(executeUrl);
WebElement element = new WebDriverWait(webDriver, Duration.ofMillis(2000))
.until(ExpectedConditions.presenceOfElementLocated(
By.cssSelector(selector)));
// 파싱
result = Jsoup.parse(mainContainer.getAttribute("outerHTML"));
} catch (Exception e) {
result = null;
}
return result;
}
위의 메서드로 요소를 추출하여 파싱 후 반환하도록 하였다.
implicit wait 은 설정하지 않았고 PageStrategy.NONE 과 explicit wait 을 설정하였기 때문에, 명시적 대기 시간인 2초 이내에 요소를 가져와야 했다. Thread.sleep(500) 이 들어간 이유는 스크래핑 대상이었던 디시인사이드가 1초에 2회이상 지속적으로 요청시 응답 속도가 느려지거나 차단당했기 때문이다.
목표는 1 ~ 1.5 초당 1페이지 씩 스크래핑 하는것이었으며 8월 말까지는 잘 동작하였으나 9월 초 쯤부터 문제가 발생하기 시작했다. (ChromeDriver v12X -> v128 -> v129, 문제 발생은 v128부터)
1. 문제
1.1 간헐적인 StaleElementReferenceException 발생
해당 예외는 셀레니움 드라이버가 페이지의 요소를 미리 기록해두었으나 이후의 요청 시점에 그 요소를 DOM 에서 엑세스 할 수 없을때 발생하는 예외이다. 주로 WebDriverWait.until() 로 찾아놓은 element 객체에 element.getAttribute() 메서드 호출시점에 발생했다. headless 모드를 끄고 브라우저를 통해 직접 확인해 보았으나 DOM 에 변화가 없었음에도 불구하고 예외가 발생했다.
재시도 로직이 있었고 발생 빈도가 높지 않았기 때문에 크게 문제될것은 없었지만, 이전버전에서 발생하지 않았으므로 기록했다.
1.2 퍼포먼스 문제, 버그
v128 이전 버전에서는 기록해 두지는 않았지만 크롬이 대략 5~6 개의 프로세스, 20%내외의 CPU 점유, 1G 미만의 메모리를 사용했다.
하지만 현재 v129 버전의 경우 위의 스크린샷 처럼 20개가 넘은 프로세스를 사용하며 메모리역시 최소 1G, CPU 자원도 20 ~ 50% 까지 사용하는 등 이전버전보다 확연히 시스템 자원 사용량이 늘었다.
시스템 자원 사용량이 늘어남에 따라 다른작업과 동시에 진행할때 속도가 중간중간 느려지거나 시스템 자체의 안정성이 떨어지는 문제도 발생했다.
추가로 headless 모드에도 버그가 생겼는데, 이는 "headless=old" 옵션을 통해 해결할 수 있었다.
위의 버그는 크롬 버전의 업데이트에 따라 얼마든지 셀레니움에 버그가 발생할 수 있고, 크롬의 업데이트 주기가 생각보다 빠르다는걸 상기시켜주었다.
1.3 Timeout 동작 문제
사실 이문제가 결정적이었는데, 셀레니움의 WebDriverWait이 의도되로 동작되지 않는 경우가 종종 발생했다.
위의 코드 대로라면 셀레니움 에서 WebDriverWait.until() 메서드 실행 후 2초 뒤에 반드시 org.openqa.selenium.TimeoutException 이 발생해야 한다. 하지만 실제로는 아래와 같이 webDriver.get(executeUrl) 에서 WebDriverWait.until(...) (mainContainer loaded) 까지의 시간이 2초를 초과하여 늘어나는 모습이 자주 보였다.
이 문제가 심해지면 아래와 같이 3분동안 대기한 뒤 java.util.concurrent.TimeoutException 이 발생하며 이 예외가 발생하면 높은 확률로 셀레니움 드라이버를 통해 실제 브라우저의 조작이 불가능해진다. (quit 도 불가능)
해당 문제로 인해 스크래퍼의 안정성이 지나치게 낮아져 스크래핑에 실패할 확률이 매우 높아졌다. 이를 해결하기 위해 이것저것 찾아보았는데 나와 같은 예외가 발생하는 사람이 적은듯 하여 정보가 별로 없었다. 아래는 나름대로 알아보고 정리한 내용이다.
1.3.1 WebDriverWait 의 timeout 동작 방식과 한계
// Selenium 의 FluentWait<T> 클래스 내부의 until 메서드 (주석 수정됨)
@Override
public <V> V until(Function<? super T, V> isTrue) {
Instant end = clock.instant().plus(timeout);
Throwable lastException;
while (true) {
try {
V value = isTrue.apply(input); // ExpectedConditions 를 적용하는 부분
if (value != null && (Boolean.class != value.getClass() || Boolean.TRUE.equals(value))) {
return value;
}
lastException = null;
} catch (Throwable e) {
lastException = propagateIfNotIgnored(e);
}
// Timeout 을 실제로 확인하는 부분
if (end.isBefore(clock.instant())) {
String message = messageSupplier != null ? messageSupplier.get() : null;
String timeoutMessage =
String.format(
"Expected condition failed: %s (tried for %d second(s) with %d milliseconds"
+ " interval)",
message == null ? "waiting for " + isTrue : message,
timeout.getSeconds(),
interval.toMillis());
throw timeoutException(timeoutMessage, lastException);
}
try {
sleeper.sleep(interval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new WebDriverException(e);
}
}
}
위는 셀레니움의 WebDriverWait 이 상속받는 FluentWait<T> 클래스의 until 메서드의 코드이다. 위의 코드에서 WebDriverWait 객체에 설정한 timeout 을 실제로 확인하는 로직이 ExpectedConditions 를 확인하는 작업보다 늦게 실행되기 때문에, ExpectedConditions를 확인하는 작업은 반드시 1회 이상 실행되도록 보장받는다.
ExpectedConditions 를 확인하는 작업을 포함하는 전체적인 흐름을 간략하게 아래의 그림으로 나타내었다.
위의 그림에서 보이는 것 처럼 FluentWait 의 apply(ExpectedConditions) 메서드는 반드시 1회 이상의 실행을 보장받기에 이후 JdkHttpClient 의 http 요청, Selenium Driver 와 Browser(이하 브라우저) 간의 상호작용, 이후 response 를 받아 처리하는 과정까지가 WebDriverWait 객체의 timeout 과 관계없이 무조건 1회 이상 실행된다.
이 때 JdkHttpClient 에서 http 요청을 보낸 후 SeleniumDriver 와 브라우저 간의 상호작용에 문제가 발생하면 http 응답이 돌아오지 않게되며 JdkHttpClient 는 해당 객체에 정해진 timeout (기본값 3분) 을 기다린 뒤에 future.get() 메서드에서 java.util.concurrent.TimeoutException 을 발생시키게 된다.
이 문제는 Selenium 의 구 버전에서도 종종 발생하던것으로 보이며, 이를 해결하기 위해 Selenium 측에서 JDK 버전을 11로 올린다던가 JdkHttpClient 의 timeout 을 설정할 수 있는 생성자를 제공하는등 갖가지 노력을 한 듯 하다.
public static WebDriver getChromeDriver() {
ChromeOptions chromeOptions = new ChromeOptions();
chromeOptions.addArguments(
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
"--lang=ko",
"--disable-images",
"--blink-settings=imagesEnabled=false"
);
chromeOptions.setPageLoadStrategy(PageLoadStrategy.NONE);
// JdkHttpClient 설정 수정
ClientConfig clientConfig = ClientConfig.defaultConfig().
readTimeout(Duration.ofSeconds(60)) // http 요청 응답 대기 시간
.withRetries();
return new ChromeDriver(ChromeDriverService.createDefaultService(),
chromeOptions, clientConfig);
}
위의 코드대로 드라이버를 만들면 JdkHttpClient 의 timeout 을 설정하여 대기시간을 줄일 수 있으나, java.util.concurrent.TimeoutException 이 발생하면 높은 확률로 WebDriver 객체 자체를 사용할 수 없게 되어버리기 때문에 해결에 도움이 되진 않았다. (마치 브라우저와 통신이 끊어진것 처럼 quit() 메서드 조차 동작하지 않는다)
위의 문제는 implicit wait 을 사용시에도 동일하게 발생하며, driver 에 걸 수 있는 다른 timeout 인 pageLoadTimeout() 등으로도 해결이 불가능 했다.
1.4 Thread.sleep() 과 동시사용 문제
기본적으로 Selenium 은 Thread.sleep() 과 Selenium 의 Wait 을 동시에 적용하는것을 권장하지 않는다.
하지만 내 경우 요소를 최대한 빨리 추출하면서도 1초 이내에 2회이상 요청하면 안된다는 문제가 있었기 때문에 Thread.sleep() 을 삽입하여 사용할 수 밖에 없었다.
Thread.sleep() 이 Selenium 의 Wait 이나 내부 동작에 영향을 주는 듯 한 경우를 발견했으나 정확히 파악하기 어려워 일단 문제가 될 수 있다고 판단하는 정도로 마무리 했다.
2. 해결?
이런 저런 방법을 시도해보았지만, java.util.concurrent.TimeoutException 발생 시 브라우저와 연결이 끊어지는 현상을 고칠 수 없었다. 개인적으로 내 노트북의 시스템 자원에 여유가 없어서 이 문제의 발생 빈도가 올라간 것이 아닐까 하는 생각도 했다. 문제의 근원이 셀레니움 드라이버와 실제 브라우저 간의 상호작용에 있다고 생각하니 더 이상 내가 할 수 있는 일이 없게 느껴졌다. 그래서 라이브러리 자체를 Playwright 로 바꾸기로 했다.
Playwright 로 옮긴 이유와 좋았던 점은 다음과 같다.
- Chrome 보다 가볍고 리소스 사용량이 적음 (CPU 5 ~ 20% 점유, 메모리 150MB 사용)
- 자체적으로 Chromium 을 다운받아 사용하기 때문에 로컬 Chrome 버전과 상관없이 실행
- Selenium 보다 웹 드라이버 켜고 끄기가 편함 (try-resource 사용)
추가로 Playwright 의 옵션상 요소를 얻어오는 속도(1300millis) 가 Selenium 에서의 최고 속도(500 ~ 700millis) 보다 느렸지만, 이로 인해 Thread.sleep() 대기를 삭제 할 수 있게 되어 결과적으로 속도가 거의 같았던 점 도 좋았다.
일주일이 넘는 기간동안 스트레스 받은 것이 너무 아까울 정도로 Playwright 로의 라이브러리 변경은 간단하고 좋았다. 단점을 굳이 찾자면 드라이버 번들의 용량이 많이 크다는 것 정도? 혹시 비슷한 문제가 발생하는 다른 사람이 있다면 그냥 Playwright 로 바꿔 보라고 말하고 싶다.
+ 2024 10/10 추가
현재 이 버그?는 수정되어 시스템 자원 사용량이 종전대로 줄어들었다. (chromedriverVersion: 129.0.6668.100 , cdpVersion: 129.0.6668.90)
하지만 Selenium 은 기본적으로 로컬에 설치된 브라우저를 사용하기 때문에, 내 컴퓨터의 크롬 브라우저가 자동 업데이트 될 때 마다 언제든지 비슷한 버그가 발생할 수 있다고 생각한다. 그래서 따로 크로미움을 설치하고 사용하는 Playwright 를 그대로 사용하고 있다.
'학습정리(공개)' 카테고리의 다른 글
일본어 곡 제목, 가수 명 한글 검색 구현 후기 (0) | 2025.02.27 |
---|---|
ElasticBeanstalk 배포 서비스에 Cloudflare https 적용 및 보안관련 수정 (0) | 2025.02.20 |
Supabase 무료플랜 임베딩 데이터 용량 초과 대응후기 (0) | 2024.09.12 |
JPA 쓰기 지연 저장소 flush 시점 관련 테스트 및 정리. (0) | 2024.06.06 |
아마존 리눅스 2023 Cloudwatch 로그 스트리밍 구성 설정 마이그레이션 후기 (0) | 2024.04.23 |