QA 없는 팀에서 E2E 테스트 대시보드를 만든 이야기 — Maestro + SQS + 로컬 워커
외주 QA가 YAML을 입력하면 로컬 워커가 받아서 Maestro로 앱을 테스트하고 결과를 대시보드에 쌓는 시스템. 왜 이 구조가 됐는지.
QA 없는 팀에서 E2E 테스트 대시보드를 만든 이야기 — Maestro + SQS + 로컬 워커
상황: QA 담당자도, 테스트 코드도 없었다
SNSB-3는 의료진이 환자의 신경심리검사를 진행하고 결과를 관리하는 Flutter 앱이다. 검사 도중 오작동이 생기면 환자 데이터가 날아갈 수 있고, 병원 현장에서 바로 VOC가 들어온다.
그런데 팀에 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: "검사 목록"선택 이유는 세 가지였다.
- OS 네이티브 팝업 처리 가능: Flutter 테스트가 못 하는 권한 팝업을 Maestro는 처리할 수 있다.
allowPermissions커맨드 하나로 해결된다 - YAML 문법: 코드가 아니라 선언적인 YAML이라 개발자가 아닌 QA 담당자도 작성할 수 있다
- Maestro Studio: 앱 화면을 보면서 인터랙티브하게 selector를 추출하는 GUI 도구가 있어, QA 담당자 온보딩이 빠르다
전체 아키텍처
이 시스템의 핵심은 클라우드에 있는 API 서버와 로컬 머신에서 돌아가는 워커 서버의 분리다.
[외주 QA 담당자]
│
│ 대시보드에서 테스트 케이스(YAML) 등록
▼
[대시보드 UI] ─── API 호출 ──▶ [NestJS API 서버 (ECS)]
│
│ TC + 버전 DB 저장
│ Run 생성 시 SQS에 Job 푸시
▼
[AWS SQS]
│
│ Long Polling (20초)
▼
[로컬 워커 서버 (Mac)]
│
│ API 서버에서 YAML 가져옴
│ 임시 파일로 저장
│ maestro test 실행
│
│ 결과 API 서버로 전송
▼
[NestJS API 서버 (ECS)]
│
│ run_results, run_logs DB 저장
▼
[대시보드 UI] ─── 결과 확인
왜 워커가 로컬이어야 했나
Maestro는 에뮬레이터나 실제 디바이스에 직접 붙어서 앱을 조작한다. 클라우드 환경(ECS 등)에서 안드로이드 에뮬레이터를 띄우는 건 가능하지만, 세팅 비용과 인프라 비용이 상당하다.
초기 단계에서 CI 서버에 에뮬레이터 환경을 구성하는 것보다, 개발 머신이나 QA 머신에 워커를 띄워 에뮬레이터와 연결하는 방식이 현실적이었다. 나중에 CI 서버로 이전하더라도 워커 코드 자체는 그대로 쓸 수 있다.
SQS가 이 구조를 가능하게 했다. API 서버는 어디서든 메시지를 큐에 넣으면 되고, 워커는 어디에 있든 SQS를 폴링해서 Job을 가져갈 수 있다. 워커 위치가 아키텍처에 영향을 주지 않는다.
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 생성과 SQS 푸시
POST /v1/runs를 호출하면 Run을 생성하고 SQS에 Job을 넣는다.
// runs.service.ts
async create(dto: CreateRunDto): Promise<Run> {
// 1. tcIds가 명시되지 않으면 해당 버전의 전체 TC 조회
const tcIds = dto.tcIds ?? (
await this.testCaseRepository.findByVersionId(dto.versionId)
).map(tc => tc.id);
// 2. Run 레코드 생성
const run = await this.runRepository.save({
versionId: dto.versionId,
status: 'queued',
});
// 3. SQS에 Job 푸시
await this.queueService.enqueue({
runId: run.id,
tcIds,
versionId: dto.versionId,
});
return run;
}// sqs-queue.service.ts
async enqueue(job: Job): Promise<void> {
await this.sqsClient.send(new SendMessageCommand({
QueueUrl: process.env.SQS_QUEUE_URL,
MessageBody: JSON.stringify(job),
}));
}워커 서버: SQS 폴링 → Maestro 실행 → 결과 전송
워커는 Node.js(TypeScript)로 작성했다. 에뮬레이터에 붙어있는 로컬 머신에서 실행된다.
// worker.ts - 핵심 루프
async function pollAndProcess() {
while (true) {
// 1. SQS Long Polling (최대 20초 대기)
const response = await sqsClient.send(new ReceiveMessageCommand({
QueueUrl: process.env.SQS_QUEUE_URL,
MaxNumberOfMessages: 1,
WaitTimeSeconds: 20, // Long Polling
}));
if (!response.Messages?.length) continue;
const message = response.Messages[0];
const job: Job = JSON.parse(message.Body!);
// 2. 메시지 먼저 삭제 (중복 실행 방지)
await sqsClient.send(new DeleteMessageCommand({
QueueUrl: process.env.SQS_QUEUE_URL,
ReceiptHandle: message.ReceiptHandle!,
}));
// 3. Job 처리
await processJob(job);
}
}처리 순서가 중요한 포인트가 있다. 메시지를 처리하기 전에 먼저 삭제한다. 처리 후 삭제하면, 처리 도중 워커가 죽었을 때 메시지가 다시 큐로 돌아와 중복 실행될 수 있다. 앞서 삭제하면 실패 시 재시도가 안 되지만, 이 단계에서는 중복 실행이 더 큰 문제였다.
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 ?? ''),
});
}
);
});
}개발하면서 만난 문제들
1. Redis → SQS 마이그레이션
초기에는 Redis BRPOP으로 큐를 구현했다. 단순하고 빠르다.
// 초기 Redis 방식
const result = await redis.brpop('maestro:queue', 0); // 블로킹 대기그런데 Redis는 워커와 같은 네트워크 안에 있어야 한다. 워커가 로컬이라면 Redis도 로컬이거나 터널링이 필요하다. 확장을 생각하면 관리 포인트가 늘어난다.
SQS는 URL과 IAM 키만 있으면 어디서든 접근 가능하다. 워커 위치와 무관하게 동작한다. 전환 후 오히려 구조가 단순해졌다.
2. 한글 깨짐
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);3. 빈 응답 처리
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;작은 문제지만, 이걸 모르면 워커가 결과 전송에 실패했다고 착각해서 디버깅 시간을 낭비한다.
대시보드 데이터 구조
대시보드는 버전별 테스트 현황을 한눈에 보여주는 구조다.
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가 그대로 저장되어 있다.
전체 흐름 정리
1. 외주 QA가 대시보드에서 테스트 케이스 등록
- 버전 선택 (예: v1.2.0)
- TC ID, 제목, Maestro YAML 입력
2. 개발자 또는 CI가 "Run 실행" 버튼 클릭
- POST /v1/runs
- API 서버: Run 레코드 생성 + SQS에 Job 푸시
3. 로컬 워커가 SQS에서 Job 수신
- Long Polling으로 대기 중
- Job 받으면 즉시 SQS에서 삭제 (중복 방지)
4. TC별 순차 실행
- API 서버에서 YAML 가져옴
- 임시 파일 저장
- maestro test 실행
- 결과(PASS/FAIL + 로그) API 서버에 전송
5. 대시보드에서 결과 확인
- 전체 통과율, 실패 TC 목록, 실패 로그
이 구조를 만들면서 느낀 것
처음 설계할 때 "왜 그냥 로컬에서 직접 Maestro를 실행하지 않고 굳이 SQS를 끼우나"라는 질문을 받았다.
대답은 간단하다. SQS가 있어야 실행 주체와 에뮬레이터 위치를 분리할 수 있다.
대시보드에서 버튼을 누르는 사람(API 서버)과 Maestro를 실행하는 사람(워커)이 다른 머신에 있어야 한다. 워커를 CI 서버로 옮기든, 다른 팀원의 머신을 워커로 추가하든, API 서버 코드를 건드릴 필요가 없다.
또 하나 배운 건 QA 자동화에서 가장 어려운 건 코드가 아니라 프로세스라는 것이다. 아무리 좋은 시스템을 만들어도 외주 QA가 YAML을 제대로 작성하지 않으면 의미가 없다. selector가 틀렸거나, 화면 전환 타이밍이 맞지 않으면 테스트가 flaky해진다.
시스템을 만드는 것과, 그 시스템이 실제로 돌아가도록 프로세스를 정착시키는 것은 다른 일이다.