Puppeteer로 브라우저 자동화 시작하기

Puppeteer로 브라우저 자동화 시작하기

웹 페이지 스크린샷을 자동으로 찍어야 할 때, HTML을 PDF로 변환해야 할 때, 또는 특정 사이트에서 데이터를 수집해야 할 때 어떻게 하시나요? 브라우저를 직접 열어서 하나하나 수동으로 처리하고 계신다면 이제 Puppeteer에게 맡겨보세요.

Puppeteer는 Google에서 만든 Node.js 라이브러리로 Chrome 브라우저를 코드로 제어할 수 있게 해줍니다. 2017년에 처음 공개된 이후 GitHub 스타 9만 3천 개 이상을 받았고, 브라우저 자동화 하면 가장 먼저 떠오르는 도구가 되었는데요. 페이지를 열고 클릭하고 입력하는 것부터 스크린샷이나 PDF 만들기, 데이터 수집까지 브라우저에서 손으로 할 수 있는 거의 모든 걸 자동화할 수 있습니다.

이 글에서는 Puppeteer의 설치부터 핵심 API와 실전 활용법까지 차근차근 살펴보겠습니다.

설치

Puppeteer는 두 가지 패키지로 나뉩니다. puppeteer는 Chrome 브라우저를 함께 다운로드해주는 올인원 패키지이고, puppeteer-core는 브라우저 없이 라이브러리만 설치하는 가벼운 패키지입니다.

처음 시작한다면 puppeteer를 설치하는 게 편합니다. 별도로 브라우저를 준비할 필요 없이 바로 사용할 수 있거든요.

bun add puppeteer
# 또는
npm install puppeteer

설치하면 호환되는 Chrome 바이너리가 자동으로 다운로드됩니다. 이미 시스템에 설치된 Chrome을 쓰고 싶거나 Docker 환경처럼 브라우저를 직접 관리하는 경우에는 puppeteer-core를 선택하면 됩니다.

bun add puppeteer-core
# 또는
npm install puppeteer-core

puppeteer-core를 사용할 때는 브라우저 실행 경로를 직접 지정해야 합니다.

import puppeteer from "puppeteer-core";

const browser = await puppeteer.launch({
  executablePath: "/usr/bin/google-chrome",
});

기본 구조 이해하기

Puppeteer의 핵심 개념은 간단합니다. Browser를 실행하고, 그 안에 Page(탭)를 열고, 페이지에서 원하는 작업을 수행하는 흐름이에요.

import puppeteer from "puppeteer";

// 브라우저 실행
const browser = await puppeteer.launch();

// 새 탭 열기
const page = await browser.newPage();

// 페이지 이동
await page.goto("https://example.com");

// 작업 수행...

// 브라우저 종료
await browser.close();

이 패턴이 Puppeteer로 작업할 때의 기본 뼈대입니다. 브라우저를 열고, 탭에서 작업하고, 끝나면 브라우저를 닫는 세 단계를 항상 거치게 됩니다. browser.close()를 빠뜨리면 Chrome 프로세스가 백그라운드에 남아서 메모리를 잡아먹으니 꼭 닫아주세요.

스크린샷 찍기

Puppeteer로 가장 많이 하는 작업이 스크린샷입니다. 웹 페이지를 열고 screenshot() 메서드 하나면 이미지 파일이 뚝딱 나옵니다.

import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto("https://example.com", { waitUntil: "networkidle2" });

// 보이는 영역만 캡처
await page.screenshot({ path: "viewport.png" });

// 페이지 전체를 캡처 (스크롤 포함)
await page.screenshot({ path: "fullpage.png", fullPage: true });

await browser.close();

waitUntil: "networkidle2"는 네트워크 요청이 2개 이하로 줄어들 때까지 기다린다는 뜻입니다. 동적으로 콘텐츠를 로드하는 페이지에서 스크린샷이 비어 있는 문제를 방지할 수 있어요.

특정 요소만 캡처하고 싶다면 해당 요소를 선택해서 스크린샷을 찍을 수도 있습니다.

const element = await page.waitForSelector("h1");
await element.screenshot({ path: "heading.png" });

스크린샷 형식은 기본적으로 PNG인데, JPEG로 바꾸면서 품질을 조절할 수도 있습니다.

await page.screenshot({
  path: "compressed.jpg",
  type: "jpeg",
  quality: 80,
});

요소 선택과 데이터 추출

웹 페이지에서 데이터를 가져오려면 DOM 요소를 선택하고 내용을 읽어야 합니다. 가장 기본이 되는 건 CSS 셀렉터예요.

import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("https://example.com");

// 단일 요소에서 텍스트 가져오기
const title = await page.$eval("h1", (el) => el.textContent);
console.log(title);

// 여러 요소에서 데이터 가져오기
const links = await page.$$eval("a", (els) =>
  els.map((el) => ({
    text: el.textContent,
    href: el.href,
  })),
);
console.log(links);

await browser.close();

$eval()은 첫 번째 매칭 요소에 함수를 실행하고, $$eval()은 모든 매칭 요소에 함수를 실행합니다. 여기서 주의할 점은 콜백 함수가 브라우저 컨텍스트에서 실행된다는 것입니다. 그래서 Node.js 쪽 변수를 직접 참조할 수 없고, DOM API만 사용할 수 있어요.

좀 더 복잡한 데이터 추출이 필요하면 evaluate()를 사용하면 됩니다.

const data = await page.evaluate(() => {
  return {
    title: document.title,
    url: window.location.href,
    metaDescription: document
      .querySelector('meta[name="description"]')
      ?.getAttribute("content"),
  };
});

CSS 셀렉터 외에도 Puppeteer는 텍스트, ARIA 속성, XPath로 요소를 찾을 수 있는 커스텀 셀렉터를 제공합니다.

// 텍스트로 요소 찾기
const button = await page.waitForSelector("::-p-text(로그인)");

// ARIA 속성으로 요소 찾기
const searchInput = await page.waitForSelector("::-p-aria(검색)");

// XPath로 요소 찾기
const heading = await page.waitForSelector("::-p-xpath(//h2)");

폼 입력과 클릭

로그인 자동화나 데이터 입력 같은 작업에서는 폼을 다루는 게 핵심이겠죠. 텍스트 입력부터 버튼 클릭, 드롭다운 선택까지 다 됩니다.

import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("https://example.com/login");

// 텍스트 입력
await page.type("#username", "myuser");
await page.type("#password", "mypass");

// 버튼 클릭
await page.click('button[type="submit"]');

// 페이지 이동 대기
await page.waitForNavigation();

await browser.close();

type() 메서드는 실제로 키보드 입력을 시뮬레이션합니다. delay 옵션을 주면 사람이 타이핑하는 것처럼 한 글자씩 천천히 입력할 수도 있어요.

await page.type("#search", "puppeteer tutorial", { delay: 100 });

드롭다운(<select>)은 select() 메서드로 값을 선택합니다.

// 단일 선택
await page.select("#country", "KR");

// 다중 선택
await page.select("#languages", "ko", "en", "ja");

체크박스나 라디오 버튼은 click()으로 처리합니다.

await page.click("#agree-terms");
await page.click('input[name="plan"][value="pro"]');

대기 전략

브라우저 자동화에서 가장 까다로운 부분이 타이밍입니다. 페이지가 아직 로딩 중인데 요소를 찾으려고 하면 실패하거든요. Puppeteer는 이를 위해 여러 대기 메서드를 제공합니다.

waitForSelector()는 특정 요소가 DOM에 나타날 때까지 기다립니다.

// 요소가 나타날 때까지 대기
const element = await page.waitForSelector(".loaded-content", {
  visible: true,
  timeout: 5000,
});

// 로딩 스피너가 사라질 때까지 대기
await page.waitForSelector(".loading-spinner", { hidden: true });

페이지 이동을 기다릴 때는 waitForNavigation()을 사용하는데, 클릭과 함께 사용할 때 주의가 필요합니다. 클릭 후에 waitForNavigation()을 호출하면 이미 이동이 끝나버려서 타임아웃이 날 수 있거든요. Promise.all()로 동시에 실행해야 안전합니다.

await Promise.all([
  page.waitForNavigation({ waitUntil: "networkidle2" }),
  page.click("a.next-page"),
]);

특정 조건이 충족될 때까지 기다리고 싶으면 waitForFunction()을 씁니다.

// 목록 아이템이 10개 이상 로드될 때까지 대기
await page.waitForFunction(
  () => document.querySelectorAll(".item").length > 10,
);

API 응답을 기다리는 것도 가능합니다.

const response = await page.waitForResponse(
  (res) => res.url().includes("/api/data") && res.status() === 200,
);
const data = await response.json();

PDF 생성

HTML을 PDF로 변환하는 것도 Puppeteer가 잘하는 영역입니다. 보고서 자동 생성이나 영수증 발행 같은 곳에서 유용하게 쓰입니다.

import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto("https://example.com/report", {
  waitUntil: "networkidle2",
});

await page.pdf({
  path: "report.pdf",
  format: "A4",
  printBackground: true,
  margin: {
    top: "20mm",
    right: "20mm",
    bottom: "20mm",
    left: "20mm",
  },
});

await browser.close();

printBackground: true를 빼면 배경색이나 배경 이미지가 PDF에 포함되지 않으니 주의하세요.

HTML 문자열로 직접 PDF를 만들 수도 있습니다. URL 없이 동적으로 콘텐츠를 만들어야 할 때 편리합니다.

await page.setContent(`
  <h1>월간 보고서</h1>
  <p>2026년 3월 실적 요약</p>
  <table>
    <tr><th>항목</th><th>수치</th></tr>
    <tr><td>매출</td><td>1,200만원</td></tr>
    <tr><td>방문자</td><td>45,000명</td></tr>
  </table>
`);

await page.pdf({ path: "monthly-report.pdf", format: "A4" });

머리글과 바닥글도 넣을 수 있습니다.

await page.pdf({
  path: "document.pdf",
  format: "A4",
  displayHeaderFooter: true,
  headerTemplate:
    '<div style="font-size: 10px; text-align: center; width: 100%;">기밀 문서</div>',
  footerTemplate: `
    <div style="font-size: 10px; text-align: center; width: 100%;">
      <span class="pageNumber"></span> / <span class="totalPages"></span>
    </div>
  `,
  margin: { top: "40mm", bottom: "40mm" },
});

<span class="pageNumber"><span class="totalPages">는 Puppeteer가 자동으로 페이지 번호로 치환해주는 특수 클래스입니다.

웹 스크래핑

웹 스크래핑에도 Puppeteer를 많이 씁니다. fetchaxios로 HTML만 가져오는 것과 달리 JavaScript로 렌더링되는 동적 페이지에서도 데이터를 뽑을 수 있다는 게 큰 장점이에요.

간단한 예제로 Hacker News의 상위 기사를 수집해보겠습니다.

import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.setViewport({ width: 1280, height: 720 });
await page.goto("https://news.ycombinator.com", {
  waitUntil: "networkidle2",
});

const stories = await page.$$eval(".titleline > a", (links) =>
  links.map((link) => ({
    title: link.textContent,
    url: link.href,
  })),
);

console.log(stories.slice(0, 5));
await browser.close();
결과
[
  { title: 'Show HN: ...', url: 'https://...' },
  { title: 'Why ...', url: 'https://...' },
  ...
]

여러 페이지를 돌면서 데이터를 모으는 것도 자연스럽게 할 수 있습니다.

const allStories = [];

for (let i = 0; i < 3; i++) {
  const pageStories = await page.$$eval(".titleline > a", (links) =>
    links.map((link) => ({
      title: link.textContent,
      url: link.href,
    })),
  );
  allStories.push(...pageStories);

  // "More" 링크를 클릭해서 다음 페이지로 이동
  const moreLink = await page.$(".morelink");
  if (moreLink) {
    await Promise.all([page.waitForNavigation(), moreLink.click()]);
  }
}

console.log(`총 ${allStories.length}개의 기사를 수집했습니다.`);

스크래핑할 때 뷰포트 크기를 설정하는 건 중요합니다. 반응형 웹사이트는 뷰포트 크기에 따라 다른 레이아웃을 보여주기 때문에 모바일용 페이지가 렌더링되면 원하는 셀렉터를 찾지 못할 수 있거든요.

Headless vs Headed 모드

Puppeteer는 기본적으로 headless 모드로 실행됩니다. 브라우저 창이 보이지 않아서 서버 환경이나 CI/CD에서 쓰기에 적합합니다. 그런데 개발하거나 디버깅할 때는 브라우저 창을 직접 보면서 작업하는 게 훨씬 편하죠.

// 기본: headless 모드 (창이 안 보임)
const browser = await puppeteer.launch();

// headed 모드 (창이 보임)
const browser = await puppeteer.launch({ headless: false });

// headed + 느리게 실행 (디버깅용)
const browser = await puppeteer.launch({
  headless: false,
  slowMo: 250, // 각 동작 사이에 250ms 대기
});

slowMo 옵션은 모든 Puppeteer 동작 사이에 지연 시간을 넣어줍니다. 브라우저에서 어떤 일이 벌어지고 있는지 눈으로 확인하면서 디버깅할 때 매우 유용해요.

Puppeteer에는 headless 모드가 두 종류 있다는 것도 알아두면 좋습니다. 기본 headless 모드는 일반 Chrome과 동일한 코드로 동작해서 기능이 완전하고, "shell" 모드는 별도의 경량 바이너리를 사용해서 성능이 더 빠릅니다.

// 고성능 headless 모드 (기능 일부 제한)
const browser = await puppeteer.launch({ headless: "shell" });

PDF 생성처럼 빠른 처리가 중요한 작업에서는 "shell" 모드가 유리할 수 있지만, 일반적인 용도에서는 기본 headless 모드를 쓰는 것이 안전합니다.

Locator API

Puppeteer 최근 버전에서 도입된 Locator API는 요소 인터랙션을 더 안정적으로 만들어줍니다. 기존에는 waitForSelector()로 요소를 찾고 click()으로 클릭하는 두 단계를 거쳐야 했는데, Locator는 요소가 보이고 활성화된 상태가 될 때까지 자동으로 기다려줍니다.

// 기존 방식: 직접 대기 + 클릭
const button = await page.waitForSelector("button.submit");
await button.click();

// Locator 방식: 자동 대기 + 클릭
await page.locator("button.submit").click();

// 입력도 마찬가지
await page.locator("input#email").fill("user@example.com");

Locator를 사용하면 요소가 아직 렌더링되지 않았거나 다른 요소에 가려져 있는 경우에도 자동으로 재시도해주기 때문에 테스트나 자동화 스크립트의 안정성이 높아집니다.

실전 팁

Puppeteer를 실무에서 쓸 때 알아두면 좋은 팁 몇 가지를 정리해봤습니다.

여러 페이지를 순회할 때는 매번 새 탭을 여는 것보다 기존 페이지를 재사용하는 게 좋습니다. 탭을 계속 열면 메모리가 계속 쌓이거든요.

// 비권장: 매번 새 탭
for (const url of urls) {
  const page = await browser.newPage();
  await page.goto(url);
  // ... 작업 ...
  await page.close();
}

// 권장: 탭 하나를 재사용
const page = await browser.newPage();
for (const url of urls) {
  await page.goto(url);
  // ... 작업 ...
}
await page.close();

페이지 로딩 속도가 중요하다면 요청 가로채기를 활용해보세요. 이미지나 폰트 같은 리소스를 차단하면 속도가 크게 빨라집니다.

await page.setRequestInterception(true);
page.on("request", (request) => {
  const resourceType = request.resourceType();
  if (["image", "stylesheet", "font"].includes(resourceType)) {
    request.abort();
  } else {
    request.continue();
  }
});

기본 User-Agent에는 “HeadlessChrome”이라는 문자열이 들어 있어서 일부 사이트에서 자동화로 감지되어 차단될 수 있습니다. User-Agent를 바꿔주면 이 문제를 피할 수 있어요.

await page.setUserAgent(
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
);

네트워크 문제나 타임아웃은 언제든 터질 수 있으니 try-catch로 감싸고 finally에서 브라우저를 닫아주는 습관을 들이세요.

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.goto("https://example.com");
  // ... 작업 ...
} catch (error) {
  console.error("자동화 중 오류 발생:", error.message);
} finally {
  await browser.close();
}

Playwright와 비교

브라우저 자동화를 이야기할 때 빠질 수 없는 게 Playwright입니다. 재미있는 건 Playwright를 만든 팀이 원래 Puppeteer를 만들던 사람들이라는 거예요. Google에서 Microsoft로 옮기면서 새로 만든 거죠.

두 도구의 가장 큰 차이는 브라우저 지원 범위입니다. Puppeteer는 Chrome과 Firefox를 지원하고, Playwright는 여기에 WebKit(Safari 엔진)까지 추가로 지원합니다. 크로스 브라우저 테스트가 중요하다면 Playwright가 유리합니다.

언어 지원도 다릅니다. Puppeteer는 JavaScript/TypeScript 전용이지만, Playwright는 Python, Java, C#도 지원합니다. Node.js 환경에서만 작업한다면 크게 상관없지만, 다른 언어 팀과 협업할 때는 Playwright 쪽이 선택지가 넓어요.

반면 Puppeteer는 Chrome에 특화된 만큼 Chrome DevTools Protocol을 직접 사용하는 작업에서는 더 세밀한 제어가 가능합니다. 네트워크 모니터링이나 성능 프로파일링 같은 Chrome 고유 기능을 활용할 때 강점이 있고, 간단한 스크립트 작성에는 Puppeteer가 가볍고 빠릅니다.

프로젝트 성격에 따라 선택하면 됩니다. E2E 테스트 프레임워크가 필요하면 Playwright, Chrome 브라우저 자동화나 빠른 스크립팅이 목적이면 Puppeteer가 잘 맞습니다. 더 자세한 비교는 agent-browser 포스팅에서도 다루고 있으니 참고해보세요.

마치며

Puppeteer는 입문 도구로도 실무 도구로도 훌륭합니다. Chrome을 코드로 다룬다는 개념만 잡으면 스크린샷이든 PDF든 스크래핑이든 금방 구현할 수 있어요.

puppeteer.launch()로 브라우저를 열고 page.goto()로 이동하는 기본 흐름, screenshot()pdf()로 콘텐츠를 캡처하는 법, $eval()evaluate()로 데이터를 뽑는 법, 그리고 waitForSelector()나 Locator로 안정적인 자동화를 만드는 법까지 살펴봤습니다. 이 정도면 웬만한 브라우저 자동화 작업은 처리할 수 있을 겁니다.

한 가지 더, 요즘은 Puppeteer 위에 AI를 얹어서 자연어로 브라우저를 제어하는 시도도 활발합니다. 관심이 있다면 Playwright MCP도 읽어보세요. 브라우저 자동화가 어디까지 갈 수 있는지 감이 올 겁니다.

더 자세한 API 레퍼런스는 Puppeteer 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord