[Spring Boot] Selenium을 사용한 웹 크롤링(배포 환경) 및 트러블 슈팅 #2

Sungjin Cho·2024년 9월 19일
0

Spring Boot

목록 보기
14/15
post-thumbnail

배포 환경에서의 Selenium 사용 및 트러블 슈팅

#1 에서는 로컬 환경에서 돌아가는 것 까지만 구현을 했었다.

로컬, 배포 환경의 분리를 위해 application.yml 파일이 webdriver.chrome.path를 작성한다.

spring:
  profiles:
    active: prod
    group:
      local:
        - common
      prod:
        - common
---
# 생략 ..
---
spring:
  config:
    activate:
      on-profile: local

webdriver:
  chrome:
    path: D:\chromedriver\chromedriver.exe

---
spring:
  config:
    activate:
      on-profile: prod

webdriver:
  chrome:
    path: /usr/bin/chromedriver

다른 설정은 생략하였다.

@Value("${webdriver.chrome.path}")
private String chromeDriverPath;

위 설정으로 chromeDriverPath를 가져오고

System.setProperty("webdriver.chrome.driver", chromeDriverPath);

Path를 setProperty 하도록 코드를 변경하였다.

그리고 Dockerfile 에서 chromedriver를 다운로드 하고 위 지정한 경로에 저장하도록 하였다.

FROM gradle:7.6-jdk17 AS build
WORKDIR /home/gradle/src
COPY --chown=gradle:gradle . .
RUN gradle build -x test --no-daemon

FROM openjdk:17-slim
WORKDIR /app
EXPOSE 8081

RUN apt-get update && \
    apt-get install -y sqlite3 libsqlite3-dev default-mysql-client wget unzip && \
    wget https://storage.googleapis.com/chrome-for-testing-public/129.0.6668.58/linux64/chromedriver-linux64.zip && \
    unzip chromedriver-linux64.zip && \
    mv chromedriver-linux64 /usr/bin/chromedriver && \
    chmod +x /usr/bin/chromedriver && \
    rm chromedriver-linux64.zip && \
    apt-get install -y chromium && \
    rm -rf /var/lib/apt/lists/*

COPY --from=build /home/gradle/src/build/libs/*.jar app.jar

RUN mkdir -p /app/citymart_backup && chmod 777 /app/citymart_backup
RUN mkdir -p /app/sqlite && touch /app/sqlite/pos.db && chmod 664 /app/sqlite/pos.db

ENV SPRING_SECOND_DATASOURCE_JDBCURL=jdbc:sqlite:/app/sqlite/pos.db

ENTRYPOINT java $JAVA_OPTS -jar app.jar

이렇게 설정하고 실행했을 때 크롤링 api를 호출하면 500 에러가 발생하였다. 뭐가 문제일까?

# cd usr/bin
# cd chromedriver
# ls
LICENSE.chromedriver  THIRD_PARTY_NOTICES.chromedriver  chromedriver
# ls -la
total 18060
drwxr-xr-x 1 root root     4096 Sep 19 02:03 .
drwxr-xr-x 1 root root     4096 Sep 19 02:04 ..
-rw-r--r-- 1 root root     1536 Sep 16 20:04 LICENSE.chromedriver
-rw-r--r-- 1 root root   514213 Sep 16 20:04 THIRD_PARTY_NOTICES.chromedriver
-rwxr-xr-x 1 root root 17959840 Sep 16 20:04 chromedriver
# pwd
/usr/bin/chromedriver

우선 도커 컨테이너에 들어가서 chromedriver가 올바르게 설치되어 있는지를 확인하였다. 올바른 경로에 chromedriver가 잘 설치되어 있었다.

더 자세한 원인을 알기 위해서 controller에서 에러가 발생하면 null 을 return 하는 부분을 에러 메시지를 반환하도록 변경하였다.

    @PostMapping("/crawl-product")
    public ResponseEntity<?> crawlProduct(@RequestBody BarcodeRequestDto request) {
        try {
            CrawlResponseDto responseDto = registerService.crawlProductInfo(request.barcode());
            return ResponseEntity.ok(responseDto);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("Failed to crawl product: " + e.getMessage());
        }
    }
Failed to crawl product: Could not start a new session. Possible causes are invalid address of the remote server or browser start-up failure. 
Host info: host: '0712b7161880', ip: '172.21.0.2'
Build info: version: '4.19.1', revision: 'abe0ee07dc'
System info: os.name: 'Linux', os.arch: 'amd64', os.version: '5.15.153.1-microsoft-standard-WSL2', java.version: '17.0.2'
Driver info: org.openqa.selenium.chrome.ChromeDriver
Command: [null, newSession {capabilities=[Capabilities {browserName: chrome, goog:chromeOptions: {args: [--headless, --no-sandbox, --disable-dev-shm-usage], extensions: []}}]}]

이러한 에러가 발생한다.

이는 현재 chromedriver의 버전과 컨테이너에 설치된 chrome 브라우저 버전의 불일치로 발생할 수 있는 에러라고 한다.

버전을 확인해본 결과 컨테이너에서 사용하는 chrome(chromium)의 버전이 chrome driver의 버전과 일치하지 않았다.

따라서 dockerfile에서 chorome과 chromedriver의 버전을 일치시켜서 다운로드 받도록 수정하였다.

FROM gradle:7.6-jdk17 AS build
WORKDIR /home/gradle/src
COPY --chown=gradle:gradle . .
RUN gradle build -x test --no-daemon

FROM openjdk:17-slim
WORKDIR /app
EXPOSE 8081

# Install necessary packages
RUN apt-get update && apt-get install -y \
    wget \
    unzip \
    gnupg \
    curl \
    libglib2.0-0 \
    libnss3 \
    libgconf-2-4 \
    libfontconfig1 \
    libx11-6 \
    libx11-xcb1 \
    libxcb1 \
    libxcomposite1 \
    libxcursor1 \
    libxdamage1 \
    libxext6 \
    libxfixes3 \
    libxi6 \
    libxrandr2 \
    libxrender1 \
    libxss1 \
    libxtst6 \
    libappindicator1 \
    libasound2 \
    libatk1.0-0 \
    libatk-bridge2.0-0 \
    libcairo2 \
    libcups2 \
    libdbus-1-3 \
    libexpat1 \
    libgcc1 \
    libgdk-pixbuf2.0-0 \
    libpango-1.0-0 \
    libpangocairo-1.0-0 \
    libstdc++6 \
    libxcb1 \
    libxcomposite1 \
    libxcursor1 \
    libxdamage1 \
    libxfixes3 \
    libxi6 \
    libxrandr2 \
    libxrender1 \
    libxss1 \
    libxtst6 \
    fonts-liberation \
    libappindicator3-1 \
    libasound2 \
    libatk-bridge2.0-0 \
    libgtk-3-0 \
    default-mysql-client \
    sqlite3 \
    libsqlite3-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Chrome
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list \
    && apt-get update && apt-get install -y google-chrome-stable=129.0.6668.58-1 \
    && rm -rf /var/lib/apt/lists/*

# Install ChromeDriver
RUN CHROME_VERSION=$(google-chrome --no-sandbox --version | awk '{print $3}' | awk -F. '{print $1}') \
    && CHROMEDRIVER_VERSION=$(curl -s "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_$CHROME_VERSION") \
    && wget -q --continue -P /chromedriver "https://storage.googleapis.com/chrome-for-testing-public/129.0.6668.58/linux64/chromedriver-linux64.zip" \
    && unzip /chromedriver/chromedriver-linux64.zip -d /usr/bin/ \
    && mv /usr/bin/chromedriver-linux64/chromedriver /usr/bin/chromedriver \
    && rm -rf /chromedriver /usr/bin/chromedriver-linux64 \
    && chmod +x /usr/bin/chromedriver

COPY --from=build /home/gradle/src/build/libs/*.jar app.jar

RUN mkdir -p /app/citymart_backup && chmod 777 /app/citymart_backup
RUN mkdir -p /app/sqlite && touch /app/sqlite/pos.db && chmod 664 /app/sqlite/pos.db

ENV SPRING_SECOND_DATASOURCE_JDBCURL=jdbc:sqlite:/app/sqlite/pos.db

# Add these environment variables
ENV JAVA_OPTS="-Dwebdriver.chrome.driver=/usr/bin/chromedriver"
ENV CHROME_BIN=/usr/bin/google-chrome

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Dockerfile이 많이 지저분해지긴 했지만 chrome을 설치하기 위해 위 패키지를 다운로드 받아야 한다. 그리고 다운된 chrome과 같은 버전의 chromedriver를 다운로드 하도록 설정한다.

배포환경에서 Selenium 사용하기 성공

추가 개선 사항

  • exception 처리 → exception 말고 특정 exception으로 구분
  • webdriver 객체 static 으로 생성해놓고 사용하는 것에 대한 고려
  • 필요 없는 chrome option 제거
  • thread sleep 을 2초로 잡지 말고, 1초나 0.5초 등 짧게 루프문을 돌려서 가져오면 중지, 못가져오면 exception

위의 사항을 반영하기 위해 코드를 수정하였다.

exception 처리

        } catch (TimeoutException e) {
            log.error("Timeout while waiting for element: ", e);
            return null;
        } catch (WebDriverException e) {
            log.error("WebDriver exception occurred: ", e);
            return null;
        } catch (InterruptedException e) {
            log.error("Thread interrupted: ", e);
            Thread.currentThread().interrupt();
            return null;
        } catch (Exception e) {
            log.error("Unexpected error occurred: ", e);
            return null;
        }

이런 식으로 Exception 하나를 사용하는 것이 아닌 발생할 수 있는 예외 상황에 대해 exception 처리를 해주었다.

static 객체 생성

크롤링을 할 때마다 webdriver를 생성하고 제거 하는 것을 반복 하는 것보다 객체를 만들어 놓는 것이 검색 속도 측면에서 유리할 것인지 의문이 생김

당연히 객체를 생성하고 애플리케이션이 실행되는 동안 계속 띄워 놓는것 또한 리소스가 들겠지만 검색 속도 측면에서는 static으로 생성하는 것이 유리하고 띄워 놓는 것이 큰 리소스를 차지하지 않는다고 생각해서 코드 수정

    private static WebDriver driver;

    @PostConstruct
    public void initializeWebDriver() {
        System.setProperty("webdriver.chrome.driver", chromeDriverPath);
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless", "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu");
        driver = new ChromeDriver(options);
    }

    @PreDestroy
    public void quitWebDriver() {
        if (driver != null) {
            driver.quit();
        }
    }

@PostConstruct와 @PreDestory 어노테이션을 사용하여 애플리케이션이 시작되면 객체를 생성하고 종료되면 제거하도록 하였다.

추가적으로 필요 없는 chrome option도 제거 하였다.

기존에는


ChromeOptions options = new ChromeOptions();
options.addArguments("--headless");
options.addArguments("--no-sandbox");
options.addArguments("--disable-dev-shm-usage");
options.addArguments("--remote-debugging-port=9222");
options.addArguments("--disable-gpu");
options.addArguments("--window-size=1920,1080");

이렇게 사용하던 옵션에서 필요 없는 window size, debugging 옵션은 제거

  • -headless:
    • 브라우저를 화면에 표시하지 않고 백그라운드에서 실행
    • 서버 환경이나 GUI가 없는 환경에서 유용
  • -no-sandbox:
    • Chrome의 샌드박스 보안 기능을 비활성화
    • 주로 root 사용자로 실행하거나 특정 Linux 환경에서 필요
    • 주의: 보안상 위험할 수 있으므로 신뢰할 수 있는 환경에서만 사용
  • -disable-dev-shm-usage:
    • /dev/shm 파티션 사용을 비활성화하고 대신 /tmp를 사용
    • 일부 Linux 시스템에서 발생할 수 있는 메모리 부족 문제를 해결
  • -remote-debugging-port=9222:
    • 원격 디버깅을 위한 포트를 설정
    • 주로 개발 및 디버깅 목적으로 사용
  • -disable-gpu:
    • GPU 하드웨어 가속을 비활성화
    • 일부 환경(특히 헤드리스 모드)에서 발생할 수 있는 버그를 방지
  • -window-size=1920,1080:
    • 브라우저 창의 크기를 설정
    • 특정 해상도에서 웹페이지가 어떻게 보이는지 테스트하거나,
      일관된 스크린샷을 위해 사용

각 옵션은 위의 기능을 가지고 있다.

검색 대기 시간 로직 변경

2초의 대기 시간(sleep)을 두는 것 보다 짧은 시간으로 여러 번 sleep을 걸고 데이터를 가져왔다면 반복문을 종료 시키는 것이 더 안전하고 효율적이라고 생각되었다.

           WebElement categoryElement = null;
            WebElement productNameElement = null;
            int maxAttempts = 5;
            int attempts = 0;

            while (attempts < maxAttempts) {
                try {
                    categoryElement = driver.findElement(By.xpath("/html/body/div/section/div[2]/div/div[1]/div[3]/div/ul/li[1]/p[2]"));
                    productNameElement = driver.findElement(By.xpath("/html/body/div/section/div[2]/div/div[1]/div[3]/div/ul/li[2]/p[2]"));

                    if (categoryElement != null && productNameElement != null) {
                        break;
                    }
                } catch (org.openqa.selenium.NoSuchElementException e) {
                    log.error("Element not found: ", e);
                }

                attempts++;
                if (attempts < maxAttempts) {
                    Thread.sleep(1000);
                }
            }

            if (categoryElement == null || productNameElement == null) {
                throw new TimeoutException("Failed to find product information after " + maxAttempts + " attempts");
            }

이런식으로 1초의 sleep을 걸고 반복하도록 수정

수정된 Service 클래스의 전체 코드

RegisterService.java

@Service
@RequiredArgsConstructor
@Slf4j
public class RegisterService {
    private final RegisterProductInfoRepository registerProductInfoRepository;

    @Value("${webdriver.chrome.path}")
    private String chromeDriverPath;

    private static WebDriver driver;

    @PostConstruct
    public void initializeWebDriver() {
        System.setProperty("webdriver.chrome.driver", chromeDriverPath);
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless", "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu");
        driver = new ChromeDriver(options);
    }

    @PreDestroy
    public void quitWebDriver() {
        if (driver != null) {
            driver.quit();
        }
    }

    public CrawlResponseDto crawlProductInfo(String barcode) {
        log.info("Starting crawlProductInfo with barcode: {}", barcode);

        try {
            driver.get("https://m.retaildb.or.kr/service/product_info");

            WebElement barcodeInput = driver.findElement(By.xpath("/html/body/div/section/div[2]/fieldset/div/input"));
            barcodeInput.sendKeys(barcode);

            WebElement searchButton = driver.findElement(By.xpath("/html/body/div/section/div[2]/fieldset/a"));
            searchButton.click();

            WebElement categoryElement = null;
            WebElement productNameElement = null;
            int maxAttempts = 5;
            int attempts = 0;

            while (attempts < maxAttempts) {
                try {
                    categoryElement = driver.findElement(By.xpath("/html/body/div/section/div[2]/div/div[1]/div[3]/div/ul/li[1]/p[2]"));
                    productNameElement = driver.findElement(By.xpath("/html/body/div/section/div[2]/div/div[1]/div[3]/div/ul/li[2]/p[2]"));

                    if (categoryElement != null && productNameElement != null) {
                        break;
                    }
                } catch (org.openqa.selenium.NoSuchElementException e) {
                    log.error("Element not found: ", e);
                }

                attempts++;
                if (attempts < maxAttempts) {
                    Thread.sleep(1000);
                }
            }

            if (categoryElement == null || productNameElement == null) {
                throw new TimeoutException("Failed to find product information after " + maxAttempts + " attempts");
            }

            String category = categoryElement.getText();
            String productName = productNameElement.getText();

            return new CrawlResponseDto(productName, category);
        } catch (TimeoutException e) {
            log.error("Timeout while waiting for element: ", e);
            return null;
        } catch (WebDriverException e) {
            log.error("WebDriver exception occurred: ", e);
            return null;
        } catch (InterruptedException e) {
            log.error("Thread interrupted: ", e);
            Thread.currentThread().interrupt();
            return null;
        } catch (Exception e) {
            log.error("Unexpected error occurred: ", e);
            return null;
        }
    }

    public RegisterProductInfo saveProduct(RegistProductRequestDto dto) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        String formattedDate = LocalDateTime.now().format(formatter);

        RegisterProductInfo productInfo = RegisterProductInfo.builder()
                .caName(dto.caName())
                .caName2(dto.caName2())
                .caName3(dto.caName3())
                .caName4(dto.caName4())
                .itName(dto.itName())
                .enItName(dto.enItName())
                .itBarcode(dto.itBarcode())
                .itDistributionPeriod(dto.itDistributionPeriod())
                .sdName(dto.sdName())
                .sppReceiptDate(dto.sppReceiptDate())
                .sppVat(dto.sppVat())
                .sppWholesaleDis(dto.sppWholesaleDis())
                .sppSeniorDcRatio(dto.sppSeniorDcRatio())
                .sppInitialBoxQuantity(dto.sppInitialBoxQuantity())
                .appBoxSet(dto.appBoxSet())
                .createdAt(formattedDate)
                .build();

        return registerProductInfoRepository.save(productInfo);
    }

    public Optional<List<RegisterProductInfo>> getProductList(String from, String to) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        LocalDate fromDate = LocalDate.parse(from, formatter);
        LocalDate toDate = LocalDate.parse(to, formatter);

        String startDateTime = fromDate.atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        String endDateTime;

        if (fromDate.equals(toDate)) {
            endDateTime = fromDate.plusDays(1).atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        } else {
            endDateTime = toDate.plusDays(1).atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        }

        return registerProductInfoRepository.findAllCreatedAtDateBetween(startDateTime, endDateTime);
    }
}

크롤링에 사용된 사이트: https://m.retaildb.or.kr/service/product_info

0개의 댓글