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를 많이 씁니다. fetch나 axios로 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