본문 바로가기

Programming/Spring

[Selenium + Headless Chromium] Java로 인스타그램 대용량 크롤링하기 1

반응형

 

 

selenium

 

 3개월 간 빡세게 진행했던 프로젝트에서 Selenium 크롤링을 거의 메인으로 사용했었습니다. 프로젝트를 하면서 크롤링 덕에 고생을 너무 많이 해서 내가 이것만 끝나면 블로그에 흔적이라도 남길 거라고 이를 갈았는데 막상 끝내고 포스팅 할 때가 되니 미화가 돼 버렸습니다..ㅎ

 

 사실 구글링해서 나오는 블로그들에서 이미 selenium 라이브러리를 사용하여 크롤링 코드를 구현한 예제들을 많이 소개하고 있습니다. 하지만 이 포스팅에서 소개하는 크롤링 소스코드는 다음과 같은 점에서 차별점이 있음을 고려하며 읽어주셨으면 합니다. 

 

1. 크롤링이 배치 프로그램 내에서 돌아감 

2. 약 10000개 이상의 글을 연속적으로 크롤링

3. 크롤링을 stop 하는 기준점이 존재

4. System의 Interruption과 대용량 크롤링에서의 시간낭비를 발생시키는 Thread.sleep() 대신 WebDriverWait 객체를 사용

 

 1번과 2번은 비슷한 이슈를 가지고 있는 데, 바로 안정성입니다. 배치프로그램의 경우 다른 비즈니스 로직들보다도 더 꼼꼼한 테스트를 필요로 한다고 생각하고 있습니다. 특히나 제가 진행했던 프로젝트에서는 RDBMS의 테이블이 서로 참조 관계로 엮여있었으며 배치 프로그램은 총 4개의 Job이 직렬 또는 병렬 프로세스로 구성되어있었습니다. 즉, n번째 Job에서 크롤링이 어떤 이유로 중단되어 Job이 작업을 제대로 마무리하지 못했다고 가정해보겠습니다. 이렇게 Job이 FAILED 되었을 때, 운이 나쁘다면 그 뒷 타임에 예정되어 있던 Job또한 줄줄이 FAILED 되거나 의미없는 코드 수행을 하게 될 지도 몰랐었다는 것이죠.

 

 

* 참고 

배치프로그램은 여러 개의 Job을 가질 수 있으며, 한 개의 Job은 여러 Step을 가질 수 있습니다. 1개의 Step은 reader - processor - writer 의 구조를 가지거나 Tasklet으로 구성될 수 있습니다. 하나의 Job을 코드 단에서 정의할 때, 개발자는 cronExpression과 같은 표현방식으로 해당 Job의 수행 시간을 정의할 수 있습니다. 예를 들어, "0 30 7 * * ? *" 을 cronExpression으로 둔다면, '매일 아침 7시 30분 마다 이 Job을 수행' 한다는 의미를 담을 수 있습니다. cronExpression은 각 Job을 실질적으로 구동시킬 수 있는 Trigger Factory Bean (ex. CronTriggerFactoryBean) 내에서 주로 정의하며, 이 빈이 SchedularFactoryBean 에서 주입되지 않는다면, Job은 구동되지 않습니다. 

 개발자는 Job들을 병렬 프로세싱으로 구성할 수도, 순차적으로 구성할 수도 있습니다. 마찬가지로, 모든 Job들은 비동기 처리가 가능합니다. 

 Batch 프로그램에 관한 설명은 Web - Spring 카테고리에 더 많이 설명되어 있습니다.

 

 

 인스타그램의 웹 사이트는 한 페이지에서 다음 버튼을 눌러가며 글을 최신순으로 조회하는 방식으로 이루어져 있습니다. 한 개의 링크에서 이루어지는 간단한 크롤링이었다면 안정성까지 고려하지 않아도 되겠지만, 만약 버튼을 9999번 쯤 누르게 될 일이 생긴다면 고민해 볼 예외상황이 한 두개가 아니겠죠.. 

 

그렇다면, 코드를 통해 본격적으로 크롤링을 구현하며 겪었던 오류들과 고민해보았던 점들을 소개해드리겠습니다.

 

 

개발 환경 및 프로젝트 정보

 

1. 개발 환경

 

  • spring framework : spring-webmvc 5
  • spring batch 4
  • quartz 2.3.1
  • java 1.8
  • build : gradle
  • selenium 3.141
  • chrome driver 75 (headless)

 

2. 프로젝트 정보

 

크롤링은 배치 프로그램에서만 사용되었으며 별도의 서비스 로직에서는 사용하지 않았습니다. 배치 프로그램에서는 크롤링을 사용하여 다음과 같은 과정을 거칩니다. 

 

1. 약 300여개의 지하철 역 이름들을 DB에서 가져옴

2 - 1. 특정 지하철 역에 대해, 이미 DB에 담겨있는 crawled 정보가 없다면 ? -> 어제부터 3개월 치의 인스타그램 게시물을 크롤링

2 - 2.  crawled 정보가 있다면 ? -> 가장 최근에 crawled 된 게시물의 날짜 받아오기 -> 3개월보다 더 오래된 게시물이 있다면 삭제 -> from 그 날짜+1 to 어제 까지의 인스타그램 게시물을 크롤링

: 이 때 크롤링한 정보들이 JSON 형태로 DB로 들어간다면, JSON 내에서 수정/ 삭제를 하도록 했습니다. JSON 전체를 삭제/ 다시 update 하지 않습니다.

3. 300여 개의 지하철 역이 이 과정을 반복

 

 

Spring Framework : Chrome Driver 를 set-up 하는 Bean 만들기

 

이 부분은 Spring Framework를 사용하고 있는 경우에 해당되며, 그게 아니시라면, 건너뛰어도 좋습니다.

저는 맥북을 사용하고 있어, CHROME_DRIVER_PATH 에서 확장자를 써 주지 않았지만, 윈도우 운영체제를 사용하시는 분이라면, 맨 뒤에 exe를 붙여주어야 한다고 알고 있습니다.

 

package org.webapp.config;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.slf4j.*;

@Configuration
public class ChromeDriverContext {

    private WebDriver driver;
    private static final Logger logger = LoggerFactory.getLogger(ChromeDriverContext.class);
    private static final String CHROME_DRIVER_PATH = "/Users/.../.../chromedriver";

    @Bean
    public WebDriver getDriver() {
        return driver;
    }

    @Bean
    public WebDriver setupChromeDriver() throws Exception {
        System.setProperty("webdriver.chrome.driver", CHROME_DRIVER_PATH);

        ChromeOptions options = new ChromeOptions();
        options.addArguments("--window-size=1366,768");
        options.addArguments("--headless");
        options.setProxy(null);
        DesiredCapabilities capabilities = DesiredCapabilities.chrome();
        capabilities.setCapability(ChromeOptions.CAPABILITY, options);
        capabilities.setCapability("pageLoadStrategy", "none");

        try {
            /*
             *
             * @ params
             * option : headless
             *
             */
            driver = new ChromeDriver(capabilities);
        } catch (Exception e) {
            logger.error("### [driver error] msg: {}, cause: {}", e.getMessage(), e.getCause());
        }

        return driver;
    }
}

 

 배치프로그램은 하루에 한 번 수행되고 크롤링을 수행하는 4개의 Job이 있다고 설명해드렸는 데, 만약 하루에 4번 크롤링이 수행될 때 마다 크롬 드라이버가 new ChromeDriver() 를 통해 생성된다면, 부하가 엄청 커질 것 같습니다. 하루에 4번이라면 일주일에 최소 16번의 객체 생성을 하는 격입니다. 배치 프로그램은 분명 원초적으로 시스템의 과부하를 줄이기 위해 사용되는데 그 목적도 달성하지 못하게 생겼습니다. 

 

 때문에 저는 먼저 Chrome driver를 빈으로 생성해두었습니다. 스프링이 싱글톤 레지스트리라는 강력한 장점을 활용해보도록 합시다! 하지만 이 때 driver.quit() 을 수행하는 메소드는 빈으로 정의하면 안 되는 점에 주의하여야 합니다. 싱글톤 오브젝트를 만드는 스프링은 프로그램을 구동시킬 때 모든 빈을 생성하는 데, 이 때 quit() 이 담긴 메소드를 빈으로 정의해놓는다면, driver가 set up 되지도 않았는데 quit() 을 하는 이상한 상황이 벌어집니다. NPE 가 날 수 밖에 없지요. 즉, 프로젝트 내에서 빈으로 정의한 다른 소스코드에서도 driver.quit()은 호출되어서는 안된다는 것입니다.

 

 

Instagram : Date 크롤링하기

 

 별도의 메소드를 통해 이미 WebDriver 를 생성했다고 가정하겠습니다.

 

@Override
    public String getDate(WebDriver driver) {
        this.driver = driver;
        WebElement element = driver.findElement(By.xpath("/html/body/div[4]/div[2]/div/article/div[2]/div[2]/a/time"));
        String realdate = element.getAttribute("title");

        return realdate;
    }

 

단순히 WebElement.getText() 를 뽑아낼 경우, '3일 전', '2주 전' 과 같은 형태로 출력됩니다. 그리고, headless 일 경우에는 이게 영어로 출력되죠. 그래서 뽑아오는 날짜 형식에 통일성을 주기 위해 개발자 모드를 켜서 인스타그램의 html 코드를 뒤지게 되었습니다. 

 

Instagram의 소스코드

역시나 title 이라는 attribute 에서 같은 형식으로 날짜를 나타내주고 있었습니다. 그래서 초반에는 위와 같이 코드를 구현하였고, 테스트케이스였던 10여개의 게시글은 오류없이 잘 돌아갔습니다. 또, 애초에 테스트를 Headless 로 진행하지 않았기에 저 코드를 그대로 프로젝트에 사용할 수는 없었습니다. 하지만 작은 데이터셋이라면 위 코드로도 충분할 것이라 생각합니다.

 

Date 만 간단히 크롤링하는 데에도 생각해 봐야 할 점이 몇 개 있습니다.

 

1. TimeoutException, NoSuchElementException

 저는 웹 드라이버를 최초 로딩하는 메소드에서 this.webDriverWait = new WebDriverWait(this.driver, 40); 를 통해 WebDriverWait가 최대로 기다려 줄 수 있는 시간을 정의했습니다. 그런데 만약 40초가 지난 뒤에도 웹 페이지가 로드되지 않았다면

 이 때는 TimeoutException이 발생합니다. TimeoutException이 발생한 건, 내가 필요한 element나 locator를 받아오지 못했다는 소리이며, NoSuchElementException 이 발생할 상황입니다. 이걸 그냥 던져낸다면 배치에서 이 아이를 받아 처리해야 하는데, 저는 배치 코드가 크롤링에서 발생한 사건을 모르도록, 코드의 독립성을 유지해주고 싶었으므로 이 에러를 try-catch 로 잡아 해결해보도록 하였습니다. 

 

 다시 한번 WebDriverWait를 주어, 설정한 locator가 보일 때 까지 대기하는 코드를 넣어주었지만 해결하지 못했습니다.

 이미 locator는 로드를 끝낸 상황이었고, 제가 맨 처음에 준 webDriverWait.until(ExpectedConditions.visibilityOfElementLocated(by)); 이 또 새로운 locator가 변경되어 들어오는 줄 알고 기다리고 있었기 때문입니다. 

 

 따라서, 

 

 TimeoutException이 발생했을 때 벌어질 수 있는 상황은, 1. element/ locator가 이미 이전에 다 로드됐을 때 2. 정말 40초 내에 element나 locator가 로드되지 못했을 때

 둘 다가 될 수 있는 점을 생각하며 해결법을 찾아야 합니다.

 

2. NPE

 중간에 webdriver 가 quit() 되어버린 이상한 상황입니다.

 

3. Headless

 Headless Chrome Driver 로 사용할 경우, 0000년 00월 00일 의 포맷을 유지하지 않습니다. 따라서 java에서 제공하는 SimpleDateFormat으로 크롤링한 결과물을 한번 더 파싱해주고, Jan/ Feb/ ... 등의 형식으로 크롤링 된 '월' 의 경우는 따로 replacement 처리를 해주도록 합시다. 

 

 

위의 세 가지 문제점을 해결하여 다음 코드를 완성할 수 있었습니다.

 

@Override
    public Date getDate(WebDriver driver) throws ParseException {
        this.driver = driver;
        String realdate = "";
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM dd, yyyy");
        By by = By.cssSelector("body > div._2dDPU.vCf6V > div.zZYga > div > article > div.eo2As > div.k_Q0X.NnvRN > a > time");

        try {

            try {
                webDriverWait.until(ExpectedConditions.visibilityOfElementLocated(by));
            } catch(TimeoutException e) {
                logger.error("TimeoutException in <getDate>");
                Thread.sleep(10000);
            }
            
            this.element = driver.findElement(by);

            realdate = element.getAttribute("title");

        } catch (NoSuchElementException one_more_try) {

            try {
                webDriverWait.until(ExpectedConditions.visibilityOfElementLocated(by));
                this.element = driver.findElement(by);
                realdate = element.getAttribute("title");
            } catch (NoSuchElementException no_date_exist) {
                realdate = "";
            }

        } catch (NullPointerException e) {

            realdate = "";

        } finally {
        
            if (!realdate.equals("")) {
                realdate = getMonth(realdate);
                java.util.Date utilDate = simpleDateFormat.parse(realdate);
                Date settingDate = new Date(utilDate.getTime());
                return settingDate;
            }
            else {
                return new Date(0);
            }
            
        }
    }

    private String getMonth(String date) {
    
        switch (date.substring(0, 3)) {
            case "Jan" :
                date = date.replace("Jan", "1");
                break;

            case "Feb" :
                date = date.replace("Feb", "2");
                break;

            case "Mar" :
                date = date.replace("Mar", "3");
                break;

            case "Apr" :
                date = date.replace("Apr", "4");
                break;

            case "May" :
                date = date.replace("May", "5");
                break;

            case "Jun" :
                date = date.replace("Jun", "6");
                break;

            case "Jul" :
                date = date.replace("Jul", "7");
                break;

            case "Aug" :
                date = date.replace("Aug", "8");
                break;

            case "Sep" :
                date = date.replace("Sep", "9");
                break;

            case "Oct" :
                date = date.replace("Oct", "10");
                break;

            case "Nov" :
                date = date.replace("Nov", "11");
                break;

            case "Dec" :
                date = date.replace("Dec", "12");
                break;
        }

        return date;
    }

 

 핵심적으로 '무언가' 를 수행하는 코드는 앞서 첨부한 소스와 다를 바 없습니다. 하지만 좀 더 안정적인 구동을 위한 예외처리와 별도의 메소드 실행을 추가했습니다.

 

 NPE의 경우는 매우 가끔 발생하지만 이 예외를 처리해주지 않으면 다음에 Batch에 대한 recovery를 실행할 때 머리아픈 상황이 나올 수도 있던 터라, 그냥 해당 게시글의 date를 공백으로 남긴 뒤 오늘 날짜로 재생성하는 방향을 택했습니다. 이 부분은 각자 프로젝트의 특성에 맞게 개발하시면 될 것 같습니다. 

 

참고사항으로 적자면, 오늘 날짜로 재생성하는 이유는 배치 프로그램 내에서 오늘 날짜를 담은 데이터셋을 DB insert 대상에서 제외시키기 때문입니다. 어차피 걸러질 것이기 때문에 임시방편으로 처리해 놓은 것입니다.

 

 

다음 포스팅에서는 Instagram의 장소태그와 해시태그를 크롤링해서 데이터베이스에 저장한 방법에 대해 소개하겠습니다. 그리고 이 때 사용한 배치 코드도 함께 첨부하여 설명드리려 합니다. 

반응형