etc

(언제 막힐지 모르는)Bun을 사용한 크롤링 방지 우회 방법과 원리(2)

하리하링웹 2024. 11. 28. 12:58

1편 보러가기

https://jjongsk.tistory.com/entry/Bun%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%9C-%ED%81%AC%EB%A1%A4%EB%A7%81-%EB%B0%A9%EC%A7%80-%EC%9A%B0%ED%9A%8C

 

(언제 막힐지 모르는)Bun을 사용한 크롤링 방지 우회 방법과 원리(1)

Node의 기본 fetch를 사용하여 크롤링을 시도할 경우 요청이 차단되는 경우가 있다. 대표적으로 네이버의 검색이나 리뷰에 대한 크롤링을 시도할 때 이를 확인할 수 있다. 아래는 크롤링 요청 예

jjongsk.tistory.com

 

 

 

방법을 딱히 찾지 못해서 좀 오랫동안 작업이 멈춰있었는데 아이디어가 떠올라서 해당 아이디어로 테스트를 진행해보았다. 일단 직접적인 원인은 Connection 헤더가 아닌것으로 판명되었으며 Sec-Fetch-Mode 헤더가 원인인것으로 결론지었다.

 

떠오른 아이디어는 WireShark를 사용하여 HTTP 패킷을 직접 분석해보는 것이였다. 근거로는 과정이 어찌되었건 요청 패킷이 같다면 응답이 무조건 같아야하기에 헤더 뿐만 아니라 다른 단서를 얻을 수 있을 것 같아서이다.

 

요청 프로토콜을 http로 변경하고 WireShark에서 http로 필터를 건뒤 패킷을 캡쳐해보자

 

첫번째 요청이 node 런타임이고 두번째 요청이 bun 런티임이다. 이미지상 1,2번째가 node(request,response) 3,4번째가 bun이다.

 

먼저 node 런타임에서의 기본 fetch의 HTTP 패킷 헤더는 아래와 같다.

Hypertext Transfer Protocol
    GET /geonlab/products/10453317502 HTTP/1.1\r\n
        Request Method: GET
        Request URI: /geonlab/products/10453317502
        Request Version: HTTP/1.1
    host: smartstore.naver.com\r\n
    connection: keep-alive\r\n
    accept: text/html\r\n
    accept-language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\r\n
    sec-ch-ua: "Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"\r\n
    user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r\n
    Accept-Encoding: gzip, deflate, br\r\n
    sec-fetch-mode: cors\r\n
    \r\n
    [Response in frame: 44]
    [Full request URI: http://smartstore.naver.com/geonlab/products/10453317502]

 

다음으로 bun 런타임에서의 패킷헤더는 아래와 같다.

Hypertext Transfer Protocol
    GET /geonlab/products/10453317502 HTTP/1.1\r\n
        Request Method: GET
        Request URI: /geonlab/products/10453317502
        Request Version: HTTP/1.1
    accept: text/html\r\n
    accept-encoding: gzip, deflate, br\r\n
    accept-language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\r\n
    sec-ch-ua: "Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"\r\n
    user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r\n
    Connection: keep-alive\r\n
    Host: smartstore.naver.com\r\n
    \r\n
    [Response in frame: 782]
    [Full request URI: http://smartstore.naver.com/geonlab/products/10453317502]

 

 

두 개의 차이가 보이는가? 대,소문자와 같은 자잘한 차이도 있지만 가장 큰 차이가 있다. bun 런타임에서의 요청에서는 sec-fetch-mode 헤더값이 설정되어 있지 않다는 점이다. 반면 node 런타임에서는 요청 시 별다른 설정을 하지 않았음에도 해당 헤더가 포함되어 있는 것을 확인할 수 있었다.

 

크롤링 타겟 서버에서는 요청에 이러한 헤더가 포함되어 있으면 해당 요청을 의심되는 요청으로 판단하여 결과값을 주지 않는 것이다. 실제로 브라우저의 주소창에 주소를 입력한 패킷을 캡쳐하여 헤더를 확인해보면 해당 헤더가 포함되어 있지 않는것을 확인할 수 있다.

GET /geonlab/products/10453317502 HTTP/1.1\r\n
        Request Method: GET
        Request URI: /geonlab/products/10453317502
        Request Version: HTTP/1.1
    Host: smartstore.naver.com\r\n
    Connection: keep-alive\r\n
    Upgrade-Insecure-Requests: 1\r\n
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36\r\n
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r\n
    Accept-Encoding: gzip, deflate\r\n
    Accept-Language: en-US,en;q=0.9,ko-KR;q=0.8,ko;q=0.7\r\n

 

그렇다면 node-fetch에서는 왜 성공했던것일까? node-fetch의 패킷도 캡쳐해보았다.

Hypertext Transfer Protocol
    GET /geonlab/products/10453317502 HTTP/1.1\r\n
        Request Method: GET
        Request URI: /geonlab/products/10453317502
        Request Version: HTTP/1.1
    accept: text/html\r\n
    accept-encoding: gzip, deflate, br\r\n
    accept-language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\r\n
    connection: keep-alive\r\n
    sec-ch-ua: "Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"\r\n
    user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r\n
    Host: smartstore.naver.com\r\n
    \r\n
    [Response in frame: 293]
    [Full request URI: http://smartstore.naver.com/geonlab/products/10453317502]

 

위에 보이는 것처럼 node-fetch에서도 해당 헤더를 같이 보내지 않는다는 정보를 얻을 수 있었다. 

 

이제 원인은 좀 명확해진 것 같다. 마지막으로 테스트해야할 부분이 있다. 과연 해당 헤더를 명시적으로 넣어준다면 bun 환경에서 요청이 실패할까? 라는 테스트이다. 만약 여기서 성공해버린다면 지금까지의 가설, 검증이 모두 의미가 없어진다. 그럼 테스트를 진행해보자, 요청 코드는 아래와 같이 작성하여 테스트를 진행하였다.

 

import fetch from "node-fetch";
import fs from "fs";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

console.log("fetching");
fetch("http://smartstore.naver.com/geonlab/products/10453317502", {
  // agent:new HttpsProxyAgent('http://127.0.0.1:8888'),
  headers: {
    accept: "text/html",
    "accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
    "sec-ch-ua":
      '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"',
    "user-agent":
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
    "Accept-Encoding": "gzip, deflate, br",
    "sec-fetch-mode": "cors",
  },
})
  .then((res) => res.text())
  .then((res) => {
    console.log("res", res);
    fs.writeFileSync("result.html", res);
  })
  .catch((err) => {
    console.log("err", err);
  });

 

Hypertext Transfer Protocol
    GET /geonlab/products/10453317502 HTTP/1.1\r\n
        Request Method: GET
        Request URI: /geonlab/products/10453317502
        Request Version: HTTP/1.1
    accept: text/html\r\n
    accept-encoding: gzip, deflate, br\r\n
    accept-language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\r\n
    sec-ch-ua: "Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"\r\n
    sec-fetch-mode: cors\r\n
    user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r\n
    Connection: keep-alive\r\n
    Host: smartstore.naver.com\r\n
    \r\n
    [Response in frame: 449]
    [Full request URI: http://smartstore.naver.com/geonlab/products/10453317502]

 

패킷에는 sec-fetch-mode가 cors로 포함되어 있는 것을 확인할 수 있다. 그러면 결과는 어떨까?

 

 

짜잔 실제로 요청에 실패한 모습을 확인할 수 있다.

 

이번 주제에 대해 다양한 OS 환경, 여러 Node.js 버전, 그리고 서로 다른 API 요청을 조합해 테스트를 진행한 결과, 몇 가지 흥미로운 차이를 확인할 수 있었다. 예를 들어, Bun에서의 HTTP 요청은 Node.js와 비교했을 때 패킷 내부의 destination address 값이 다르게 설정된다거나, Node.js 18.18.0 버전과 18.18.2 버전 간 기본 User-Agent 값이 달라 요청 결과에 영향을 미치는 점이 있다. (이는 아마도 버그일 가능성이 높으며, 추후 이에 대한 자세한 내용을 별도의 글로 다룰 예정이다.)

하지만 한 가지 공통점이 있었는데, 대부분의 상용 서버가 환경에 상관없이 Bun에서 오는 요청을 차단하지 않는다는 점이다. 이는 이번 글의 주제인 Bun을 통한 크롤링 우회가 가능하다는 점을 다양한 환경에서 검증한 결과이기도 하다.

더 이상의 테스트와 분석은 과하다고 판단하여, 추후 기회가 될 때 진행하도록 하고 분석은 여기서 종료하도록 하겠다.

 

 

결론

지금까지 Node와 Bun에서의 fetch가 왜 다르게 동작하는지에 대해 깊이 탐구해보았다. 특히, Bun이 Cloudflare를 포함한 여러 웹 서버의 크롤링 방지를 어떻게 우회할 수 있는지에 관한 분석도 자세하게 진행하였다.

 

이번 글에서는 가설을 세우고 이를 검증하는 과정을 통해 이 차이의 원인을 탐색했긴 하지만, 현재 분석은 가설 기반의 추론과 검증으로 이루어진 것이기에 모든 정보가 정확하다고 100% 장담할 수는 없다.

만약 잘못된 정보가 있다면, 댓글이나 이메일을 통해 알려준다면 좀 더 자세히 확인해보고 수정하도록 하겠다.

 

이 글이 누군가에게 도움이 되기를 바라며, 여기서 마치도록 하겠다.