개념
이 레슨의 출발점
모듈 13 레슨 7에서 Appium이 무엇이고 웹 자동화와 무엇이 다른지 배웠고, 레슨 3·5·6에서 견고한 로케이터·대기 전략·불안정 관리 원칙을 웹 기준으로 익혔다. 이 레슨은 그 원칙을 모바일의 실제 API 위에서 다시 쓴다 — 같은 단어를 쓰지만 구현과 함정이 전부 다르기 때문이다.
Appium 2 이후의 구조 — 드라이버가 분리되어 있다
Appium은 네 덩어리로 나뉜다. 코어(API 정의), 드라이버(플랫폼에 붙는 부분), 클라이언트(각 언어 라이브러리), 플러그인(기능 확장). 예전에는 서버 하나에 모든 플랫폼 지원이 들어 있었지만, 지금은 서버를 설치한 뒤 필요한 드라이버를 따로 설치 한다.
npm install -g appium # 서버(코어)
appium driver install uiautomator2 # Android
appium driver install xcuitest # iOS
appium driver list --installed # 설치 상태 확인QA가 이 구조에서 얻는 것은 세 가지다. 버전 문제를 분리해서 말할 수 있고("Appium이 안 돼요"가 아니라 "서버는 정상인데 UiAutomator2 드라이버가 특정 Android 버전에서 실패한다"), 플랫폼별 업그레이드를 따로 하며(iOS 새 버전 대응은 XCUITest 드라이버만 올리면 되는 경우가 많다), 드라이버가 곧 기능 범위다(mobile: 확장 명령은 드라이버가 제공하므로 플랫폼마다 목록이 다르다).
Appium은 W3C WebDriver 프로토콜을 기반으로 한다. 오래된 예제의 TouchAction·MultiTouchAction 은 더 이상 권장되지 않으며, 제스처는 아래의 W3C Actions 또는 드라이버의 mobile: 확장 명령으로 쓴다. 서버 기본 경로와 각 API의 지원 중단 시점 등 버전에 얽힌 세부는 바뀌므로 Appium 공식 문서에서 원문 확인 한다.
로케이터 전략 — 우선순위를 고정한다
모바일 UI는 웹보다 구조가 자주 바뀌고, 같은 화면이라도 플랫폼마다 트리가 완전히 다르다. 로케이터는 취향이 아니라 팀 규칙 이어야 한다.
| 순위 | 전략 | 왜 이 순위인가 |
|---|---|---|
| 1 | 접근성 id (accessibility id) | 양 플랫폼 공통. 의도적으로 붙인 값이라 UI가 바뀌어도 잘 안 변한다. 접근성 검증(레슨 6)과 같은 자산을 쓴다 |
| 2 | 플랫폼 고유 id — Android resource-id, iOS accessibility identifier | 빠르고 안정적이지만 플랫폼별로 코드가 갈린다 |
| 3 | 플랫폼 고유 질의 — Android -android uiautomator, iOS -ios predicate string / -ios class chain | 네이티브 엔진이 직접 평가해 XPath보다 빠르고 표현력이 좋다 |
| 4 | 화면에 보이는 텍스트 | 문구·번역이 바뀌면 깨진다. 다국어 앱에서는 사실상 금지 |
| 5 | XPath | 최후의 수단. 느리고 트리 구조 변화에 극도로 취약하다 |
1순위를 강조하는 이유가 중요하다. 접근성 id는 자동화를 위한 별도 비용이 아니라 접근성 레이블과 같은 기반 이다. "자동화 때문에 id를 붙여 달라"가 아니라 "접근성과 자동화가 같이 요구하는 것"이라는 프레이밍이 개발팀 설득에 훨씬 잘 먹힌다.
Android와 iOS는 같은 이름이 다른 속성에 매핑된다. 이걸 모르면 "왜 Android에서만 되지?"가 반복된다.
| 개념 | Android(UiAutomator2) | iOS(XCUITest) |
|---|---|---|
| 접근성 id | content-desc | accessibilityIdentifier(없으면 label로 대체되기도) |
| 고유 id | resource-id (com.example:id/btn_pay) | accessibility identifier |
| 화면 텍스트 · 요소 종류 | text · class(android.widget.Button) | label/value · type(XCUIElementTypeButton) |
| 고유 질의 | -android uiautomator — new UiSelector().textContains("결제") | -ios predicate string — label CONTAINS '결제', -ios class chain |
그래서 실무에서는 플랫폼별 로케이터를 한 곳에 모으고 페이지 오브젝트는 공통으로 쓰는 구조를 택한다(모듈 13 레슨 4의 패턴을 모바일로 옮긴 형태다).
PAY_BUTTON = { # 접근성 id 를 양쪽 같은 문자열로 맞추면 분기 자체가 사라진다
"android": (AppiumBy.ACCESSIBILITY_ID, "pay_button"),
"ios": (AppiumBy.ACCESSIBILITY_ID, "pay_button"),
}
AMOUNT_FIELD = {
"android": (AppiumBy.ID, "com.example:id/amount"),
"ios": (AppiumBy.IOS_PREDICATE, "type == 'XCUIElementTypeTextField' AND name == 'amount'"),
}XPath를 금지하지는 않되, 쓰는 순간을 기록한다. id가 없는 화면은 반드시 남는다. XPath로 임시로 막되 "id 요청" 티켓을 같이 만든다. 절대 하지 말 것은 도구가 자동 생성한 절대 경로 XPath(/hierarchy/android.widget.FrameLayout[1]/…)를 그대로 쓰는 것이다 — 한 번의 레이아웃 변경으로 전부 깨진다.
로케이터를 고르려면 실제 트리를 봐야 한다. Appium Inspector 가 표준 도구이고, Android는 Layout Inspector, iOS는 Xcode의 Accessibility Inspector(레슨 6)로 교차 확인한다. 세션 중에는 driver.page_source 로 현재 화면의 XML 트리를, driver.get_window_size() 로 제스처 좌표 계산의 기준이 되는 논리 좌표계 크기를 얻는다. page_source 는 현재 컨텍스트 의 트리만 준다 — 네이티브 컨텍스트에서 웹뷰 안을 찾으면 아무것도 안 나온다.
하이브리드 앱 — NATIVE_APP과 WEBVIEW를 오간다
하이브리드 앱은 네이티브 껍데기 안에 웹뷰가 있다. Appium은 둘을 컨텍스트 로 구분하고 한 번에 하나만 본다.
contexts = driver.contexts # ['NATIVE_APP', 'WEBVIEW_com.example.app', ...]
driver.switch_to.context(next(c for c in contexts if c.startswith("WEBVIEW")))
driver.find_element(AppiumBy.CSS_SELECTOR, "#submit").click() # 웹 로케이터가 동작한다
driver.switch_to.context("NATIVE_APP") # 반드시 되돌아온다실무에서 걸리는 지점은 API가 아니라 환경 이다.
- 웹뷰가 목록에 안 나온다. 대개 앱의 웹뷰가 디버깅 가능 상태가 아니어서다. Android는 앱 코드에서 웹뷰 디버깅을 켜 줘야 하고(디버그 빌드에서만 켜는 팀이 많다), iOS는 웹 인스펙터 계열 설정이 필요하다. "테스트 빌드에서는 웹뷰 디버깅을 켠다"를 팀 합의로 만드는 것 이 이 레슨의 실무 조치다
- Android는 Chromedriver가 따로 필요하다. 기기의 시스템 WebView·Chrome 버전과 맞는 것이 있어야 하고, 어긋나면 전환이 실패한다. 기기마다 WebView 버전이 다르므로 디바이스 팜에서 특정 기기만 실패하는 전형적 원인 이다(레슨 4의 "축으로 가르기")
- 컨텍스트 이름이 여러 개다. 첫 번째를 무조건 집는 코드는 언젠가 엉뚱한 웹뷰를 잡는다. 제목이나 URL로 식별하는 보조 로직을 둔다
- 전환 직후가 비어 있다. 문서가 아직 로드되지 않은 상태가 흔하다. 전환 후 명시적 대기 를 건다
- 되돌아오지 않는다. 웹뷰에 머문 채 네이티브 요소를 찾으면 전부 실패한다.
try/finally로 원복을 보장한다
@contextmanager # 전환·대기·원복을 한 군데로 모은다
def webview(driver, timeout=10):
original = driver.current_context
handle = WebDriverWait(driver, timeout).until(
lambda d: next((c for c in d.contexts if c.startswith("WEBVIEW")), None))
driver.switch_to.context(handle)
try:
yield
finally:
driver.switch_to.context(original)"웹뷰가 보이는데 클릭이 안 된다"의 상당수는 네이티브 좌표와 웹 좌표가 어긋난 문제 다. 키보드나 네이티브 바가 웹뷰를 가리면 웹 로케이터는 요소를 찾지만 실제 탭은 다른 곳에 떨어진다. 네이티브 컨텍스트로 돌아가 스크롤하거나 키보드를 내린 뒤 다시 시도한다.
W3C Actions API — 제스처를 직접 조립한다
스와이프·롱프레스·핀치에는 전용 명령이 없다. 입력 장치를 정의하고 동작을 시간순으로 나열해 보내는 것이 W3C WebDriver의 방식이다. 구조는 네 가지 입력 소스(none, key, pointer, wheel)와 동작 항목(pointerMove, pointerDown, pointerUp, pause)으로 이루어진다. 포인터는 mouse·pen·touch 중 하나이고 모바일에서는 touch를 쓴다. pointerMove 의 기준점(origin)은 viewport·pointer·element 중에서 고른다. 서버 엔드포인트는 POST /session/{id}/actions(수행)와 DELETE /session/{id}/actions(입력 상태 해제)다.
스와이프 — 손가락 하나가 내려가고, 움직이고, 떼는 세 동작이다.
def swipe(driver, start, end):
finger = PointerInput(interaction.POINTER_TOUCH, "finger")
actions = ActionBuilder(driver, mouse=finger)
actions.pointer_action.move_to_location(*start)
actions.pointer_action.pointer_down()
actions.pointer_action.pause(0.1) # 관성 대신 안정적인 드래그
actions.pointer_action.move_to_location(*end)
actions.pointer_action.pointer_up()
actions.perform()
size = driver.get_window_size(); cx = size["width"] // 2 # 항상 비율로 계산한다
swipe(driver, (cx, int(size["height"] * 0.8)), (cx, int(size["height"] * 0.2)))좌표를 화면 크기의 비율로 계산 하는 것이 핵심이다. 절대 픽셀을 박으면 해상도가 다른 기기에서 전부 깨진다. 시작점은 가장자리에서 충분히 떨어뜨린다 — 가장자리는 OS의 뒤로 가기·알림창 제스처 영역이라 시스템이 먼저 먹는다.
롱프레스 는 같은 뼈대에서 pointer_down() 뒤에 pause(1.2) 를 넣고 떼면 된다. 핀치(확대/축소) 는 손가락이 둘이므로 포인터 입력 소스를 두 개 만들어 add_input() 으로 등록하고 서로 반대 방향으로 움직인다 — 두 시퀀스의 동작 개수를 맞추면 스텝 단위로 동기화된다.
실무 조언이 하나 붙는다. 드라이버가 mobile: 확장 명령을 제공하면 그것을 먼저 쓴다. UiAutomator2와 XCUITest 드라이버는 스크롤·스와이프·핀치에 해당하는 확장 명령을 제공하고, 플랫폼 네이티브 구현이라 직접 조립한 것보다 안정적인 경우가 많다. Actions API는 확장 명령으로 표현되지 않는 제스처 를 만들 때의 무기다. 지원 목록은 드라이버 문서에서 원문 확인 한다.
대기 — 암묵적과 명시적을 섞지 않는다
암묵적 대기(implicit wait) 는 세션 전역 설정으로 요소를 못 찾으면 지정 시간까지 재시도하고, 명시적 대기(explicit wait) 는 "이 조건이 참이 될 때까지"를 매번 지정한다.
driver.implicitly_wait(0) # 암묵적 대기는 끄고 시작한다
WebDriverWait(driver, 15).until(
EC.element_to_be_clickable((AppiumBy.ACCESSIBILITY_ID, "pay_button"))
).click()둘을 같이 쓰면 대기 시간이 예측 불가능해진다. 암묵적 대기가 켜진 상태에서 "요소가 없어야 한다"를 확인하면 매번 전체 시간을 기다려 느려지고, 실패까지의 시간도 불규칙해진다. 암묵적 대기 0, 명시적 대기만 사용 으로 팀 규칙을 고정하는 편이 관리하기 쉽다.
모바일에서는 조건이 한 겹 더 필요하다. "존재한다"와 "탭할 수 있다"가 다르기 때문이다 — 애니메이션 중인 요소는 트리에 있지만 탭이 엉뚱한 좌표에 떨어진다. element_to_be_clickable 처럼 상호작용 가능성까지 보는 조건 을 쓰고, 그래도 불안정하면 "요소 좌표가 두 번 연속 같을 때까지" 같은 사용자 정의 조건을 만든다.
모바일 특유의 불안정 요인
모듈 13 레슨 6의 불안정 관리 원칙 위에, 모바일에만 있는 원인을 얹는다.
| 원인 | 증상 | 대응 |
|---|---|---|
| 화면 전환 애니메이션 | 탭이 이전 화면에 떨어진다 | 상호작용 가능 조건으로 대기. 테스트 환경에서 애니메이션 배율 0 설정 |
| 권한 팝업(레슨 2) | 엉뚱한 시점에 떠서 탭을 가로챈다 | 세션 시작 전 adb pm grant 로 권한 선부여, 또는 자동 수락 옵션 |
| 키보드·시스템 대화상자 | 요소를 가리거나 탭을 가로챈다 | 입력 후 키보드를 닫고 진행. 기기 설정을 고정 |
| 웹뷰 로딩 | 컨텍스트 전환 직후 빈 문서 | 전환 후 문서 준비 상태를 대기 |
| 앱 콜드 스타트 | 첫 실행만 느려 타임아웃 | 앱 시작 대기를 별도 상수로 분리 |
| 기기 성능 편차 | 저사양 기기에서만 실패 | 기기별로 시간을 늘리지 말고 조건 기반 대기 로 전환 |
| 네트워크 | 목록이 비어 단언 실패 | 목을 쓰거나 데이터 준비를 API로(모듈 13 레슨 10) |
저사양 기기에서 깨진다고 대기 시간을 늘리는 것은 해결이 아니다. 전체 실행이 느려지고 원인은 남는다. 조건 기반 대기로 바꾸면 빠른 기기에서는 빠르게, 느린 기기에서는 필요한 만큼만 기다린다.
실무에서 왜 필요한가
모바일 자동화가 실패하는 팀의 패턴은 거의 같다. 도구를 못 다뤄서가 아니라 XPath로 시작해 불안정을 시간 늘리기로 덮다가 아무도 결과를 안 믿게 되는 경로다. 로케이터 우선순위를 규칙으로 못 박고, 암묵적 대기를 끄고, 제스처를 비율 좌표로 쓰는 세 가지만 초기에 잡아도 유지보수 비용이 크게 달라진다.
하이브리드 컨텍스트 전환은 특히 국내 앱에서 자주 필요하다. 결제·본인확인·약관·이벤트 페이지가 웹뷰로 들어오는 구조가 흔해서, 컨텍스트를 못 넘으면 핵심 플로우 자동화가 통째로 막힌다. "웹뷰 디버깅을 테스트 빌드에서 켜 달라", "Chromedriver 버전 매칭을 CI에서 관리하자"를 제때 요청하는 것이 QA의 실질적 기여다.
채용시장에서의 위치
모바일 자동화 경력 면접에서 변별력이 큰 질문은 "로케이터를 어떻게 고르시나요"와 "하이브리드 앱은 어떻게 다루시나요" 두 개다. 전자는 접근성 id를 1순위로 두는 이유를 접근성과 자동화가 같은 자산을 쓴다 는 맥락으로 설명할 수 있는지가 갈림길이고, 후자는 컨텍스트 목록·Chromedriver 매칭·전환 후 대기까지 말할 수 있으면 해 본 사람으로 읽힌다. W3C Actions로 제스처를 조립해 본 경험은 그 위의 가점이다.