QA 없는 팀에서 QA 자동화 시스템을 만든 이야기
QA 없는 팀에서 QA 자동화 시스템을 만든 이야기
의료진이 환자의 신경심리검사를 진행하고 결과를 관리하는 프로그램을 개발·유지보수하고 있다. 검사 도중 오작동이 생기면 환자 데이터가 날아갈 수 있고, 병원 현장에서 바로 VOC가 들어온다.
그런데 팀에 QA 담당자가 없었다. 팀원이 2명뿐이라 개발도 하고 QA도 해야 하는 상황이었다. 배포 전에 개발자가 수동으로 주요 흐름을 클릭해보는 게 전부였고, 매 배포마다 "혹시 뭔가 깨진 게 있지 않을까" 하는 불안이 있었다. 실제로 급하게 기한을 맞추다가 미처 생각지 못한 사이드 이펙트를 놓쳐 에러가 난 적도 있었다.
다른 회사들은 이걸 어떻게 하나 싶어 기술 블로그들을 찾아봤다. Playwright나 Maestro 같은 E2E 자동화 툴을 쓰고, AI로 테스트 스크립트를 만들어 자동화하는 시스템을 운영하고 있었다. 우리 팀에 절실한 게 바로 이거다 싶었다. 그래서 내가 자동화 시스템을 만들겠다고 제안했고, 그렇게 시작하게 됐다.
Maestro를 선택한 이유
Maestro는 모바일 앱 E2E 테스트 도구다. YAML로 테스트 시나리오를 작성하면 에뮬레이터나 실제 디바이스에서 앱을 직접 조작하며 테스트를 실행한다.
# 예시: 로그인 테스트 시나리오
appId: com.example.medical
---
- launchApp
- tapOn: "아이디 입력"
- inputText: "test@hospital.com"
- tapOn: "비밀번호 입력"
- inputText: "password123"
- tapOn: "로그인"
- assertVisible: "검사 목록"고른 이유는 단순하다. 시나리오가 코드가 아니라 YAML 텍스트라는 것. 텍스트라서 DB에 그대로 저장하기 편하고, AI가 잘 써준다. 기간제로 협업하는 외주 QA 업체의 테스트 케이스 문서도 쉽게 스크립트로 옮길 수 있고, 나중에 QA 담당자가 들어와도 온보딩이 쉽다. 개발자가 테스트 코드에 매여야 하는 구조를 피하려던 처음 목적에 잘 맞았다.
전체 아키텍처

DB 설계
QA 도메인은 네 가지 엔티티로 구성했다.
versions — 앱 버전 단위 (v1.2.0 등)
└─ test_cases — 버전에 속한 테스트 케이스 (YAML 포함)
runs — 특정 버전의 TC 실행 묶음
└─ run_results — TC별 실행 결과 (PASS/FAIL)
└─ run_logs — Maestro stdout/stderr 로그
-- 핵심 테이블 구조
CREATE TABLE test_cases (
id UUID PRIMARY KEY,
version_id UUID REFERENCES versions(id),
tc_id VARCHAR NOT NULL, -- 사람이 읽을 수 있는 ID (예: TC-001)
title VARCHAR NOT NULL,
yaml_content TEXT NOT NULL, -- Maestro YAML 원문
automation_status VARCHAR NOT NULL -- in_progress | pending | completed | excluded
CHECK (automation_status IN ('in_progress', 'pending', 'completed', 'excluded'))
);
CREATE TABLE runs (
id UUID PRIMARY KEY,
version_id UUID REFERENCES versions(id),
status VARCHAR NOT NULL, -- queued | running | done | failed
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE run_results (
id UUID PRIMARY KEY,
run_id UUID REFERENCES runs(id),
tc_id UUID REFERENCES test_cases(id),
status VARCHAR NOT NULL -- pass | fail | skipped
);API 서버: Run 생성과 큐 역할
큐를 따로 두지 않으니 Run 생성은 단순하다. POST /v1/runs를 호출하면 runs 레코드를 status='queued'로 만들어 두기만 하면 된다. 이 status 컬럼이 곧 대기열이다.
// runs.service.ts
async create(dto: CreateRunDto): Promise<Run> {
// tcIds가 없으면 해당 버전의 전체 TC를 대상으로
const tcIds = dto.tcIds ?? (
await this.testCaseRepository.findByVersionId(dto.versionId)
).map(tc => tc.id);
// Run을 'queued' 상태로 적재. 이 row 자체가 대기열이다
return this.runRepository.save({
versionId: dto.versionId,
tcIds,
status: 'queued',
});
}워커가 가져갈 때는 별도 엔드포인트로 queued인 Run을 하나 집어 running으로 바꿔 내려준다.
// 워커가 폴링하는 엔드포인트 (GET /v1/runs/next)
async claimNext(): Promise<Run | null> {
// 가장 오래 기다린 queued Run 하나를 running으로 선점하고 반환
// UPDATE runs SET status='running'
// WHERE id = (SELECT id FROM runs WHERE status='queued'
// ORDER BY created_at LIMIT 1)
// RETURNING *
return this.runRepository.claimOneQueued();
}지금은 워커가 한 대라 이걸로 충분하다. 나중에 워커를 여러 대로 늘리면, 같은 Run을 둘이 동시에 집지 않도록 FOR UPDATE SKIP LOCKED로 선점만 막아주면 된다.
워커 서버: API 폴링 → Maestro 실행 → 결과 전송
워커는 Node.js(TypeScript)로 작성했고, 에뮬레이터가 붙은 로컬 머신에서 돈다. 하는 일은 단순하다. 몇 초에 한 번 API에 "다음 Run 있어?"라고 묻고, 있으면 받아서 Maestro를 돌리고, 결과를 다시 API로 보낸다.
// worker.ts - 핵심 루프
async function pollAndProcess() {
while (true) {
// 다음 Run 요청 (큐 역할은 API 뒤의 DB가 한다)
const job = await fetchNextRun(); // GET /v1/runs/next
if (!job) {
await sleep(5000); // 대기 중인 Run이 없으면 5초 쉬고 다시
continue;
}
await processJob(job); // 결과는 API로 보내 runs.status 갱신
}
}폴링 간격(5초)만큼 픽업이 늦어지지만, QA 실행은 실시간일 필요가 없어 체감 차이는 없다.
테스트 대상이 앱만 있는 것도 아니다. 웹 쪽 TC도 같은 워커가 받아서, TC 종류에 따라 앱이면 Maestro, 웹이면 Playwright로 돌린다. 워커 입장에선 실행기가 하나 더 있을 뿐이라 대시보드부터 결과 저장까지 흐름은 완전히 같다. 아래 코드는 앱(Maestro) 경로 기준이다.
async function processJob(job: Job) {
for (const tcId of job.tcIds) {
// 1. API 서버에서 YAML 가져오기
const tc = await fetchTestCase(tcId);
// 2. 임시 파일로 저장
const tmpFile = `/tmp/maestro-${tcId}.yaml`;
fs.writeFileSync(tmpFile, tc.yamlContent);
// 3. Maestro 실행
const result = await runMaestro(tmpFile);
// 4. 결과 API 서버로 전송
await postResult(job.runId, tcId, result);
// 5. 임시 파일 정리
fs.unlinkSync(tmpFile);
}
}
async function runMaestro(yamlPath: string) {
return new Promise((resolve) => {
execFile(
process.env.MAESTRO_BIN!,
['test', yamlPath],
{
env: {
...process.env,
LANG: 'en_US.UTF-8', // 한글 로그 깨짐 방지
LC_ALL: 'en_US.UTF-8',
},
},
(err, stdout, stderr) => {
console.log('[maestro stdout]', stdout); // 로컬 디버깅용
console.log('[maestro stderr]', stderr);
resolve({
success: !err,
stdout,
stderr: stderr || (err?.message ?? ''),
});
}
);
});
}개발하면서 만난 문제들
자식 프로세스가 삼킨 로케일: 한글이 깨졌다
Maestro 실행 결과 로그에서 한글이 ���̵� 형태로 깨졌다.
앱 UI 텍스트가 한글이라, Maestro가 assertVisible 실패 시 어떤 텍스트가 보였는지 로그에 남기는데 그게 깨져서 나왔다. 로그만 봐서는 뭐가 문제인지 알 수가 없었다.
원인은 execFile로 실행된 자식 프로세스가 부모 프로세스의 로케일 환경변수를 상속받지 못한 것이었다.
// 수정 전
execFile(MAESTRO_BIN, ['test', yamlPath], callback);
// 수정 후
execFile(MAESTRO_BIN, ['test', yamlPath], {
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' }
}, callback);빈 body에 JSON 파싱이 터졌다
API 서버의 로그 저장 엔드포인트가 201 Created를 반환하지만 body가 비어 있었다. 워커에서 res.json()을 호출하면 Unexpected end of JSON input 에러가 났다.
// 수정 전
const data = await res.json(); // 빈 body에서 실패
// 수정 후
const text = await res.text();
const data = text ? JSON.parse(text) : undefined;작은 문제지만 이걸 모르면 워커가 결과 전송에 실패했다고 착각해서 디버깅 시간을 낭비한다.
처음엔 큐로 했다가 뺐다
처음엔 API와 워커 사이에 잡 큐를 두고 구현했다. 그런데 실행이 중간에 실패했을 때 재시작을 못 하는 문제가 생겼다. 메시지는 소비하는 순간 사라지는 물건이라, 실패한 테스트가 흔적 없이 증발했다.
그래서 큐를 빼고, runs 테이블에 status(queued / running / done / failed)로 넣고 워커가 폴링으로 집어가는 스케줄링 구조로 바꿨다. row는 사라지지 않는다. 실패하면 status='failed'로 그대로 남고, 다시 돌리고 싶으면 'queued'로 되돌리는 UPDATE 한 줄이면 된다. 워커가 중간에 죽어도 running인 채로 박혀 있어서, 너무 오래 running인 Run을 골라 되돌리는 것도 어렵지 않다.
로컬 워커 입장에서도 단순해졌다. 브로커 접속 정보 없이, 원래 YAML 받고 결과 보내느라 쓰던 API 하나만 알면 된다. 관리 포인트가 API 서버와 DB, 딱 원래 있던 두 개로 끝난다.
마치며
돌아보면 판단 기준은 하나였다. 발행자와 실행자를 떼어놔야 하면 큐가 필요하고, DB가 그 일을 할 수 있으면 큐는 사치다. 여기선 runs 테이블이면 충분했고, 큐를 썼다 뺀 뒤에야 그걸 알았다.
비슷한 판단을 한 게 이번이 처음도 아니다. 무거운 처리를 떼어내려고 SQS를 기꺼이 넣은 적도 있다. 도구를 먼저 정해놓고 끼우는 게 아니라, 상황을 보고 넣고 뺀다.
자동화가 회귀는 잡아주지만 새로 들어간 기능은 여전히 내가 직접 봐야 한다. 완성이 아니라, QA 담당자가 없어서 일단 만든 거다. QA가 없으면 QA를 만들면 된다고 생각했다.