Post

Jsoup 과 크롤링 팁

크롤링은 Selenium 으로 하는거 아닌가요?

Jsoup 과 크롤링 팁

최근, 크롤링 작업을 하게 된 일이 있었다. 근데, Selenium 을 사용하지 않고 Jsoup 이라는 라이브러리를 사용하고 있어서 의아심이 있었다. 예전에 비트코인 매매봇, 수강 신청 매크로 등을 만들때 매번 셀레니움을 사용했기 때문이다.

Jsoup

https://github.com/jhy/jsoup

Jsoup 은 HTML 과 XML 을 작업하게 해주는 라이브러리이다. DOM 에 관련된 API, CSS 및 XPath 를 사용해 선택할 수 있게 해준다.

VS Selenium

Jsoup 은 HTTP 요청을 날려서 가져온다. 즉, 서버로부터 응답받은 정적 HTML 데이터만 작업 가능하다는 의미다.

최신 웹사이트들은, 빠른 반응성을 위해 사용자가 콘텐츠를 서버에 요청했을 때 클라이언트 사이드 랜더링(CSR, Client-Side Rendering) 을 사용한다.

  1. 서버는 데이터만 브라우저에 전송하고
  2. 브라우저에서는 이 데이터를 가지고 화면 랜더링

CSR은 브라우저에서 화면을 그려준다는 동적인 특성 때문에 서버에 데이터를 요청하는 HTTP Request를 사용하면 실제 화면에 그려진 데이터는 수집할 수 없게 된다.

Selenium 은 WebDriver를 이용하므로 동적인 데이터들을 수집할 수 있다.

WebDriver

브라우저를 프로그래밍적으로 제어할 수 있도록 설계된 자동화 인터페이스이다. ( 직접 열고,닫고,클릭, 네트워크 이벤트 등 제어 )

  • 브라우저 마다 각각의 Driver 를 가진다.
  • 컴퓨터 내 존재하는 브라우저와 버전이 일치해야 한다.

사용해보기

단순, 성능 측정을 하려 했는데 매우 귀찮은 과정을 거쳤다. 설치하니 인식 못하고, 맥북이라 애플리케이션 지정했는데 안되고 등등등… ( 이 역시도 Selenium 의 단점 아닐까… )

1
2
3
4
5
6
7
8
WebDriverManager.chromedriver().setup();  
final WebDriver driver = new ChromeDriver();  

try {  
	driver.get("https://www.google.com/");  
} finally {  
	driver.quit();  
}

설치되어 있는 Google Chrome 과 버전이 일치해야 하는데 이를 맞추기 어려울 수 있다. ( Driver 를 제공해주지 않거나, 버전 업데이트가 일어나거나 ) -> 그렇기에, 버전을 자동으로 맞춰주는 'io.github.bonigarcia:webdrivermanager:5.5.3' 의존성을 사용했다.

버전이 있는지 확인하고, 없다면 자동으로 다운로드 + 경로 지정을 해준다. ( 로컬 캐시를 통해 반복적인 다운로드는 방지 )

이 부분 역시도 불필요한 시간을 소요한다.

1
Setup Time Taken: 347ms

단순, https://www.google.com/ 에 요청을 보내보면?

1
2
3
4
...
Document document = Jsoup.connect(url).get();
driver.get(url);
...        
1
2
3
Jsoup Time Taken: 795ms

Selenium Time Taken: 1649ms

이와같이 시간의 차이가 난다. + Chrome 을 실행시키는 것이므로 CPU 와 메모리가 더 소모가 된다. ( 확인할때는, 3MB 정도 발생 )

결론

Selenium 은 왠만하면 사용할 필요가 없다고 생각한다. ( 시간적인 면 + 리소스적인 면에서 성능적 차이가 나기 때문에 )

자기가 크롤링 해야하는게

  • 단계별 작업을 필요로 하는지 ( 로그인을 하고, 어떤 상호작용을 해야하며… )
  • 정적인 데이터가 아닌, 동적인 데이터를 필요로 하는지

가 아니라면, 정적 크롤링을 사용하도록 하자.


이제부터 Jsoup 을 사용하는 간단한 방법들과 팁을 정리하고 끝내겠다. 생각보다 CookBook 을 제공을 너무 잘해줘서 이 부분만 읽는다면 매우 편하게 작업할 수 있을 것이다.

Document 가져오기

1
Jsoup.connect(url)

URL 을 통해 연결할 Connection 을 만든다.

1
2
3
public interface Connection {
	...
}

Timeout,Header,Cookie 등 우리가 흔히 아는 모든 HTTP 요소들을 가능하게 해준다.

1
2
3
4
5
6
7
@Override  
public Document get() throws IOException {  
    req.method(Method.GET);  
    execute();  
    Validate.notNull(res);  
    return res.parse();  
}

이와같이 메소드를 호출하면 요청을 보내고, Document 를 받아온다.

이미 가져왔다면?

1
2
String html = "<html><head><title>Example</title></head><body><p>Hello, World!</p></body></html>";
Document document = Jsoup.parse(html);

Jsoup.parse 를 사용하면 된다.

select

예제 URL

와 같은 예시가 있을때?

개발자 도구를 통해 들어가서 Elements 부분이 우리가 사용할 수 있는 요소들이다. ( 물론, CSR 을 통해 생성된 동적인 요소들인지는 확인해야 한다. )

1
document.html()

을 통해 확인가능하다.

Jsoup 은 CSS Selector 를 제공해주기 때문에 이를 사용해 DOM 의 요소들을 매우 편리하게 추출할 수 있다.

  • 기본 선택자 ( tag, .class, #id )
  • 계층 선택자
  • 속성 선택자
  • 상태 선택자 ( Jsoup 이 지원해주는 요소 ) - div:contains(Hello)

계층 선택자에서 흔히 실수하는 요소가 있는데

  • ancestor descendant : 특정 조상의 모든 자손 선택
  • parent > child : 특정 부모의 바로 아래 자식 선택 이 두개의 차이점을 주의해야 한다.

HTML 문서들은 트리 구조로 표현이 된다. ( 각 태그가 트리의 노드로 변환 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static Elements select(Evaluator evaluator, Element root) {  
    return Collector.collect(evaluator, root);  
}

public static Elements collect (Evaluator eval, Element root) {  
    eval.reset();  
  
    return root.stream()  
        .filter(eval.asPredicate(root))  
        .collect(Collectors.toCollection(Elements::new));  
}

public Predicate<Element> asPredicate(Element root) {  
    return element -> matches(root, element);  
}

// 이는 구현체 ( 매 요소마다 다름 )

public boolean matches(Element root, Element element) {  
    return element.hasAttr(key) && value.equalsIgnoreCase(element.attr(key).trim());  
}
  • <meta property="product:sale_price:amount" content="84000"> 가 있으면?
1
2
var element = document.selectFirst("meta[property='product:sale_price:amount']")  
return element.attr("content") // 84000

를 통해 meta property 를 찾고, 그 내부 요소를 추출한다.

1
var elements = document.select("div.xans-product-additional #prdDetail img")

divxans-product-additional 클래스를 가지는 -> prdDetail 라는 ID 를 가지는 요소의 모든 자손 중 img 들만 추출한다.

간단한 팁

최대한 단순하게

샘플 링크

해당 링크에서 상세내용이 필요해 Selector 를 복사해보면? -> #infoArea_fixed > div.xans-element-.xans-product.xans-product-detaildesign > table 가 나온다.

document.select("#infoArea_fixed > div.xans-element-.xans-product.xans-product-detaildesign > table > tbody")

를 통해서 가져올 수 있지만, .xans-product > table > tbody 로도 가능하다. 두 개의 성능 차이는?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void main(final String[] args) {  
  
        TimeUtil.measureExecutionTime("jsoup deep", () -> {  
            final var baseUrl = "https://riverstone.co.kr";  
            final var document = getDocument("https://riverstone.co.kr/category/women/25/?page=3");  
  
            final var documents = parseDocument(document, "ul.prdList a")  
                    .stream()  
                    .map(element -> element.attr("href"))  
                    .map(url -> getDocument(url, baseUrl))  
                    .toList();  
  
            final var result1 = findFirst(documents);  
//            final var result2 = findSecond(documents);  
        });  
}

private static List<String> findFirst(final List<Document> documents) {  
    return documents.stream()  
            .map(document -> document.select(".xans-product > table > tbody"))  
            .map(element -> Optional.ofNullable(element)  
                    .map(Elements::text)  
                    .orElse(""))  
            .toList();  
}  
  
private static List<String> findSecond(final List<Document> documents) {  
    return documents.stream()  
            .map(document -> document.select("#infoArea_fixed > div.xans-element-.xans-product.xans-product-detaildesign > table > tbody"))  
            .map(element -> Optional.ofNullable(element)  
                    .map(Elements::text)  
                    .orElse(""))  
            .toList();  
}

캐싱 및 추가적인 요소들을 피하기 위해 의도적으로 페이지네이션을 통해 가져오게 했다.

1
2
// .xans-product > table > tbody - 51291ms  
// #infoArea_fixed > div.xans-element-.xans-product.xans-product-detaildesign > table > tbody - 50122ms

놀랍게도 큰 차이가 없다. 이는 DOM 트리 탐색 방식 때문이다. 해당 내용은 자세히는 다루지 않는다. ( 주제에 벗어난 부분 ) 모던 JavaScript 문서 해당 부분을 더 참고하면 좋을거 같다.

  • DOM 은 트리 형태 계층적, 요소를 찾기 위해 계층을 따라 위 아래 이동 가능하다. - 관련 없는 부분 검색할 필요 없음.
  • 고유 ID 를 기반으로 요소를 빠르게 찾도록 최적화 - HashMap 과 유사하게 O(1) 기대
  • 브라우저가 잘못된 html 들은 DOM 탐색 및 조작을 효율적으로 처리하기 위해 자동으로 교정해준다.

이런 요소들을 기반으로 시간의 차이가 없게 만든다.

그럼에도, 필요한 요소들만 적용해서 크롤링을 해야 한다. 자세하게 나타낼수록, 변경에 민감해진다.

  • #infoArea_fixed#infoArea 로 변경이 된다면? -> 해당 크롤링은 변경이 되어야 한다.

  • div.xans-element-.xans-product.xans-product-detaildesignxans-product-detaildesign 이 필요가 없어진다면? -> 해당 크롤링은 변경이 되어야 한다.

브라우저가 자동으로 제공해주는 Selector 가 아니라 자신이 직접 검색해서 필요한 요소들만 구성을 하자.

특히, 단순 Copy Selector 를 하면 #prdDetail > div > div > div > div > div > img:nth-child(1) 이와 같이 요소들의 모든 선택자들을 가져오는 경우가 있다. 이때, div > ... > div 는 필요없을 수 있다. 어차피, #prdDetailimg 들을 가져오게 하는게 핵심이므로

변하지 않게

샘플 링크

썸네일용 사진을 가져오려고 하면?

#prdDetail > img:nth-child(1) 와 같이 가져온다.

nth-child 는 매우 가변적인 요소이다. img 중 n번째 자식 라는 의미이기 때문이다. 이 역시도, DOM 의 구조가 변하거나 웹 사이트 개발자들이 이미지 순서를 변경하면 침범을 받는다.

대부분의 상품성 사이트들은 검색 엔진 최적화를 위해 다양한 요소들을 제공한다.

  • descritpion,keywords,author
  • og:title,og:type,og:url ( Open Graph Protocol )
  • naver,google site vertification

meta 를 통해 검색하거나, head 부분에 보면 매우 다양한 정보들을 제공해준다. 우리가 사용하려는 썸네일은? og:image 를 통해서도 같은 사진이 제공이 된다.

본문 내용에서 추출하는 것 보다 검색 엔진을 위해 제공해주는 것 이 더 변하지 않고, 항상 제공될 거라 기대할 수 있다.

이와 같은 맥락으로 자신이 지금 크롤링 하는 부분이 얼마나 변화에 민감할지, 고정으로 제공해줄지 등을 판단하는 것 역시도 매우 좋은 요소가 될 것이다.

당연한 robots

너무나 당연하지만 robots.txt 는 항상 잘 살펴보자. 웹 크롤러에게 특정 부분에 대한 접근을 허용하거나 차단하는 지침을 제공해준다.

  • 특정 크롤러 ( User-Agent ) 를 차단하게 해주거나 - Github 는 User-Agent 를 비운채 요청을 보내면 4xx 를 던진다.
  • 특정 경로를 접근하지 못하게 하거나 - /admin, /api
  • SEO 가 불필요한 접근을 하지 않거나

의 요소들로 이루어진다.

특히, 허용하지 않는 요소들을 무분별하게 크롤링해 상업적 이용을 하면 처벌이 될수도 있다. ### 형사는 무죄, 민사는 “10억 배상”…데이터 크롤링 어디까지 되나 ( 유명한 야놀자 vs 여기어때의 크롤링 법적 공방 )

함수화

이건 크롤링보다 사소한 꿀팁이지만

1
2
3
4
5
6
7
8
@Component  
class ThumbnailImageInMeta : ThumbnailImage {  
  
    override fun parseFrom(document: Document, domain: String): String {  
        val element = document.selectFirst("meta[property='og:image']")  
        return element?.attr("content")?:throw IllegalStateException("Not Exist ThumbnailImage In Meta")  
    }
}

자주 사용한다면, @Component 화 해서 이를 주입해서 사용을 하게하자. 이는 크롤링 하려는 요소의 성격에 따라 다르지만, 비슷한 양상을 가진다면 선언하고 주입을 받아 코드 중복을 방지하자.

어차피, 크롤링은 기존 서버와 분리된 경우가 많으며 + 정해진 시간 or 규칙적으로 되는 경우가 많다. ( 메모리 및 성능에도 크게 구애 받지 않는다. )

1
2
3
private val crawler = Crawler(  
    ThumbnailImageInMeta(),
)

와 같이 단위 테스트를 할때는? -> 그냥 생성을 할 수 있으므로 Spring 의존성을 벗어나도 상관없다.

물론, 성급하게 코드를 추상화 및 공통화 하지 말고 적절히 된다면 도입하자.

1
2
3
4
abstract class Cafe24Crawler(
	...
)
class SampleCrawler():Cafe24Crawler

이런식의 추상 클래스 : 구현 클래스 로 코드 중복 감소 역시도 가능하다.

결론

크롤링도 최대한 빠르고,쉽게 접근해 구현하면 가능하다.

하지만

  • 크롤링적으로도 변경에 민감하지 않게
  • 코드적으로 변경에 민감하지 않게

이 두가지를 잘 생각해서 크롤링을 만들어나가자.

This post is licensed under CC BY 4.0 by the author.