크롤러를 새로 짤 때마다 나는 같은 갈림길 앞에 선다. requests로 HTML을 바로 긁을까, 브라우저 개발자도구에서 내부 API를 찾아 때릴까, 아니면 그냥 Selenium으로 사람처럼 클릭하게 둘까. 셋 다 “데이터를 가져온다”는 목적은 같지만, 6개월 뒤 유지보수 비용은 하늘과 땅 차이다. 오늘은 가상의 부동산 매물 목록 페이지 하나를 놓고 세 방법을 모두 적용해본 기록을 남긴다.

flowchart TD
    subgraph S["동적 페이지, 데이터는 어디서 오나?"]
      A["① 페이지 접속"] --> B["② HTML에 데이터가<br/>이미 박혀 있나?"]
    end
    B -->|"있다(SSR)"| C["requests + 파서"]
    B -->|"없다(JS가 나중에 채움)"| D["③ 데이터는<br/>어떻게 채워지나?"]
    D -->|"내부 API 호출로"| E["내부 JSON API 직격"]
    D -->|"복잡한 JS 렌더링"| F["Selenium 헤드리스"]

    classDef q fill:#fff3bf,stroke:#f08c00,color:#8c5000;
    classDef a fill:#e7f5ff,stroke:#1c7ed6,color:#10548f;
    classDef b fill:#ebfbee,stroke:#2f9e44,color:#1c5f2c;
    class B,D q
    class C,E a
    class F b

그 데이터는 정말 HTML 안에 있을까?

가장 먼저 확인할 것은 “화면에 보이는 값이 실제 HTML 소스에 들어있는가”다. 브라우저에서 우클릭 후 페이지 소스 보기를 눌러 매물 가격을 검색해본다. 여기서 갈린다.

확인 결과의미1순위 후보
소스에 가격이 그대로 보임서버가 완성된 HTML을 준다(SSR)requests
소스엔 없고 화면엔 있음JS가 나중에 그려 넣는다(CSR)내부 API 또는 Selenium
Network 탭에 깔끔한 JSON 응답이 있음프런트가 API를 호출해 채운다내부 JSON API

대부분의 “동적처럼 보이는” 페이지는 사실 세 번째다. 화면은 자바스크립트가 그리지만, 그 재료인 데이터는 결국 어딘가의 JSON API에서 온다. 이 API를 찾아내면 Selenium 같은 무거운 도구가 아예 필요 없어진다.

정적 HTML을 requests로 긁으면 어떻게 되나?

데이터가 HTML에 박혀 있다면 이게 가장 빠르고 가볍다. 브라우저를 띄우지 않으니 메모리도 거의 안 쓴다.

import requests
from bs4 import BeautifulSoup
 
# 합성 예시 URL — 실제 서비스 아님
res = requests.get("https://example.com/listings?page=1",
                   headers={"User-Agent": "Mozilla/5.0"})
soup = BeautifulSoup(res.text, "html.parser")
 
for card in soup.select(".listing-card"):
    print(card.select_one(".price").get_text(strip=True),
          card.select_one(".addr").get_text(strip=True))

문제는 이 방식이 HTML 구조(DOM)에 강하게 묶인다는 점이다. 디자이너가 .listing-card 클래스명 하나만 바꿔도 크롤러가 조용히 빈 리스트를 뱉는다. 그래서 나는 requests 방식을 쓸 때 “결과가 0건이면 알림” 같은 방어 로직을 반드시 같이 넣는다.

내부 JSON API를 직접 때리는 게 왜 최선일 때가 많나?

개발자도구 Network 탭을 열고 XHR/Fetch 필터를 켠 뒤 페이지를 스크롤하면, 매물 데이터를 실어 나르는 요청이 보인다. 응답이 JSON이라면 로또다. HTML 파싱을 건너뛰고 구조화된 데이터를 바로 받는다.

import requests
 
# 브라우저 Network 탭에서 확인한 내부 API를 모방한 합성 예시
res = requests.get(
    "https://example.com/api/v2/listings",
    params={"region": "1168", "page": 1, "size": 50},
    headers={"User-Agent": "Mozilla/5.0", "Referer": "https://example.com/"},
)
data = res.json()
for item in data["result"]["items"]:
    print(item["price"], item["address"], item["area"])

이 방식의 강점은 디자인 변경에 안 흔들린다는 것이다. 화면 클래스명이 바뀌어도 API 스펙은 그대로인 경우가 많다. 다만 함정도 있다. 인증 토큰이나 서명값이 헤더에 필요하거나, 짧은 시간에 여러 번 부르면 차단되는 레이트리밋이 걸린다. 요청 간격을 두고, 필요한 헤더를 브라우저 요청 그대로 복사해 넣는 게 핵심이다.

Selenium은 언제 꺼내야 하나?

로그인 후 복잡한 JS 흐름을 거쳐야만 데이터가 나오거나, 무한 스크롤·버튼 클릭이 얽혀 API를 도저히 못 찾을 때가 있다. 이럴 때만 마지막 카드로 Selenium을 꺼낸다. 진짜 브라우저를 띄우니 사람이 보는 화면 그대로 다룰 수 있지만, 그만큼 무겁고 느리고 잘 깨진다.

from selenium import webdriver
from selenium.webdriver.common.by import By
 
opts = webdriver.ChromeOptions()
opts.add_argument("--headless=new")
driver = webdriver.Chrome(options=opts)
driver.get("https://example.com/listings")
 
for card in driver.find_elements(By.CSS_SELECTOR, ".listing-card"):
    print(card.find_element(By.CSS_SELECTOR, ".price").text)
driver.quit()

Selenium은 브라우저·드라이버 버전이 어긋나면 실행 자체가 안 되고, CI 서버에 올릴 때 의존성 지옥을 만든다. “이것 말고 방법이 없을 때”의 도구라는 마음가짐이 유지보수 비용을 아껴준다.

그래서 무엇을 언제 고를까?

세 방법을 같은 페이지에 적용해보고 체감한 차이를 정리하면 이렇다.

기준requests + 파서내부 JSON APISelenium
상대 속도빠름가장 빠름느림(브라우저 부팅)
안정성DOM 변경에 취약API 스펙에 의존, 대체로 안정드라이버·버전 이슈 잦음
유지보수성중간높음(데이터 구조가 깔끔)낮음(무겁고 잘 깨짐)
데이터 형태HTML 파싱 필요구조화된 JSONHTML 파싱 필요
추천 상황SSR 정적 페이지내부 API가 열려 있을 때그 외 방법이 다 막혔을 때
flowchart LR
    A["① SSR인가?"] -->|yes| R["requests"]
    A -->|no| B["② 내부 JSON<br/>API 찾았나?"]
    B -->|yes| J["JSON API 직격"]
    B -->|no| S["Selenium<br/>(최후의 수단)"]

    classDef a fill:#e7f5ff,stroke:#1c7ed6,color:#10548f;
    classDef b fill:#ebfbee,stroke:#2f9e44,color:#1c5f2c;
    classDef c fill:#ffe3e3,stroke:#e03131,color:#a31212;
    class R a
    class J b
    class S c

내 결론은 단순하다. JSON API를 먼저 찾아라. 대부분의 동적 페이지는 5분만 Network 탭을 뒤지면 깔끔한 API가 나온다. 그걸 못 찾았을 때 SSR이면 requests, 그마저 안 되면 그때 Selenium이다. 도구를 고르는 순서만 바꿔도 6개월 뒤 나 자신이 훨씬 편해진다.

크롤링 시에는 대상 사이트의 이용약관과 robots.txt, 관련 법령을 반드시 확인하고 서버에 부담을 주지 않는 선에서 요청하자. 위 코드와 URL은 모두 설명용 합성 예시다.