[KMP] Maestro로 iOS/Android UI 테스트를 스크립트 한 벌로 끝내기

우발자·2일 전


[KMP] Maestro로 iOS/Android UI 테스트를 스크립트 한 벌로 끝내기

들어가며 🙋🏻‍♂️

KMP로 앱을 개발하면서 제일 좋았던 건 역시 "코드 한 벌로 iOS/Android 둘 다"였다.
그런데 코드가 한 벌이면 뭐하나... QA는 두 벌이었다.

배포 전마다 하는 스모크 테스트가 대충 이랬다.

  • Android 폰 들고 로그인 → 홈 → 리스트 탐색 → 핵심 기능 → 채팅 한 바퀴
  • iOS 시뮬레이터 켜서 똑같은 거 한 바퀴 더

게다가 우리 서비스는 유저 타입이 여러 개로 나뉘어 있어서, 한 바퀴가 사실 세 바퀴다. 그걸 플랫폼별로 두 번씩. 기능 하나 고칠 때마다 이 짓을 반복하다 보니 어느 순간 현타가 왔다. 🤦🏻‍♂️

UI 코드는 Compose Multiplatform이라 100% 공유하는데, 왜 검증은 내 손가락이 여섯 바퀴를 돌고 있지?

그래서 도입한 게 Maestro다. 그리고 여기에 Claude(+ Figma MCP) 를 얹었더니 생각보다 훨씬 재밌는 물건이 됐다.

Maestro가 뭔데? 🔍

Maestro는 모바일 UI 테스트 프레임워크다.
테스트를 코드가 아니라 yaml 파일로 작성하는 게 특징이다. 실제로 쓰고 있는 스모크 테스트가 이렇게 생겼다.

appId: com.example.app.dev
---
# 스모크 테스트: 비로그인 상태에서 앱이 뜨고 로그인 화면이 보이는지 확인
- launchApp:
    clearState: true
    permissions:
      all: allow
- tapOn:
    text: "허용"
    optional: true
- extendedWaitUntil:
    visible: "이메일로 시작하기"
    timeout: 20000
- takeScreenshot: smoke-login

이게 끝이다. 진짜 이게 끝이다.

기존 선택지들과 비교하면 이렇다.

플랫폼작성 언어러닝커브
EspressoAndroid 전용Kotlin
XCUITestiOS 전용Swift
Appium둘 다아무거나높음 (환경 세팅부터 고통)
Maestro둘 다yaml낮음

Espresso + XCUITest 조합은 같은 시나리오를 두 언어로 두 번 짜야 해서 탈락. Appium은 세팅 난이도로 탈락. 😇
Maestro는 CLI 하나 깔면 끝이고, 접근성 트리 기반이라 화면에 보이는 텍스트로 요소를 찾는다. 셀렉터 찾을 땐 maestro studio 열어서 눌러보면 된다.

근데 이게 KMP랑 만나면 진짜 물건이다 ✨

Maestro는 화면의 텍스트/contentDescription으로 동작하는데, KMP의 특성을 생각해보자.

  • UI가 commonMain에 있으니 iOS든 Android든 화면이 완전히 똑같다
  • 버튼 문구도, 화면 순서도, contentDescription도 똑같다
  • 우리는 appId까지 양 플랫폼 동일하게 맞춰놨다

즉, yaml 플로우 파일 하나가 두 플랫폼에서 그대로 돈다.

# Android 실물폰에서
./.maestro/run_all.sh <adb serial>

# iOS 시뮬레이터에서 — 스크립트도, 플로우도 전부 그대로
./.maestro/run_all.sh <시뮬레이터 UDID>

기기 ID만 바꾸면 끝. 네이티브를 따로 개발하는 팀이라면 "iOS는 버튼 문구가 미묘하게 다른데..." 같은 분기가 생기기 마련인데, KMP는 같은 Composable이 그리는 화면이라 구조적으로 그럴 일이 없다.

물론 플랫폼 차이가 아예 없진 않다. 그런 부분만 Maestro의 when: platform 조건으로 처리한다. 실제 공용 로그인 플로우의 한 조각이다.

# hideKeyboard 는 iOS 에서 불안정 — Android 에서만 수행
- runFlow:
    when:
      platform: Android
    commands:
      - hideKeyboard
# iOS 는 알림 권한 다이얼로그가 런타임에 뜨므로 허용 탭
- runFlow:
    when:
      platform: iOS
    commands:
      - tapOn:
          text: "허용"
          optional: true

플로우 파일 60개 중에 플랫폼 분기는 이런 시스템 다이얼로그/키보드 정도가 전부다.

플로우는 유저 타입별 × 피쳐별로 쪼갰다 📂

플로우가 늘어나면서 파일 구조를 잡았는데, 서비스의 유저 타입을 최상위로, 그 아래를 피쳐 단위로 나눴다. 대충 이런 모양이다.

.maestro/
├── run_all.sh              # 읽기(탐색) 스위트 — 데이터 변경 없음
├── run_write.sh            # 쓰기 시나리오 — 테스트 서버 데이터 변경됨!
├── smoke.yaml              # 비로그인 스모크
├── common/                 # 공용 서브플로우 (로그인, 탭 순회 등)
├── user_a/                 # 유저 타입 A: 탐색/신청/작성/마이페이지 ...
├── user_b/                 # 유저 타입 B: 검색/제안/검토/결제 직전 ...
└── user_c/                 # 유저 타입 C: 콘텐츠 등록 (유형별로 파일 분리)

이렇게 나누니 좋은 점:

  • 읽기/쓰기 스위트 분리 — 탐색만 하는 플로우는 언제든 돌려도 안전하고, 신청/등록처럼 테스트 서버 데이터를 건드리는 플로우는 run_write.sh로 격리
  • 공용 플로우 재사용 — 로그인은 runFlow: common/login.yaml 한 줄로 모든 플로우에서 재사용
  • yaml이 곧 QA 시나리오 문서 — 파일명과 스텝만 읽어도 시나리오가 그대로 보여서, 비개발자도 읽을 수 있다

깨알 팁 하나: 매 플로우마다 정식 로그인을 태우면 느리고 잘 깨진다. 그래서 디버그 빌드에만 숨은 디버그 로그인을 뚫어놨다 — 로그인 화면에서 로고 더블탭 → 숨은 입력창에 테스트 닉네임 입력 → 로고 롱프레스하면 테스트 API가 닉네임만으로 토큰을 발급해준다. 모든 플로우가 runFlow 한 줄로 이 공용 로그인을 타니까 빠르고 안정적이다. E2E 하려면 이런 테스트 전용 통로에 대한 투자가 은근 중요하다.

여기서 한 발 더 - 스크립트를 Claude한테 쓰게 했다 🤖

사실 이 글의 진짜 주제는 여기부터다. 플로우 60개를 내가 다 손으로 짰냐? 아니다. Claude Code한테 시켰다.

방식은 이렇다.

  1. Claude가 앱 소스를 읽고 플로우를 쓴다. Maestro는 텍스트 셀렉터 기반이라, 화면의 버튼 문구가 곧 셀렉터다. Claude는 Composable 소스에서 라벨을 직접 대조해가며 yaml을 짠다. 사람이 짜면 "문구가 '로그인'이었나 '로그인하기'였나" 하며 앱을 켜보게 되는데, 소스를 읽는 에이전트는 그럴 필요가 없다.
  2. Figma MCP로 고도화한다. 화면 개편이 들어오면 Claude가 피그마 MCP로 디자인 명세를 직접 조회해서, 바뀐 문구/구조/여백을 플로우와 앱 코드 양쪽에 반영한다.
  3. maestro hierarchy / 스크린샷 덤프를 Claude가 읽는다. 플로우가 실패하면 Maestro가 남기는 UI 계층 덤프와 스크린샷을 에이전트가 읽고 셀렉터를 스스로 고친다. "같은 라벨이 두 개라 index를 줘야 한다" 같은 것도 계층 덤프 보고 알아서 처리한다.

그 결과물이 재밌는 게, 에이전트가 삽질하며 배운 내용이 .maestro/README.md에 계속 축적된다. 예를 들면 이런 것들이다.

  • iOS에서 hideKeyboard는 불안정 → 키보드 해제는 "항상 보이는 상단 타이틀 탭"으로. 키보드가 덮은 좌표를 탭하면 자판이 눌려 필드에 글자가 들어가는 사고가 남 (실제 발생)
  • 상세 화면의 지도 뷰가 화면 중앙에 오면 기본 scroll을 지도가 삼켜 멈춤 → 스와이프 좌표를 지정한 공용 스크롤 플로우 사용
  • iOS eraseText는 빈 문자열 붙여넣기로 구현이라, Mac 클립보드가 시뮬레이터와 동기화돼 있으면 복사해둔 텍스트가 필드에 들어감 → 플로우 전에 클립보드 비우기

이 지식이 문서로 남으니, 다음에 플로우를 추가할 때 에이전트가 같은 삽질을 반복하지 않는다. 에이전트의 경험치가 레포에 쌓이는 느낌이라 꽤 신선했다.

진짜 백미 - 테스트가 버그를 찾으면 Claude가 고친다 🔧

E2E를 돌리는 주체가 "앱 소스를 전부 읽을 수 있는 에이전트"라는 게 어떤 의미냐면, 테스트 실패가 곧바로 버그 수정으로 이어진다는 거다. 실제로 이 조합으로 찾아서 고친 것들이다.

  • 특정 화면 진입 시 iOS 크래시(SIGSEGV) — 서버에서 내려온 대용량 HTML을 가공하는 정규식이 Kotlin/Native 정규식 엔진에서 글자마다 재귀해서 스택 오버플로가 났다. E2E가 크래시를 재현했고, 에이전트가 원인 정규식을 찾아 제거했다. (JVM에선 멀쩡한데 Native에서만 죽는, KMP 특유의 함정)
  • 등록 요청이 서버 400 — 요청 DTO에 서버가 필수로 요구하는 필드 하나가 빠져 있었다. 플로우 실패 → 서버 에러 메시지 → DTO 수정까지 한 사이클에 끝.
  • 목록 화면 진입 크래시 — 특정 케이스에서 서버가 필드를 null로 주는데 DTO가 non-null이라 파싱에서 죽었다. nullable 처리 + 파싱 방어 코드 추가.
  • 앱/서버 검증 불일치 — 금액 입력의 최솟값 검증 기준이 앱과 서버가 서로 달랐다. 앱에서 통과한 값을 서버가 거절 → 에이전트가 양쪽 기준을 대조해서 앱 상수를 수정.
  • 소셜 연동이 영구적으로 재연결 안내에 걸리는 문제 — OAuth scope 하나를 빼먹고 요청해서, 재연결을 해도 계속 재연결하라고 뜨는 상태. E2E가 차단 지점을 발견했고 Android/iOS 양쪽 연동 코드에 scope를 추가했다.

수동 QA였으면 유저 제보를 받고 나서야 디버깅을 시작했을 것들이다. 지금은 밤에 스위트 돌려놓으면 아침에 "이런 버그 찾아서 이렇게 고쳤다"는 리포트가 와 있는 흐름이 된다. 이게 되니까 QA 자동화가 아니라 거의 QA 엔지니어를 한 명 앉혀놓은 느낌이다.

한계도 있다 🥲

솔직하게 아쉬운 점도 적어본다.

  • 외부 SDK/웹뷰 화면은 어렵다. 소셜 로그인이나 결제창(PG)처럼 접근성 트리가 우리 통제 밖인 화면은 플로우가 자주 깨진다. 그래서 결제는 결제창 직전까지만 검증하고 복귀하도록 설계했다.
  • 쓰기 시나리오는 데이터 관리가 필요하다. 신청/등록 플로우는 테스트 서버 데이터를 실제로 바꾸니까, 멱등하게(이미 신청돼 있으면 취소 후 재신청) 설계하고 읽기 스위트와 분리해야 한다. 이 설계가 은근 품이 든다.
  • 시각적 검증은 못 한다. "버튼이 눌린다"는 보장해도 "버튼이 예쁘게 나온다"는 보장 못 한다. 레이아웃 깨짐은 스크린샷 찍어두고 사람(또는 에이전트) 눈으로 봐야 한다.
  • wasmJs는 대상이 아니다. Maestro는 모바일용이라 KMP의 웹 타겟까지는 커버 못 한다.

마무리 🙏

정리하면 이렇다.

KMP로 UI를 한 벌로 만들었다면, QA도 yaml 한 벌로 만들 수 있다.
그리고 그 yaml을 쓰고, 돌리고, 실패 원인을 고치는 것까지 에이전트에게 맡길 수 있다.

  • Maestro 덕분에 iOS/Android 스모크·시나리오 테스트가 스크립트 한 벌, 명령어 한 줄이 됐고
  • 플로우를 유저 타입별 × 피쳐별로 쪼개니 그 자체로 살아있는 QA 시나리오 문서가 됐고
  • Claude + Figma MCP 조합으로 화면 개편이 와도 플로우 갱신이 따라가고
  • E2E가 찾은 크래시/서버 불일치를 에이전트가 수정까지 하면서, 배포 전 불안감이 확 줄었다

KMP 하는 팀이라면 Maestro는 진짜 도입 비용 대비 효율이 미쳤다. 거기에 코드 읽을 줄 아는 에이전트까지 붙이면 QA의 차원이 달라진다. 끝!

profile
어제보다 나은 개발자가 되자

0개의 댓글