LLM이 만든 QA를 LLM 없는 폐쇄망에서 돌리기: aqa-runner 제작 기록

고객사명과 내부 호스트명은 가렸습니다. 케이스 수 등 숫자는 실제 값입니다.
요약
버전을 올릴 때마다 같은 QA 시나리오를 사람이 손으로 다시 돌리는 게 아까웠습니다. 처음엔 "우리가 쓸 Claude 스킬 하나"로 작게 시작했고, 써 보니 쓸 만해서 "이럴 거면 고객사 폐쇄망에서도 돌게 하자"로 범위를 넓혔습니다. 그 결과가 aqa-runner입니다. LLM이 만든 테스트 케이스를, LLM도 npm도 인터넷도 없는 곳에서 zip 하나 풀고 더블클릭으로 실행합니다. 이 글은 무엇을 보고 만들기로 했는지, 어디서 범위가 바뀌었는지, 무엇을 빼고 무엇을 남겼는지, 사람 손에 쥐어 줬을 때 뭐가 터졌는지를 순서대로 적은 기록입니다.
01 — 매 릴리스마다 같은 것을 손으로 눌렀다
AIPub은 릴리스 주기가 짧지 않지만, 버전이 올라갈 때마다 확인해야 하는 시나리오는 거의 같습니다. 로그인한다, 프로젝트를 만든다, 이미지를 올린다, 워크로드를 띄운다, 권한 없는 계정으로 같은 화면에 들어가 본다. 케이스 하나는 길어야 1~2분입니다.
문제는 케이스 하나의 길이가 아니었습니다. QA에 들어가기 전에 "이번엔 누가 돌리지" 를 정해야 한다는 것, 그 사람의 반나절을 비워 둬야 한다는 것, 그리고 그 사람이 이전 릴리스 때와 똑같은 순서로 똑같은 버튼을 누르게 된다는 것이었습니다. 한 번은 짧아도 릴리스마다 반복되면 그건 공수입니다. 그리고 반복되는 순간 사람은 집중을 잃고, 그 틈으로 놓치는 게 생깁니다.
"이거 사람이 안 해도 되는 일 아닌가"는 아마 QA를 해 본 모든 개발자가 한 번쯤 한 생각일 겁니다. 저도 그 생각을 했고, 이번엔 그냥 만들어 보기로 했습니다.
02 — 일단 Claude 스킬 하나로
거창한 계획은 없었습니다. 사내 Claude Code 플러그인(ten-toolkit)에 스킬을 하나 추가했습니다. 이름은 aqa-inspect.
하는 일은 단순합니다. Figma 디자인이나 실제 띄워진 URL을 주면 LLM이 화면을 보고 테스트 케이스를 뽑아 cases.yaml에 적고, 그걸 브라우저로 실행하고, 케이스별 결과를 results.csv와 HTML 리포트로 남깁니다. 실패한 케이스는 aqa-jira 스킬이 사람 승인을 거쳐 Jira 티켓으로 올립니다.
실행 엔진은 둘
케이스는 자연어입니다. "로그인 후 프로젝트 생성 버튼이 보여야 한다" 같은 문장이고, 셀렉터는 사람이 적지 않습니다. 이 문장을 실제 클릭으로 바꾸는 엔진을 실행할 때 고릅니다.
playwright 엔진을 쓰려면 케이스를 만들 때 각 스텝에 op를 함께 붙입니다. op는 operation의 줄임말로, goto·fill·click처럼 브라우저가 수행할 동작 하나를 가리키는 고정 단어입니다. 실행할 때 문장을 해석하는 대신 이 단어만 보고 움직이기 때문에 LLM이 필요 없고, 결과도 매번 같습니다.
browser-use | playwright | |
|---|---|---|
동작 | 스크린샷을 LLM이 보고 스텝마다 해석 | 생성 단계에서 각 스텝에 붙여 둔 |
장점 | 셀렉터가 바뀌어도 버팀. 설정이 거의 없음 | 결정적이고 빠름. 실행 루프에 LLM 없음 |
단점 | 스텝마다 LLM 호출이라 느림. Python CLI 별도 설치 | 생성 시 |
맞는 자리 | 처음 보는 화면, 자주 바뀌는 UI | 안정된 화면, 반복 실행 |
두 번째 실행부터는 다르게
같은 케이스를 같은 화면에 두 번째 돌릴 때 LLM이 같은 버튼을 또 찾게 두는 건 낭비입니다. 그래서 첫 실행이 찾아낸 요소 위치를 cases.yaml에 되써 넣고(셀렉터 캐시), 두 번째부터는 그걸 바로 쓰게 했습니다. 통과한 케이스는 건너뛰고 실패한 것만 다시 돌리는 --rerun-failed도 같이 붙였습니다. 구체적인 동작과 효과는 05에서 적습니다.
03 — 이럴 거면 폐쇄망에서도
AIPub 고객사의 상당수는 폐쇄망입니다. 인터넷이 없고, 따라서 Claude도 없고, npm install도 안 됩니다. 이건 처음부터 알고 있었습니다. 그래서 처음엔 선을 그었습니다. "이건 우리가 사내에서 쓰는 도구다. 고객사 환경은 생각하지 말자."
그런데 스킬을 몇 주 쓰다 보니 생각이 바뀌었습니다. 케이스가 쌓이고, 리포트가 쌓이고, 이게 꽤 믿을 만하다는 감이 왔습니다. 그러자 자연스럽게 다음 질문이 나왔습니다. "이걸 고객사 현장에서도 돌릴 수 있으면, 설치 검증을 사람이 안 해도 되는 것 아닌가."
문제는 명확했습니다. 지금 도구는 Claude Code 안에서 돕니다. 케이스를 만드는 것도 LLM이고, 실행을 지휘하는 것도 LLM이고, Playwright조차 npm으로 받아야 합니다. 고객사에는 그중 아무것도 없습니다. 즉 LLM으로 만든 QA 도구를, LLM이 없는 곳에서 돌려야 합니다.
정리하면 이런 구조가 됩니다. 도구 안에서 케이스를 만드는 것은 LLM인데, 그 도구가 실제로 돌아가는 곳엔 LLM이 없습니다. 문장이 좀 이상한데, 구조가 그렇습니다.
04 — 무엇을 빼고 무엇을 남길 것인가
LLM이 하던 일을 둘로 쪼개는 것이 출발점이었습니다.
일 | 필요한 것 | 어디서 |
|---|---|---|
화면을 이해하고 케이스를 만든다 | LLM, 인터넷 | 온라인 (사내) · 한 번 |
케이스를 눌러 보고 확인한다 | 브라우저 | 오프라인 (고객사) · 반복 |
이해는 한 번 하면 되고, 실행은 매 릴리스마다 반복됩니다. 비싸고 느리고 인터넷이 필요한 쪽을 "한 번"에 몰고, 반복되는 쪽은 아무것도 필요 없게 만들면 됩니다.
경계에 중간 표현을 둔다
두 세계의 경계가 cases.compiled.yaml입니다. 온라인에서 aqa-inspect가 케이스를 한 번 성공적으로 실행하면, 그 과정을 자연어가 하나도 없는 형태로 기록합니다. 셀렉터, 입력값, 클릭, 그리고 확인(assert)만 남습니다. 각 스텝은 op 필드에 브라우저에 내릴 동작 한 가지를 적습니다. 자연어 문장이 아니라 goto, click처럼 정해진 단어입니다.
ir_version: 2
name: "Project"
cases:
- case_id: project-001
name: "로그인 후 프로젝트 생성 버튼이 보인다"
steps:
- op: goto
url: "https://<host>/login"
- op: fill
selector: { strategy: role, role: textbox, name: "Username" }
value: "qa-user"
- op: fill
selector: { strategy: role, role: textbox, name: "Password" }
value_ref: auth_password
sensitive: true
- op: click
selector: { strategy: role, role: button, name: "Sign in" }
- op: goto
url: "https://<host>/projects"
- op: assert
assert: { type: visible, selector: { strategy: role, role: button, name: "Create" } }러너는 이 파일만 읽습니다. 동작은 8종(goto, fill, click, select, check, hover, press, assert), 확인도 8종(visible, hidden, text_contains, url_matches, enabled, disabled, value_equals, count)으로 고정했습니다. 자연어 action 필드가 하나라도 있으면 컴파일 안 된 파일로 보고 거부합니다. "대충 알아서 해 주겠지"가 끼어들 자리를 처음부터 없앴습니다. 알아서 해 줄 LLM이 그 자리에 없기 때문입니다.
비밀값은 파일에 남기지 않는다
로그인 비밀번호 같은 값은 IR에 리터럴로 들어가면 안 됩니다. sensitive: true인 입력이 리터럴 값을 가지면 로더가 거부합니다. 대신 value_ref로 이름만 적고, 실행 시점에 secrets.env에서 읽거나, 없으면 터미널에서 마스킹된 입력으로 물어봅니다. 로그와 리포트에도 값 대신 ****가 남습니다.
설치가 없어야 한다
폐쇄망에 "Node 20 이상을 설치하고 Playwright 브라우저를 받으세요"라고 할 수는 없습니다. 그래서 OS별로 포터블 Node, Playwright Chromium, 러너를 전부 zip 하나에 넣어 GitHub Release로 올립니다. 받는 사람은 압축 풀고, cases/ 폴더에 yaml을 넣고, 더블클릭합니다. 끝입니다.
결과 형식은 바꾸지 않는다
results.csv 스키마를 aqa-inspect와 동일하게 맞췄습니다. 그래서 폐쇄망에서 돌린 결과를 사내로 가져오면 aqa-jira가 그대로 읽어 티켓을 만듭니다. 새 도구를 만들었지만 뒤에 붙는 파이프라인은 하나도 손대지 않았습니다.
05 — 느려서 고친 것 둘
zip이 나온 뒤 진짜 문제는 "되냐"가 아니라 "언제 끝나냐"였습니다. 두 군데가 느렸고, 둘 다 같은 일을 매번 반복하고 있었습니다.
① 매 스텝 LLM에게 다시 묻고 있었다 — inspect 쪽. browser-use 엔진은 스텝마다 스크린샷을 LLM에 보여 주고 "Sign in 버튼이 어디냐"를 묻습니다. 처음 보는 화면엔 이게 맞지만, 같은 케이스를 두 번째 돌릴 때도 똑같이 묻고 있었습니다. 케이스가 천 개를 넘자 한 바퀴 도는 시간이 감당이 안 됐습니다.
고친 방법은 02에서 적은 셀렉터 캐시입니다. 첫 실행에서 엔진이 찾아낸 위치를 cases.yaml의 해당 스텝에 되써 넣습니다.
- action: "Sign in 버튼을 클릭한다"
selector: { strategy: role, role: button, name: "Sign in" } # 1회차 실행이 기록
selector_anchor: "Sign in" # 재사용 시 확인용 텍스트두 번째부터는 selector가 있으면 LLM을 거치지 않고 바로 그 요소를 집습니다. 화면이 바뀌어 selector_anchor의 텍스트가 안 맞으면 그때만 LLM이 다시 찾고, 바뀐 기록은 selector-drift.json에 남겨 둡니다. 여기에 --rerun-failed를 붙여 통과한 케이스는 건너뛰고 실패·보류만 다시 돌리게 하니, 2회차 이후는 "사람이 고친 것만 다시 확인하는" 짧은 루프가 됐습니다. 그리고 이 캐시가 쌓인 cases.yaml이 그대로 폐쇄망용 IR의 재료가 됩니다.
② 1,435개 케이스가 전부 로그인부터 — runner 쪽. 실제 컴파일된 스위트를 받아 보니 1,435개 케이스 중 1,423개가 동일한 로그인 접두(로그인 페이지 → 아이디 → 비밀번호 → Sign in)로 시작했습니다. 케이스마다 새 브라우저 컨텍스트를 쓰니 매번 콜드 로드와 실제 로그인을 치르고, 케이스당 1~4초가 로그인에만 들어갔습니다.
--shared-session 플래그를 추가했습니다. 첫 케이스의 로그인 접두만 먼저 실행해 Playwright storageState를 스냅샷하고, 이후 케이스는 각자 새 컨텍스트에 그 상태를 주입받아 로그인 다음 goto부터 시작합니다. 단, 전부에게 적용하면 안 됩니다.
케이스 종류 | 수 | 처리 |
|---|---|---|
로그인 접두 뒤에 바로 | 1,419 | 세션 주입, 로그인 건너뜀 |
로그인 접두 없음 (로그아웃 상태 검증) | 12 | 깨끗한 컨텍스트로 전체 실행 |
로그인 직후 바로 assert (로그인 자체 검증) | 4 | 로그인을 실제로 수행 |
기본값은 여전히 "케이스마다 완전 격리"이고, 공유 세션은 켜야 동작합니다. 빠르게 하려다 로그아웃 테스트가 로그인된 상태로 도는 일은 없어야 하니까요.
06 — 지금은
고객사 현장에서의 절차는 이렇습니다.
인터넷이 되는 곳에서 Release zip을 받아서 승인된 경로로 고객사에 반입한다.
압축을 풀고
cases/에cases.compiled.yaml을 넣는다.run.command(Windows는run.bat)를 실행한다. 비밀번호가 필요하면 그 자리에서 마스킹 입력으로 물어본다.reports/<timestamp>/report.html을 연다. 종료 코드 0이면 전부 통과, 1이면 하나 이상 실패.
만든 방식에 대하여. 기능 하나를 붙일 때마다 문제·목표·비목표·적용 규칙을 적은 설계 문서를 먼저 쓰고, 그걸 구현 계획으로 쪼개고, 구현한 뒤 리뷰하고, 실제 스위트로 돌려 봤습니다. 공유 세션의 "어떤 케이스에 적용하고 어떤 케이스는 제외하는가" 같은 규칙은 코드보다 설계 문서에서 먼저 정해졌습니다.
그 과정에서 제가 계속 붙들고 있던 건 세 가지였습니다. 경계 — LLM이 할 일과 러너가 할 일을 어디서 가르는가. 기본값 — 격리냐 속도냐, 비밀값을 어디까지 허용하느냐를 어느 쪽으로 두는가. 검증 — 생성된 코드를 읽고, 실제 케이스로 돌려 보고, 어긋나면 되돌리는 것.
더 읽을거리
aqa-runner 저장소 — github.com/ten1010-io/aqa-runner. IR 스키마는
schema/ir.md, 공유 세션 설계 문서는docs/superpowers/specs/ten-toolkit (aqa-inspect · aqa-jira 스킬) — github.com/ten1010-io/ten-toolkit. 엔진 계약은
skills/aqa-inspect/references/engine-*.mdbrowser-use — github.com/browser-use/browser-use
Playwright 로케이터(role · label · text) — playwright.dev/docs/locators
Playwright 인증 상태 재사용(storageState) — playwright.dev/docs/auth