목차
- 변경 전과 변경 후, 한눈에
- 3월: Selenium 스위트가 무너지던 자리
- 4월 초: Playwright를 고른 이유
- 4월 중순: 첫 2주의 시행착오
- 결정적이었던 3가지 차이
- 5월 말: CI 통합과 안정성 변화
- 6월: 옮기지 않은 70개
- 회고 — 다음 프로젝트라면
selenium playwright 비교는 벤치마크 글 몇 개 읽어서는 결정이 안 됐다. 그래서 240개짜리 Selenium 스위트를 3개월에 걸쳐 Playwright로 옮겨봤다. 평균 18분 걸리던 CI는 9분대로 줄었고, 주 2~3회 터지던 flaky 실패는 한 자릿수로 떨어졌다.
결국, 다 옮긴 건 아니다. 마지막 70여 개는 결국 Selenium에 그대로 남겼다. 그 결정에 이르기까지의 흐름을 시간 순서로 풀어본다.
변경 전과 변경 후, 한눈에
3개월 전 상태와 지금 상태를 먼저 숫자로 본다. 환경은 M2 MacBook Pro + Python 3.12, CI는 GitHub Actions (ubuntu-latest, 4코어 가상 머신).
| 항목 | 변경 전 (Selenium 4.15) | 변경 후 (Playwright 1.41 → 1.49) |
|---|---|---|
| 평균 CI 실행 시간 | 약 18분 | 약 9분 |
| 주간 flaky 실패 | 2~3회 | 0~1회 |
| 전체 테스트 수 | 240 (Selenium) | 170 (Playwright) + 70 (Selenium 잔존) |
| iframe 3단 중첩 처리 코드 | 평균 12~15줄 | 평균 3~4줄 |
| 실패 디버깅 | 스크린샷 + 콘솔 로그 | trace viewer (DOM + 네트워크 + 액션) |
| 도커 이미지 | 자체 빌드 | mcr.microsoft.com/playwright/python |
수치는 우리 프로젝트 기준이고, 다른 팀에서 같은 결과가 나온다는 보장은 없다. 그래도 이 글에서 일관된 비교 축이 필요해 명시한다.
3월: Selenium 스위트가 무너지던 자리
물론, 이 무렵 운영하던 어드민 대시보드는 React 18 기반 SPA였다. 그 위에 E2E 테스트가 240개. selenium-wire, webdriver-manager, pytest-selenium을 얹어 5년 가까이 쌓아온 코드였다.
결국, 문제는 두 가지였다. 첫째, 느렸다. CI 한 번에 18분. 둘째, 불안정했다. StaleElementReferenceException이 일주일에 두어 번씩 다른 위치에서 터졌다. 같은 셀렉터, 같은 시퀀스인데도 어제는 통과하고 오늘은 실패한다. 재시도 로직을 두 번 감싸도 잡히지 않는 케이스가 누적됐다.
특히 React 18로 올린 뒤로 동시성 렌더링 때문에 WebDriverWait을 길게 잡아도 타이밍 문제가 줄지 않았다. 명시적 time.sleep(0.5)을 박는 건 패배다. 그렇다고 안 박으면 통과를 못 한다. 이 사이에서 셀렉터별로 잡음을 다는 작업이 한 달 가까이 누적됐다.
그래서, CI 한 번 돌리는 비용이 점점 무거워지자 PR이 쌓이기 시작했다. "이번 PR은 flaky 한 번만 만나도 30분 손해" 같은 메모가 채널에 돌았다. 더는 못 버틸 시점이었다. 도구 자체를 갈아엎는 의사결정으로 넘어간 게 4월 초다.
4월 초: Playwright를 고른 이유
그러나, 당시 후보는 셋이었다. Cypress 13, Playwright 1.41, Selenium 4 유지 + 안정화. 평가 기준은 네 가지로 좁혔다. 실행 속도, 셀렉터 안정성, iframe/멀티 탭 대응, 디버깅 도구.
| 기준 | Selenium 4 유지 | Cypress 13 | Playwright 1.41 |
|---|---|---|---|
| Python 1급 지원 | O | X (JS만) | O |
| auto-wait 내장 | △ (수동 wait 위주) | O | O |
| 멀티 origin/탭 | O | △ | O |
| 디버깅 도구 | 외부 의존 | UI 모드 | trace viewer |
| 학습 곡선 | 낮음 (사용중) | 중간 | 중간 |
Cypress는 DX가 좋다는 평이 많다. 단, 멀티 origin과 멀티 탭 흐름이 까다롭다는 점은 그대로였다 (작성 시점 기준, Cypress 공식 문서 — Web Security 참고). 우리는 SSO 단계에서 외부 도메인을 거치는 케이스가 많아 후순위로 밀렸다.
Selenium 4 유지가 비용은 가장 낮다. 그런데 어차피 React 18 동시성 렌더링과 안 맞는 게 핵심 문제라 본질이 안 풀린다.
예를 들어, Playwright는 Python 바인딩이 1급 시민이고 auto-wait이 셀렉터 단계에 박혀 있다는 점이 결정적이었다. 백엔드 코드도 pytest로 묶고 싶었던 우리 팀 사정에 맞았다. JS만 쓰는 팀이었다면 Cypress도 진지하게 봤을 거다.
4월 중순: 첫 2주의 시행착오
처음에는 페이지 객체 패턴을 그대로 옮길 줄 알았다. 결과는 절반만 맞았다.
Selenium 시절 find_element(By.CSS_SELECTOR, "...") 패턴은 Playwright locator()로 거의 1:1로 옮겨졌다. 진짜 문제는 대기 패턴이었다. Selenium의 WebDriverWait + expected_conditions 패턴을 그대로 옮기면 안 됐다.
# 옮기지 말아야 할 패턴 — Selenium 스타일을 그대로 가져옴
locator = page.locator("button.submit")
# 이중 대기. 굳이 추가
page.wait_for_selector("button.submit", state="visible")
locator.click()
물론, Playwright locator는 액션을 호출하는 시점에 자체적으로 대기한다. visible + stable + enabled를 모두 보장하고 동작한다. 명시적 wait을 그대로 옮기면 이중 대기가 돼 오히려 느려진다.
# Playwright다운 패턴
from playwright.sync_api import expect
page.locator("button.submit").click() # auto-wait이 알아서
expect(page.locator(".toast.success")).to_be_visible()
따라서, 첫 주에 옮긴 30여 개가 위 이유로 느려졌다. 두 번째 주 코드 리뷰에서 패턴이 잡혀 일괄 정리했다. 이 시기에 가장 많이 들은 말이 "wait 빼라"였다.
또 하나, 셀렉터 전략을 같이 바꿨다. Selenium에서는 xpath나 긴 CSS 선택자가 주력이었다. Playwright에서는 get_by_role, get_by_label, get_by_text 같은 의미 기반 로케이터가 권장된다 (Playwright 공식 문서 — Locators). 옮기는 김에 셀렉터 자체를 다시 짠 케이스가 절반쯤 됐다.
결정적이었던 3가지 차이
결국, 마이그레이션 6주차쯤 되니 어느 게 진짜 차이를 만들었는지 보였다. 셋이다.
auto-wait이 만든 안정성
그러나, 가장 컸다. Selenium에서는 클릭 직전 요소가 "보이지만 인터랙션 불가능" 상태일 때가 있다. 애니메이션 중이거나 부모가 transition 중일 때다. Playwright click()은 visible + stable + enabled를 모두 확인하고 동작한다.
이 차이 하나로 StaleElementReferenceException 류 오류가 거의 사라졌다. flaky 실패 빈도가 떨어진 가장 큰 이유다. 우리 팀에서는 이 효과만으로 마이그레이션 비용을 정당화할 수 있다고 봤다.
iframe 처리
그래서, 내부 대시보드에는 결제 모듈과 보안 토큰 모듈이 각각 iframe으로 들어가 있다. 거기에 또 iframe이 중첩되는 케이스가 있었다. 깊이가 3이 되면 Selenium 코드가 12~15줄로 늘어났다.
# Selenium — frame switch 매번 수동
driver.switch_to.frame("payment-outer")
driver.switch_to.frame("payment-inner")
driver.find_element(By.CSS_SELECTOR, "input[name=card]").send_keys("4242...")
driver.switch_to.default_content()
# Playwright — frame_locator 체이닝
frame = page.frame_locator("#payment-outer").frame_locator("#payment-inner")
frame.locator("input[name=card]").fill("4242...")
# 빠져나오기 같은 거 없음
한편, (개인적으로 frame_locator 체이닝은 Selenium으로 못 돌아가게 만든 결정적 차이다.) iframe 코드가 1/3 수준으로 줄었고, 깊이가 늘어나도 가독성이 무너지지 않는다.
trace viewer가 줄여준 디버깅 시간
따라서, CI에서 실패한 테스트를 로컬에서 재현하는 건 늘 비쌌다. Selenium 시절에는 스크린샷 한 장과 로그를 보고 추측해야 했다. 운 없으면 한 케이스 디버깅에 두세 시간이 갔다.
그래서, Playwright의 trace viewer는 실패 직전 상태를 DOM 스냅샷, 네트워크 요청, 콘솔, 액션 로그까지 묶어 보여준다. CI에서 trace 파일을 아티팩트로 받고, 로컬에서 playwright show-trace trace.zip으로 열면 된다.
결국, 체감상 실패 원인 파악 시간이 절반 이하로 줄었다. 특정 셀렉터가 왜 못 찾았는지, 그 시점 DOM이 어떻게 생겼는지를 바로 확인할 수 있어서다 (Playwright 공식 문서 — Trace Viewer).
5월 말: CI 통합과 안정성 변화
그래서, GitHub Actions에서 Playwright는 공식 도커 이미지를 그대로 쓸 수 있다. 브라우저 바이너리가 이미지 안에 포함돼 있어 매번 다운로드 시간이 사라진다.
jobs:
e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright/python:v1.49.0-jammy
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements-test.txt
# 실패한 케이스만 trace 남김. 모든 케이스에 남기면 아티팩트가 너무 커진다
- run: pytest tests/e2e --tracing=retain-on-failure
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: test-results/
그러나, 전체 실행 시간은 평균 9분대로 안정됐다. 18분에서 절반이다. 절반은 auto-wait 덕에 명시적 sleep을 다 뺀 결과로 보인다. 나머지 절반은 Playwright의 CDP 기반 IPC가 Selenium의 WebDriver HTTP 라운드트립보다 빠른 영향이 있는 것 같다 (정확한 분해는 못 했다).
flaky 실패는 주 2~3회에서 주 0~1회로 떨어졌다. 완전히 0이 아닌 이유는 외부 결제 sandbox API 의존성 때문이다. 이건 도구 문제가 아니라 테스트 설계 문제로 봐야 한다. 외부 의존 케이스는 별도 잡으로 분리하고 야간에만 돌리는 식으로 정리 중이다.
물론, trace 아티팩트 용량이 누적된다는 게 운영상 새로 등장한 부담이다. 30일 retention을 7일로 줄이고, 통과한 PR의 trace는 즉시 삭제하는 워크플로우를 따로 만들었다.
6월: 옮기지 않은 70개
따라서, 처음 계획은 240개 전체였다. 결국 170개만 옮기고 70개는 Selenium에 남겼다. 이유는 셋이다.
그래서, 첫째, IE11 호환 모드를 강제하는 사내 레거시 페이지. Playwright는 IE를 지원하지 않는다. Edge IE 모드는 Selenium IE Driver로만 자동화가 가능하다. 사내에서만 쓰는 신청서 시스템 일부가 여기 걸렸다. 이건 페이지 자체가 단계적으로 폐기 예정이라 굳이 옮길 동기가 약했다.
둘째, 특정 보안 모듈(예전 ActiveX 후속)이 들어간 페이지 1종. Selenium에서도 자동화가 반쯤 깨져 있었고, Playwright로 옮기는 비용이 페이지 1종을 위해 일주일 들이는 수준이었다. 보류했다.
그러나, 셋째, 단순 페이지인데 이미 잘 도는 50여 개. 굳이 건드릴 이유가 없었다. 풍선효과처럼 옮기는 작업이 새 버그를 만들 위험이 더 컸다.
그런데, CI 파이프라인은 둘로 쪼갰다. Playwright 잡과 Selenium 잡을 병렬로 돌린다. 전체 시간은 Selenium 잔존 잡(약 6분)이 결정한다. Playwright 잡만 따로 돌리면 4분대다. 잔존분이 줄어들수록 전체 파이프라인이 짧아지는 구조가 됐다.
회고 — 다음 프로젝트라면
그런데, 지금 다시 같은 결정을 한다면 첫 2주의 시행착오를 줄일 수 있을 거다. Selenium 패턴을 그대로 옮기지 않는다. trace 설정을 첫날에 잡는다. 도커 이미지를 처음부터 쓴다. 이 셋이 가장 큰 학습이었다.
당장 적용 가능한 액션 세 가지를 적어둔다.
- 기존 Selenium 스위트의 평균 실행 시간과 주간 flaky 실패 횟수를 먼저 측정해라. 비교 기준이 있어야 마이그레이션 ROI가 보인다. 측정 없이 시작하면 끝나고도 "더 좋아졌나?" 답을 못 한다.
- 작은 페이지 5~10개를 한 주 안에 Playwright로 옮겨봐라. auto-wait,
frame_locator, trace viewer 셋이 본인 프로젝트에서 차이를 만드는지 직접 확인하는 게 가장 빠르다. 책상에서 결정하지 마라. - 옮기지 않을 영역(레거시, IE 강제, 보안 모듈)을 처음부터 구분해라. 전체 이주를 목표로 잡으면 마지막 20%에서 시간이 무한정 늘어진다. 70%만 옮기는 게 답일 때도 있다.
한편, 다만 Playwright의 trace 아티팩트 누적 용량과 도커 이미지의 GitHub Actions 캐시 전략은 작성 시점(2026-06) 기준으로 우리 팀에서도 더 지켜봐야 한다.
관련 글
- Make 자동화 사용법 완전정복 — Zapier 대신 Make를 고른 이유 – Zapier 요금 폭탄을 맞고 Make로 갈아탄 기록이다. 시나리오 빌더가 처음엔 낯설지만, Operation 단위 과금이 작업 수가 많아…
- Celery Redis 비동기 작업 큐 실전 가이드 — 설치, 태스크, 재시도, Flower까지 – 프론트엔드에서 백엔드로 넘어온 시점에 가장 먼저 마주친 게 비동기 작업 큐였다. Celery와 Redis 조합을 처음 세팅하는 사람을 위해…
- n8n 사용법 설치부터 GitHub·슬랙 자동화까지 (실전 메모) – GitHub Actions로 짜던 알림 자동화를 n8n으로 옮기면서 알게 된 것들을 정리한다. Docker 설치 옵션, Webhook UR…