읽는 데 7분조회 6
Playwright 브라우저 자동화 도입기 — 화면 없이 시험하고 관리 작업까지 맡기기
버전 1.0.0
작성 기준: 2026-09-20
브라우저 시험이라고 하면 먼저 떠오르는 모습이 있었다. Chrome 창이 뜨고, 마우스가 움직이고, 사람이 그 앞에서 화면을 지켜보는 모습이다. Playwright 를 쓰고 나서는 그 생각이 바뀌었다.
창은 뜨지 않는데 Chromium 은 실제로 페이지를 연다. 로그인하고, 입력칸을 채우고, 버튼을 누르고, 파일을 올리고, 결과 화면을 확인한다. 실패하면 어느 단계에서 무엇을 보고 있었는지도 남긴다.
MyHome 에서는 이 브라우저를 E2E 시험에만 쓰지 않았다. 블로그 글을 관리 화면으로 올리고, 미리보기를 검사하고, 나만 보기 상태까지 확인하는 작업도 맡겼다. 시험 코드와 운영 작업이 같은 문법으로 움직이는 점이 특히 마음에 들었다.
이 글은 Playwright 를 MyHome 에 붙여 시험과 작업에 함께 써 본 기록이다.
1. 화면이 없어도 브라우저는 브라우저다
Playwright 는 Chromium·Firefox·WebKit 을 코드로 다루는 도구다. 기본 시험은 headless 로 돈다. 브라우저 창을 화면에 띄우지 않는다는 뜻이지, 브라우저를 흉내 낸다는 뜻은 아니다.
flowchart LR
A[Node.js 스크립트] --> B[Playwright]
B --> C[headless Chromium]
C --> D[실제 웹 페이지]
D -->|DOM · HTTP · screenshot · trace| B
JavaScript 실행, cookie, redirect, CSS, 네트워크 요청을 실제 브라우저가 처리한다. 창만 없으니 서버의 CI 에서도 돌고, 사람이 다른 일을 하는 동안에도 작업을 끝낸다.
설치는 두 단계다.
npm install -D @playwright/test
npx playwright install chromium
시험은 그대로 실행한다.
npx playwright test
평소에는 창이 뜨지 않는다. 눈으로 따라가야 할 때만 --headed 또는 --ui 를
붙인다.
npx playwright test --headed
npx playwright test --ui
나는 기본을 headless 로 두고, 실패를 재현할 때만 화면을 켜는 쪽이 맞았다.
2. 좌표가 아니라 사람이 보는 이름으로 찾는다
처음에는 브라우저 자동화라기에 화면의 좌표를 찍는 도구를 떠올렸다. Playwright 는 그 반대에 가깝다. 버튼의 역할, 입력칸의 라벨, 화면에 보이는 글자를 기준으로 요소를 찾는다.
await page.getByLabel("비밀번호").fill(password);
await page.getByRole("button", { name: "로그인", exact: true }).click();
await page.getByRole("heading", { name: "받은 메시지" }).waitFor();
getByRole·getByLabel 같은 locator 는 사용자가 화면을 읽는 방식과 가깝다.
DOM 의 깊은 CSS 경로보다 화면 구조가 조금 바뀌어도 덜 깨지고, 접근성 이름이
잘못된 화면도 함께 드러난다.
MyHome 로그인에는 exact: true 가 꼭 필요했다. 로그인만 찾으면
패스키로 로그인까지 함께 잡힐 수 있었다. locator 가 엄격해서 귀찮은 것이
아니라, 사람이 보아도 헷갈릴 선택을 코드가 먼저 지적한 셈이다.
Playwright locator 문서는 역할과 라벨 같은 사용자에게 보이는 속성을 먼저 쓰라고 권한다. 실제로 오래 버틴 시험도 그렇게 쓴 코드였다.
3. 기다리는 코드가 줄었다
예전 브라우저 자동화 예제에는 sleep(3000) 같은 줄이 자주 있었다. 서버가
3초 안에 답할 것이라는 희망을 코드에 박는 방식이다. 빠르면 쓸데없이 기다리고,
느리면 그대로 깨진다.
Playwright 의 action 은 누르기 전에 대상이 하나인지, 보이는지, 움직임이 멎었는지, 다른 요소에 가리지 않았는지, 사용할 수 있는지를 확인한다. 조건이 맞을 때까지 기다리다가 제한시간을 넘기면 실패한다.
await page.getByRole("button", { name: "이미지 올리기" }).click();
const markdown = page.locator("code", { hasText: "/uploads/" }).first();
await markdown.waitFor({ timeout: 30_000 });
이미지를 줄이고 저장하는 시간이 매번 달라도 "몇 초 쉰다"가 아니라 결과가 나올 때까지 기다린다. assertion 도 같은 방식으로 조건을 다시 확인한다.
await expect(page.getByRole("heading", { name: title })).toBeVisible();
await expect(page).toHaveURL(/\/admin\/posts\/\d+/);
자동 대기 문서를 읽고 나서 내가 직접 넣은 고정 대기 대부분을 걷어 냈다.
4. 시험과 작업이 같은 문법이다
Playwright Test 에서는 test와 expect로 시나리오를 만든다.
import { expect, test } from "@playwright/test";
test("관리 화면을 연다", async ({ page }) => {
await page.goto("/admin/monitoring");
await expect(page.getByRole("heading", { name: "감시" })).toBeVisible();
await expect(page.locator("iframe")).toHaveAttribute("src", /grafana/);
});
일회성 작업에서는 browser 와 page 를 직접 만든다.
import { chromium } from "@playwright/test";
const browser = await chromium.launch();
const page = await browser.newPage({ locale: "ko-KR" });
await page.goto("https://leechis.dev/admin");
await page.getByLabel("제목").fill("글 제목");
await page.getByLabel("본문").fill(body);
await page.getByRole("button", { name: "미리보기" }).click();
await browser.close();
둘 다 page.goto, locator, click, fill, screenshot 을 쓴다. 시험에서 익힌
문법으로 관리 작업을 만들고, 작업 중에 발견한 화면의 약한 부분을 다시
시험으로 남길 수 있다.
이번 글도 그렇게 올렸다. 관리자 로그인, 제목·요약·태그·본문 입력, 미리보기 검사, 나만 보기 저장, 비로그인 404 확인을 headless Chromium 이 끝까지 수행했다.
5. MyHome 시험 설정에서 고른 것
현재 MyHome 은 Playwright 1.63.0 을 쓰고 있다. 설정의 중요한 부분은 이렇다.
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:40002";
export default defineConfig({
testDir: "./tests/e2e",
workers: 1,
fullyParallel: false,
timeout: 60_000,
expect: { timeout: 10_000 },
reporter: process.env.CI ? "github" : "list",
use: {
baseURL,
locale: "ko-KR",
timezoneId: "Asia/Seoul",
trace: "retain-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
});
운영 주소 대신 localhost
기본 시험 대상은 dev.leechis.dev가 아니라 localhost:40002다. Cloudflare 를
한 겹 지나면 같은 시험이 네트워크 상태에 따라 다른 곳에서 깨졌다. 앱을
시험하는데 CDN 지연까지 섞을 이유가 없었다.
127.0.0.1 대신 localhost인 데도 까닭이 있다. WebAuthn 의 rpID 는 도메인을
받지만 IP 주소는 거절한다. 패스키 시험까지 실제 브라우저로 끝내려면
localhost가 필요했다.
worker 는 하나
Playwright 는 원래 여러 시험을 병렬로 돌릴 수 있다. MyHome 은 시험이 같은 PostgreSQL DB 에 글을 만들고 지우므로 worker 를 하나로 제한했다. 빠르게 돌리는 것보다 서로의 데이터를 밟지 않는 쪽을 골랐다.
실패한 trace 만 보관
trace: "retain-on-failure"로 두었다. 성공한 시험의 기록은 버리고, 깨진
시험만 남긴다. trace 에는 action, DOM snapshot, screenshot, console,
network 요청이 시간순으로 들어간다.
npx playwright show-trace path/to/trace.zip
Trace Viewer 문서를 보면 실패 당시 브라우저를 다시 재생하듯 훑을 수 있다. CI 에서만 깨지는 시험을 잡을 때 터미널의 한 줄 오류보다 훨씬 많은 것을 말해 준다.
6. 보이지 않는 작업도 증거를 남긴다
headless 의 약점은 사람이 진행 화면을 보고 있지 않다는 점이다. 대신 screenshot 과 trace 를 남긴다.
await page.screenshot({
path: "/tmp/preview.png",
fullPage: true,
});
블로그 글을 올릴 때는 저장 전에 미리보기로 바꾸고 제목·코드블록·표 개수를 세었다. 그다음 전체 화면을 찍었다.
const rendered = await page.evaluate(() => ({
h2: document.querySelectorAll("main h2").length,
pre: document.querySelectorAll("main pre").length,
table: document.querySelectorAll("main table").length,
}));
화면이 없다는 것은 확인할 수 없다는 뜻이 아니다. 사람이 계속 보고 있지 않아도, 필요한 순간의 화면과 브라우저 내부 상태를 더 촘촘하게 남길 수 있다.
7. 오늘 자동화가 세 번 멈춘 까닭
이번 CI/CD 글의 제목을 고치는 작업에서도 Playwright 가 몇 번 멈췄다.
첫 번째는 로그인 비밀번호 칸이 접혀 있었기 때문이다. 보이지 않는 입력칸을
그냥 채우려 하니 실패했다. 먼저 비밀번호로 로그인을 눌러 펴도록 고쳤다.
두 번째는 로그인 성공 화면을 소개 관리라고 잘못 짚었기 때문이다. 실제
화면의 제목은 받은 메시지였다. 기다릴 대상을 정확히 고쳤다.
세 번째는 새 글과 기존 글의 단추가 달랐기 때문이다. 새 글은 나만 보기,
기존 글은 저장이었다. Playwright 는 비슷한 단추를 마음대로 누르지 않고
30초 뒤 멈췄다.
처음에는 답답해 보였지만 결과는 반대였다. 잘못된 화면에서 엉뚱한 단추를 누르지 않았고, 글 공개 상태도 바꾸지 않았다. 실패가 조용히 잘못된 작업으로 이어지는 것보다 크게 멈추는 편이 낫다.
8. 자동화할수록 경계를 먼저 정한다
브라우저로 할 수 있는 일은 사람이 할 수 있는 일과 거의 같다. 그래서 시험 환경과 실제 작업의 경계를 코드보다 먼저 정해야 했다.
내가 둔 규칙은 이렇다.
- 비밀번호는
.env.local에서 읽고 코드·출력·스크린샷에 남기지 않는다 - 새 글은 언제나
나만 보기로 저장한다 - 공개·삭제·글 내리기는 사람이 명시적으로 요청하지 않으면 누르지 않는다
- 같은 제목의 글이 있으면 새로 만들지 않고 중단한다
- 작업 뒤에는 로그인 상태의 화면과 비로그인 상태의 404를 함께 확인한다
- 임시 스크립트와 초안은
/tmp에 두고 끝나면 지운다
headless 는 사람이 안 본다는 이유로 안전한 것이 아니다. 오히려 사람처럼 로그인하고 누를 수 있으니 권한과 종료 조건을 더 분명히 적어야 한다.
9. 언제 화면을 켜는가
headless 로 충분하지 않은 때도 있다.
- 처음 locator 를 잡을 때
- animation·drag·canvas처럼 움직임을 봐야 할 때
- 실패 원인을 trace 만으로 찾지 못했을 때
- 사람이 느끼는 속도와 화면 전환을 확인할 때
그때는 --headed, --ui, slowMo를 쓴다.
const browser = await chromium.launch({
headless: false,
slowMo: 100,
});
평소에는 보이지 않게 빠르게 돌리고, 조사할 때만 화면을 켠다. 둘 중 하나만 고를 필요가 없다는 것도 Playwright 가 편한 이유였다.
정리
Playwright 를 붙인 순서는 이랬다.
- Chromium 과 Playwright Test 를 설치했다
getByRole·getByLabel로 사람이 보는 이름을 따라갔다- 고정 sleep 대신 locator 와 assertion 의 자동 대기를 썼다
- E2E 시험은 localhost·worker 1개·실패 trace 보존으로 정리했다
- 같은 문법으로 로그인·입력·업로드·미리보기 같은 관리 작업을 맡겼다
- screenshot·trace·로그인/비로그인 검증으로 보이지 않는 작업의 증거를 남겼다
- 공개·삭제 같은 위험한 단추는 자동화의 경계 밖에 두었다
그리고 배운 것 하나.
화면 없는 브라우저의 장점은 사람 몰래 움직이는 데 있지 않았다. 사람이 계속 지켜보지 않아도 같은 절차를 반복하고, 멈춰야 할 때 정확히 멈추는 데 있었다.
댓글
첫 댓글을 남겨보세요.