브랜드별 음료에 대한 데이터를 관리하고, 그와 연결되어 정보 및 기능이 제공되는 애플리케이션이었기에 크롤링 로직의 구현이 필수적이었다. 관리자용 페이지를 제공하고 따로 데이터를 생성 및 수정하는 방향도 생각해 보았지만 지속적인 데이터의 추가와 사람이 관여하지 않는 자동화된 데이터 관리를 위해 크롤링 시스템을 구축하였다.
Jsoup과 Selenium
정적인 문서를 파싱하고 크롤링하는것이 목적이라면, Jsoup을 사용하여도 상관없지만 크롤링 대상 브랜드인 스타벅스, 메가커피, 빽다방의 경우 개발자 도구에서 Disable JavaScript를 체크하면 음료의 정보가 정상적으로 로딩되지 않는다. 즉, 음료 정보가 JavaScript에 의해 동적으로 로드된다는 의미이기에 동적 크롤링을 진행해야 했다.
위 이유로 동적 크롤링을 지원하는 Selenium을 사용하여 크롤링 로직 구현을 진행하였다.
환경 구성
크롤링 프로젝트 분리
기존에는 REST API 서버 프로젝트 내부에 크롤링 로직이 포함되어 있었다. 하지만 두 로직 간 데이터베이스를 통한 상호작용을 제외하면 직접적인 연관성이 없었기에 프로젝트를 완전히 분리하여 구현을 진행하였다. 덕분에 각 프로젝트의 구조가 더 명확해져 코드에 대한 이해 및 관리가 더 쉬워졌다.
build.gradle
크롤링 주체인 WebDriver와 브라우저 버전과 호환되는 드라이버 버전의 자동 선택 및 다운로드를 지원하는 WebDriverManager를 사용하기 위해 아래와 같이 gradle 파일을 작성하였다.
// Selenium
implementation 'org.seleniumhq.selenium:selenium-java:4.27.0'
// Chrome Driver 자동 설정 지원
implementation 'io.github.bonigarcia:webdrivermanager:5.9.2'
application.yml
크롤링 대상 URL의 경우 yml 파일에 작성, @Value 어노테이션을 통해 로직 내부에서 사용하였다.
selenium:
base-url:
starbucks:
base: https://www.starbucks.co.kr/menu/drink_list.do
view: https://www.starbucks.co.kr/menu/drink_view.do
mega: https://www.mega-mgccoffee.com/menu/?menu_category1=1&menu_category2=1
paik:
new: https://paikdabang.com/menu/menu_new/
coffee: https://paikdabang.com/menu/menu_coffee/
drink: https://paikdabang.com/menu/menu_drink/
dessert: https://paikdabang.com/menu/menu_dessert/
ccino: https://paikdabang.com/menu/menu_ccino/
WebDriverService
처음에는 Config 클래스를 통해 WebDriver 객체를 Bean으로 등록하여 사용했지만, WebDriver 객체를 싱글톤으로 관리하게 되면 WebDriver 객체의 리소스 해제 등의 문제가 발생하여 WebDriver 객체를 관리하는 별도의 서비스 클래스를 정의하여 사용하였다.
@Service
public class WebDriverService {
public WebDriver createWebDriver() {
WebDriverManager.chromedriver().setup();
ChromeOptions chromeOptions = new ChromeOptions();
chromeOptions.addArguments("--headless"); // GUI 없이 헤드리스 모드로 Chrome 실행
chromeOptions.addArguments("--lang=ko"); // 브라우저 언어를 한국어(ko)로 설정
chromeOptions.addArguments("--no-sandbox"); // 샌드박스 모드 비활성화
chromeOptions.addArguments("--disable-dev-shm-usage"); // /dev/shm(공유 메모리 디렉토리) 사용 비활성화
chromeOptions.addArguments("--disable-gpu"); // GPU 가속 비활성화
WebDriver driver = new ChromeDriver(chromeOptions);
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30));
return driver;
}
public void quitWebDriver(WebDriver driver) {
if (driver != null) {
driver.quit();
}
}
}
Crawling 로직 구현
BaseCrawler
WebDriver의 설정을 위한 `setWebDriver()`, List<Beverage> 형태의 데이터를 반환하는 `crawlBeverageList()`, 페이지 이동을 처리하는 `navigateTo()` 메소드를 정의하고 BaseCrawler를 extends 하는 방식으로 브랜드별 크롤러를 구현하였다.
public abstract class BaseCrawler {
protected WebDriver driver;
public void setWebDriver(WebDriver driver) {
this.driver = driver;
}
public abstract List<Beverage> crawlBeverageList();
protected void navigateTo(String url) {
driver.get(url);
}
}
브랜드별 Crawler
하단은 메가커피 크롤링 컴포넌트의 일부 로직이다. URL 정보는 설정 파일에서 가져와 페이지 이동에 사용하였고, 이후 해당 페이지의 도메인 특성에 따라 크롤링 로직을 구현하였다.
@Component
public class MegaCrawler extends BaseCrawler {
@Value("${spring.selenium.base-url.mega}")
private String BASE_URL;
@Override
public List<Beverage> crawlBeverageList() {
List<Beverage> results = new ArrayList<>();
try {
navigateTo(BASE_URL);
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(20));
// 브랜드별 사이트 도메인 특성에 따른 크롤링 로직 구현
CrawlingService
CrawlingService에서는 상단 크롤링 로직들을 통해 수집된 List<Beverage> 형태의 데이터에 대해 기존 존재여부를 확인하여, 새로운 데이터는 추가하고 기존 데이터는 최신 정보로 업데이트해주는 로직을 작성하였다.
SchedulerConfig
스프링의 스케쥴링 기능을 통해 주기적인 크롤링을 진행할 수 있도록 로직을 작성하였다.
@Configuration
public class CrawlingSchedulerConfig {
private final CrawlingService crawlingService;
@Autowired
public CrawlingSchedulerConfig(CrawlingService crawlingService) {
this.crawlingService = crawlingService;
}
@Scheduled(cron = "0 0 4 1,15 * ?")
public void scheduleCrawling() {
crawlingService.executeCrawling();
}
}
구현 중 주요 사항들
크롤링 특성상 구현 목적에 사이트의 데이터 구조가 완벽히 부합할 순 없기에, 먼저 구현하고 있는 어플리케이션의 데이터 구조를 명확히 정하고, 사이트에서 제공하는 데이터의 구조를 대응시키는 과정이 매우 중요하게 느껴졌다.
프로젝트 초반에는 음료 카테고리, 사이즈 등 명확한 기획이 정해지지 않아 데이터 구조화가 진행되지 않았기에, 크롤링 컴포넌트를 구현하며 해당 요소들에 대해 PM분과 소통하여 정해나갔었다. 이 과정에서 명확한 데이터 구조화가 중요하다는 것을 뼈저리게 느꼈다..
추가적으로, element 내부 문자열을 가져오는 메소드에는 `getText()`와 `getAttribute("textContent")` 두 가지가 있는데, `getText()` 메소드는 숨겨진 요소에 대해 데이터를 가져오지 못하기에 이를 감안한 적절한 메소드의 사용이 필요했다.
마치며
크롤링 시스템을 구현하며, 여러 카페 브랜드 사이트들을 탐색하고 도메인 지식에 대한 이해가 중요함을 느낄 수 있었고 생각보다 브랜드 웹사이트 구조가 깔끔하다는 생각은 안들어 역으로 자신감을(?) 얻을 수 있었다. 그리고 그 무엇보다.. 명확한 데이터 구조화의 중요성을 체감할 수 있었다.
