QA 없는 팀에서 QA 자동화 시스템을 만든 이야기
팀원 2명이 개발도 QA도 해야 하는 상황에서 만든 자동화 시스템. 외주 QA가 대시보드에 테스트 케이스를 올리면 로컬 워커가 앱은 Maestro, 웹은 Playwright로 돌린다. 잡 큐 없이 DB 폴링으로 워커를 물린 설계 이유까지.
QA 없는 팀에서 QA 자동화 시스템을 만든 이야기
상황: QA 담당자도, 테스트 코드도 없었다
SNSB-3는 의료진이 환자의 신경심리검사를 진행하고 결과를 관리하는 Flutter 앱이다. 검사 도중 오작동이 생기면 환자 데이터가 날아갈 수 있고, 병원 현장에서 바로 VOC가 들어온다.
그런데 팀에 QA 담당자가 없었다. 팀원이 2명뿐이라 개발도 하고 QA도 해야 하는 상황이었다. 배포 전에 개발자가 수동으로 주요 흐름을 클릭해보는 게 전부였고, 매 배포마다 "혹시 뭔가 깨진 게 있지 않을까" 하는 불안이 있었다. 그래서 내가 자동화 시스템을 만들겠다고 제안했고, 그렇게 시작된 작업이다.
Flutter 통합 테스트(Integration Test)를 작성하는 방향도 검토했다. 그런데 두 가지 문제가 있었다.
첫째, OS 네이티브 팝업을 제어할 수 없다. 마이크, 카메라, 위치 권한 요청이 뜨면 Flutter 테스트 프레임워크가 그걸 처리하지 못한다. SNSB 앱은 권한 요청이 여러 단계에 걸쳐 있었다.
둘째, 개발자가 테스트 시나리오를 코드로 작성하는 건 그 자체로 병목이다. 테스트 케이스가 늘수록 유지 비용도 늘고, 개발자가 비즈니스 로직보다 테스트 코드에 시간을 더 쓰게 된다.
그래서 방향을 바꿨다. 테스트 실행은 자동화하되, 시나리오 작성은 개발자가 하지 않는 구조로 가기로 했다.
Maestro를 선택한 이유
Maestro는 모바일 앱 E2E 테스트 도구다. YAML로 테스트 시나리오를 작성하면 에뮬레이터나 실제 디바이스에서 앱을 직접 조작하며 테스트를 실행한다.
# 예시: 로그인 테스트 시나리오
appId: com.hitek.snsb
---
- launchApp
- tapOn: "아이디 입력"
- inputText: "test@hospital.com"
- tapOn: "비밀번호 입력"
- inputText: "password123"
- tapOn: "로그인"
- assertVisible: "검사 목록"Maestro가 맞았던 이유는 몇 개가 맞물렸다. 우선 Flutter 테스트가 못 넘기던 OS 네이티브 권한 팝업을 allowPermissions 커맨드 하나로 처리한다. 그리고 시나리오를 코드가 아니라 선언적 YAML로 쓰기 때문에, 개발자가 아닌 QA 담당자도 작성할 수 있다. 거기에 Maestro Studio라는 GUI로 앱 화면을 보면서 selector를 바로 뽑을 수 있어서, QA 담당자를 앉혀놓고 온보딩하기도 빨랐다. 개발자가 테스트 코드에 매여야 하는 구조를 피하려던 처음 목적에 잘 맞았다.
전체 아키텍처
이 시스템의 핵심은 클라우드에 있는 API 서버와 로컬 머신에서 돌아가는 워커 서버의 분리다.

워커가 결과를 API로 보내면 run_results·run_logs가 저장되고 runs.status가 done 또는 failed로 바뀐다. 대시보드는 그걸 읽어 보여줄 뿐이다.
왜 워커가 로컬이어야 했나
문제는 워커의 서식지였다. Maestro는 에뮬레이터나 실제 디바이스 화면에 직접 붙어서 앱을 조작해야 한다. ECS 같은 클라우드 위에 안드로이드 가상 장치(AVD)를 올리는 것도 검토는 해봤는데, 빌드 세팅의 지옥과 만만찮은 인프라 비용이 먼저 떠올라 접었다.
초기 단계의 현실적인 타협은 명확했다. 놀고 있는 사무실 맥이나 개발 머신을 워커로 삼고, 거기에 에뮬레이터를 물려두는 것. 나중에 CI 서버로 옮기더라도 워커 코드는 그대로 쓸 수 있다.
그래서 워커는 클라우드의 API 서버하고만 대화하게 했다. 워커가 로컬 어디에 처박혀 있든 API에 "다음 Job 줘"라고 묻고, API가 DB에서 하나 집어준다. 워커 위치가 아키텍처에 영향을 주지 않는다. 이 사이에 잡 큐를 따로 두지 않은 이유는 뒤에서 다룬다.
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 실행 결과 로그에서 한글이 ���̵� 형태로 깨졌다.
SNSB 앱의 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;작은 문제지만 이걸 모르면 워커가 결과 전송에 실패했다고 착각해서 디버깅 시간을 낭비한다.
잡 큐를 따로 두지 않은 이유: DB가 이미 큐다
이런 구조에서 흔한 선택은 API와 워커 사이에 SQS나 Redis 같은 잡 큐를 끼우는 거다. 검토는 했지만 안 넣었다. runs 테이블에 status(queued / running / done / failed) 컬럼이 이미 있었고, 그게 이미 진실의 원천이었기 때문이다. DB가 큐 노릇을 할 수 있는데 그 옆에 큐를 하나 더 두면, 같은 상태를 두 군데서 관리하게 된다.
메시지 큐를 썼다면 골치 아팠을 지점이 하나 더 있다. 테스트가 실패했을 때 "진짜 앱 버그로 터진 건지" "워커가 죽은 건지"를 구분해서 다뤄야 하는데, 메시지는 소비하는 순간 사라지는 물건이라 이 추적을 별도로 설계해야 한다. row는 다르다. 사라지질 않는다. 실패하면 status='failed'로 그대로 남고, 다시 돌리고 싶으면 'queued'로 되돌리는 UPDATE 한 줄이면 된다. 워커가 중간에 죽어도 running인 채로 박혀 있어서, 너무 오래 running인 Run을 골라 되돌리는 것도 어렵지 않다.
로컬 워커 입장에서도 이게 단순하다. 브로커 접속 정보나 IAM 키 없이, 원래 YAML 받고 결과 보내느라 쓰던 API 하나만 알면 된다. 관리 포인트가 API 서버와 DB, 딱 원래 있던 두 개로 끝난다.
대시보드 데이터 구조
대시보드는 버전별 테스트 현황을 한눈에 보여주는 구조다.
GET /v1/dashboard/status-summary
{
"version": "v1.2.0",
"totalTc": 42,
"latestRun": {
"id": "...",
"status": "done",
"pass": 38,
"fail": 3,
"skipped": 1,
"runAt": "2026-04-14T09:30:00Z"
}
}
Run 상세 화면에서는 TC별로 PASS/FAIL 상태와 Maestro 실행 로그를 볼 수 있다. FAIL인 케이스는 어떤 단계에서 어떤 에러가 났는지 stdout/stderr가 그대로 저장돼 있다.
마치며
돌아보면 이 시스템의 판단 기준은 하나였다. 발행자와 실행자를 떼어놔야 하면 큐가 필요하고, DB가 이미 그 일을 하고 있으면 큐는 사치다. 여기선 runs 테이블이 처음부터 그 일을 하고 있었다.
비슷한 판단을 한 게 이번이 처음도 아니다. 규모가 작아 SQS를 outbox로 걷어낸 적도 있고, 반대로 무거운 처리를 떼어내려고 SQS를 기꺼이 넣은 적도 있다. 도구를 먼저 정해놓고 끼우는 게 아니라, 상황을 보고 넣고 뺀다.
자동화가 회귀는 잡아주지만 새로 들어간 기능은 여전히 내가 직접 봐야 한다. 완성이 아니라, QA 담당자가 없어서 일단 만든 거다. QA가 없으면 QA를 만들면 된다고 생각했다.