이채강

읽는 데 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 에서는 testexpect로 시나리오를 만든다.

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 를 붙인 순서는 이랬다.

  1. Chromium 과 Playwright Test 를 설치했다
  2. getByRole·getByLabel로 사람이 보는 이름을 따라갔다
  3. 고정 sleep 대신 locator 와 assertion 의 자동 대기를 썼다
  4. E2E 시험은 localhost·worker 1개·실패 trace 보존으로 정리했다
  5. 같은 문법으로 로그인·입력·업로드·미리보기 같은 관리 작업을 맡겼다
  6. screenshot·trace·로그인/비로그인 검증으로 보이지 않는 작업의 증거를 남겼다
  7. 공개·삭제 같은 위험한 단추는 자동화의 경계 밖에 두었다

그리고 배운 것 하나.

화면 없는 브라우저의 장점은 사람 몰래 움직이는 데 있지 않았다. 사람이 계속 지켜보지 않아도 같은 절차를 반복하고, 멈춰야 할 때 정확히 멈추는 데 있었다.

댓글

첫 댓글을 남겨보세요.

이름 20자, 댓글 1000자까지

← 글 목록으로