Appium으로 모바일 자동화 시작하기: 안드로이드와 iOS의 차이
- 09 Dec, 2025
Appium 시작하고 3일 만에 깨달은 것
Appium 공부 시작했다. 회사에서 모바일 앱 자동화하라고 했다. 첫날은 설치만 했다. 두 번째 날은 에러만 봤다. 셋째 날 실행됐는데 안드로이드만 됐다. iOS는 또 다른 세계더라.
크로스 플랫폼이라고 했다. 같은 코드로 두 플랫폼 테스트 가능하다고 했다. 반은 맞다. 반은 거짓말이다. 기본 개념은 같다. 세부 구현은 완전히 다르다.
지금 Appium 3개월 썼다. 안드로이드와 iOS 둘 다 돌린다. 아직도 매일 새로운 이슈 만난다. 이게 모바일 자동화의 현실이다.

안드로이드 시작: 상대적으로 쉬운 편
안드로이드부터 했다. 설정이 iOS보다 직관적이다. Android Studio 깔고, SDK 설치하고, AVD 만들면 된다. 에뮬레이터 띄우는 데 10분 걸렸다.
UIAutomator2가 기본이다. 요소 찾기가 편하다.
resource-id, content-desc, text로 찾으면 대부분 된다.
driver.find_element(AppiumBy.ID, "com.app:id/button") 이렇게.
앱 빌드도 간단하다. APK 파일 하나면 끝이다.
개발자한테 디버그 APK 받아서 adb install app.apk 치면 설치된다.
권한 문제도 별로 없다.
실제 코드 짜보니까 금방 돌았다.
driver.find_element(AppiumBy.ID, "username").send_keys("test")
driver.find_element(AppiumBy.ID, "password").send_keys("1234")
driver.find_element(AppiumBy.ID, "login_button").click()
이거 5분 만에 작동했다.
문제는 버전이다. 안드로이드 파편화가 심하다. API 28, 29, 30, 31 다 테스트해야 한다. 에뮬레이터 5개 만들었다. 디스크 용량 50GB 날아갔다.
스크롤도 플랫폼마다 다르다.
새로운 UiScrollable 쓰면 되는데 문법이 특이하다.
driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiScrollable(new UiSelector()).scrollIntoView(text("원하는텍스트"))')
처음엔 이게 뭔가 싶었다.
그래도 안드로이드는 괜찮다. 디버깅이 쉽다. Appium Inspector로 요소 바로 확인된다. xpath 복잡해도 일단 동작한다.

iOS 진입: 벽이 높다
iOS 시작했다. 막혔다. 설정부터 복잡하다. Xcode 깔고, Command Line Tools 설정하고, WebDriverAgent 빌드해야 한다. 첫날 4시간 걸렸다. 제대로 안 됐다.
Provisioning Profile이 문제였다. 서명 이슈다. 무료 Apple ID로는 7일마다 재빌드해야 한다. 회사 계정 쓰면 1년인데 설정이 어렵다.
시뮬레이터는 빠르다. 그런데 Mac만 된다. 윈도우 개발자는 테스트 못 돌린다. 우리 팀 절반이 윈도우 쓴다. 문제다.
XCUITest가 기본 드라이버다. 요소 찾기가 다르다.
안드로이드처럼 ID 없다. accessibility identifier 써야 한다.
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "LoginButton")
개발자가 accessibility 안 넣으면 못 찾는다.
처음엔 xpath만 썼다. //XCUIElementTypeButton[@name="Login"]
느리고 불안정하다. 개발자한테 요청했다.
버튼, 텍스트 필드 타입이 XCUIElement로 시작한다. XCUIElementTypeButton, XCUIElementTypeTextField 이런 식이다. 안드로이드랑 완전히 다르다. 코드 재사용 안 된다.
실기기는 더 복잡하다. UDID 등록해야 한다.
idevice_id -l 로 확인하고 capabilities에 넣는다.
케이블로 연결해야 한다. 무선은 불안정하다.
앱 설치도 까다롭다. .app 파일이 시뮬레이터용이다. 실기기는 .ipa 파일 필요하다. 서명도 맞아야 한다. 개발자한테 Ad-hoc 빌드 받아야 한다.
iOS 13 이후로 권한 팝업이 많다. “Allow ‘App’ to access your location?” 이런 거. 자동으로 처리 안 된다. 직접 클릭 코드 짜야 한다.
try:
alert = driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='Allow']")
alert.click()
except:
pass
예외 처리 안 하면 테스트 멈춘다.
한 달 걸렸다. iOS 자동화 제대로 돌리는 데. 안드로이드의 3배 시간 들었다. 아직도 가끔 서명 만료로 깨진다.
플랫폼별 Desired Capabilities: 설정이 다르다
안드로이드 capabilities는 간단하다.
android_caps = {
"platformName": "Android",
"platformVersion": "12",
"deviceName": "Pixel_5_API_31",
"app": "/path/to/app.apk",
"automationName": "UiAutomator2",
"appPackage": "com.example.app",
"appActivity": ".MainActivity"
}
appPackage랑 appActivity 넣으면 된다.
APK 안 넣고 이미 설치된 앱 실행 가능하다.
adb shell pm list packages 로 패키지명 확인한다.
iOS는 복잡하다.
ios_caps = {
"platformName": "iOS",
"platformVersion": "16.0",
"deviceName": "iPhone 14",
"app": "/path/to/app.app",
"automationName": "XCUITest",
"bundleId": "com.example.app",
"xcodeOrgId": "TEAMID",
"xcodeSigningId": "iPhone Developer",
"udid": "device-udid-here"
}
실기기는 UDID 필수다. xcodeOrgId도 넣어야 한다. 서명 관련 설정 빠뜨리면 에러 난다. 처음엔 뭐가 필요한지 몰라서 하나씩 추가했다.
autoAcceptAlerts 옵션이 있다. 권한 팝업 자동 승인한다.
"autoAcceptAlerts": True
근데 완벽하지 않다. 커스텀 팝업은 직접 처리해야 한다.
noReset 옵션도 중요하다.
"noReset": True
True면 앱 상태 유지한다. 로그인 풀리지 않는다. False면 매번 초기화한다. 깨끗하지만 느리다.
테스트 목적에 따라 바꾼다. 로그인 테스트는 noReset False. 로그인 후 기능 테스트는 True.
두 플랫폼 동시에 돌리려면 코드 분기해야 한다.
if platform == "android":
caps = android_caps
else:
caps = ios_caps
driver = webdriver.Remote("http://localhost:4723", caps)
처음엔 하나로 합치려 했다. 안 됐다. 공통 부분만 베이스로 두고 나머지 분리했다. 유지보수가 더 쉬웠다.

에뮬레이터 vs 실기기: 무엇을 선택할까
에뮬레이터부터 시작했다. 설정이 쉽다. Android Studio AVD Manager로 몇 번 클릭하면 만들어진다. Xcode 시뮬레이터도 바로 뜬다.
속도가 빠르다. 테스트 실행이 실기기보다 2배 빠르다. CI/CD 파이프라인에 넣기 좋다. Jenkins에서 돌리는데 문제없다.
비용이 없다. 무제한으로 만들 수 있다. 안드로이드 에뮬레이터 5개 띄워서 병렬 테스트 돌린다. 실기기면 불가능하다.
하지만 한계가 있다. 실제 사용자 환경이 아니다. 카메라, GPS, 블루투스 테스트 안 된다. 하드웨어 의존 기능은 실기기 필수다.
성능도 다르다. 에뮬레이터가 너무 빠르다. 실기기에서는 느린 애니메이션 기다려야 하는데 에뮬레이터는 바로 넘어간다. 타이밍 이슈 생긴다.
네트워크도 차이 난다. 에뮬레이터는 호스트 네트워크 쓴다. 실기기는 WiFi나 LTE다. 속도 다르다. 네트워크 지연 테스트는 실기기로 해야 한다.
우리 팀 전략은 이렇다.
- 개발 중: 에뮬레이터로 빠르게 테스트
- PR 머지 전: 실기기 1대로 스모크 테스트
- 릴리즈 전: 실기기 여러 대로 풀 테스트
실기기 관리가 일이다. 충전 신경 써야 한다. 케이블 빠지면 테스트 멈춘다. 회사 돈 들여서 기기 팜 만들었다.
안드로이드 실기기 3대, iOS 2대 있다. 삼성, LG, 픽셀 / 아이폰 12, 13 주요 버전 커버한다.
USB 허브로 다 연결했다. 동시 테스트 가능하다. Appium Grid 쓰면 병렬로 돌린다. 5대 동시 실행하면 30분 → 6분 걸린다.
기기 이름 라벨 붙였다. 헷갈린다. “Android_Samsung_S21”, “iOS_iPhone13” 코드에서 deviceName으로 지정한다.
처음엔 실기기만 고집했다. 시간 오래 걸렸다. 지금은 적절히 섞는다. 상황에 맞게. 완벽한 정답은 없다.
요소 찾기 전략: ID vs Accessibility vs XPath
안드로이드는 resource-id가 최고다.
driver.find_element(AppiumBy.ID, "com.app:id/login_button")
빠르고 안정적이다. 화면 구조 바뀌어도 동작한다.
개발자한테 고유 ID 달라고 한다. 처음엔 안 넣어줬다. 설득했다. “자동화 안 되면 수동으로 계속 테스트해야 합니다” 그랬더니 넣어줬다.
text로 찾는 건 위험하다.
driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'text("로그인")')
텍스트 바뀌면 깨진다. 다국어 지원하면 더 문제다. 영어 버전 테스트는 “Login”으로 바꿔야 한다.
content-desc도 좋다. 접근성 레이블이다.
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_button")
안드로이드와 iOS 둘 다 쓸 수 있다. 크로스 플랫폼 코드 작성할 때 유용하다.
iOS는 accessibility identifier가 표준이다.
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "LoginButton")
개발자가 accessibilityIdentifier 속성 넣어줘야 한다.
Swift 코드로 이렇게 넣는다고 한다.
button.accessibilityIdentifier = "LoginButton"
없으면 XPath 써야 한다.
XPath는 최후의 수단이다.
driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='로그인']")
느리다. 화면 구조 바뀌면 바로 깨진다. 3초 걸리던 게 XPath 쓰면 10초 걸린다.
복잡한 XPath는 더 나쁘다.
driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeOther[2]/XCUIElementTypeOther[1]/XCUIElementTypeButton[3]")
이건 유지보수 불가능하다. 절대 쓰지 말 것.
Class name으로도 찾는다.
driver.find_element(AppiumBy.CLASS_NAME, "android.widget.Button")
여러 개 나온다. 인덱스로 접근해야 한다.
buttons = driver.find_elements(AppiumBy.CLASS_NAME, "android.widget.Button")
buttons[2].click()
위험하다. 버튼 순서 바뀌면 끝이다.
우리 팀 규칙은 이렇다.
- 먼저 ID 또는 accessibility identifier 확인
- 없으면 개발자한테 요청
- 급하면 임시로 XPath, 나중에 리팩토링
- text는 상수로 관리
페이지 오브젝트 패턴 쓴다.
class LoginPage:
USERNAME = (AppiumBy.ID, "username")
PASSWORD = (AppiumBy.ID, "password")
LOGIN_BTN = (AppiumBy.ACCESSIBILITY_ID, "login_button")
def login(self, user, pw):
self.driver.find_element(*self.USERNAME).send_keys(user)
self.driver.find_element(*self.PASSWORD).send_keys(pw)
self.driver.find_element(*self.LOGIN_BTN).click()
로케이터 한 곳에 모았다. 변경 쉽다. 테스트 코드는 깔끔하다.
login_page = LoginPage(driver)
login_page.login("test", "1234")
처음엔 테스트 코드에 직접 썼다. 지옥이었다. 화면 하나 바뀌면 10개 파일 수정했다. 리팩토링하는 데 2주 걸렸다. 후회했다.
플랫폼별 제스처: 스와이프, 탭, 스크롤
터치 제스처가 핵심이다. 모바일은 마우스 없다. 안드로이드와 iOS 구현 방법이 다르다.
안드로이드 스와이프는 TouchAction 쓴다.
from appium.webdriver.common.touch_action import TouchAction
action = TouchAction(driver)
action.press(x=500, y=1000).wait(1000).move_to(x=500, y=300).release().perform()
좌표 기반이다. 화면 크기마다 다르다.
상대 좌표로 바꿨다.
size = driver.get_window_size()
start_x = size['width'] * 0.5
start_y = size['height'] * 0.8
end_y = size['height'] * 0.2
action.press(x=start_x, y=start_y).wait(1000).move_to(x=start_x, y=end_y).release().perform()
모든 기기에서 동작한다.
iOS는 좀 다르다. W3C Actions API 쓴다.
driver.execute_script("mobile: swipe", {"direction": "up"})
간단하다. 하지만 커스터마이징 어렵다.
정교한 스와이프는 여전히 좌표 써야 한다.
from selenium.webdriver.common.actions import interaction
from selenium.webdriver.common.actions.action_builder import ActionBuilder
from selenium.webdriver.common.actions.pointer_input import PointerInput
actions = ActionBuilder(driver, mouse=PointerInput(interaction.POINTER_TOUCH, "touch"))
actions.pointer_action.move_to_location(500, 1000)
actions.pointer_action.pointer_down()
actions.pointer_action.pause(1)
actions.pointer_action.move_to_location(500, 300)
actions.pointer_action.release()
actions.perform()
복잡하다. 처음엔 이해 안 됐다.
스크롤은 더 까다롭다. 안드로이드는 UiScrollable 쓴다.
driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR,
'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(text("찾을텍스트"))')
텍스트 찾을 때까지 스크롤한다.
iOS는 그런 게 없다. 직접 구현해야 한다.
def scroll_to_element(driver, element_id):
max_swipes = 10
for i in range(max_swipes):
try:
element = driver.find_element(AppiumBy.ACCESSIBILITY_ID, element_id)
return element
except:
size = driver.get_window_size()
driver.execute_script("mobile: scroll", {"direction": "down"})
raise Exception(f"Element {element_id} not found after {max_swipes} swipes")
무한 스크롤은 더 어렵다. 이전 요소 저장하고 같으면 멈춘다.
prev_page_source = ""
while True:
current_page_source = driver.page_source
if prev_page_source == current_page_source:
break
# 스크롤 로직
prev_page_source = current_page_source
탭과 클릭은 거의 같다.
element.click()
가끔 안 될 때 있다. 좌표로 직접 탭한다.
location = element.location
size = element.size
x = location['x'] + size['width'] / 2
y = location['y'] + size['height'] / 2
action = TouchAction(driver)
action.tap(x=x, y=y).perform()
더블 탭은 빠르게 두 번 탭한다.
action.tap(x=x, y=y).wait(100).tap(x=x, y=y).perform()
롱 프레스는 press().wait().release()다.
action.press(x=x, y=y).wait(2000).release().perform()
2초 누른다. 컨텍스트 메뉴 나온다.
핀치 줌은 복잡하다. 두 손가락 시뮬레이션이다. 거의 안 쓴다. 필요하면 개발자한테 버튼 만들라고 한다.
제스처 코드는 헬퍼 함수로 만들었다.
class GestureHelper:
@staticmethod
def swipe_up(driver):
# 구현
@staticmethod
def swipe_down(driver):
# 구현
@staticmethod
def scroll_to_text(driver, text):
# 구현
테스트에서 이렇게 쓴다.
GestureHelper.swipe_up(driver)
GestureHelper.scroll_to_text(driver, "설정")
깔끔하다. 재사용 쉽다.
크로스 플랫폼 코드 작성: 공통과 분기
처음엔 한 코드로 두 플랫폼 테스트하려 했다. 불가능했다. 요소 찾기부터 다르다.
공통 부분만 추출했다. 테스트 로직이다.
def test_login(driver):
login_page = get_login_page(driver)
login_page.enter_username("test")
login_page.enter_password("1234")
login_page.click_login()
assert login_page.is_logged_in()
get_login_page()가 플랫폼 구분한다.
def get_login_page(driver):
platform = driver.capabilities['platformName']
if platform == 'Android':
return AndroidLoginPage(driver)
else:
return IOSLoginPage(driver)
각 페이지 클래스는 같은 인터페이스 구현한다.
class LoginPageBase:
def enter_username(self, username):
raise NotImplementedError
def enter_password(self, password):
raise NotImplementedError
def click_login(self):
raise NotImplementedError
def is_logged_in(self):
raise NotImplementedError
class AndroidLoginPage(LoginPageBase):
USERNAME = (AppiumBy.ID, "com.app:id/username")
PASSWORD = (AppiumBy.ID, "com.app:id/password")
LOGIN_BTN = (AppiumBy.ID, "com.app:id/login")
def enter_username(self, username):
self.driver.find_element(*self.USERNAME).send_keys(username)
# ...
class IOSLoginPage(LoginPageBase):
USERNAME = (AppiumBy.ACCESSIBILITY_ID, "UsernameField")
PASSWORD = (AppiumBy.ACCESSIBILITY_ID, "PasswordField")
LOGIN_BTN = (AppiumBy.ACCESSIBILITY_ID, "LoginButton")
def enter_username(self, username):
self.driver.find_element(*self.USERNAME).send_keys(username)
# ...
로케이터만 다르다. 메서드는 동일하다. 테스트 코드는 플랫폼 몰라도 된다.
제스처도 분기한다.
class GestureHelper:
@staticmethod
def swipe_up(driver):
platform = driver.capabilities['platformName']
if platform == 'Android':
# 안드로이드 구현
else:
# iOS 구현
처음엔 if문 남발했다. 코드가 더러웠다. 지금은 클래스 분리했다. 깔끔하다.
설정 파일도 나눴다.
# config/android.py
ANDROID_CAPS = {
"platformName": "Android",
# ...
}
# config/ios.py
IOS_CAPS = {
"platformName": "iOS",
# ...
}
pytest fixture로 드라이버 생성한다.
@pytest.fixture
def driver(request):
platform = request.config.getoption("--platform")
if platform == "android":
caps = ANDROID_CAPS
else:
caps = IOS_CAPS
driver = webdriver.Remote("http://localhost:4723", caps)
yield driver
driver.quit()
명령어로 플랫폼 지정한다.
pytest --platform=android
pytest --platform=ios
양쪽 다 돌리려면 parameterize 쓴다.
@pytest.mark.parametrize("platform", ["android", "ios"])
def test_login(platform):
driver = get_driver(platform)
# 테스트 로직
CI/CD에서는 병렬로 돌린다.
jobs:
android:
runs-on: ubuntu-latest
steps:
- run: pytest --platform=android
ios:
runs-on: macos-latest
steps:
- run: pytest --platform=ios
안드로이드는 우분투에서, iOS는 맥에서 돌린다. 동시에 실행된다. 시간 절반으로 줄었다.
완전한 크로스 플랫폼은 불가능하다. 70% 정도 공통화 가능하다. 나머지는 분기해야 한다. 처음부터 이렇게 설계하면 유지보수 쉽다.
실제로 겪은 문제들과 해결
키보드 문제가 제일 짜증났다. 안드로이드는 키보드 올라오면 화면 가린다. 아래쪽 버튼 못 찾는다.
driver.hide_keyboard() 쓴다.
element.send_keys("text")
driver.hide_keyboard()
driver.find_element(AppiumBy.ID, "submit").click()
iOS는 키보드 내리기 어렵다. 화면 빈 곳 탭하거나 Return 키 쳐야 한다.
driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='Return']").click 