본문 바로가기
DEVEL/PYTHON

Playwright Python 무한 스크롤 크롤링 예제: 동적 페이지 데이터 수집 실전 가이드

by codebyai 2026. 6. 16.
반응형

requests로는 왜 무한 스크롤 데이터가 안 보일까?

Python으로 크롤링을 처음 시작하면 보통 requests로 HTML을 가져오고, BeautifulSoup으로 원하는 태그를 찾습니다. 정적인 HTML 페이지라면 이 방식이 가장 단순하고 빠릅니다. 그런데 요즘 많은 웹사이트는 처음부터 모든 데이터를 HTML에 넣어 내려주지 않습니다. 화면이 열린 뒤 JavaScript가 API를 호출하고, 사용자가 스크롤을 내릴 때마다 새 데이터를 추가하는 방식으로 동작합니다.

이런 페이지에서 requests.get()으로 HTML을 확인하면 .item, .title, .price 같은 요소가 보이지 않을 수 있습니다. 이는 Python 코드가 잘못된 것이 아니라, 브라우저에서 JavaScript가 실행된 뒤에야 데이터가 만들어지는 구조이기 때문입니다. 이때 사용할 수 있는 도구가 Playwright입니다.

Playwright는 Chromium, Firefox, WebKit 같은 브라우저를 자동화할 수 있고, Python에서 sync API와 async API를 모두 사용할 수 있는 브라우저 자동화 도구입니다. 공식 문서에서도 Playwright Python은 웹 애플리케이션 자동화를 위한 API를 제공하며, sync와 async 방식 모두 지원한다고 설명합니다.

이 글은 특정 실제 사이트를 대상으로 데이터를 무단 수집하는 글이 아닙니다. 예제 URL은 https://example.com 또는 가상의 경로만 사용하며, selector도 .item, .title, .price, .load-more 같은 교육용 CSS selector를 기준으로 설명합니다.


5줄 핵심 요약

Playwright Python 무한 스크롤 크롤링은 JavaScript 렌더링 크롤링이 필요할 때 유용합니다.
정적 HTML 페이지라면 requests + BeautifulSoup이 더 간단하고 적합할 수 있습니다.
무한 스크롤은 보통 스크롤 이벤트, API 호출, DOM 추가 렌더링 흐름으로 동작합니다.
안전한 크롤링 코드는 최대 스크롤 횟수, 높이 비교, 아이템 개수 비교 같은 종료 조건을 반드시 가져야 합니다.
robots.txt, 서비스 약관, 요청 빈도 제한, 개인정보 수집 금지를 지키는 것이 코드보다 먼저입니다.


Playwright가 필요한 경우와 필요 없는 경우

Playwright는 강력하지만 모든 크롤링 문제의 정답은 아닙니다. 브라우저를 실제로 실행하기 때문에 단순 HTTP 요청보다 무겁습니다. 따라서 먼저 페이지 구조를 확인한 뒤 필요한 경우에만 사용하는 것이 좋습니다.

Playwright가 필요한 경우

다음과 같은 상황이라면 Playwright Python 사용법을 익혀두는 것이 좋습니다.

  • HTML 원본에는 데이터가 없고, 브라우저에서만 데이터가 보이는 경우
  • React, Vue, Angular, Next.js 같은 SPA 구조로 화면이 렌더링되는 경우
  • 스크롤을 내려야 .item 요소가 추가되는 무한 스크롤 페이지
  • “더 보기” 버튼을 눌러야 다음 데이터가 나타나는 페이지
  • 클릭, 입력, 탭 전환 등 브라우저 이벤트가 필요한 Python 웹 자동화
  • Selenium 대체 도구로 더 간결한 브라우저 자동화를 시도하고 싶은 경우

Playwright의 Page 객체는 브라우저의 단일 탭과 상호작용하는 API를 제공하며, page.goto() 같은 메서드로 URL에 접속할 수 있습니다. 공식 문서 예제도 browser.new_page() 후 page.goto()를 사용하는 흐름을 보여줍니다.

Playwright가 필요하지 않은 경우

다음 상황에서는 Playwright보다 requests + BeautifulSoup이 더 적합할 수 있습니다.

  • HTML 원본에 이미 필요한 데이터가 들어 있는 경우
  • 로그인, 클릭, 스크롤, JavaScript 실행이 필요 없는 경우
  • 공개 API가 제공되어 있고 API 이용 조건이 명확한 경우
  • 많은 페이지를 빠르게 수집해야 하지만 브라우저 렌더링이 필요 없는 경우
  • 서버 부하를 줄이고 단순한 데이터 파싱만 하면 되는 경우

requests + BeautifulSoup은 브라우저를 띄우지 않기 때문에 보통 구조가 단순하고 유지보수가 쉽습니다. 반면 Playwright는 브라우저 상태, 렌더링 시간, selector 안정성, timeout, 종료 조건을 함께 관리해야 합니다.


표 1. requests, BeautifulSoup, Selenium, Playwright 비교

도구적합한 상황장점단점무한 스크롤 적합도
requests 정적 HTML, 공개 API 호출 빠르고 가볍다 JavaScript 실행 불가 낮음
BeautifulSoup 내려받은 HTML 파싱 문법이 쉽고 초보자에게 적합 렌더링 후 DOM은 볼 수 없음 낮음
Selenium 브라우저 조작, 레거시 자동화 오래된 자료와 예제가 많음 드라이버·대기 처리 관리가 번거로울 수 있음 중간~높음
Playwright 동적 페이지 크롤링, JavaScript 렌더링 크롤링, 브라우저 자동화 sync·async API, Chromium·Firefox·WebKit 지원, locator 기반 자동화 단순 정적 페이지에는 과할 수 있음 높음

Playwright가 “무조건 최고”라는 뜻은 아닙니다. 단순 정적 페이지는 requests와 BeautifulSoup이 더 좋은 선택일 수 있고, JavaScript 렌더링 크롤링이나 Playwright scroll 처리가 필요한 경우에 Playwright를 선택하는 것이 합리적입니다.


무한 스크롤 페이지의 동작 원리

무한 스크롤 페이지는 보통 다음 흐름으로 동작합니다.

  1. 사용자가 페이지에 접속한다.
  2. 초기 데이터 몇 개만 화면에 표시된다.
  3. 사용자가 아래로 스크롤한다.
  4. 브라우저의 scroll 이벤트 또는 Intersection Observer가 동작한다.
  5. JavaScript가 서버 API에 다음 페이지 데이터를 요청한다.
  6. 받은 데이터를 DOM에 .item 형태로 추가한다.
  7. 더 이상 데이터가 없으면 로딩이 멈추거나 “마지막입니다” 같은 문구가 나타난다.

중요한 점은 데이터가 HTML 원본에 처음부터 존재하지 않을 수 있다는 것입니다. 그래서 Python 무한 스크롤 크롤링에서는 단순히 HTML을 한 번 가져오는 방식이 아니라, 브라우저에서 스크롤을 발생시키고 새 데이터가 렌더링될 시간을 기다린 뒤 DOM을 읽어야 합니다.

무한 스크롤에서 반드시 필요한 종료 조건

무한 스크롤 코드는 종료 조건이 없으면 끝나지 않습니다. 실무에서는 하나의 조건만 믿기보다 여러 조건을 함께 둡니다.

예를 들어 다음 조건을 조합할 수 있습니다.

  • 페이지 높이가 더 이상 늘지 않을 때
  • .item 개수가 더 이상 늘지 않을 때
  • 목표 아이템 개수에 도달했을 때
  • “더 이상 결과가 없습니다” 텍스트가 나타났을 때
  • 최대 스크롤 횟수에 도달했을 때
  • “더 보기” 버튼이 사라졌을 때

표 2. 무한 스크롤 종료 조건 비교

종료 조건장점단점추천 상황
페이지 높이가 더 이상 늘지 않을 때 구현이 쉽고 범용적 가상 스크롤 구조에서는 높이가 안 늘 수도 있음 일반적인 목록형 페이지
아이템 개수가 더 이상 늘지 않을 때 실제 수집 대상 기준이라 안정적 selector가 정확해야 함 .item 같은 카드 목록이 있는 페이지
특정 텍스트가 나타날 때 마지막 상태를 명확히 감지 가능 사이트 문구 변경에 취약 “더 이상 결과가 없습니다” 표시가 있는 페이지
최대 스크롤 횟수에 도달했을 때 무한 루프 방지에 필수 데이터가 남아 있어도 중단될 수 있음 모든 크롤링 코드의 안전장치
“더 보기” 버튼이 사라졌을 때 버튼형 페이지에서 직관적 버튼이 비활성화만 되고 남아 있을 수 있음 Load more 버튼 기반 페이지

Playwright Python 설치하기

Playwright 공식 문서의 Library 설치 섹션은 pip 기준으로 pip install playwright 후 playwright install을 실행하는 흐름을 안내합니다. 이 명령은 Playwright 패키지를 설치하고 Chromium, Firefox, WebKit 브라우저 바이너리를 설치합니다.

Playwright 설치 명령어

 
# pip 자체를 최신 버전으로 올립니다.
python -m pip install --upgrade pip

# Playwright Python 패키지를 설치합니다.
pip install playwright
 

Chromium 브라우저 설치 방법

모든 브라우저를 설치하려면 다음 명령을 사용합니다.

 
# Chromium, Firefox, WebKit 브라우저 바이너리를 설치합니다.
playwright install
 

Chromium만 사용할 계획이라면 다음처럼 설치할 수 있습니다. 공식 문서에서도 Chromium만 설치하는 예시로 playwright install chromium 형태를 보여줍니다.

 
# Chromium 브라우저만 설치합니다.
playwright install chromium
 

일부 환경에서는 Python 모듈 방식으로 실행하는 편이 명확합니다.

 
# 현재 Python 환경에 설치된 Playwright CLI를 통해 Chromium을 설치합니다.
python -m playwright install chromium
 

설치 확인용 기본 실행 파일

아래 코드는 브라우저를 열고 페이지 제목을 출력하는 가장 작은 예제입니다. 실제 크롤링이 아니라 설치 확인용입니다.

 
# basic_title.py

# Playwright의 동기 API를 가져옵니다.
from playwright.sync_api import sync_playwright

# sync_playwright는 Playwright 실행 환경을 열고 닫아주는 컨텍스트 매니저입니다.
with sync_playwright() as p:
    # Chromium 브라우저를 headless 모드로 실행합니다.
    browser = p.chromium.launch(headless=True)

    # 새 탭을 엽니다.
    page = browser.new_page()

    # 예제 페이지로 이동합니다.
    page.goto("https://example.com", timeout=30_000)

    # 현재 페이지 제목을 출력합니다.
    print(page.title())

    # 브라우저를 종료합니다.
    browser.close()
 

실행:

 
python basic_title.py
 

예제에서 사용할 가상의 HTML 구조

이 글의 예제는 특정 실제 사이트가 아니라 다음과 같은 가상의 HTML 구조를 기준으로 합니다.

 
<!-- 교육용 가상 HTML 구조입니다. 실제 사이트 구조가 아닙니다. -->
<div class="list">
  <div class="item" data-id="p001">
    <h2 class="title">Sample Product 1</h2>
    <span class="price">10000</span>
  </div>

  <div class="item" data-id="p002">
    <h2 class="title">Sample Product 2</h2>
    <span class="price">15000</span>
  </div>
</div>

<button class="load-more">더 보기</button>
 

앞으로 사용할 selector는 다음과 같습니다.

selector의미
.item 수집할 카드 하나
.title 아이템 제목
.price 아이템 가격
.load-more 더 보기 버튼

실제 프로젝트에서는 반드시 브라우저 개발자 도구로 DOM 구조를 확인하고, 사이트 약관과 robots.txt를 먼저 확인해야 합니다.


기본 예제: 페이지 접속 후 제목 가져오기

Playwright 크롤링 예제의 기본 흐름은 다음과 같습니다.

  1. Playwright 실행
  2. 브라우저 실행
  3. 새 페이지 생성
  4. URL 접속
  5. 원하는 데이터 추출
  6. 브라우저 종료

Playwright 공식 문서의 기본 예제도 sync_playwright()로 Playwright를 시작하고, chromium.launch(), browser.new_page(), page.goto(), page.title() 흐름을 보여줍니다.

 
# 01_basic_page_title.py

from playwright.sync_api import sync_playwright

def main():
    # Playwright 실행 환경을 시작합니다.
    with sync_playwright() as p:
        # Chromium 브라우저를 실행합니다.
        # headless=True이면 브라우저 UI가 보이지 않습니다.
        browser = p.chromium.launch(headless=True)

        # 새 페이지를 만듭니다.
        page = browser.new_page()

        # 기본 timeout을 30초로 설정합니다.
        page.set_default_timeout(30_000)

        # 교육용 예제 URL로 이동합니다.
        page.goto("https://example.com", timeout=30_000)

        # 페이지 제목을 가져옵니다.
        title = page.title()

        # 제목을 출력합니다.
        print(f"페이지 제목: {title}")

        # 브라우저를 닫습니다.
        browser.close()

if __name__ == "__main__":
    main()
 

page.set_default_timeout()은 timeout 옵션을 받는 여러 메서드의 기본 최대 대기 시간을 바꾸는 데 사용됩니다. Playwright 공식 Page API 문서는 기본 timeout이 30초이며, page.set_default_timeout()으로 변경할 수 있다고 설명합니다.


무한 스크롤 기본 코드

무한 스크롤의 핵심은 “스크롤 → 대기 → 데이터 확인”을 반복하는 것입니다. 여기서는 두 가지 방식으로 스크롤을 발생시킵니다.

첫 번째는 page.mouse.wheel()입니다. 공식 Mouse API 문서에 따르면 mouse.wheel(delta_x, delta_y)는 wheel 이벤트를 발생시키며, 일반적으로 페이지를 수동으로 스크롤할 때 사용됩니다. 다만 wheel 메서드는 스크롤 완료를 기다리지 않으므로 별도의 대기 조건이 필요합니다.

page.mouse.wheel을 이용한 스크롤 예제

 
# 02_scroll_with_mouse_wheel.py

from playwright.sync_api import sync_playwright

def main():
    with sync_playwright() as p:
        # 디버깅할 때는 headless=False로 바꿔서 화면을 확인할 수 있습니다.
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.set_default_timeout(30_000)

        # 가상의 목록 페이지입니다.
        page.goto("https://example.com/products", timeout=30_000)

        # 최대 스크롤 횟수를 제한해 무한 루프를 방지합니다.
        max_scrolls = 10

        for scroll_index in range(max_scrolls):
            # 마우스 휠을 아래 방향으로 1200px만큼 이동시킵니다.
            page.mouse.wheel(0, 1200)

            # wheel은 스크롤 완료를 기다리지 않으므로 짧게 대기합니다.
            # 실제 코드에서는 아이템 개수 변화나 로딩 표시 사라짐 같은 조건 기반 대기를 함께 사용합니다.
            page.wait_for_timeout(800)

            # 현재까지 로드된 아이템 개수를 확인합니다.
            item_count = page.locator(".item").count()

            print(f"{scroll_index + 1}회 스크롤 후 아이템 개수: {item_count}")

        browser.close()

if __name__ == "__main__":
    main()
 

JavaScript evaluate를 이용한 스크롤 예제

두 번째 방식은 page.evaluate()로 브라우저 안에서 JavaScript를 실행하는 것입니다. 공식 Page API 문서에 따르면 page.evaluate()는 브라우저 컨텍스트에서 JavaScript expression을 평가하고 값을 반환합니다. 함수가 Promise를 반환하면 Promise가 resolve될 때까지 기다립니다.

 
# 03_scroll_with_evaluate.py

from playwright.sync_api import sync_playwright

def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.set_default_timeout(30_000)

        page.goto("https://example.com/products", timeout=30_000)

        max_scrolls = 10

        for scroll_index in range(max_scrolls):
            # 브라우저 내부에서 JavaScript를 실행해 문서 맨 아래로 이동합니다.
            page.evaluate("window.scrollTo(0, document.body.scrollHeight)")

            # 새 데이터가 렌더링될 시간을 줍니다.
            page.wait_for_timeout(800)

            # 현재 문서 높이를 가져옵니다.
            current_height = page.evaluate("document.body.scrollHeight")

            print(f"{scroll_index + 1}회 스크롤 후 페이지 높이: {current_height}")

        browser.close()

if __name__ == "__main__":
    main()
 

page.mouse.wheel()은 실제 사용자 입력에 가까운 방식이고, page.evaluate("window.scrollTo(...)")는 직접 JavaScript를 실행하는 방식입니다. 페이지 구조에 따라 둘 중 하나가 더 잘 동작할 수 있습니다. 다만 어떤 방식이든 최대 반복 횟수와 종료 조건이 반드시 필요합니다.


종료 조건 1: 페이지 높이 변화가 없을 때 멈추기

가장 흔한 종료 조건은 document.body.scrollHeight를 비교하는 방식입니다. 스크롤 전 높이와 스크롤 후 높이가 같다면 새 콘텐츠가 더 이상 추가되지 않았다고 판단할 수 있습니다.

 
# 04_stop_by_scroll_height.py

from playwright.sync_api import sync_playwright

def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.set_default_timeout(30_000)

        page.goto("https://example.com/products", timeout=30_000)

        max_scrolls = 20

        # 스크롤 전 페이지 높이를 저장합니다.
        previous_height = page.evaluate("document.body.scrollHeight")

        for scroll_index in range(max_scrolls):
            # 문서의 가장 아래로 스크롤합니다.
            page.evaluate("window.scrollTo(0, document.body.scrollHeight)")

            # 새 데이터가 붙을 시간을 기다립니다.
            page.wait_for_timeout(1_000)

            # 스크롤 후 페이지 높이를 다시 측정합니다.
            current_height = page.evaluate("document.body.scrollHeight")

            print(
                f"{scroll_index + 1}회차: 이전 높이={previous_height}, 현재 높이={current_height}"
            )

            # 높이가 변하지 않으면 더 이상 새 콘텐츠가 없다고 판단하고 종료합니다.
            if current_height == previous_height:
                print("페이지 높이가 더 이상 증가하지 않아 스크롤을 종료합니다.")
                break

            # 다음 비교를 위해 현재 높이를 이전 높이로 업데이트합니다.
            previous_height = current_height

        browser.close()

if __name__ == "__main__":
    main()
 

이 방식은 단순하고 좋지만 한계가 있습니다. 일부 사이트는 가상 스크롤을 사용해 화면에 보이는 DOM만 유지합니다. 이런 경우 페이지 전체 높이가 예상대로 늘어나지 않을 수 있습니다. 그런 페이지에서는 아이템 개수, API 응답, “마지막” 문구, 버튼 상태 같은 다른 조건을 함께 봐야 합니다.


종료 조건 2: 아이템 개수가 더 이상 늘지 않을 때 멈추기

무한 스크롤 데이터 수집에서는 .item 개수를 기준으로 종료하는 방식이 더 직관적일 때가 많습니다.

 
# 05_stop_by_item_count.py

from playwright.sync_api import sync_playwright

def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.set_default_timeout(30_000)

        page.goto("https://example.com/products", timeout=30_000)

        max_scrolls = 20

        # 스크롤 전 아이템 개수를 저장합니다.
        previous_count = page.locator(".item").count()

        for scroll_index in range(max_scrolls):
            # 마우스 휠로 아래로 스크롤합니다.
            page.mouse.wheel(0, 1200)

            # 새 아이템이 렌더링될 시간을 기다립니다.
            page.wait_for_timeout(1_000)

            # 현재 아이템 개수를 확인합니다.
            current_count = page.locator(".item").count()

            print(
                f"{scroll_index + 1}회차: 이전 아이템 수={previous_count}, 현재 아이템 수={current_count}"
            )

            # 아이템 수가 더 이상 늘지 않으면 종료합니다.
            if current_count == previous_count:
                print("아이템 개수가 더 이상 증가하지 않아 종료합니다.")
                break

            # 다음 비교를 위해 현재 아이템 수를 저장합니다.
            previous_count = current_count

        browser.close()

if __name__ == "__main__":
    main()
 

Playwright의 Locator는 페이지에서 요소를 찾는 핵심 방식입니다. 공식 문서에 따르면 Locator는 특정 시점의 요소를 찾는 방법을 나타내며, page.locator()로 만들 수 있습니다. 또한 동적으로 변하는 목록에서 locator.all()은 즉시 현재 요소를 반환하므로, 목록이 안정적으로 로드된 뒤 호출해야 한다고 설명합니다.


“더 보기” 버튼이 있는 페이지 처리하기

모든 무한 스크롤 페이지가 스크롤만으로 동작하지는 않습니다. 어떤 페이지는 아래에 “더 보기” 버튼이 있고, 버튼을 클릭해야 다음 데이터가 추가됩니다.

이 경우에는 .load-more 버튼을 찾고, 보이는 동안 클릭하면 됩니다.

 
# 06_click_load_more.py

from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError

def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.set_default_timeout(30_000)

        page.goto("https://example.com/products", timeout=30_000)

        max_clicks = 10

        for click_index in range(max_clicks):
            # 더 보기 버튼 locator를 만듭니다.
            load_more_button = page.locator(".load-more")

            try:
                # 버튼이 화면에 나타날 때까지 최대 5초 기다립니다.
                load_more_button.wait_for(state="visible", timeout=5_000)

                # 현재 아이템 수를 저장합니다.
                previous_count = page.locator(".item").count()

                # 더 보기 버튼을 클릭합니다.
                load_more_button.click(timeout=5_000)

                # 클릭 후 아이템 개수가 증가할 때까지 기다립니다.
                page.wait_for_function(
                    """previousCount => document.querySelectorAll('.item').length > previousCount""",
                    previous_count,
                    timeout=10_000,
                )

                # 클릭 후 아이템 수를 확인합니다.
                current_count = page.locator(".item").count()

                print(
                    f"{click_index + 1}회 클릭: 이전={previous_count}, 현재={current_count}"
                )

            except PlaywrightTimeoutError:
                # 버튼이 없거나, 클릭 후 새 아이템이 늘지 않으면 종료합니다.
                print("더 보기 버튼이 없거나 새 데이터가 없어 종료합니다.")
                break

        browser.close()

if __name__ == "__main__":
    main()
 

여기서 핵심은 click()만 반복하지 않는 것입니다. 클릭 후 .item 개수가 늘어나는지 확인해야 합니다. 버튼이 비활성화되거나 사라지는 경우도 있으므로 timeout과 최대 클릭 횟수를 함께 둡니다.


데이터 추출하기

이제 스크롤로 아이템을 충분히 로드했다고 가정하고, .item 안의 .title, .price를 추출해 리스트에 저장해보겠습니다.

 
# 07_extract_items.py

from playwright.sync_api import sync_playwright

def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.set_default_timeout(30_000)

        page.goto("https://example.com/products", timeout=30_000)

        # 예제에서는 간단히 몇 번만 스크롤합니다.
        for _ in range(5):
            page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
            page.wait_for_timeout(800)

        # 수집 결과를 담을 리스트입니다.
        results = []

        # 현재 페이지에 있는 모든 .item 개수를 구합니다.
        item_count = page.locator(".item").count()

        for index in range(item_count):
            # n번째 아이템 locator를 가져옵니다.
            item = page.locator(".item").nth(index)

            # 제목과 가격 텍스트를 추출합니다.
            # 실제 사이트에서는 요소가 없을 가능성도 고려해 예외 처리를 추가하는 것이 좋습니다.
            title = item.locator(".title").inner_text(timeout=5_000).strip()
            price = item.locator(".price").inner_text(timeout=5_000).strip()

            # 아이템의 data-id 속성을 가져옵니다.
            item_id = item.get_attribute("data-id") or ""

            # 딕셔너리 형태로 리스트에 저장합니다.
            results.append(
                {
                    "id": item_id,
                    "title": title,
                    "price": price,
                }
            )

        print(results)

        browser.close()

if __name__ == "__main__":
    main()
 

실전에서는 .price가 없는 카드, 광고 카드, 품절 카드, 추천 영역이 섞일 수 있습니다. 그래서 최종 코드에서는 안전하게 텍스트를 가져오는 helper 함수를 두는 편이 좋습니다.


중복 제거하기

무한 스크롤에서는 같은 아이템이 중복으로 나타날 수 있습니다. 또 코드가 중간에 재시도되거나, 스크롤 이벤트가 중복 발생하면 같은 데이터가 여러 번 리스트에 들어갈 수 있습니다.

가장 좋은 기준은 고유 ID입니다. 예제에서는 data-id를 기준으로 중복 제거를 합니다. 고유 ID가 없다면 title + price 같은 조합을 사용할 수 있지만, 같은 제목과 가격을 가진 다른 상품이 있을 수 있으므로 주의해야 합니다.

 
# 08_deduplicate_items.py

items = [
    {"id": "p001", "title": "Sample Product 1", "price": "10000"},
    {"id": "p002", "title": "Sample Product 2", "price": "15000"},
    {"id": "p001", "title": "Sample Product 1", "price": "10000"},
]

# 이미 본 id를 저장할 set입니다.
seen_ids = set()

# 중복 제거된 결과를 담을 리스트입니다.
unique_items = []

for item in items:
    # id가 없으면 title과 price 조합을 대체 키로 사용합니다.
    unique_key = item.get("id") or f"{item.get('title')}|{item.get('price')}"

    # 이미 본 키라면 건너뜁니다.
    if unique_key in seen_ids:
        continue

    # 처음 보는 키라면 set에 추가하고 결과 리스트에 넣습니다.
    seen_ids.add(unique_key)
    unique_items.append(item)

print(unique_items)
 

CSV와 JSON으로 저장하기

Playwright CSV 저장과 JSON 저장은 Python 표준 라이브러리만으로도 충분합니다.

CSV 저장 예제

 
# 09_save_csv.py

import csv

items = [
    {"id": "p001", "title": "Sample Product 1", "price": "10000"},
    {"id": "p002", "title": "Sample Product 2", "price": "15000"},
]

# CSV 파일 경로입니다.
csv_path = "items.csv"

# newline=""은 Windows에서 빈 줄이 추가되는 문제를 줄이기 위해 사용합니다.
with open(csv_path, "w", newline="", encoding="utf-8-sig") as file:
    # 저장할 컬럼명을 지정합니다.
    fieldnames = ["id", "title", "price"]

    # DictWriter는 딕셔너리 리스트를 CSV로 저장할 때 편리합니다.
    writer = csv.DictWriter(file, fieldnames=fieldnames)

    # 헤더 행을 씁니다.
    writer.writeheader()

    # 데이터 행을 씁니다.
    writer.writerows(items)

print(f"CSV 저장 완료: {csv_path}")
 

utf-8-sig는 Excel에서 한글이 깨지는 일을 줄이기 위해 자주 사용합니다.

JSON 저장 예제

 
# 10_save_json.py

import json

items = [
    {"id": "p001", "title": "Sample Product 1", "price": "10000"},
    {"id": "p002", "title": "Sample Product 2", "price": "15000"},
]

# JSON 파일 경로입니다.
json_path = "items.json"

# ensure_ascii=False는 한글을 유니코드 이스케이프가 아니라 그대로 저장하게 합니다.
with open(json_path, "w", encoding="utf-8") as file:
    json.dump(items, file, ensure_ascii=False, indent=2)

print(f"JSON 저장 완료: {json_path}")
 

CSV는 스프레드시트로 열기 좋고, JSON은 나중에 프로그램에서 다시 읽어 처리하기 좋습니다.


sync 코드와 async 코드 차이

Playwright Python은 동기 방식과 비동기 방식을 모두 지원합니다. 공식 문서는 Playwright가 sync API와 async API 두 가지 변형을 지원하며, 현대적인 프로젝트에서 asyncio를 사용한다면 async API를 사용하라고 안내합니다.

sync 방식

sync 방식은 코드가 위에서 아래로 순서대로 실행되는 형태라 초보자가 이해하기 쉽습니다.

 
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com")
    print(page.title())
    browser.close()
 

async 방식

async 방식은 asyncio 기반 프로젝트, 여러 페이지 작업, 다른 비동기 작업과 함께 실행해야 하는 경우에 적합합니다.

 
import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        await page.goto("https://example.com")
        print(await page.title())
        await browser.close()

asyncio.run(main())
 

초보자라면 처음에는 sync 방식으로 구조를 이해한 뒤, 필요할 때 async 방식으로 넘어가는 것을 추천합니다. 단, 이미 FastAPI, asyncio worker, 비동기 파이프라인 안에서 동작하는 코드라면 처음부터 async API를 사용하는 편이 자연스럽습니다.


headless/headed 모드와 디버깅 팁

Playwright headless 모드는 브라우저 화면을 보이지 않고 실행하는 방식입니다. Playwright 공식 문서에 따르면 브라우저는 기본적으로 headless 모드로 실행되며, UI를 보려면 headless=False 옵션을 사용합니다. 또한 slow_mo 옵션으로 동작을 느리게 만들어 디버깅할 수 있습니다.

headless 모드

 
# 브라우저 UI를 표시하지 않습니다.
browser = p.chromium.launch(headless=True)
 

장점은 자동화 작업에 적합하고 서버 환경에서 사용하기 쉽다는 것입니다. 단점은 화면을 볼 수 없어 selector 문제나 스크롤 문제를 파악하기 어렵다는 것입니다.

headed 모드

 
# 브라우저 UI를 표시합니다.
browser = p.chromium.launch(headless=False, slow_mo=100)
 

headed 모드는 브라우저 창이 실제로 열립니다. 스크롤이 되는지, 버튼이 보이는지, .item이 추가되는지 눈으로 확인할 수 있습니다. 초보자라면 처음부터 headless로만 돌리지 말고, headed 모드에서 충분히 확인한 뒤 headless로 바꾸는 것이 좋습니다.

디버깅 방법

 
# 코드 중간에서 Playwright Inspector를 열어 멈출 수 있습니다.
page.pause()
 

또는 verbose 로그를 볼 수 있습니다.

 
# Bash 기준
DEBUG=pw:api python your_script.py
 

Playwright Trace Viewer는 실행 과정을 기록하고 나중에 단계별로 확인할 수 있는 도구입니다. 공식 문서는 Trace Viewer가 기록된 테스트 트레이스를 앞뒤로 이동하며 각 액션의 상태를 시각적으로 확인할 수 있는 GUI 도구라고 설명합니다.

 
# trace_debug.py

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    context = browser.new_context()

    # 스크린샷, DOM snapshot, 소스 정보를 포함해 trace를 기록합니다.
    context.tracing.start(screenshots=True, snapshots=True, sources=True)

    page = context.new_page()
    page.goto("https://example.com", timeout=30_000)

    # 필요한 자동화 작업을 수행합니다.
    print(page.title())

    # trace.zip 파일로 저장합니다.
    context.tracing.stop(path="trace.zip")

    browser.close()
 

Trace 열기:

 
playwright show-trace trace.zip
 

실전에서 자주 나는 오류와 해결 방법

Playwright 크롤링 예제를 그대로 따라 했는데도 실제 페이지에서 동작하지 않는 경우가 많습니다. 대부분은 Playwright 자체 문제가 아니라 페이지 구조, 대기 조건, selector, 종료 조건 문제입니다.

표 3. 초보자가 자주 하는 실수

실수왜 문제가 되는가해결 방법
while True로 무한 루프를 만드는 것 데이터가 더 이상 없어도 코드가 끝나지 않음 최대 스크롤 횟수, 높이 비교, 아이템 개수 비교를 넣기
time.sleep만 길게 넣는 것 느리고 불안정하며 페이지 상태와 무관하게 기다림 wait_for_function, locator.wait_for, 개수 변화 확인 사용
selector를 너무 취약하게 잡는 것 DOM 구조가 조금만 바뀌어도 깨짐 .item, data-testid, 의미 있는 클래스 등 안정적인 selector 사용
중복 데이터를 제거하지 않는 것 결과 CSV에 같은 데이터가 반복 저장됨 id, URL, 제목+가격 조합 등으로 중복 제거
robots.txt와 약관을 확인하지 않는 것 법적·윤리적 문제가 생길 수 있음 수집 전 robots.txt, 서비스 약관, API 정책 확인
개인정보를 무단 수집하는 것 개인정보보호법 등 법적 리스크가 큼 공개적으로 수집 가능한 비식별 데이터만 다루기
headless 모드에서만 테스트하는 것 실제로 화면에서 무슨 일이 일어나는지 모름 초기 개발은 headless=False, slow_mo로 확인
스크롤 후 바로 추출하는 것 JavaScript 렌더링이 끝나기 전 DOM을 읽음 스크롤 후 아이템 개수 변화나 로딩 완료 조건을 기다리기
실제 사이트 URL을 예제 코드에 하드코딩하는 것 약관 위반이나 무단 수집으로 이어질 수 있음 교육용 URL 또는 허가된 테스트 페이지 사용

오류 1. TimeoutError

원인:

  • selector가 틀림
  • 요소가 아직 렌더링되지 않음
  • 실제 페이지에는 해당 요소가 없음
  • 버튼이 화면 아래에 있거나 가려져 있음

해결:

 
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError

try:
    page.locator(".item").first.wait_for(state="visible", timeout=10_000)
except PlaywrightTimeoutError:
    print("지정한 시간 안에 .item 요소를 찾지 못했습니다.")
 

오류 2. 스크롤했는데 아이템이 늘지 않음

가능한 원인:

  • 스크롤 대상이 window가 아니라 내부 컨테이너임
  • 페이지가 “더 보기” 버튼 방식임
  • API 응답이 느림
  • 마지막 페이지에 도달함
  • selector가 실제 목록이 아니라 다른 요소를 가리킴

내부 컨테이너를 스크롤해야 하는 경우 예시:

 
# .scroll-container라는 내부 영역을 스크롤하는 예시입니다.
page.evaluate("""
    const container = document.querySelector('.scroll-container');
    if (container) {
        container.scrollTo(0, container.scrollHeight);
    }
""")
 

오류 3. headless에서는 안 되고 headed에서는 됨

가능한 원인:

  • viewport 크기 차이
  • lazy loading 조건 차이
  • 애니메이션 또는 렌더링 타이밍 차이
  • 화면에 보이는 위치에 따라 로딩되는 구조

해결:

 
# viewport를 명시해 headless/headed 차이를 줄입니다.
page = browser.new_page(viewport={"width": 1280, "height": 900})
 

오류 4. 중복 데이터가 계속 저장됨

가능한 원인:

  • 스크롤 이벤트가 같은 페이지 데이터를 다시 렌더링함
  • DOM에 기존 아이템이 유지된 상태에서 매번 전체를 다시 읽음
  • 저장 전에 중복 제거를 하지 않음

해결:

 
seen = set()
unique_items = []

for item in items:
    key = item.get("id") or item.get("title")
    if key in seen:
        continue
    seen.add(key)
    unique_items.append(item)
 

크롤링할 때 반드시 지켜야 할 주의사항

Playwright Python 무한 스크롤 크롤링을 할 때는 코드보다 먼저 지켜야 할 기준이 있습니다.

1. robots.txt 확인

robots.txt는 크롤러가 어떤 경로에 접근해도 되는지 사이트 운영자가 표현하는 표준적인 방식입니다. IETF RFC 9309는 Robots Exclusion Protocol을 서비스 운영자가 자동화 클라이언트의 접근 방식을 제어하기 위해 제공하는 규칙으로 설명합니다. 다만 RFC 문서도 robots.txt 규칙이 접근 권한 부여 자체는 아니라고 명시합니다. 즉, robots.txt가 허용처럼 보이더라도 약관과 법률 검토가 별도로 필요합니다.

Google 문서 역시 크롤러가 사이트를 크롤링하기 전에 robots.txt를 다운로드하고 파싱해 어떤 영역을 크롤링할 수 있는지 확인한다고 설명합니다. robots.txt는 사이트 최상위 경로에 위치해야 하며, host·protocol·port 범위에만 적용됩니다.

2. 서비스 약관 확인

robots.txt와 별개로 서비스 약관에서 자동 수집, 스크래핑, 재배포, 상업적 이용을 제한할 수 있습니다. 약관을 위반하는 방식의 크롤링은 하지 않아야 합니다.

3. 요청 빈도 제한

무한 스크롤 자동화는 사람이 스크롤하는 것보다 훨씬 빠르게 많은 요청을 만들 수 있습니다. 다음 원칙을 지키는 것이 좋습니다.

  • 최대 스크롤 횟수 제한
  • 페이지당 최대 수집 개수 제한
  • 스크롤 사이 짧은 대기
  • 실패 시 무한 재시도 금지
  • 서버 오류가 반복되면 즉시 중단

4. 개인정보 수집 주의

개인정보, 민감정보, 로그인 후 데이터, 유료 콘텐츠, 사내 시스템 데이터는 무단 수집하지 않아야 합니다. 이 글은 공개적이고 허가된 테스트 대상 또는 본인이 소유한 페이지를 기준으로 학습하는 용도입니다.

5. 다루지 않는 내용

이 글에서는 다음 내용을 다루지 않습니다.

  • 캡차 우회
  • 로그인 우회
  • 봇 탐지 회피
  • 차단 회피
  • 프록시 우회 전략
  • 유료 콘텐츠 무단 수집
  • 개인정보 무단 수집

최종 코드 전체 예제

아래 코드는 교육용 가상 페이지를 기준으로 만든 async 방식 최종 예제입니다. 실제로 https://example.com/products에 이 구조가 있다는 뜻은 아닙니다. 실제 프로젝트에서는 본인이 소유했거나 수집 허가를 받은 페이지에서 selector와 대기 조건을 조정해야 합니다.

이 최종 예제에는 다음 요소가 들어 있습니다.

  • async Playwright 사용
  • Chromium 실행
  • headless/headed 옵션
  • timeout 설정
  • 최대 스크롤 횟수 제한
  • 페이지 높이 비교 종료 조건
  • 아이템 개수 비교 종료 조건
  • 목표 수집 개수 종료 조건
  • “더 보기” 버튼 처리 함수
  • 데이터 추출
  • 중복 제거
  • CSV 저장
  • JSON 저장
  • 예외 처리
 
# final_async_infinite_scroll_crawler.py

import asyncio
import csv
import json
from typing import Dict, List, Optional

from playwright.async_api import (
    async_playwright,
    TimeoutError as PlaywrightTimeoutError,
)


# 교육용 예제 URL입니다.
# 실제 사이트를 대상으로 사용할 때는 robots.txt, 서비스 약관, 수집 허가 여부를 먼저 확인하세요.
TARGET_URL = "https://example.com/products"

# CSS selector는 가상의 구조를 기준으로 합니다.
ITEM_SELECTOR = ".item"
TITLE_SELECTOR = ".title"
PRICE_SELECTOR = ".price"
LOAD_MORE_SELECTOR = ".load-more"

# 안전장치 값입니다.
MAX_SCROLLS = 20
MAX_ITEMS = 100
SCROLL_DELAY_MS = 800

# 출력 파일 경로입니다.
CSV_PATH = "items.csv"
JSON_PATH = "items.json"


async def safe_inner_text(item_locator, selector: str, timeout: int = 3_000) -> str:
    """
    특정 아이템 내부에서 텍스트를 안전하게 가져옵니다.
    요소가 없거나 timeout이 발생하면 빈 문자열을 반환합니다.
    """
    try:
        # item 내부에서 selector에 해당하는 요소의 텍스트를 가져옵니다.
        text = await item_locator.locator(selector).inner_text(timeout=timeout)

        # 앞뒤 공백을 제거해 반환합니다.
        return text.strip()

    except PlaywrightTimeoutError:
        # 요소가 없거나 지정 시간 안에 찾지 못하면 빈 문자열을 반환합니다.
        return ""


async def extract_items(page) -> List[Dict[str, str]]:
    """
    현재 페이지 DOM에 렌더링된 .item 요소들을 읽어 리스트로 변환합니다.
    """
    results: List[Dict[str, str]] = []

    # 현재 페이지에 있는 아이템 개수를 계산합니다.
    item_count = await page.locator(ITEM_SELECTOR).count()

    for index in range(item_count):
        # n번째 아이템을 선택합니다.
        item = page.locator(ITEM_SELECTOR).nth(index)

        # data-id 속성을 가져옵니다. 없으면 빈 문자열을 사용합니다.
        item_id: Optional[str] = await item.get_attribute("data-id")

        # 제목과 가격 텍스트를 안전하게 가져옵니다.
        title = await safe_inner_text(item, TITLE_SELECTOR)
        price = await safe_inner_text(item, PRICE_SELECTOR)

        # 제목과 가격이 모두 비어 있다면 의미 없는 카드로 보고 건너뜁니다.
        if not title and not price:
            continue

        # 딕셔너리 형태로 결과에 추가합니다.
        results.append(
            {
                "id": item_id or "",
                "title": title,
                "price": price,
            }
        )

    return results


def deduplicate_items(items: List[Dict[str, str]]) -> List[Dict[str, str]]:
    """
    id를 우선 기준으로 중복을 제거합니다.
    id가 없다면 title과 price 조합을 임시 키로 사용합니다.
    """
    seen_keys = set()
    unique_items: List[Dict[str, str]] = []

    for item in items:
        # 고유 id가 있으면 id를 사용합니다.
        # id가 없으면 title과 price 조합을 사용합니다.
        unique_key = item.get("id") or f"{item.get('title')}|{item.get('price')}"

        # 이미 처리한 데이터라면 건너뜁니다.
        if unique_key in seen_keys:
            continue

        # 처음 보는 데이터라면 set과 결과 리스트에 추가합니다.
        seen_keys.add(unique_key)
        unique_items.append(item)

    return unique_items


def save_to_csv(items: List[Dict[str, str]], path: str) -> None:
    """
    수집한 데이터를 CSV 파일로 저장합니다.
    """
    with open(path, "w", newline="", encoding="utf-8-sig") as file:
        # CSV 컬럼 순서를 지정합니다.
        fieldnames = ["id", "title", "price"]

        # 딕셔너리 데이터를 CSV로 쓰기 위한 writer를 만듭니다.
        writer = csv.DictWriter(file, fieldnames=fieldnames)

        # 헤더를 씁니다.
        writer.writeheader()

        # 데이터 행을 씁니다.
        writer.writerows(items)


def save_to_json(items: List[Dict[str, str]], path: str) -> None:
    """
    수집한 데이터를 JSON 파일로 저장합니다.
    """
    with open(path, "w", encoding="utf-8") as file:
        # ensure_ascii=False로 한글을 그대로 저장합니다.
        json.dump(items, file, ensure_ascii=False, indent=2)


async def click_load_more_if_exists(page) -> bool:
    """
    '더 보기' 버튼이 있으면 클릭하고 True를 반환합니다.
    버튼이 없거나 timeout이 발생하면 False를 반환합니다.
    """
    load_more = page.locator(LOAD_MORE_SELECTOR)

    try:
        # 더 보기 버튼이 보이는지 짧게 확인합니다.
        await load_more.wait_for(state="visible", timeout=2_000)

        # 클릭 전 아이템 개수를 저장합니다.
        previous_count = await page.locator(ITEM_SELECTOR).count()

        # 더 보기 버튼을 클릭합니다.
        await load_more.click(timeout=5_000)

        # 클릭 후 아이템 개수가 늘어나는지 기다립니다.
        await page.wait_for_function(
            """previousCount => document.querySelectorAll('.item').length > previousCount""",
            previous_count,
            timeout=10_000,
        )

        return True

    except PlaywrightTimeoutError:
        # 버튼이 없거나 새 아이템이 늘지 않으면 False를 반환합니다.
        return False


async def scroll_until_done(page) -> None:
    """
    무한 스크롤 페이지를 안전하게 스크롤합니다.
    종료 조건:
    1. 최대 스크롤 횟수 도달
    2. 목표 아이템 개수 도달
    3. 페이지 높이 변화 없음
    4. 아이템 개수 변화 없음
    """
    # 초기 페이지 높이와 아이템 개수를 저장합니다.
    previous_height = await page.evaluate("document.body.scrollHeight")
    previous_count = await page.locator(ITEM_SELECTOR).count()

    for scroll_index in range(MAX_SCROLLS):
        # 목표 수집 개수에 도달하면 종료합니다.
        if previous_count >= MAX_ITEMS:
            print(f"목표 아이템 개수 {MAX_ITEMS}개에 도달해 종료합니다.")
            break

        # 먼저 더 보기 버튼이 있는 유형인지 확인하고 클릭합니다.
        clicked = await click_load_more_if_exists(page)

        if not clicked:
            # 더 보기 버튼이 없다면 JavaScript로 문서 하단까지 스크롤합니다.
            await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")

            # 실제 사용자 스크롤 이벤트가 필요한 페이지를 위해 wheel 이벤트도 한 번 보냅니다.
            await page.mouse.wheel(0, 1200)

        # 새 데이터 렌더링을 위한 짧은 대기입니다.
        # 실전에서는 로딩 스피너 사라짐, 아이템 개수 변화 같은 조건 기반 대기를 함께 사용하세요.
        await page.wait_for_timeout(SCROLL_DELAY_MS)

        # 현재 페이지 높이와 아이템 개수를 다시 측정합니다.
        current_height = await page.evaluate("document.body.scrollHeight")
        current_count = await page.locator(ITEM_SELECTOR).count()

        print(
            f"{scroll_index + 1}회차 | "
            f"높이: {previous_height} -> {current_height} | "
            f"아이템: {previous_count} -> {current_count}"
        )

        # 높이와 아이템 개수가 모두 변하지 않으면 더 이상 로드할 데이터가 없다고 판단합니다.
        if current_height == previous_height and current_count == previous_count:
            print("페이지 높이와 아이템 개수가 모두 변하지 않아 종료합니다.")
            break

        # 다음 반복을 위해 현재 값을 이전 값으로 업데이트합니다.
        previous_height = current_height
        previous_count = current_count


async def main() -> None:
    """
    Playwright async API를 사용하는 최종 실행 함수입니다.
    """
    async with async_playwright() as p:
        # headless=False로 바꾸면 브라우저 화면을 보며 디버깅할 수 있습니다.
        browser = await p.chromium.launch(
            headless=True,
            timeout=30_000,
        )

        # 브라우저 컨텍스트를 생성합니다.
        context = await browser.new_context(
            viewport={"width": 1280, "height": 900}
        )

        # 새 페이지를 엽니다.
        page = await context.new_page()

        # Playwright 기본 timeout을 설정합니다.
        page.set_default_timeout(30_000)
        page.set_default_navigation_timeout(30_000)

        try:
            # 대상 페이지로 이동합니다.
            await page.goto(TARGET_URL, wait_until="domcontentloaded", timeout=30_000)

            # 첫 아이템이 보일 때까지 기다립니다.
            # 실제 페이지에 초기 아이템이 없는 구조라면 이 조건은 조정해야 합니다.
            try:
                await page.locator(ITEM_SELECTOR).first.wait_for(
                    state="visible",
                    timeout=10_000,
                )
            except PlaywrightTimeoutError:
                print("초기 .item 요소를 찾지 못했습니다. 페이지 구조나 selector를 확인하세요.")

            # 무한 스크롤을 수행합니다.
            await scroll_until_done(page)

            # 현재 DOM에서 데이터를 추출합니다.
            items = await extract_items(page)

            # 중복 데이터를 제거합니다.
            unique_items = deduplicate_items(items)

            # CSV와 JSON으로 저장합니다.
            save_to_csv(unique_items, CSV_PATH)
            save_to_json(unique_items, JSON_PATH)

            print(f"수집 개수: {len(unique_items)}")
            print(f"CSV 저장 완료: {CSV_PATH}")
            print(f"JSON 저장 완료: {JSON_PATH}")

        except PlaywrightTimeoutError as error:
            # Playwright timeout 관련 예외를 처리합니다.
            print(f"Timeout 발생: {error}")

        except Exception as error:
            # 예상하지 못한 예외를 처리합니다.
            print(f"예상하지 못한 오류 발생: {error}")

        finally:
            # 브라우저 컨텍스트와 브라우저를 반드시 닫습니다.
            await context.close()
            await browser.close()


if __name__ == "__main__":
    asyncio.run(main())
 

체크리스트

크롤링을 실행하기 전에 아래 항목을 확인하세요.

  • 대상 사이트의 robots.txt를 확인했다.
  • 서비스 약관에서 자동 수집이 허용되는지 확인했다.
  • 개인정보, 로그인 후 데이터, 유료 콘텐츠를 수집하지 않는다.
  • 예제 selector를 실제 페이지 구조에 맞게 수정했다.
  • MAX_SCROLLS, MAX_ITEMS 같은 안전장치를 넣었다.
  • 스크롤 사이에 짧은 대기와 조건 기반 대기를 함께 사용했다.
  • 페이지 높이 비교 또는 아이템 개수 비교 종료 조건을 넣었다.
  • “더 보기” 버튼이 있는 페이지인지 확인했다.
  • 중복 제거 기준을 정했다.
  • CSV와 JSON 저장 시 인코딩을 확인했다.
  • 처음에는 headless=False로 화면을 보며 디버깅했다.
  • 실제 테스트하지 않은 결과를 성능 비교처럼 단정하지 않는다.
  • 캡차 우회, 로그인 우회, 차단 회피, 프록시 우회 코드를 사용하지 않는다.

FAQ

Q1. Playwright로 무한 스크롤 크롤링을 할 수 있나요?

네. Playwright Python 무한 스크롤 크롤링은 가능합니다. 브라우저를 실제로 자동화하므로 JavaScript 렌더링 후 DOM에 추가되는 .item 요소를 읽을 수 있습니다. 다만 반드시 최대 스크롤 횟수, 페이지 높이 비교, 아이템 개수 비교 같은 종료 조건을 넣어야 합니다.

Q2. Selenium보다 Playwright가 무한 스크롤에 더 좋은가요?

상황에 따라 다릅니다. Playwright는 sync·async API, locator 기반 자동화, Chromium·Firefox·WebKit 지원 등 장점이 있습니다. 하지만 기존 프로젝트가 Selenium으로 안정적으로 운영되고 있다면 무조건 바꿀 필요는 없습니다. 새 Python 크롤링 자동화나 Python 웹 자동화 프로젝트라면 Playwright를 검토할 만합니다.

Q3. requests와 BeautifulSoup으로 무한 스크롤 페이지를 크롤링할 수 없나요?

HTML 원본에 데이터가 없고 JavaScript가 나중에 데이터를 렌더링하는 구조라면 requests + BeautifulSoup만으로는 화면에 보이는 데이터를 바로 얻기 어렵습니다. 다만 페이지가 내부적으로 공개 API를 호출하고, 그 API 사용이 약관상 허용된다면 브라우저 자동화보다 API 요청 방식이 더 효율적일 수 있습니다.

Q4. Playwright에서 스크롤이 안 될 때는 어떻게 해야 하나요?

먼저 스크롤 대상이 window인지 내부 컨테이너인지 확인해야 합니다. 어떤 페이지는 document.body가 아니라 .scroll-container 같은 내부 영역이 스크롤됩니다. 또한 page.mouse.wheel()과 page.evaluate("window.scrollTo(...)") 중 어떤 방식이 동작하는지 headed 모드에서 확인하는 것이 좋습니다.

Q5. headless 모드에서 크롤링이 다르게 동작하는 이유는 무엇인가요?

headless와 headed 모드는 viewport, 렌더링 타이밍, lazy loading 조건에서 차이가 생길 수 있습니다. 그래서 초기 개발 단계에서는 headless=False, slow_mo=100으로 화면을 보면서 테스트하고, 이후 headless=True로 전환하는 것이 좋습니다.

Q6. 무한 스크롤 크롤링에서 종료 조건은 어떻게 잡아야 하나요?

하나만 쓰기보다 여러 조건을 조합하는 것이 안전합니다. 추천 조합은 최대 스크롤 횟수, 페이지 높이 변화 없음, .item 개수 변화 없음, 목표 아이템 개수 도달입니다. “더 보기” 버튼형 페이지라면 버튼이 사라지거나 클릭 후 아이템 개수가 늘지 않는 조건도 추가합니다.

Q7. 크롤링할 때 robots.txt를 꼭 확인해야 하나요?

확인해야 합니다. robots.txt는 자동화 클라이언트가 어떤 경로에 접근해도 되는지 사이트 운영자가 표현하는 표준적 방법입니다. 다만 robots.txt만으로 모든 법적 문제가 해결되는 것은 아니므로 서비스 약관, 저작권, 개인정보, 데이터 재사용 조건도 함께 확인해야 합니다.

Q8. Playwright 크롤링 결과를 CSV로 저장하려면 어떻게 하나요?

Python 표준 라이브러리 csv를 사용하면 됩니다. 딕셔너리 리스트를 만든 뒤 csv.DictWriter로 저장하면 됩니다. 한글 데이터가 Excel에서 깨지는 경우가 있으므로 encoding="utf-8-sig"를 사용하는 것이 좋습니다.

Q9. Playwright 크롤링 결과를 JSON으로 저장하려면 어떻게 하나요?

Python 표준 라이브러리 json을 사용하면 됩니다. json.dump(items, file, ensure_ascii=False, indent=2) 형태로 저장하면 한글이 깨지지 않고 사람이 읽기 좋은 형태로 저장됩니다.

Q10. async Playwright를 써야 하는 경우는 언제인가요?

이미 asyncio 기반 프로젝트를 사용하고 있거나, 여러 비동기 작업과 Playwright를 함께 실행해야 한다면 async API가 적합합니다. 단순한 학습용 스크립트나 작은 자동화 도구라면 sync 방식이 더 이해하기 쉽습니다.

Q11. time.sleep()을 쓰면 안 되나요?

절대 쓰면 안 된다는 뜻은 아니지만, time.sleep()만 길게 넣는 방식은 비효율적이고 불안정합니다. Playwright에서는 locator.wait_for(), page.wait_for_function(), page.wait_for_timeout() 등을 상황에 맞게 조합하는 편이 좋습니다.

Q12. 특정 사이트에서 차단되면 어떻게 해야 하나요?

이 글에서는 차단 회피, 캡차 우회, 봇 탐지 회피, 프록시 우회 전략을 다루지 않습니다. 차단이 발생한다면 먼저 자동 수집이 허용되는 대상인지, 요청 빈도가 과도하지 않은지, 공식 API나 데이터 제공 경로가 있는지 확인해야 합니다.

반응형