클로드 코드에서 iOS 시뮬레이터 조작을 최적화할 수 있을까? - 1
클로드 코드가 iOS 시뮬레이터를 지원하기 시작한 뒤로, 코드 구현부터 시뮬레이터 조작까지 자동화할 수 있어 잘 사용하고 있다.
내가 요청한 동작을 알아서 시뮬레이터에서 재현하는 모습은 꽤 기특하다. 다만 가만히 지켜보고 있자니 너무 느렸다. 그래서 클로드 코드의 iOS 시뮬레이터 조작 시간과 토큰 사용량을 줄여 보기로 했고, 그 과정을 기록해 보려고 한다.
측정 대상은 실제 개발 중인 앱이며, 모델은 Opus 5.0을 기본으로 사용했다. 구체적인 앱 이름이나 내부 식별자는 일반화해서 적었다.
1. 첫 번째 가설 - 좌표 추정 오류
에이전트가 시뮬레이터를 조작하는 방식을 먼저 알아봤다.
- 조작 도구는 하나. 액션은
screenshot/tap/swipe/text/launch정도다. - 화면 요소를 조회하는 수단이 없다. 웹 브라우저 자동화에는 접근성 트리와 요소 참조(
ref)가 있는데, 시뮬레이터 도구에는 그에 해당하는 것이 없다. 화면을 알려면 스크린샷을 직접 읽어야 한다. screenshot은 919×1937 픽셀 이미지를 대화에 반환한다. 기기 원본(1206×2622)과도, 탭 좌표 단위인 포인트 공간(402×874)과도 배수가 맞지 않아 좌표를 쓸 때마다 비율을 환산해야 한다.- 액션 하나가 툴 호출 하나다. 탭 → 스크린샷 → 판독 → 탭이 각각 모델 왕복이다.
처음에는 이렇게 결론 내렸다. “스크린샷만 보고 좌표를 추정하니 빗나가고, 그 재시도가 비용이다.” 스크린샷만으로 위치를 파악하기 어렵거나 너무 작은 요소는 정확히 탭하기 어려울 거라는 가정이었다. 해법으로 앱에 뷰 계층 덤프 기능을 넣을 계획도 세웠다. 화면의 모든 뷰를 순회해 클래스명과 프레임 좌표를 로그로 남기는 디버그 전용 코드다.
여기가 이 프로젝트에서 가장 비싼 실수였다. 느리다는 문제는 실제로 있었지만, 좌표가 빗나간다는 원인은 측정 없이 세운 가설이었다.
2. 첫 번째 가설 탈락
구현 전에 기존 방식을 측정하기로 했다. 측정 기준을 세 구간으로 나눴다.
- A(진입): 홈 → 검색 버튼 탭 → 한글 키워드 입력 → 검색 결과 도착
- B(조작): 검색 결과 첫 상품 탭 → 상품 상세 도착
- C(밀집 조작): 필터 열기 → 색상 칩 n개 중 하나 선택 → 적용
세션에 쌓인 컨텍스트가 결과에 영향을 줄 수 있어 지시문을 따로 만들어 별도 세션에서 돌렸다.
첫 결과는 이랬다.
| 구간 | 시간 | 툴 호출 | 스크린샷 | 실패·재시도 |
|---|---|---|---|---|
| A | 176초 | 17 | 7 | 3 |
| B | 71초 | 10 | 4 | 0 |
실패·재시도 3회를 확인해 보니 코치마크를 닫는 데 1회, 앱 무반응 1회, 한글 검색어 입력 오류 1회였다. 좌표를 잘못 짚어서 발생한 실패는 없었다.
첫 시도에서 발생한 실패는 좌표 추정 성능과 관계없는 문제였다. 한글 입력은 클립보드로 대체하고, 코치마크와 권한 알림 등을 우회하는 방법을 지시문에 추가해 다시 측정했다.
| 구간 | run1 | run2 | 배수 |
|---|---|---|---|
| A 시간 | 176초 | 85.4초 | 2.1배 |
| B 툴 호출 | 10 | 2 | 5배 |
| 실패·재시도 | 3 | 0 | — |
앱 코드를 한 줄도 고치지 않았는데 B 구간의 호출이 5분의 1로 줄었다. 바뀐 건 지시문뿐이었고, 좌표 4곳도 모두 첫 시도에 맞췄다.
작은 요소도 정확히 탭하는지 확인하려고, 요소가 촘촘한 화면에서도 다시 쟀다. 26pt 높이 칩이 6줄 격자로 붙어 있는 필터 화면이면 좌표가 빗나갈 거라 기대했는데, 여기서도 오조작 0회였다. 조작당 소요 시간도 21.5초로 넓은 화면(21.4초)과 같았다.
첫 가설은 탈락됐다. 좌표 오조작은 네 번의 런 전 구간에서 0회였다. 계층 덤프가 없앨 실패가 애초에 없었다.
3. 두 번째 가설 - 스크린샷 토큰
좌표가 문제가 아니라면 시간이 어디에 쓰이는지 다시 봐야 했다. 조작 횟수와 스크린샷 수가 거의 비례했다.
| 구간 | 조작 수 | 스크린샷 |
|---|---|---|
| A | 6 | 4 |
| B | 1 | 1 |
| C | 6 | 5 |
조작 1회당 스크린샷은 약 0.8장이었다. 이번에 사용한 환경에서 이미지 토큰을 대략 (가로 × 세로) / 750으로 계산하면, 919×1937 이미지는 장당 약 2,400 토큰이다. 한 과제에서 스크린샷 10장을 읽으면 24,000 토큰을 쓰게 된다.
같은 화면을 계층 덤프로 만들면 필터 항목과 칩 좌표를 40~50줄, 1,000~1,250 토큰에 담을 수 있다. 스크린샷보다 약 10분의 1 적은 토큰으로 비슷한 정보를 전달할 수 있다.
좌표 정확도를 높이기 위해 계획했던 계층 덤프가 이번에는 토큰을 줄이는 방법으로 다시 보였다. 결론은 같았지만 이유가 달라졌다.
스크린샷 해상도 줄이기
스크린샷 해상도를 줄이면 토큰도 줄어든다. 우선 파일로 받은 뒤 크기를 줄여봤다.
xcrun simctl io <udid> screenshot out.png
sips -Z 600 out.png
결과는 276×600, 약 220 토큰이었다. 기존 이미지의 11분의 1 수준이었고 한글 라벨도 읽을 수 있었다.
하지만 여기서 구한 좌표로 탭하자 아무 반응이 없었다. 실제 칩 중심과 비교해 보니 수직으로 40pt, 수평으로 83pt가 어긋나 있었다. 높이가 26pt인 칩에서 40pt 오차면 완전히 다른 곳을 탭한 셈이다.
축소본에서는 1px이 1.457pt에 해당했다. 이미지에서 픽셀 한두 개를 잘못 읽으면 실제 탭 좌표에서는 수 pt의 오차로 커졌다.
타깃 영역만 원본 해상도로 잘라서 다시 읽는 방법도 시도했다. 탭에는 성공했지만 다른 문제가 생겼다.
“타깃 영역만 크롭해야 할 텐데, 그 영역 자체는 어떻게 찾는데?”
영역을 찾으려면 먼저 축소본을 봐야 하고, 그 축소본에는 이미 40pt의 오차가 있었다. 결국 같은 문제로 돌아왔다.
1px = 1pt로 맞추기
원본 이미지와 포인트 공간의 크기를 다시 확인했다. 포인트 공간은 402×874이고 원본 이미지는 1206×2622였다. 정확히 3배 차이다.
원본 이미지의 최대변을 874로 줄이면 결과가 402×874가 된다. 이미지의 1px과 시뮬레이터의 1pt를 같게 맞출 수 있었다.
sips -Z 874 out.png # → 402×874
이렇게 줄인 이미지는 약 468 토큰으로 기존의 5분의 1 수준이다. 이미지에서 읽은 좌표를 환산하지 않고 그대로 탭 좌표로 사용할 수 있어 크롭할 영역을 따로 찾을 필요도 없었다.
필터 화면의 탭 행, 색상 칩 n개, 카테고리 11행, 하단 트레이와 버튼 라벨까지 모두 읽을 수 있었다. 선택된 칩의 진한 테두리도 구분됐고, 이미지에서 확인한 좌표를 그대로 탭해 성공했다.
여기에 OCR과 해시 비교도 추가했다.
macOS의 Vision 프레임워크를 Swift 30줄로 감싸면 스크린샷에서 pt 좌표 + 텍스트를 로컬로 추출할 수 있다. 이미지를 모델에 보내지 않고도 원하는 텍스트의 좌표를 찾을 수 있었다.
./ocr shot.png 874 | grep "적용"
→ 37,745 적용
타깃 하나를 찾는 데 약 10~50토큰이 들었다. 이미지로 읽을 때의 2% 수준이다.
화면이 바뀌었는지만 확인할 때는 해시를 비교했다. 스크린샷을 BMP로 변환한 뒤 픽셀 바이트를 비교하니 같은 화면은 0.05%, 다른 화면은 45% 차이가 났다. 기준값을 1%로 두면 두 경우를 구분할 수 있었다. 미세한 애니메이션 때문에 같은 화면도 차이가 0은 아니어서 완전 일치를 기준으로 삼으면 안 됐다. 이 방법은 토큰을 사용하지 않는다.
원래 계획했던 계층 덤프와 비슷한 결과를 앱 코드를 수정하지 않고 얻었다. 앱 코드 3파일과 딥링크 공용 경로를 수정하려던 계획은 모두 취소했다.
4. 토큰은 줄었는데 시간은 늘었다
새로운 방식으로 다시 측정했다. 이번에는 지시문에 “이미지 읽기 수”를 핵심 지표로 넣었다.
| 변경 전 | 변경 후 | |
|---|---|---|
| 이미지 읽기 | 10장 | 0장 |
| 인지 토큰 | 약 23,700 | 약 3,500 |
| 시간 | 236초 | 533초 |
| 툴 호출 | 26 | 40 |
이미지 읽기는 10장에서 0장으로 줄었고 토큰도 6~8배 적게 사용했다. 그런데 시간은 236초에서 533초로 두 배 넘게 늘었다.
원인은 지시문이었다. 이미지 읽기 수를 핵심 지표라고 적자, 에이전트는 시간을 더 쓰더라도 이미지 읽기를 0으로 만들었다. 이미지 한 장이면 확인할 수 있는 화면을 OCR 4~14회로 나눠서 확인했고, 사용할 수 있었던 pt 1:1 이미지도 한 번도 읽지 않았다.
이미지 읽기를 금지한 것은 아니었다. 하지만 사용할 수 있다고 명시하지도 않았다. 핵심 지표로 강조한 값은 최대한 줄였고, 따로 알려주지 않은 방법은 선택하지 않았다.
이 결과를 보고 다시 방향을 잡아줬다.
“토큰 줄인 건 좋은데, 더 중요한 건 시간 같다.”
맞는 말이었다. 시간을 줄이겠다고 시작했는데 토큰만 측정하고 있었다. 토큰과 시간이 실제로 어떤 관계인지도 확인하지 않은 상태였다.
두 번째 가설은 반만 맞았다. 스크린샷 토큰은 줄일 수 있었지만, 토큰을 줄인다고 작업 시간이 줄어드는 것은 아니었다.
5. 세 번째 가설 - 툴 호출 수
토큰과 시간이 직접적인 관계가 없다면 실제로 시간이 쓰이는 곳을 찾아야 했다. 먼저 로컬에서 실행하는 작업부터 하나씩 측정했다.
| 작업 | 소요 시간 |
|---|---|
| 스크린샷 캡처 | 0.42초 |
| 해상도 축소 | 0.01초 |
| 로컬 OCR | 0.02초 |
| 로그 조회 | 0.20초 |
로컬 작업은 모두 1초가 채 걸리지 않았다. 반면 툴 호출 1회에는 8~16초가 걸렸다.
경과 시간 ≈ 툴 호출 수 × 약 10초
네 번의 측정에서도 툴 호출 1회당 9.9~16.4초가 걸렸다. 로컬 작업보다 모델과 툴이 결과를 주고받는 시간이 훨씬 길었다.
앞선 측정에서 토큰을 줄이기 위해 OCR을 여러 번 호출한 것이 오히려 시간을 늘린 이유도 여기에 있었다. OCR 자체는 빨랐지만 호출을 나눌 때마다 모델이 결과를 받고 다음 행동을 결정해야 했다.
결국 시간을 줄이려면 토큰보다 툴 호출 수를 줄여야 했다.
여러 작업을 한 번에 실행하기
사용할 수 있는 방법을 실행 경로에 따라 나눠봤다.
| 수단 | 실행 경로 | 특징 |
|---|---|---|
딥링크, 스크린샷, 축소, 크롭, OCR, 해시, 로그 조회, 앱 실행, 기기 복제, sleep |
shell |
여러 작업을 1회 호출로 묶을 수 있음 |
| 탭, 스와이프, 텍스트 입력 | 전용 도구 | 동작마다 별도 호출이 필요함 |
시뮬레이터 CLI에서는 탭, 스와이프, 텍스트 입력을 할 수 없었다. UI 조작은 전용 도구로 실행하고, 결과를 확인하려면 다시 화면을 읽어야 했다. 조작 한 번에 최소 2회 호출이 필요한 구조였다.
반면 shell에서 실행할 수 있는 작업은 하나의 스크립트로 묶을 수 있었다. 화면 변화 확인, 도착한 화면의 로그, 텍스트 좌표 OCR과 필요한 이미지 준비를 한 번에 처리하도록 만들었다.
CHANGED=yes (38.49%)
SCREENS=[SearchResultViewController]
IMAGE=./screen.png (pt 1:1, 필요할 때만 읽어라)
--- OCR ---
109,135 상품 9,999+
딥링크도 shell에서 실행할 수 있어 화면 진입과 결과 확인을 한 번에 묶었다.
xcrun simctl openurl <udid> "myapp://?type=TYPE_SEARCH_RESULT¶m1=<인코딩된 키워드>"
sleep 4
bash sim-observe.sh <udid> 874 "상품|검색"
이 방법으로 구간 A가 11회 호출, 85.4초에서 1회 호출, 7초로 줄었다.
세 번째 가설은 맞았다. 시간을 결정한 것은 로컬 작업이나 토큰이 아니라 툴 호출 수였다.
6. 최종 측정 - 진입 구간에서만 효과가 컸다
툴 호출 수를 줄인 방식으로 전체 구간을 다시 측정했다.
| 측정 방식 | 툴 호출 | 시간 | 이미지 읽기 | 인지 토큰 |
|---|---|---|---|---|
| 기존 방식 | 26 | 235.8초 | 10 | 약 23,700 |
| 이미지 읽기 0회 | 40 | 약 533초 | 0 | 약 3,500 |
| 툴 호출 최소화 | 15 | 163초 | 2 | 약 5,000 |
기존 방식과 비교하면 툴 호출은 26회에서 15회로 줄었고, 시간은 235.8초에서 163초로 줄었다. 인지 토큰도 약 23,700에서 5,000으로 4.7배 감소했다.
토큰과 시간을 모두 줄인 것은 마지막 측정뿐이었다.
구간별 결과
전체 결과는 좋아졌지만 구간별로 나눠보면 차이가 컸다.
| 구간 | before | after | 결과 |
|---|---|---|---|
| A(진입) | 11회 호출, 85.4초 | 1회 호출, 7초 | 12배 개선 |
| B(조작) | 2회 호출, 21.4초 | 3회 호출, 31초 | 소폭 악화 |
| C(촘촘한 화면 조작) | 13회 호출, 129초 | 11회 호출, 125초 | 거의 같음 |
실제로 시간이 크게 줄어든 곳은 진입 구간이었다. B 구간은 오히려 느려졌고 C 구간은 거의 차이가 없었다.
기존 방식에서도 UI 조작 한 번에는 이미 2회 호출이 필요했다. 한 번은 탭하고, 한 번은 스크린샷으로 결과를 확인했다. 새로운 방식도 탭한 뒤 관찰 결과를 받아야 해서 필요한 호출 수는 같았다.
프로젝트에는 화면별 딥링크가 이미 적용되어 있었다. 별도 구현 없이 기존 딥링크를 shell에서 실행해 원하는 화면으로 바로 진입할 수 있었다. 덕분에 여러 화면을 탭해서 이동하는 과정을 통째로 없앨 수 있었다.
OCR과 해시로 화면을 확인하면서 토큰은 줄일 수 있었지만, UI 조작에 걸리는 시간은 줄이지 못했다.
| 목표 | 방법 | 결과 |
|---|---|---|
| 시간 | 딥링크와 shell 작업 묶기로 UI 조작 제거 |
진입 구간에서 12배 개선 |
| 시간 | UI 조작 후 화면 인식 방법 변경 | 거의 효과 없음 |
| 토큰 | 이미지 읽기를 OCR과 해시로 대체 | 4.7배 감소 |
어떤 작업에서 효과가 있었나
결과는 작업 유형에 따라 달랐다.
| 작업 유형 | 결과 |
|---|---|
| “이 화면 확인해줘”처럼 화면 진입이 중요한 작업 | 시간이 크게 줄어듦 |
| 여러 화면을 거쳐야 하는 작업 | 화면을 건너뛸 때마다 시간이 줄어듦 |
| 한 화면 안에서 조작만 반복하는 작업 | 거의 차이 없음 |
| 화면 이동 과정 자체를 검증하는 작업 | 딥링크를 사용할 수 없어 차이 없음 |
키보드 입력 없이 N번 탭해야 도착하는 화면이라면 직접 조작할 때 약 2N회 호출이 필요하다. 딥링크를 사용하면 화면 깊이와 관계없이 1회 호출로 진입과 확인을 끝낼 수 있다.
| 화면 깊이 | 직접 조작 | 딥링크 | 절약 |
|---|---|---|---|
| 1탭(탭바) | 2회 호출, 20초 | 1회 호출, 10초 | 10초 |
| 3탭 | 6회 호출, 60초 | 1회 호출, 10초 | 50초 |
탭바처럼 한 번만 탭하면 되는 화면은 그냥 탭하는 편이 낫다. 두 번 이상 들어가야 하는 화면부터 딥링크를 찾기로 했다.
결국 시간을 줄인 방법은 화면을 더 빠르게 읽는 것이 아니라, 기존 딥링크를 활용해 필요 없는 UI 조작 자체를 없애는 것이었다.
7. 측정하면서 발견한 문제들
측정하는 동안 예상하지 못한 문제도 여러 번 만났다. 대부분 코드를 읽는 것만으로는 알기 어렵고, 직접 실행해야 발견할 수 있는 문제였다.
로그 조회 옵션
앱이 os_log로 로그를 남기고 있었지만 조회 결과는 0건이었다. 처음에는 로그를 남기는 코드가 제대로 동작하지 않는다고 생각했다.
실제 원인은 로그 레벨이었다. 앱에서 남기는 로그의 기본 레벨은 debug였고, 로그 조회 명령은 debug와 info를 기본 결과에서 제외했다.
--debug --info
두 옵션을 추가하자 로그가 정상적으로 나왔다. 이 옵션을 빠뜨리면 앱이 로그를 남기지 않는 것처럼 보였다.
화면 진입 여부를 확인하는 기능도 따로 만들 필요가 없었다. 앱이 ViewController에 진입할 때마다 클래스명을 이미 로그로 남기고 있었다. 새로 구현하려던 계획은 취소했다.
딥링크 확인 팝업
커스텀 스킴 딥링크를 처음 실행하면 OS가 앱을 열 것인지 확인하는 팝업을 띄웠다. 기기마다 한 번씩 나타나는 팝업이었다.
팝업이 떠 있는 동안에는 앱에 진입하지 않아 로그도 남지 않았다. 측정 세션은 이 사실을 모르고 앱 로그가 나오지 않는 원인을 찾느라 7분을 사용했다.
한 번 승인한 기록은 해당 기기에 유지됐고, 기기를 복제해도 그대로 이어졌다. 기준으로 사용할 기기에서 딥링크를 한 번 실행해 승인해두면 이후 복제한 기기에서는 팝업이 나타나지 않았다.
한글 입력
도구의 텍스트 입력 기능은 ASCII만 지원해 한글을 바로 입력할 수 없었다. 한글 검색어는 클립보드에 복사한 뒤 입력창을 길게 눌러 붙여넣어야 했다.
이 방법을 지시문에 미리 넣은 것만으로 첫 번째 측정과 두 번째 측정 사이에 큰 차이가 생겼다.
화면을 가리는 요소
앱이 만드는 오버레이도 조작을 방해했다. 실행할 때마다 나타나는 온보딩 코치마크는 첫 번째 탭을 가로챘다. 화면 왼쪽 아래의 프로모션 배너는 상품 번호를 가렸고, X 버튼으로 닫히지 않아 스크롤해서 피해야 했다.
OCR도 화면이 가려진 상황에서는 스크린샷과 다르지 않았다. 화면에 보이지 않는 정보는 읽는 방법을 바꿔도 찾을 수 없었다.
8. 아직 해결하지 못한 것
UI 조작 시간
화면 진입 시간은 줄였지만 한 화면 안에서 UI를 조작하는 시간은 거의 줄이지 못했다.
탭, 스와이프, 텍스트 입력은 전용 도구로만 실행할 수 있었다. 한 번 탭한 뒤 결과를 확인하려면 다시 화면을 읽어야 해서 조작 한 번에 최소 2회 호출이 필요했다. 기존 방식도 이미 같은 구조로 동작하고 있었다.
이 시간을 더 줄이려면 idb 같은 외부 도구를 이용해 shell에서 탭을 실행할 수 있어야 한다. 탭과 결과 확인을 한 스크립트로 묶으면 조작 한 번에 필요한 호출을 2회에서 1회로 줄일 수 있다.
적은 측정 횟수
구간별 측정 횟수는 1~2회뿐이었다. 첫 번째 측정과 두 번째 측정 사이에 최대 5배의 차이도 있었다.
따라서 전체 시간이 235.8초에서 163초로 줄었다는 결과를 모든 작업에 그대로 적용하기는 어렵다. 다만 구간 A의 호출 수가 11회에서 1회로 줄어든 것은 실행 구조가 달라진 결과라 신뢰할 수 있었다.
OCR의 한계
OCR은 텍스트가 없는 아이콘이나 이미지 셀의 위치를 찾지 못했다. 같은 행에 있는 여러 라벨을 하나의 텍스트로 합쳐서 인식하는 경우도 있었다. 원본 해상도로 잘라서 다시 읽어도 분리되지 않았다.
색상, 글자 잘림, 여백처럼 화면의 시각적인 품질을 확인할 때도 OCR만으로는 부족했다. 이런 작업은 여전히 이미지를 직접 읽어야 했다.
사람이 필요한 작업
새 기기에서 도구 접근을 승인하거나 앱에 로그인하는 작업은 사람이 해야 했다. 에이전트가 비밀번호를 입력하지 않기 때문에 이 부분은 자동화할 수 없었다.
9. 마무리
처음에는 좌표가 빗나가서 느린 것이라고 생각했다. 측정해 보니 좌표 정확도는 문제가 없었고, 스크린샷 토큰을 줄이는 것만으로는 작업 시간도 줄어들지 않았다.
실제로 시간에 가장 큰 영향을 준 것은 툴 호출 수였다. 프로젝트에 이미 적용된 딥링크로 화면 이동을 줄이고, shell에서 실행할 수 있는 작업을 한 번에 묶자 시간과 토큰을 함께 줄일 수 있었다.
반면 한 화면 안에서 탭과 확인을 반복하는 작업은 거의 빨라지지 않았다. 이 부분을 개선하려면 UI 조작과 결과 확인을 한 번의 호출로 묶는 방법이 더 필요하다.
다음에는 idb 같은 도구를 이용해 이 부분을 줄여보려고 한다.
댓글 남기기