Appium 병렬 테스트 포트 설정, Android와 iOS 차이

3줄 요약
- 안드로이드
systemPort는 8200~8299에서 자동으로 빈 포트를 잡아주지만, iOSwdaLocalPort는 8100 하나로 고정이라 두 번째 세션부터 충돌합니다. - 기기 1대에서 잘 돌던 스위트를 4대로 늘릴 때 유니크하게 지정해야 하는 포트는 안드로이드 4종, iOS 3종이며, 워커 인덱스 하나로 전부 계산할 수 있습니다.
- 아래 워커 인덱스 기반 포트 할당표를 그대로 옮겨 쓰시고, 세션이 남아 대역이 고갈될 때는
adb -s <UDID> forward --remove-all로 정리하시면 됩니다.
기기 두 대까지는 아무 일도 없었습니다. 안드로이드 실기기 두 대를 맥미니에 물려놓고 병렬로 회귀 스위트를 돌렸는데, 별다른 설정 없이 그냥 됐습니다. 그래서 저는 병렬 실행이 이미 해결된 문제라고 생각했습니다. 기기를 다섯 대로 늘리고 아이폰 두 대를 섞은 다음 날, 파이프라인이 절반쯤에서 끝없이 빨간 불을 냈습니다.
로그를 열어보니 안드로이드 쪽은 여전히 멀쩡했고 아이폰 쪽만 두 번째 세션부터 무너져 있었습니다. 처음에는 기기 문제인 줄 알고 케이블을 바꿔 끼웠고, 다음에는 Xcode 버전을 의심했습니다. 실제 원인은 훨씬 단순했습니다. 안드로이드 드라이버는 포트를 안 적으면 알아서 빈 포트를 찾아주고, iOS 드라이버는 안 적으면 항상 같은 포트를 씁니다. 이 비대칭을 모르면 "안드로이드에서 됐으니 iOS도 되겠지"라는 판단이 그대로 깨집니다.
결론부터 적겠습니다. 병렬 실행의 포트 문제는 워커 인덱스 하나로 전부 계산해서 넘기면 끝납니다. 어려운 건 계산이 아니라, 유니크해야 하는 포트가 몇 종인지 아는 것과 자동 할당이 만드는 거짓 안전감을 걷어내는 것입니다. 이 글은 기기 두 대에서 다섯 대로 넘어가면서 제가 밟은 순서와 막힌 지점을 그대로 적은 기록입니다.
1. 안드로이드는 조용했고 iOS는 즉시 터졌습니다
자동 할당이 있는 쪽
Appium UiAutomator2 드라이버 문서의 appium:systemPort 설명은 이렇게 되어 있습니다. 호스트 머신에서 UiAutomator2 서버가 쓸 포트 번호이고, 기본적으로 8200부터 8299 사이에서 첫 번째로 비어 있는 포트가 선택된다는 내용입니다. 같은 머신에서 병렬 테스트를 돌린다면 이 값을 직접 지정하기를 권한다는 문장도 함께 붙어 있습니다.
핵심은 "첫 번째로 비어 있는 포트"입니다. 세션을 두 개 띄우면 하나는 8200, 다른 하나는 8201을 잡습니다. 아무것도 지정하지 않아도 충돌이 나지 않습니다. 그래서 기기를 두 대만 붙였을 때 제 스위트가 아무 문제 없이 돌았던 것입니다. 문서가 권하는 대로 하지 않았는데도 결과가 정상이었으니, 저는 이 항목을 읽고도 넘겼습니다.
웹뷰 쪽에도 같은 성질의 항목이 하나 더 있습니다. appium:webviewDevtoolsPort는 devtools 통신에 쓰는 로컬 포트이고 기본적으로 10900부터 11000 범위에서 첫 번째 빈 포트가 선택됩니다. 문서에는 병렬 테스트를 돌린다면 직접 값을 지정하는 것을 고려하라고 적혀 있습니다. 역시 자동 할당이 있으니 소규모에서는 조용합니다.
고정값이 있는 쪽
iOS는 사정이 다릅니다. XCUITest 드라이버의 병렬 테스트 가이드를 보면 wdaLocalPort는 각 병렬 세션마다 유니크한 포트 번호여야 하고 기본값은 8100이라고 되어 있습니다. 범위가 아니라 숫자 하나입니다. 자동으로 다음 포트를 찾아주는 동작이 없습니다.
그러니 두 번째 세션이 뜨는 순간 이미 8100을 쓰고 있는 WebDriverAgent와 부딪힙니다. 운이 나쁘면 에러가 아니라 더 나쁜 결과가 나옵니다. 세션은 열렸는데 명령이 엉뚱한 기기로 흘러가는 것입니다. 저는 이 상태에서 로그를 두 시간 읽었습니다. 아이폰 A를 대상으로 한 테스트가 아이폰 B의 화면을 조작하고 있었고, 스크린샷만 보고는 왜 안 되는지 짐작이 가지 않았습니다.
영상을 녹화하고 있었다면 mjpegServerPort도 같은 문제를 냅니다. 같은 문서에 각 병렬 세션마다 유니크한 포트여야 하며 기본값은 9100이라고 적혀 있습니다. 저희는 실패한 케이스만 영상을 남기는 설정이었는데, 하필 실패가 몰리는 구간에서 여러 세션이 동시에 녹화를 시작해 영상 파일이 서로 섞였습니다.
두 드라이버의 차이를 한 장으로
| 항목 | 안드로이드(UiAutomator2) | iOS(XCUITest) |
|---|---|---|
| 자동 서버 포트 | systemPort — 8200~8299 중 첫 빈 포트 자동 선택 |
wdaLocalPort — 8100 고정, 자동 탐색 없음 |
| 웹뷰 포트 | webviewDevtoolsPort — 10900~11000 자동 선택 |
해당 없음 |
| 브라우저 드라이버 포트 | chromedriverPort — 미지정 시 임의의 빈 포트 |
해당 없음 |
| 영상 스트림 포트 | mjpegServerPort — 캡 기본값 없음(미지정 시 포워딩 자체가 없음) |
mjpegServerPort — 9100 고정 |
| 빌드 산출물 경로 | 해당 없음 | derivedDataPath — 인스턴스별 분리 권장 |
| 기기 식별 | udid 또는 avd |
udid 또는 deviceName+platformVersion |
제 해석으로는 이 표에서 굵게 표시한 두 줄이 사고의 90%를 만듭니다. 자동 탐색이 있는 항목은 소규모에서 문제를 숨기고, 고정값 항목은 두 번째 세션부터 바로 드러냅니다. 문제가 드러나는 시점이 다를 뿐 둘 다 지정하지 않으면 언젠가 터집니다.
2. 워커 인덱스 하나로 전부 계산합니다
대역을 겹치지 않게 나눕니다
포트를 하나씩 손으로 적는 방식은 기기가 세 대만 넘어가도 관리가 안 됩니다. 저는 워커 인덱스 i를 0부터 매기고 대역마다 시작점을 잡아 더하는 방식으로 바꿨습니다. 대역이 서로 겹치지 않게만 잡으면 사람이 계산할 일이 없어집니다.
| 용도 | 시작점 | 워커 0 | 워커 1 | 워커 2 | 워커 3 |
|---|---|---|---|---|---|
systemPort (Android) |
8200 | 8200 | 8201 | 8202 | 8203 |
chromedriverPort (Android) |
8300 | 8300 | 8301 | 8302 | 8303 |
mjpegServerPort (Android, 호스트) |
9200 | 9200 | 9201 | 9202 | 9203 |
webviewDevtoolsPort (Android) |
10900 | 10900 | 10901 | 10902 | 10903 |
wdaLocalPort (iOS) |
8100 | 8100 | 8101 | 8102 | 8103 |
mjpegServerPort (iOS) |
9100 | 9100 | 9101 | 9102 | 9103 |
| Appium 서버 포트(per-process일 때) | 4723 | 4723 | 4724 | 4725 | 4726 |
systemPort는 8200~8299가 문서에 명시된 범위이므로 워커를 100개 이상 띄우지 않는 한 안전합니다. chromedriverPort는 문서에 정해진 범위가 없어서 저는 8300번대를 임의로 잡았습니다. 다른 로컬 서비스와 겹치지 않는지만 한 번 확인하시면 됩니다.
코드로는 이렇게 넘깁니다
파이썬에서 워커 인덱스를 받아 케이퍼빌리티를 만드는 함수입니다. pytest-xdist를 쓴다면 worker_id가 gw0, gw1 형태로 들어오므로 숫자만 떼어내면 됩니다.
# 워커 인덱스 하나로 모든 포트를 계산합니다.
# 대역 시작점만 상수로 두면 기기가 늘어도 코드를 고칠 일이 없습니다.
PORT_BASE = {
"systemPort": 8200, # 문서상 8200~8299 범위
"chromedriverPort": 8300, # 문서에 범위 없음 — 팀에서 임의로 확보한 대역
"mjpegServerPort": 9200, # 호스트 측 포워딩 포트
"webviewDevtoolsPort": 10900,
"wdaLocalPort": 8100, # 기본값 8100 — 지정하지 않으면 전 세션이 공유합니다
"iosMjpegServerPort": 9100,
}
def android_caps(udid: str, worker: int) -> dict:
return {
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:udid": udid, # 병렬이면 반드시 지정
"appium:systemPort": PORT_BASE["systemPort"] + worker,
"appium:chromedriverPort": PORT_BASE["chromedriverPort"] + worker,
"appium:mjpegServerPort": PORT_BASE["mjpegServerPort"] + worker,
"appium:webviewDevtoolsPort": PORT_BASE["webviewDevtoolsPort"] + worker,
"appium:newCommandTimeout": 120,
}
def ios_caps(udid: str, worker: int) -> dict:
return {
"platformName": "iOS",
"appium:automationName": "XCUITest",
"appium:udid": udid,
"appium:wdaLocalPort": PORT_BASE["wdaLocalPort"] + worker,
"appium:mjpegServerPort": PORT_BASE["iosMjpegServerPort"] + worker,
# derivedDataPath를 기기별로 분리해야 빌드 산출물이 서로 덮어쓰지 않습니다
"appium:derivedDataPath": f"/Users/ci/wda-dd/{udid}",
"appium:newCommandTimeout": 120,
}
def worker_index(worker_id: str) -> int:
# pytest-xdist의 'gw3' → 3, 단일 실행이면 'master' → 0
return int(worker_id[2:]) if worker_id.startswith("gw") else 0
udid를 빼면 계산이 무의미해집니다
포트를 아무리 잘 나눠도 기기를 지정하지 않으면 소용이 없습니다. UiAutomator2 문서의 appium:udid 설명에는 값을 지정하지 않으면 드라이버가 첫 번째로 연결된 기기를 쓰려 한다고 되어 있고, 병렬 테스트를 돌린다면 항상 이 케이퍼빌리티를 지정하라는 문장이 붙어 있습니다.
저희 팀에서 실제로 났던 일입니다. 워커 네 개가 서로 다른 systemPort를 정확히 들고 있었는데 udid가 빠져 있었고, 네 워커가 전부 adb devices 목록의 첫 기기를 잡았습니다. 로그에는 포트가 예쁘게 나뉘어 있어서 설정이 맞는 줄 알았습니다. 결과적으로는 기기 한 대에서 네 개의 스위트가 서로 앱을 죽이며 돌았고, 실패 원인이 매번 달라져서 플레이키로 분류될 뻔했습니다.
3. 세 가지 함정
첫 번째 함정 — 자동 할당이 준 거짓 안전감
가장 먼저 밟은 함정이자 가장 흔한 함정입니다. 안드로이드만 두 대로 시작한 팀은 포트를 지정하지 않아도 됩니다. 문제가 안 나니 설정도 안 합니다. 그러다 기기가 늘거나 iOS가 들어오는 순간, 원인을 아는 사람이 팀에 없는 상태에서 파이프라인이 무너집니다.
제가 권하는 대응은 단순합니다. 기기가 한 대일 때 포트를 명시적으로 지정해 두는 것입니다. 한 대일 때 systemPort: 8200을 적어도 아무 이득이 없어 보이지만, 그 한 줄이 나중에 확장할 때 "여기에 워커 인덱스를 더하면 되겠구나"라는 자리 표시가 됩니다. 비용이 0에 가깝고 회수 시점만 미뤄져 있는 투자입니다.
두 번째 함정 — 포트를 고정해도 대역이 고갈됩니다
포트를 다 나눴는데도 어느 날 이런 에러가 났습니다. Appium 커뮤니티에 같은 증상의 스레드가 남아 있는데, 에러 문자열은 이렇게 생겼습니다.
An unknown server-side error occurred while processing the command.
Original error: Cannot find any free port in range 8200..8299}.
Please set the available port number by providing the systemPort capability
or double check the processes that are locking ports within this range
and terminate these which are not needed anymore
메시지는 systemPort를 지정하라고 안내하지만, 이미 지정한 상태에서도 납니다. 진짜 원인은 종료되지 않은 세션이 adb forward 항목을 남겨 대역을 갉아먹는 것입니다. 스위트가 중간에 죽거나 CI 잡이 타임아웃으로 강제 종료되면 정리 코드가 돌지 않고, 그런 실행이 며칠 쌓이면 100개 슬롯이 채워집니다.
정리 명령은 이렇습니다. CI 잡의 시작과 끝에 모두 넣어두는 편이 안전합니다.
#!/usr/bin/env bash
# CI 잡 시작 전에 실행합니다. 이전 실행이 남긴 포워딩과 좀비 세션을 정리합니다.
set -euo pipefail
# 1) 연결된 기기별로 잔여 포트 포워딩을 제거합니다
adb devices | awk 'NR>1 && $2=="device" {print $1}' | while read -r UDID; do
echo "정리 중: $UDID"
adb -s "$UDID" forward --remove-all
adb -s "$UDID" reverse --remove-all || true
done
# 2) 8200~8299 대역을 실제로 점유 중인 프로세스를 확인합니다
echo "--- 8200~8299 점유 현황 ---"
lsof -nP -iTCP -sTCP:LISTEN | awk '$9 ~ /:82[0-9][0-9]$/ {print $1, $2, $9}'
# 3) iOS 쪽은 WebDriverAgent 잔여 프로세스를 확인합니다 (8100번대)
lsof -nP -iTCP -sTCP:LISTEN | awk '$9 ~ /:81[0-9][0-9]$/ {print $1, $2, $9}'
adb -s <UDID> forward --remove-all은 위 스레드에서 실제로 제시된 해법이고, 저희 환경에서도 이 한 줄로 재발이 멈췄습니다. 다만 근본 대응은 세션을 확실히 닫는 것입니다. 테스트 프레임워크의 teardown이 예외 상황에서도 반드시 실행되는지 확인하시기 바랍니다.
가장 비싼 함정 — 워커마다 서버를 띄우는 습관
이 함정은 에러를 내지 않고 돈만 씁니다. UiAutomator2와 XCUITest 두 드라이버 문서가 똑같이 두 가지 방식을 설명합니다. 프로세스 단위(여러 서버 프로세스가 서로 다른 포트에서 각각 한 세션을 관리)와 요청 단위(서버 프로세스 하나가 여러 세션을 관리)입니다. 문서는 요청 단위를 더 선호한다고 적으면서 자원을 덜 쓰고 실행 중인 세션을 더 잘 통제할 수 있다는 이유를 붙입니다.
| 항목 | 프로세스 단위(서버 N개) | 요청 단위(서버 1개) |
|---|---|---|
| 서버 포트 관리 | 워커마다 4723, 4724, … 별도 관리 | 4723 하나 |
| 자원 사용 | 워커 수만큼 Node 프로세스 | 프로세스 1개 |
| 세션 통제 | 서버별로 흩어져 파악이 어려움 | 한 곳에서 전체 세션 조회 가능 |
| 한 워커 크래시 영향 | 해당 워커만 죽음 | 서버가 죽으면 전체 중단 |
| 로그 | 파일이 워커 수만큼 분산 | 한 파일에 섞여 세션 ID로 구분 |
| 공식 문서 권장 | — | 더 선호(more preferable) |
저는 처음에 프로세스 단위로 짰습니다. 이유는 단순합니다. 인터넷의 예제 대부분이 그렇게 되어 있었고, 한 워커가 죽어도 나머지가 산다는 점이 안전해 보였습니다. 실제로 운영해 보니 맥미니 한 대에서 Node 프로세스 다섯 개가 메모리를 나눠 쓰면서 세션 시작 시간이 눈에 띄게 늘었습니다.
제 해석으로는 QA 4~6명 규모에서 기기 열 대 이하라면 요청 단위가 낫습니다. 서버가 죽으면 전체가 멈춘다는 위험은 있지만, 그 규모에서는 서버 크래시보다 포트 설정 실수로 잃는 시간이 훨씬 큽니다. 로그가 한 파일에 모이는 것도 실제로는 장점이었습니다. 세션 ID로 필터링하면 되고, 여러 세션이 같은 시각에 무엇을 하고 있었는지 한 화면에서 볼 수 있습니다.
4. 도입 난이도와 유지보수 비용
세 축으로 본 평가
| 평가 축 | 판단 | 근거 |
|---|---|---|
| 도입 난이도 | 낮음 | 케이퍼빌리티 다섯 줄 추가와 워커 인덱스 전달이 전부입니다. 새 도구를 붙이지 않습니다 |
| 유지보수 비용 | 낮음~중간 | 대역 상수만 한 곳에서 관리하면 기기가 늘어도 코드 변경이 없습니다. 잔여 세션 정리 스크립트를 CI에 붙이는 일이 유일한 상시 부담입니다 |
| 팀 학습곡선 | 중간 | 포트 자체는 쉽지만 "왜 안드로이드는 조용하고 iOS는 터지는가"를 이해해야 응급 대응이 됩니다. 이 표와 할당표를 팀 위키에 붙여두면 반나절이면 공유됩니다 |
4~6명 팀에서 실제로 든 시간
저희 기준으로 케이퍼빌리티 함수 작성과 CI 정리 스크립트 추가까지 반나절, 기기 다섯 대에서 전체 스위트를 두 번 돌려 안정성을 확인하는 데 하루가 더 들었습니다. 합쳐서 이틀이 안 됩니다. 그 전까지 원인 불명의 실패를 쫓아다닌 시간이 사흘이었으니, 회수는 첫 주에 끝났다고 봅니다.
주의할 점은 회수 계산에 기기 확보 비용이 들어가 있지 않다는 것입니다. 실기기를 늘리는 순간 보관, 충전, OS 업데이트 관리, 배터리 팽창 같은 물리적 유지비가 따라옵니다. 저는 이 비용이 포트 설정 비용보다 훨씬 크다고 봅니다. 병렬 실행 설정은 쉬운 쪽이고, 기기 운영이 어려운 쪽입니다.
실행 시간이 기대만큼 줄지 않는 경우
기기를 두 배로 늘려도 실행 시간이 절반이 되지는 않습니다. 저희는 다섯 대에서 이론상 5분의 1을 기대했지만 실제로는 3분의 1 수준에 머물렀습니다. 원인은 앱 설치와 세션 시작 오버헤드가 기기마다 반복되기 때문입니다.
여기서 도움이 되는 항목이 appium:skipServerInstallation입니다. 문서에는 대상 기기에 UiAutomator2 서버 컴포넌트 설치와 관련 검사를 건너뛴다고 되어 있고, 올바른 버전이 이미 설치돼 있다고 확신한다면 세션 시작을 빠르게 하는 데 도움이 된다고 적혀 있습니다. 다만 서버가 설치돼 있지 않거나 잘못된 버전이면 나중에 예상치 못한 에러를 볼 수 있다는 경고가 함께 있습니다. 기본값은 false입니다.
저는 이 옵션을 전용 CI 기기에서만 켜고, 개발자 로컬에서는 끄는 쪽으로 나눴습니다. CI 기기는 OS와 드라이버 버전이 통제되지만 로컬은 그렇지 않기 때문입니다.
5. 권할 만한 경우와 권하지 않는 경우
권할 만한 경우는 이렇습니다. 회귀 스위트가 30분을 넘겨 하루에 여러 번 돌리기 부담스러운 팀, 실기기를 이미 두 대 이상 갖고 있는데 순차로만 쓰고 있는 팀, 안드로이드로 시작해서 iOS를 곧 추가할 예정인 팀입니다. 특히 세 번째 경우라면 iOS가 들어오기 전에 미리 포트를 나눠 두시는 편이 좋습니다. 나중에 하면 원인 추적과 설정 변경을 동시에 해야 합니다.
권하지 않는 경우도 분명합니다. 아직 스위트가 불안정해서 순차 실행에서도 플레이키가 많은 팀은 병렬을 먼저 붙이면 안 됩니다. 병렬은 기존 플레이키를 그대로 두고 새 변수를 얹는 일이라, 실패 원인이 테스트 자체인지 병렬 환경인지 구분이 안 됩니다. 저희도 순차 기준으로 재실행 없이 통과율이 안정되기 전까지는 병렬 확대를 미뤘습니다.
기기가 한 대뿐인 팀도 지금 당장 할 일은 없습니다. 다만 앞서 적은 대로 케이퍼빌리티에 포트를 명시적으로 적어두는 한 줄만 넣어두시면, 나중에 확장할 때 고칠 곳이 한 곳으로 줄어듭니다.
확장 전에 돌려볼 체크리스트
기기를 늘리기 직전에 이 스크립트를 한 번 돌려 환경 상태를 확인하시기 바랍니다.
#!/usr/bin/env bash
# 병렬 확대 전 사전 점검. 실패 항목이 있으면 확대를 미룹니다.
set -uo pipefail
FAIL=0
echo "[1] 연결 기기 수와 UDID"
adb devices | awk 'NR>1 && $2=="device" {print " -", $1}'
COUNT=$(adb devices | awk 'NR>1 && $2=="device"' | wc -l | tr -d ' ')
echo " 총 $COUNT 대"
echo "[2] systemPort 대역(8200~8299) 잔여 점유"
BUSY=$(lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '$9 ~ /:82[0-9][0-9]$/' | wc -l | tr -d ' ')
echo " 점유 중: $BUSY 개"
[ "$BUSY" -gt "$COUNT" ] && { echo " 경고: 기기 수보다 많습니다. 잔여 세션을 정리하십시오"; FAIL=1; }
echo "[3] wdaLocalPort 대역(8100~8199) 잔여 점유"
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '$9 ~ /:81[0-9][0-9]$/ {print " -", $1, $9}'
echo "[4] 케이퍼빌리티에 udid가 들어 있는지"
grep -rq "appium:udid" ./tests && echo " OK" || { echo " 누락: 병렬 실행에서는 필수입니다"; FAIL=1; }
echo "[5] 드라이버 버전"
appium driver list --installed 2>&1 | sed 's/^/ /'
exit $FAIL
이 체크리스트에서 2번이 걸리면 대부분 이전 실행이 남긴 찌꺼기입니다. 앞의 정리 스크립트를 먼저 돌리고 다시 확인하시면 됩니다. 4번이 걸리면 포트를 아무리 잘 나눠도 소용이 없으니 반드시 먼저 고치시기 바랍니다.
댓글
댓글 쓰기