레거시 화면 이관 QA를 Playwright로 자동화한 과정
레거시 화면을 새 앱으로 옮기면서 가장 어려웠던 건 화면을 옮기는 일 자체가 아니었다.
옮긴 화면이 예전과 똑같이 동작한다는 걸 증명하는 일이 훨씬 어려웠다.
이번 글에서는 레거시 기능을 빠짐없이 테스트 케이스(TC)로 만들고, headless 브라우저가 TC를 하나하나 직접 눌러 보며 영상으로 증거를 남기도록 QA 체계를 만든 과정을 정리했다.
결과: 출고 도메인 5개 화면의 레거시 기능을 TC 1,073개로 자동 검증하고, 결함 54건을 찾았다. B2B 출고작업 화면 하나에서만 사람이 눌러 볼 때 놓쳤던 결함 8건을 머지 전에 고쳤다.
왜 만들었나
우리 팀은 창고관리시스템(WMS) 프론트엔드를 레거시 Next.js 단일 레포에서 Next.js 모노레포로 옮기고 있다. 단순히 저장소 구조만 바꾸는 작업은 아니다. Next.js 버전을 올리고, 쌓여 있던 레거시 코드를 리팩토링하고, 새로 구축한 디자인 시스템을 적용해서 화면 전반에 일관되고 훨씬 개선된 사용자 경험을 제공하는 것이 목표다.
사용자가 일하는 흐름은 그대로 두되 사용성은 크게 끌어올리는 작업이다 보니, 화면 구성과 컴포넌트는 대부분 새로 만들어진다. 그만큼 기존 기능이 하나라도 빠지거나 다르게 동작할 위험도 커진다. 출고 화면 하나에도 검색 조건이 십여 개, 액션 버튼이 예닐곱 개, 모달 안의 편집 기능까지 들어 있다. 사람이 매번 눌러 보며 확인하면 빠뜨리는 게 생겼고, 무엇을 확인했는지 기록도 남지 않았다.
그래서 규칙을 하나 정했다. 레거시 기능을 빠짐없이 TC로 적고, TC마다 브라우저가 직접 눌러 보게 하고, 그 장면을 영상으로 남긴다는 것이었다. 여기서 확인하는 대상은 화면이 예전과 똑같이 생겼는지가 아니라, 같은 업무를 같은 결과로 처리할 수 있는지다.
한 줄로 요약하면
레거시 기능을 TC로 만들고, TC 하나를 Playwright 테스트 하나로 작성해서, 화면 없는(headless) Chrome이 실제 화면을 조작하게 했다. 결과는 TC별 영상과 함께 Confluence에 자동으로 올라가고, 코드가 바뀌면 push할 때와 개발 서버에 배포된 뒤에 다시 검증한다.
핵심 원칙은 1 TC = 1 test = 1 영상이다. TC 문서의 OUT-B2B-215, 테스트 제목의 OUT-B2B-215, 영상 파일 OUT-B2B-215.mp4가 모두 같은 ID로 묶인다. 문서에만 있고 테스트가 없는 TC는 push 단계에서 걸러지도록 했다.
지금까지 출고 도메인 5개 화면에 1,073개의 TC를 만들었다.
| 화면 | TC 수 |
|---|---|
| B2B 출고작업 생성/조회 | 267 |
| 출고조회 | 257 |
| B2C 출고작업 생성/조회 | 255 |
| 총량피킹 작업조회 | 158 |
| Cart 관리 | 136 |
| 합계 | 1,073 |
전체 흐름
위 줄의 세 단계는 사람이 화면 PR에 함께 작성하고, 아래 줄의 세 단계는 명령 두 개로 자동으로 돌아간다.

문서에서 시작해 영상 증거로 끝나는 한 방향 흐름이다. 중간에 사람이 결과를 옮겨 적는 단계가 없도록 했다.
headless 브라우저는 무엇을 기반으로 도나
테스트 러너는 Playwright Test(@playwright/test)를 사용했고, 설정 파일 playwright.config.ts 하나가 실행 방식을 정한다.
| 구성 | 어떻게 했나 |
|---|---|
| 브라우저 | Chromium을 화면 없이 띄웠다. 창 크기는 1920×1080, 시간대는 Asia/Seoul로 고정했다. |
| 로그인 | 준비용 프로젝트가 테스트 계정으로 한 번 로그인해서 쿠키를 storageState 파일로 저장한다. 나머지 테스트는 그 세션을 재사용해서 로그인 화면을 건너뛴다. |
| 대상 서버 | 기본은 개발 서버다. 아직 머지되지 않은 코드는 로컬 Next.js 서버를 띄워 화면만 바꾸고, API는 개발 서버를 그대로 사용했다. |
| 테스트 데이터 | 테스트가 API로 자기 주문을 직접 만들고, 자기 데이터로만 확인한 뒤 끝나면 취소한다. 개발 서버는 여러 사람이 같이 쓰는 환경이기 때문이다. |
| 응답 바꾸기 | 서버 오류, 빈 결과, 경계값처럼 실제로 만들기 어려운 상황은 page.route로 네트워크 응답을 가로챘다. 라벨 프린터(Zebra)도 로컬 포트를 흉내 내도록 했다. |
| 실행 순서 | 고정 테스트 계정 하나를 공유하기 때문에 병렬로 돌리지 않고 workers: 1로 한 개씩 돌렸다. |
즉 목(mock) 서버가 아니라 실제 백엔드에 실제 데이터를 만들어서 돌린다. 응답을 바꾸는 건 재현하기 어려운 예외 상황뿐이다.
모든 테스트에 자동으로 붙는 감시 장치
테스트 코드에 따로 적지 않아도, 화면이 깨지면 실패로 판정되도록 했다. Playwright의 fixture 기능으로 공통 감시 장치를 만들고 auto: true로 모든 테스트에 켜 두었다. 감시 장치가 실패로 보는 건 세 가지다.
- 화면 스크립트 오류(
pageerror) - 서버 5xx 응답
- 번역 키 노출. 번역 문구가 빠지면
outbound.b2bList.title같은 키가 화면에 그대로 보인다.
일부러 오류를 내는 테스트는 qa.allow(/패턴/)으로 그 오류만 허용했다.
감시 장치는 영상에 표시도 그린다. addInitScript로 모든 페이지에 작은 스크립트를 넣어서, 화면 위쪽에 OUT-B2B-215 · 2. 만료 행 클릭 같은 자막을 띄우고 클릭한 자리에 빨간 동그라미를 그린다. 그래서 영상만 봐도 어느 TC의 몇 번째 단계인지 바로 알 수 있다.

화면마다 추가하는 것
화면 하나를 옮길 때마다 아래 네 가지 파일을 같은 PR에 넣었다.
| 파일 | 역할 |
|---|---|
qa/screens.json 항목 |
화면 등록부다. 라우트, 소스 폴더, 스펙 폴더, Confluence 페이지, 그리고 어떤 파일이 바뀌면 어떤 스펙을 다시 돌릴지(impact 규칙)를 적는다. |
qa/inventory/<ID>.md |
레거시 기능 목록이다. 항목마다 레거시 코드와 신규 코드의 파일:라인을 달고, 동일·차이·누락을 표시한다. |
qa/tc/<ID>.md |
TC 표다. 기능·예외·UI·사용성·동등성 관점으로 나누고, 끝에 찾은 버그를 적는다. |
tests/web/<도메인>/<화면>/*.spec.ts |
기능 묶음별 테스트 파일이다. |
테스트는 TC 표를 그대로 옮긴 모양이다. 제목은 TC ID로 시작하고, 단계는 TC 표의 문구를 그대로 tcStep에 넣었다. 이 문구가 그대로 영상 자막이 된다.
test("OUT-B2B-215 유통기한 만료 상품 선택 불가", async ({ page }) => {
await tcStep(page, "1. 유통기한 2020-01-01 재고 mock", () =>
mockStock(page, { sellYmd: "2020-01-01" }), // page.route로 응답 바꾸기
);
await openCreate(page);
await chooseSeller(page);
await tcStep(page, "2. 만료 행 클릭", () => stockRow(page, PRIMARY).click());
await expectNotice(page, "유통기한이 출고 가능 최소일 보다 작은 경우 선택이 불가합니다.");
});
여기서 꼭 지킨 원칙이 하나 있다. 앱에 결함이 있으면 테스트를 고쳐서 통과시키지 않았다. 기대결과대로 확인해서 실패로 두고 bug 주석을 달았다. 그래야 보고서에 결함으로 남기 때문이다.
녹화하고 보고서를 만드는 과정
명령 두 개로 녹화부터 Confluence 게시까지 끝나도록 만들었다.
pnpm qa:run OUT-B2B # 화면 전체를 녹화 모드로 실행
pnpm qa:report OUT-B2B # 결과를 Confluence 본문으로 만들고 게시 스크립트 생성
- 녹화 실행.
E2E_EVIDENCE=1을 켜고 Playwright를 돌린다. 모든 테스트를 1600×900으로 녹화하고, 사람이 따라볼 수 있게 동작마다 250ms씩 늦췄다(slowMo). 실패하면 한 번 재시도한다. - 영상 정리. JSON 결과에서 TC ID를 읽어 webm을 mp4로 변환한다. Confluence에서 바로 재생되게 하기 위해서다. 녹화 시작 직후 빈 페이지가 흰 화면으로 남는 문제가 있었는데, ffmpeg로 색을 반전시킨 뒤
blackdetect를 돌려 그 구간을 찾아 잘라냈다. - 결과 기록. TC별 통과·실패와 실행 커밋을
summary.json에 남긴다. - 보고서 생성. TC 표, 실행 결과, 결함 목록(
defects.json)을 합쳐 화면·도메인·개요 페이지 본문을 만든다. 실패한 TC는 Jira 결함 티켓과 연결해서 보여준다. - 게시. 로그인된 브라우저에서 Confluence REST API로 본문과 영상을 올린다. 같은 크기의 영상은 다시 올리지 않고, 사람이 페이지를 직접 고쳤다면 덮어쓰지 않고 멈추도록 했다.
이렇게 게시된 도메인 페이지는 아래와 같은 모습이다. 화면별 TC 결과가 한 표로 모이고, 화면 이름을 누르면 TC별 결과와 영상으로 이어진다.

바뀐 코드만 다시 돌리기
화면 하나를 녹화하는 데 20~45분이 걸린다. 그래서 코드가 조금 바뀌었을 때 모든 화면을 다시 돌리지 않고, 바뀐 파일과 관련된 스펙만 골라 돌리도록 했다.
화면 등록부의 impact 규칙은 “이 경로가 바뀌면 이 스펙을 다시 돌린다”는 목록이다.
{
"paths": ["apps/web/src/widgets/picking-worker-assignment/"],
"specs": ["c-worker-assignment.spec.ts"]
}
main 이후 바뀐 파일을 이 규칙에 대어 다시 돌릴 범위를 계산한다.

여러 화면이 같이 쓰는 공용 위젯은 쓰는 화면마다 규칙을 적었다. 그래서 작업자 할당 위젯을 고치면 B2B와 B2C의 작업자 할당 스펙이 함께 돌아간다. pnpm qa:run --changed를 실행하면 이 계산대로 필요한 스펙만 골라 돌린다.
세 군데서 자동으로 확인했다
사람이 깜빡해도 빠지지 않도록, 검증을 하지 않으면 다음 단계로 넘어가지 못하게 만들었다.

push 전 검사는 화면 등록부에 적힌 마지막 검증 기록(verified)을 본다. 이 기록이 main과 같다면 “코드는 바꿨는데 다시 돌리지 않았다”는 뜻이므로 push를 막는다. 마지막 실행이 바뀐 범위를 덮지 못하는 경우에도 막도록 했다.
지금까지의 결과
현재 출고 도메인 5개 화면에서 1,073개 TC를 실행해 954개가 통과하고 116개가 실패했으며, 3개는 환경 제약으로 보류했다. 통과율은 88.9%이다. 발견한 결함은 54건이고 그중 심각도 높음이 4건이라, 출고 도메인은 “결함 수정 후 재검증 필요”로 판정해 두었다. 높음 결함이 해결되면 관련 TC를 다시 돌려 통과를 확인할 예정이다. 입고, 재고/상품, 그 외 도메인은 순서대로 진행한다.
결과 판정 기준은 단순하게 세 가지로 나눴다.
| 통과 | 기대결과와 일치 |
|---|---|
| 실패 | 기대결과와 다름. 결함 티켓과 연결 |
| 보류 | 환경 제약으로 자동 실행 불가. 사유 기록 |
TC를 설계할 때는 다섯 가지 관점으로 나눴다.
| 기능 | 조회·생성·변경·출력 등 업무 동작과 호출 API |
|---|---|
| 예외 | 서버 오류·빈 결과·잘못된 입력·경계값에서의 동작 |
| UI | 레이아웃·해상도별 잘림·번역 문구·다이얼로그 표시 |
| 사용성 | 키보드 흐름·포커스·중복 클릭 방지·처리 중 상태 |
| 동등성 | 레거시와 같은 요청 값·같은 표시·같은 규칙 |
새 화면을 이관할 때는 이관한 개발자가 Claude Code에서 /migration-qa를 실행해 TC 작성부터 녹화·게시까지 마치도록 했고, push 전에 pnpm check:qa가 QA 기록을 확인한다.
실제로 써 보니
B2B 출고작업 화면 하나에서만 사람이 눌러 볼 때 놓쳤던 앱 결함 8건을 찾아 머지 전에 고쳤다. 대표적인 것들은 다음과 같다.
- 출고수량 입력칸이 한 글자 칠 때마다 포커스를 잃어 두 자리 수를 입력할 수 없었다. 표 컬럼 정의가 렌더링마다 새로 만들어지면서 입력칸이 다시 그려진 것이 원인이었다.
- 날짜 변경 확인창에서 취소해도 입력칸에는 새 날짜가 남아, 화면에 보이는 값과 저장되는 값이 달랐다.
- 피킹 생성 완료 알림이 바로 뜨는 슬립 창 아래에 깔려 보이지 않았다.
테스트 자체의 함정도 있었다.
- 로컬 실행에서만 깨지는 테스트. 언어 전환 쿠키를 개발 서버 도메인에만 넣고 있어서, localhost에서 돌리면 언어가 바뀌지 않았다. 쿠키 주소를 실행 주소(
baseURL) 기준으로 바꿔 해결했다. - 구현 세부에 기대는 선택자. 접근성을 개선하려고 입력칸 이름을
<span>에서<label>로 바꿨더니,span을 찾던 다른 화면의 테스트 4개가 깨졌다. 테스트는 가능하면 역할(role)과 라벨로 요소를 찾는 게 안전하다는 걸 배웠다. - 동시 실행 시의 타이밍. 녹화는 동작을 늦춰서 돌리기 때문에, 두 화면을 동시에 녹화하면 일부가 시간 초과로 실패했다. 일반 실행에서 통과하면 “테스트 코드 문제”로 사유를 기록하도록 했다.
아직 남은 과제도 있다. 도메인 요약 페이지를 게시하는 브랜치의 등록부만으로 다시 만들다 보니, 두 브랜치가 각자 화면을 게시하면 나중에 올린 쪽이 앞의 화면을 지워 버린다. 다른 브랜치의 등록부도 합쳐서 만들도록 고칠 계획이다.
마치며
이 체계를 만들고 나서 가장 크게 바뀐 건 “확인했다”는 말의 무게였다. 예전에는 누가 어디까지 눌러 봤는지 알 수 없었지만, 지금은 TC 번호 하나로 문서와 코드와 영상을 바로 찾아볼 수 있게 되었다.
주변에 설명할 때는 이렇게 두 가지로 이야기했다.
- 비개발자에게: 옮긴 화면이 예전 화면과 똑같이 동작하는지, 로봇 브라우저가 기능 하나하나를 직접 눌러 보고 영상으로 남긴다.
- 개발자에게: 레거시 기준 TC를 Playwright E2E로 1:1 매핑했다. 공통 fixture가 화면 오류·서버 5xx·번역 키 노출을 잡고, impact 규칙으로 바뀐 범위만 다시 돌린다. push 전 검사와 배포 후 회귀 실행으로 빠짐없이 검증한다.
다음 단계로는 입고·재고 도메인 화면에도 같은 방식을 적용할 예정이다. 비슷하게 대규모 화면 이관을 하고 계신 분들께 조금이나마 참고가 되었으면 좋겠다.
