Puppeteer로 브라우저 자동화: 스크래핑, 스크린샷·PDF 생성, 폼 자동화, 대기 전략

이 글의 핵심

Puppeteer로 헤드리스 브라우저를 띄워 페이지 데이터를 수집하고, 스크린샷과 PDF를 만들고, 폼을 자동 입력하는 방법, 그리고 스크래핑이 자주 실패하는 대기 문제를 다루는 법을 정리합니다.

이 글의 핵심

Puppeteer로 헤드리스 브라우저를 띄워 스크래핑, 스크린샷, PDF 생성, 폼 자동화를 하는 방법을 다룹니다. 예제마다 실제로 자주 실패하는 원인, 특히 “요소가 아직 없다”는 대기 문제와 네비게이션 경쟁 조건을 함께 설명합니다.

Puppeteer를 쓰게 되는 상황

HTTP 요청만으로는 데이터가 안 보여요

fetch나 axios로 HTML을 받아 파싱하는 방식은 빠르고 가볍지만, 데이터를 JavaScript가 나중에 그려 넣는 SPA에서는 빈 껍데기 HTML만 받게 됩니다. Puppeteer는 실제 Chrome을 띄워 스크립트를 실행한 뒤의 DOM을 읽으므로 이런 페이지에서도 데이터를 얻을 수 있습니다. 대신 브라우저 하나가 수백 MB의 메모리를 쓰고 페이지 로딩 시간도 그대로 걸리므로, 데이터가 공개 API나 HTML에 이미 있다면 그쪽을 먼저 쓰는 것이 훨씬 효율적입니다.

HTML/CSS로 만든 화면을 그대로 PDF로 만들고 싶어요

PDF 전용 라이브러리로 영수증이나 보고서를 그리면 레이아웃을 좌표로 직접 계산해야 합니다. Puppeteer는 Chrome의 인쇄 엔진을 그대로 쓰기 때문에 웹에서 쓰던 HTML·CSS 템플릿을 거의 그대로 PDF로 바꿀 수 있습니다.

반복적인 브라우저 작업을 없애고 싶어요

관리자 페이지에 로그인해 매일 같은 보고서를 내려받는 작업처럼, API가 없는 웹 화면을 사람 대신 조작해야 할 때 씁니다. E2E 테스트에도 쓸 수 있지만, 테스트가 주 목적이라면 테스트 러너와 자동 대기가 통합된 Playwright가 더 편한 경우가 많습니다.


CDP 위에 올라간 Puppeteer

Chrome DevTools Protocol의 고수준 래퍼 (2017~)

Puppeteer는 2017년 Google Chrome 팀이 공개한 Node.js 라이브러리입니다. 브라우저 자동화 도구의 역사는 Selenium(2004)부터 시작되지만, Selenium은 WebDriver API(W3C 표준)를 통해 브라우저를 제어하므로 브라우저별 드라이버(chromedriver, geckodriver)가 필요했습니다.

Puppeteer의 차별점:

  • Chrome DevTools Protocol(CDP)을 직접 사용 → 별도 드라이버 프로세스 불필요
  • 브라우저 자동 설치 → npm install puppeteer가 테스트용 Chrome까지 내려받음
  • Chrome 팀 공식 → Chrome 신기능을 가장 빠르게 지원
  • Firefox 지원 → v23(2024)부터 WebDriver BiDi 프로토콜로 Firefox도 정식 지원

CDP(Chrome DevTools Protocol)란?

CDP는 브라우저와 외부 도구가 통신하는 프로토콜입니다. Chrome DevTools(F12)도 이 프로토콜로 브라우저와 통신합니다.

Puppeteer (Node.js) 
    ↓ WebSocket (CDP)
Chrome Browser
    ↓ 렌더링·JS 실행
웹 페이지

Puppeteer가 할 수 있는 것 = DevTools가 할 수 있는 것:

  • DOM 조작·쿼리
  • 네트워크 요청 가로채기 (Request interception)
  • CPU·메모리 프로파일링
  • 커버리지 측정
  • 모바일 에뮬레이션

이 구조를 이해하면 Puppeteer 코드가 왜 거의 전부 await인지 알 수 있습니다. page.click() 한 줄도 Node 프로세스가 WebSocket으로 명령을 보내고, 브라우저가 실행한 뒤 응답을 돌려주는 왕복입니다. 또 Node 코드와 페이지 안의 JavaScript는 서로 다른 프로세스에서 돕니다. page.evaluate()나 $$eval()에 넘긴 함수는 문자열로 직렬화되어 브라우저에서 실행되므로, 그 안에서는 Node의 변수나 모듈을 쓸 수 없고 돌려받을 수 있는 값도 JSON으로 직렬화 가능한 값뿐입니다. DOM 요소 자체를 반환하면 빈 객체가 오는 이유가 이것입니다.

Puppeteer vs Playwright vs Selenium

측면PuppeteerPlaywrightSelenium
개발사GoogleMicrosoft오픈소스
출시201720202004
프로토콜CDP, WebDriver BiDiCDP(Chromium) + 자체 프로토콜(Firefox·WebKit)WebDriver, WebDriver BiDi
브라우저Chrome·FirefoxChromium·Firefox·WebKit모든 주요 브라우저
API 스타일Promise 기반Promise 기반 + 테스트 러너언어별 바인딩
자동 대기Locator API에서 지원✅ 기본 동작명시적 대기 위주
병렬 테스트직접 구현✅ 러너 내장Grid로 구성
사용 사례Chrome 중심 자동화·PDF크로스 브라우저 E2E다양한 언어·기존 그리드

선택 기준:

  • Chrome 중심 자동화, 스크래핑, PDF 생성 → Puppeteer (가볍고 Chrome 기능에 빠르게 접근)
  • 크로스 브라우저 E2E 테스트 → Playwright (WebKit까지, 테스트 러너 통합)
  • Java·Python 등 여러 언어 팀, 기존 Selenium Grid → Selenium

두 도구의 API는 매우 비슷한데, Playwright의 초기 개발진 상당수가 Puppeteer를 만들던 사람들이기 때문입니다. 가장 체감되는 차이는 대기 방식입니다. Playwright는 click()이 요소가 보이고 활성화될 때까지 자동으로 기다리지만, Puppeteer의 전통적인 page.click(selector)는 그 순간 요소가 없으면 바로 실패합니다. Puppeteer도 v21부터 page.locator(selector).click() 형태의 Locator API로 자동 대기를 지원하므로, 새 코드에서는 Locator를 쓰는 편이 안정적입니다.


설치와 첫 스크립트

설치

npm install puppeteer

설치할 때 Puppeteer 버전에 맞는 테스트용 Chrome을 ~/.cache/puppeteer에 내려받습니다. CI나 Docker에서 캐시 경로가 매번 초기화되면 설치할 때마다 수백 MB를 다시 받으므로 캐시 디렉터리를 보존하거나, 이미지에 Chrome을 설치해 두고 puppeteer-core와 executablePath로 연결하는 방식이 많이 쓰입니다.

기본 예제

import puppeteer from 'puppeteer';
(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  const title = await page.title();
  console.log('Title:', title);
  await browser.close();
})();

이 코드에서 운영 시 가장 먼저 보강할 부분은 browser.close()의 위치입니다. goto나 title 중간에 예외가 나면 close()에 도달하지 못하고, 브라우저 프로세스가 남아 메모리를 계속 차지합니다. 서버에서 요청마다 브라우저를 띄우는 코드라면 이렇게 남은 Chrome 프로세스가 쌓여 결국 서버 메모리가 바닥납니다. try { ... } finally { await browser.close(); }로 감싸는 것이 기본입니다. 또 요청마다 launch()를 하면 브라우저 시작 비용이 매번 들기 때문에, 오래 사는 서비스에서는 브라우저 하나를 띄워 두고 요청마다 newPage()나 createBrowserContext()로 격리된 탭을 여는 방식이 훨씬 빠릅니다.


텍스트 추출과 스크래핑

텍스트 추출

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://news.ycombinator.com');
const titles = await page.$$eval('.titleline > a', (elements) =>
  elements.map((el) => el.textContent)
);
console.log(titles);
await browser.close();

$$eval은 셀렉터에 맞는 모든 요소를 찾아 두 번째 인자 함수에 배열로 넘기고, 그 함수는 브라우저 안에서 실행됩니다. 결과로 돌려주는 값은 직렬화되어 Node로 넘어오므로 문자열·숫자·일반 객체만 반환해야 합니다. Hacker News처럼 서버에서 HTML을 완성해 보내는 사이트는 사실 Puppeteer 없이 HTTP 요청과 HTML 파서로도 충분합니다. 이 예제는 API 사용법을 보여 주는 용도로 보면 됩니다.

복잡한 스크래핑

const products = await page.$$eval('.product', (elements) =>
  elements.map((el) => ({
    name: el.querySelector('.name')?.textContent,
    price: el.querySelector('.price')?.textContent,
    image: el.querySelector('img')?.src,
  }))
);
console.log(products);

요소마다 하위 요소를 찾을 때 옵셔널 체이닝(?.)을 쓰는 것은 일부 상품에 가격이나 이미지가 없을 때 전체가 예외로 멈추지 않게 하기 위해서입니다. TypeScript로 컴파일하면 querySelector('img')의 반환 타입이 HTMLImageElement로 추론되어 .src가 통과하지만, 클래스 셀렉터처럼 태그를 알 수 없는 경우는 Element로 추론되어 href나 value에 접근할 때 타입 에러가 납니다. querySelector<HTMLAnchorElement>('.link')처럼 제네릭으로 지정하면 됩니다. textContent는 공백과 줄바꿈을 그대로 포함하므로 .trim()을 붙이고, 가격처럼 숫자로 쓸 값은 통화 기호와 쉼표를 제거한 뒤 변환해야 합니다.

스크래핑이 실무에서 깨지는 가장 흔한 원인은 셀렉터가 사이트 개편으로 바뀌는 것입니다. 해시가 붙은 CSS 모듈 클래스(.Product_name__a1b2c)는 배포할 때마다 바뀌므로 피하고, data-testid나 의미 있는 구조(article h2)를 기준으로 삼는 편이 오래 버팁니다. 또 결과가 빈 배열이어도 에러가 나지 않으므로, 수집 건수가 0이면 스크린샷을 남기고 알림을 보내는 식의 감시를 두지 않으면 몇 주간 빈 데이터를 모으고 있었다는 사실을 늦게 알게 됩니다.


전체 페이지와 요소 스크린샷

전체 페이지

await page.goto('https://example.com');
await page.screenshot({
  path: 'screenshot.png',
  fullPage: true,
});

특정 요소

const element = await page.$('.header');
await element?.screenshot({ path: 'header.png' });

fullPage: true는 스크롤 높이 전체를 한 장에 담습니다. 기본 뷰포트는 800×600이라 반응형 사이트는 모바일에 가까운 레이아웃으로 찍힐 수 있으니 page.setViewport({ width: 1280, height: 800 })로 먼저 크기를 정하는 것이 좋습니다. 스크롤해야 이미지를 불러오는 지연 로딩(lazy loading) 페이지는 전체 페이지 스크린샷에서 아래쪽 이미지가 비어 나오는데, 이는 스크린샷이 스크롤을 흉내 내지 않기 때문입니다. 찍기 전에 page.evaluate로 페이지 끝까지 조금씩 스크롤하거나, 이미지의 loading 속성을 eager로 바꾸는 방법을 씁니다. 웹 폰트가 늦게 로드되면 대체 글꼴로 찍히므로 await page.evaluate(() => document.fonts.ready)로 폰트 로딩을 기다리는 것도 도움이 됩니다.


PDF 생성

await page.goto('https://example.com', {
  waitUntil: 'networkidle0',
});
await page.pdf({
  path: 'page.pdf',
  format: 'A4',
  printBackground: true,
  margin: {
    top: '20px',
    right: '20px',
    bottom: '20px',
    left: '20px',
  },
});

page.pdf()는 Chrome의 “인쇄 → PDF로 저장”과 같은 엔진을 쓰므로, 화면용 CSS가 아니라 인쇄용 CSS(@media print)가 적용됩니다. 화면에서는 멀쩡한데 PDF에서 레이아웃이 달라진다면 대개 이 차이 때문이며, page.emulateMediaType('screen')으로 화면용 스타일을 강제할 수 있습니다. printBackground: true가 없으면 배경색과 배경 이미지가 모두 빠집니다. 표가 페이지 사이에서 잘리는 문제는 CSS의 break-inside: avoid로, 페이지 번호는 displayHeaderFooter와 footerTemplate(<span class="pageNumber"></span>)으로 해결합니다.

waitUntil: 'networkidle0'은 “500ms 동안 네트워크 연결이 0개”가 될 때까지 기다리라는 뜻입니다. 이미지와 폰트까지 다 받은 뒤 PDF를 만들 수 있어 편하지만, 분석 스크립트나 웹소켓, 롱 폴링처럼 계속 연결을 유지하는 페이지에서는 조건이 영원히 충족되지 않아 기본 30초 뒤 TimeoutError: Navigation timeout of 30000 ms exceeded로 실패합니다. 제가 PDF 생성을 붙일 때 가장 자주 만난 에러가 이것이었는데, 템플릿 페이지에 들어 있던 채팅 위젯이 원인이었습니다. PDF 전용 템플릿에서는 외부 스크립트를 빼고, 필요하면 networkidle2(연결 2개 이하 허용)나 특정 요소 대기로 바꾸는 편이 안정적입니다. 서버에서 만든 HTML 문자열이라면 page.setContent(html, { waitUntil: 'load' })로 네트워크 없이 바로 렌더링할 수도 있습니다.


로그인과 파일 업로드 자동화

로그인

await page.goto('https://example.com/login');
await page.type('#email', '[email protected]');
await page.type('#password', 'password123');
await Promise.all([
  page.waitForNavigation(),
  page.click('button[type="submit"]'),
]);
console.log('Logged in!');

클릭과 네비게이션 대기를 Promise.all로 묶은 이유가 중요합니다. await page.click(...) 다음 줄에서 await page.waitForNavigation()을 호출하면, 페이지 이동이 빠른 경우 클릭이 끝나는 순간 이미 이동이 끝나 버려서 waitForNavigation이 다음 이동을 기다리다 30초 뒤 타임아웃됩니다. 로컬에서는 잘 되다가 빠른 서버나 캐시가 있는 환경에서만 실패하는 전형적인 경쟁 조건입니다. 대기를 먼저 걸어 두고 클릭하도록 Promise.all로 동시에 시작해야 이동을 놓치지 않습니다.

SPA의 로그인처럼 페이지 이동 없이 화면만 바뀌는 경우에는 waitForNavigation이 끝나지 않으므로, 로그인 후에만 나타나는 요소를 waitForSelector로 기다리거나 page.waitForResponse(res => res.url().includes('/api/login'))로 로그인 API 응답을 기다립니다. 성공 메시지를 찍기 전에 로그인 실패 메시지가 떴는지도 확인해야 합니다. 비밀번호 같은 자격 증명은 코드에 쓰지 말고 환경 변수에서 읽고, 로그인이 끝난 쿠키를 page.cookies()로 저장해 다음 실행에서 재사용하면 매번 로그인하지 않아도 됩니다.

파일 업로드

const fileInput = await page.$('input[type="file"]');
await fileInput?.uploadFile('./file.pdf');

uploadFile은 파일 선택 대화상자를 거치지 않고 <input type="file">에 파일을 직접 설정합니다. 경로는 Node 프로세스 기준이며, 파일이 없어도 즉시 에러가 나지 않는 경우가 있으니 경로를 먼저 확인하는 것이 좋습니다. 드래그 앤 드롭만 지원하고 input이 숨겨져 있는 업로드 UI라면 page.waitForFileChooser()와 버튼 클릭을 Promise.all로 묶어 대화상자를 가로채는 방식을 씁니다.


waitForSelector, waitForNavigation, 커스텀 대기

waitForSelector

await page.waitForSelector('.content', {
  visible: true,
  timeout: 5000,
});

waitForNavigation

await Promise.all([
  page.waitForNavigation(),
  page.click('a.link'),
]);

커스텀 대기

await page.waitForFunction(
  () => (document.querySelector('.data')?.textContent?.length ?? 0) > 0
);

동적 페이지 자동화의 안정성은 거의 전적으로 대기 전략에 달려 있습니다. 가장 나쁜 방법은 고정 시간 대기입니다. 2초를 기다리면 빠른 환경에서는 시간을 낭비하고 느린 환경에서는 여전히 실패합니다(참고로 예전의 page.waitForTimeout()은 v22에서 제거되었습니다). 기다려야 할 조건을 코드로 표현하는 것이 원칙입니다.

waitForSelector는 요소가 DOM에 생길 때까지, visible: true를 주면 화면에 보이는 상태(크기가 있고 visibility: hidden이 아님)까지 기다립니다. 로딩 스피너가 사라지는 것을 기다리려면 hidden: true를 씁니다. waitForFunction은 임의의 조건을 브라우저에서 반복 평가하므로 “목록에 항목이 20개 이상” 같은 조건도 표현할 수 있습니다. 위 예제는 요소가 없을 때 undefined > 0 비교가 TypeScript에서 에러가 나지 않도록 ?? 0으로 기본값을 줍니다. 타임아웃이 나면 기본 에러 메시지만으로는 원인을 알기 어려우므로, catch에서 page.screenshot()과 page.content()를 저장해 두면 “셀렉터가 바뀌었는지”, “봇 차단 페이지가 떴는지”를 바로 확인할 수 있습니다.


뉴스 목록 스크래핑 예제

import puppeteer from 'puppeteer';
import fs from 'fs/promises';
async function scrapeNews() {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://news.ycombinator.com');
  const articles = await page.$$eval('.athing', (elements) =>
    elements.slice(0, 10).map((el) => {
      const titleEl = el.querySelector('.titleline > a');
      const scoreEl = el.nextElementSibling?.querySelector('.score');
      return {
        title: titleEl?.textContent || '',
        url: titleEl?.href || '',
        score: scoreEl?.textContent || '0',
      };
    })
  );
  await fs.writeFile('news.json', JSON.stringify(articles, null, 2));
  console.log(`Scraped ${articles.length} articles`);
  await browser.close();
}
scrapeNews();

Hacker News는 글 제목 행(.athing)과 점수·작성자 행이 형제 <tr>로 나뉘어 있어서 nextElementSibling으로 다음 행을 찾아 점수를 읽습니다. 채용 공고처럼 점수가 없는 글도 있어 기본값 '0'을 둡니다. TypeScript에서는 titleEl이 Element로 추론되어 .href에서 타입 에러가 나므로 querySelector<HTMLAnchorElement>로 지정해야 컴파일됩니다.

여러 페이지를 반복해서 긁는 코드로 확장할 때는 대상 사이트에 부담을 주지 않는 것이 중요합니다. 요청 사이에 간격을 두고, 동시에 여는 페이지 수를 제한하고, robots.txt와 이용약관을 확인해야 합니다. 이미지·폰트가 필요 없다면 page.setRequestInterception(true)로 해당 리소스 요청을 중단시켜 속도와 트래픽을 크게 줄일 수 있습니다. 헤드리스 브라우저는 navigator.webdriver 등으로 탐지될 수 있어 일부 사이트는 캡차나 빈 페이지를 돌려주는데, 이런 차단을 우회하는 것은 대개 이용약관 위반이므로 공식 API나 데이터 제공 방식을 먼저 찾는 것이 맞습니다.


Puppeteer 요약

Puppeteer는 실제 Chrome을 코드로 조작하는 도구라, 사람이 브라우저로 할 수 있는 일은 거의 다 자동화할 수 있습니다. 대신 브라우저 비용이 크고 페이지 변화에 취약하므로, HTTP 요청만으로 되는 일에는 쓰지 않는 것이 좋습니다. 실패의 대부분은 대기 문제(고정 sleep, 클릭 후 네비게이션 대기 순서, networkidle0 타임아웃)와 자원 정리 문제(browser.close() 누락)에서 나오므로, 조건 기반 대기와 try/finally를 기본 습관으로 들이면 안정성이 크게 올라갑니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Playwright와 비교하면 어떤가요?

A. Playwright는 WebKit까지 지원하는 크로스 브라우저 도구이고 자동 대기와 테스트 러너가 통합되어 E2E 테스트에 편합니다. Puppeteer는 Chrome 팀이 만들어 CDP 기능에 가깝게 접근할 수 있고, 스크래핑이나 PDF 생성 같은 단일 자동화 스크립트에 가볍게 쓰기 좋습니다.

Q. 합법적인가요?

A. 도구 자체는 합법이지만, 스크래핑 대상 사이트의 이용약관, robots.txt, 개인정보 관련 법규를 확인해야 합니다. 로그인이 필요한 데이터나 개인정보를 수집하는 경우 특히 주의가 필요합니다.

Q. Headless 모드가 뭔가요?

A. 화면에 창을 띄우지 않고 실행하는 모드로, 서버나 CI처럼 디스플레이가 없는 환경에서 씁니다. 디버깅할 때는 puppeteer.launch({ headless: false, slowMo: 50 })로 창을 띄우고 동작을 느리게 하면 어디서 막히는지 눈으로 확인할 수 있습니다.

Q. Puppeteer로 PDF·스크린샷 서비스를 운영할 때 주의할 점은?

A. 브라우저 프로세스 관리(재사용, 종료, 메모리 누수 감시)와 동시 실행 수 제한을 직접 설계해야 하므로, 요청마다 브라우저를 띄우는 구조는 피하는 것이 좋습니다.