AI에게 실기기 검증 맡기기: 맥에서 휴대폰 웹뷰에 붙는 법

회사에서 CapacitorJS로 앱을 만들고 있습니다. 웹으로 만든 화면을 네이티브 앱의 웹뷰에 넣어서 앱으로 내보내는 방식이라, 제가 짠 코드 하나가 데스크톱 웹 말고도 네 군데에서 더 돌아갑니다.

아이폰 안드로이드
모바일 웹 Safari Chrome
앱 WKWebView Android WebView

데스크톱 웹은 브라우저 DevTools만 열면 됩니다. 문제는 이 넷입니다. 전부 휴대폰 안에서 돌아가고, 특히 키보드가 올라올 때 생기는 문제는 휴대폰에서 직접 확인해야만 드러납니다. 입력창이 키보드에 가려지거나 화면이 밀리거나 하단 버튼이 엉뚱한 곳에 뜨는 식입니다.

지난 글에서는 화면 확인을 AI에게 맡긴 이야기를 했습니다. 이번에는 그걸 실제 휴대폰에서 하게 만든 이야기입니다. 확인은 AI가 하고, 저는 휴대폰을 케이블로 꽂아 둡니다. 아이폰 키보드만은 예외인데 이건 뒤에서 설명합니다.

이미 있는 방법으로는 부족했다

휴대폰 화면을 맥에서 확인하는 방법은 이미 여러 가지가 있습니다. 몇 가지를 써 봤는데 저마다 아쉬운 데가 있었습니다.

휴대폰 화면을 확인하는 다섯 가지 방법과 한계. 1. 데스크톱 모바일 보기: 화면 크기는 흉내 내지만 엔진이 다르고 키보드·노치가 없고 앱 코드가 돌지 않는다. 2. 시뮬레이터·에뮬레이터: 진짜 엔진에서 앱이 그대로 돌지만 키보드가 실기기와 다를 수 있다. 3. 스크린샷 보기: 실기기 화면 그대로지만 찍고 읽느라 너무 느리고, 좌표를 짐작해서 조작이 부정확하고, 실제 크기를 잴 수 없다. 4. 인스펙터 창(chrome://inspect, Safari): 실기기에 붙지만 사람이 창을 조작해야 해서 AI가 명령어로 다룰 수 없다. 5. 실기기와 명령어: 실기기 그대로 AI가 직접 조작하고 측정한다. 아이폰은 스크립트를 직접 만들어야 하고, 아이폰 키보드는 사람이 확인한다.

대안 1. 데스크톱 브라우저의 모바일 보기

Chrome DevTools의 기기 모드는 화면 크기와 터치만 흉내 냅니다.

대안 2. 시뮬레이터와 에뮬레이터

Xcode의 iOS 시뮬레이터나 Android Studio의 에뮬레이터는 진짜 엔진에서 앱을 그대로 돌립니다. 꽤 쓸 만합니다. 그런데 제가 가장 확인하고 싶었던 키보드에서 걸렸습니다.

대안 3. 스크린샷을 찍어서 AI에게 보여 주기

실기기 화면을 스크린샷으로 찍어서 AI에게 보여 주는 방식은 직접 써 봤습니다. 실기기라 키보드도 진짜로 올라옵니다. 하지만 검증에 쓰기에는 부족했습니다.

이 방식을 더 잘 만든 도구도 있습니다. mobile-mcp는 AI가 화면의 요소 목록(접근성 트리)을 읽어 정확한 좌표를 얻게 해 주고, Maestro는 텍스트나 ID로 요소를 찾아 누르는 테스트 도구입니다. 좌표를 짐작하는 문제는 이런 도구로 풀립니다.

그래도 써 보지는 않았습니다. 둘 다 앱을 바깥에서, OS가 보여 주는 요소 목록으로 보는 도구입니다. 제가 확인하고 싶었던 건 웹뷰 안의 DOM과 CSS였습니다. 컴포넌트가 실제로 몇 px인지, 키보드에 얼마나 가려졌는지는 웹 안에서 재야 정확합니다.

Appium은 조금 다릅니다. 컨텍스트를 웹뷰로 바꾸면 DOM까지 다룰 수 있고, 아이폰 웹뷰에는 Safari 개발자용 메뉴가 쓰는 것과 같은 WebKit 원격 디버깅 통로로 붙습니다. 이것도 써 보지는 않았습니다. Appium 서버와 드라이버를 깔아야 하고, 아이폰은 WebDriverAgent를 빌드해 서명까지 해야 해서 준비할 게 많아 보였습니다. 그래서 Appium을 거치지 않고 그 통로에 직접 붙기로 했습니다.

대안 4. 사람이 쓰는 인스펙터

실기기를 케이블로 연결하면 안드로이드는 맥 Chrome의 chrome://inspect, 아이폰은 맥 Safari의 개발자용 메뉴로 DevTools를 열 수 있습니다. 실기기에 붙으니 키보드 문제도 보입니다.

그래서 실기기에 명령어로 붙기로 했다

필요한 건 “실기기”와 “명령어로 붙기” 두 가지였습니다. 실기기여야 키보드가 실제와 똑같이 움직이고, 명령어로 붙어야 AI가 직접 조작하고 확인합니다. 그러면 웹뷰 안의 요소를 바로 다룹니다. 좌표를 짐작할 필요 없이 원하는 요소를 고르고, getBoundingClientRect()로 실제 크기도 잽니다.

안드로이드는 이미 있는 도구로 됐고, 아이폰은 작은 스크립트를 하나 만들어야 했습니다.

맥에서 휴대폰 웹뷰까지 가는 두 경로. 안드로이드: AI가 playwright의 connectOverCDP로 맥의 9333 포트에 붙고, adb forward가 그 포트를 USB로 휴대폰 웹뷰의 디버그 소켓에 이어 준다. 프로토콜은 CDP. 아이폰: AI가 직접 만든 iphone-eval.mjs로 맥의 9222 포트에 붙고, ios_webkit_debug_proxy가 USB로 아이폰 웹뷰에 이어 준다. 프로토콜은 WebKit 인스펙터.

먼저 앱에서 웹뷰 디버깅 허용하기

모바일 웹(Safari, Chrome 탭)은 바로 붙지만 앱 안의 웹뷰는 앱이 허용해야 디버거가 붙습니다.

// iOS 16.4 이상
webView.isInspectable = true
// Android
WebView.setWebContentsDebuggingEnabled(true)

이 설정은 dev 빌드에서만 켜고 스토어에 올라가는 prod 빌드에서는 꺼 둡니다. 켜 두면 연결을 허용한 컴퓨터에서 앱 안의 JS를 실행하고 세션과 저장소를 들여다볼 수 있습니다.

안드로이드: adb로 포트를 열고 playwright로 붙는다

안드로이드 웹뷰는 Chrome과 같은 디버깅 프로토콜(CDP)을 씁니다. 휴대폰 안의 디버그 소켓을 맥의 포트로 이어 주기만 하면 playwright가 데스크톱 Chrome에 붙듯이 그대로 붙습니다.

1. USB 디버깅 켜기

맥에 adb가 없으면 brew install android-platform-tools로 먼저 설치합니다. 휴대폰에는 웹뷰 디버깅을 켠 dev 빌드를 설치해 둡니다.

휴대폰에서 설정 → 휴대전화 정보 → 소프트웨어 정보 → 빌드 번호를 일곱 번 누르면 개발자 옵션이 열립니다(갤럭시 기준, 기종마다 메뉴 이름이 조금 다릅니다). 개발자 옵션에서 USB 디버깅을 켜고 케이블로 연결한 뒤, 휴대폰에 뜨는 “USB 디버깅을 허용하시겠습니까?“에서 허용을 누릅니다. adb devices에서 기기가 device로 보이면 됩니다.

2. 웹뷰의 디버그 소켓 찾기

앱을 실행한 상태에서 소켓 이름을 찾습니다.

adb shell cat /proc/net/unix | grep devtools_remote
# ... @webview_devtools_remote_12345

앱 웹뷰는 webview_devtools_remote_<pid>, 모바일 Chrome은 chrome_devtools_remote로 보입니다. 이름에 프로세스 번호가 들어 있어서 앱을 다시 실행하거나 새로 설치하면 이름이 바뀝니다. 그때는 이 단계와 다음 단계를 다시 합니다.

3. 맥의 포트로 이어 주기

adb forward tcp:9333 localabstract:webview_devtools_remote_12345

이제 맥의 127.0.0.1:9333이 휴대폰 웹뷰의 디버거입니다. 아래 아이폰 프록시가 9222~9240을 쓰기 때문에 겹치지 않게 그 범위 밖인 9333으로 열었습니다. 제대로 이어졌는지는 curl -s http://127.0.0.1:9333/json으로 확인합니다.

4. playwright로 붙기

import { chromium } from 'playwright';

const browser = await chromium.connectOverCDP('http://127.0.0.1:9333');
// 페이지가 여러 개면 page.url()을 보고 고른다
const page = browser.contexts()[0].pages()[0];

await page.getByRole('button', { name: '다음' }).click();
await page.screenshot({ path: 'after.png' });

클릭, 입력, 스크린샷, 네트워크 확인은 데스크톱에서 쓰던 playwright 코드 그대로 됐습니다. playwright로 입력창을 누르면 실제 키보드도 올라옵니다.

웹 안에서 알 수 없는 것은 OS에 물어봅니다. 키보드가 정말 올라왔는지는 이렇게 확인합니다.

adb shell dumpsys input_method | grep mInputShown
# mInputShown=true 면 키보드가 떠 있음

키보드에 가려졌는지는 웹 안에서 숫자로 잽니다. 입력창의 아래쪽이 화면에 보이는 영역의 아래쪽보다 위에 있으면 가려지지 않은 것입니다.

await page.evaluate(() => {
  const input = document.activeElement.getBoundingClientRect();
  const viewport = window.visualViewport;
  return { inputBottom: input.bottom, visibleBottom: viewport.offsetTop + viewport.height };
});

이 비교는 키보드가 뜰 때 웹뷰가 줄어드는 설정(adjustResize)에서 의미가 있습니다. 웹뷰가 줄지 않는 설정이면 보이는 영역의 크기가 그대로라 항상 “안 가려짐”으로 나옵니다.

아이폰: 프록시를 띄우고 직접 만든 스크립트로 붙는다

아이폰은 이렇게 바로 붙지 못합니다. 아이폰 웹뷰는 CDP가 아니라 WebKit 인스펙터 프로토콜을 쓰는데, playwright는 이 프로토콜로 실기기에 붙지 못합니다.

그래서 ios_webkit_debug_proxy로 USB 통로를 맥의 포트로 열고, 그 위에서 JS를 실행하는 작은 스크립트를 만들었습니다. Safari 개발자용 메뉴가 쓰는 것과 같은 통로입니다.

1. 준비

2. 프록시 띄우기

ios_webkit_debug_proxy -c null:9221,:9222-9240 &
curl -s http://127.0.0.1:9222/json

/json 목록에 앱 웹뷰가 보이면 성공입니다. 아이폰 Safari에서 열어 둔 탭도 같이 보이니 모바일 웹도 같은 방법으로 다룰 수 있습니다. 빈 배열 []만 나오면 앱이 꺼져 있거나, 웹뷰 디버깅이 꺼진 빌드이거나, 웹 속성이 꺼져 있는 경우입니다.

3. Target으로 감싸서 보내기

처음에는 CDP처럼 Runtime.evaluate를 그대로 보냈는데 'Runtime' domain was not found 오류만 돌아왔습니다. iOS 12.2부터 WebKit 인스펙터는 Target 단위로 동작합니다. 그래서 명령을 Target 메시지라는 봉투에 한 번 감싸서 보내야 합니다.

명령을 보내는 방식 비교. 안드로이드(CDP)는 Runtime.evaluate 명령을 그대로 보내면 바로 실행된다. 아이폰(WebKit 인스펙터, iOS 12.2 이상)은 Runtime.evaluate를 그대로 보내면 'Runtime' domain was not found 오류가 난다. 먼저 받은 targetId를 붙여 Target.sendMessageToTarget 안에 Runtime.evaluate 명령을 문자열로 감싸 보내야 하고, 결과는 Target.dispatchMessageFromTarget 안에 문자열로 돌아온다.
  1. 연결하면 Target.targetCreated 이벤트로 targetId가 옵니다.
  2. 실제 명령을 JSON 문자열로 만들어 Target.sendMessageToTarget에 넣어 보냅니다.
  3. 결과는 Target.dispatchMessageFromTarget 이벤트 안에 다시 문자열로 들어 있습니다.

이걸 처리하는 스크립트입니다. 내장 WebSocket을 쓰기 때문에 Node 22 이상이나 Bun이 필요합니다.

// iphone-eval.mjs
// 사용법: node iphone-eval.mjs '<JS 표현식>' [URL에 들어 있는 글자]
const [expression, urlPart = ''] = process.argv.slice(2);
if (!expression) {
  console.error("사용법: node iphone-eval.mjs '<JS 표현식>' [URL 일부]");
  process.exit(2);
}

let pages;
try {
  pages = await (await fetch('http://127.0.0.1:9222/json')).json();
} catch {
  console.error('프록시에 연결 못 함: ios_webkit_debug_proxy를 먼저 띄울 것');
  process.exit(1);
}
const page = pages.find((p) => p.webSocketDebuggerUrl && p.url.includes(urlPart));
if (!page) {
  console.error('붙을 페이지가 없음: 앱 실행, 웹뷰 디버깅, 웹 속성을 확인할 것');
  process.exit(1);
}
console.error(`대상: ${page.url}`);

const ws = new WebSocket(page.webSocketDebuggerUrl);
let targetId;

// 실제 명령을 문자열로 만들어 Target 봉투에 넣어 보낸다
function sendToTarget(id, method, params) {
  ws.send(JSON.stringify({
    id,
    method: 'Target.sendMessageToTarget',
    params: { targetId, message: JSON.stringify({ id, method, params }) },
  }));
}

// 연결 뒤 Target.targetCreated 이벤트를 기다린다.
// 제 환경에서는 명령을 하나 보내 두어야 이벤트가 확실히 왔다.
ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Target.exists' }));

ws.onmessage = (event) => {
  const message = JSON.parse(String(event.data));

  if (message.method === 'Target.targetCreated' && !targetId) {
    targetId = message.params.targetInfo.targetId;
    sendToTarget(10, 'Runtime.enable', {});
    sendToTarget(11, 'Runtime.evaluate', { expression, returnByValue: true });
    return;
  }

  // 결과도 봉투 안에 문자열로 들어 있다
  if (message.method === 'Target.dispatchMessageFromTarget') {
    const inner = JSON.parse(message.params.message);
    if (inner.id === 11) {
      console.log(JSON.stringify(inner.error ?? inner.result, null, 2));
      process.exit(inner.error || inner.result?.wasThrown ? 1 : 0);
    }
  }
};

ws.onerror = () => {
  console.error('웹소켓 연결 실패: 페이지가 아직 열려 있는지 확인할 것');
  process.exit(1);
};

setTimeout(() => {
  console.error('timeout');
  process.exit(1);
}, 10_000);

4. JS 실행하기

# 지금 어느 화면인지
node iphone-eval.mjs '({ url: location.href, title: document.title })'

# 기기를 만지지 않고 버튼 누르기
node iphone-eval.mjs '(() => {
  const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes("다음"));
  button?.click();
  return button ? "clicked" : "not found";
})()'

둘째 인자로 URL 일부를 넘기면 그 페이지를 고릅니다. Safari 탭과 앱이 같이 떠 있을 때 엉뚱한 쪽에 붙었는지 바로 알 수 있도록, 스크립트가 붙은 페이지의 주소를 먼저 출력합니다.

넘기는 코드는 값 하나로 끝나는 식이 다루기 편합니다. return을 쓰려면 (() => { ... })()로 감싸고, 객체는 ({ ... })처럼 괄호로 감쌉니다. 다 쓰고 나면 pkill -f ios_webkit_debug_proxy로 프록시를 내립니다.

이 방법의 한계

playwright만큼 편하지는 않습니다. 스크립트가 하는 일은 JS 실행 하나뿐이라 클릭도 button.click()처럼 JS로 합니다. click()은 실제 터치가 아니라서 버튼이 다른 요소에 가려져 있어도 눌립니다. 실제로 눌리는 자리인지는 크기와 좌표를 따로 재서 확인합니다. 스크린샷도 이 스크립트로는 찍지 않고, 화면은 DOM의 글자와 요소 크기로 확인합니다.

가장 큰 한계는 키보드를 코드로 띄울 수 없다는 점입니다. iOS는 사용자가 직접 누르지 않으면 focus()만으로는 키보드가 올라오지 않습니다. 그래서 아이폰 키보드 확인만은 사람 몫으로 남겼습니다. AI가 상태를 만들고 키보드를 확인할 화면까지 이동한 뒤 알림을 보내면 제가 입력창을 눌러 직접 확인합니다.

AI에게 맡기기

여기까지 오면 AI가 실기기를 직접 다룰 수 있습니다. AI에게 위의 명령어들을 알려 주고 요구사항을 넘기면 이 순서로 확인합니다.

AI가 실기기에서 검증하는 흐름. 1. 상태 만들기: localStorage에 목 세션과 MSW 시나리오를 넣고 새로고침한다. 2. 조작: 버튼 클릭, 입력, 화면 이동을 명령어로 한다. 3. 확인: DOM의 글자와 요소 크기를 읽는다. 안드로이드 키보드는 dumpsys input_method로 확인하고, 아이폰 키보드는 AI가 알림을 보내면 사람이 확인한다. 4. 판정: 통과하면 끝, 틀리면 AI가 코드를 고치고 다시 빌드해 설치한 뒤 처음부터 다시 한다.

상태는 목으로 만듭니다. 매번 실제 계정으로 로그인하고 데이터를 만들 수는 없습니다. 저는 MSW로 API 응답을 흉내 내고 있고, localStorage에 목 세션과 시나리오 설정을 넣으면 앱이 그걸 읽어 목 응답을 돌려주도록 해 두었습니다. 그래서 원하는 상황을 만드는 일이 localStorage.setItem(...) 몇 줄로 끝나고, 안드로이드와 아이폰에서 똑같이 됩니다.

판정은 값으로 합니다. “키보드가 올라온 것 같다”, “버튼이 잘 보인다”가 아니라 mInputShown=true인지, 입력창 아래쪽이 보이는 영역 안에 있는지를 값으로 봅니다. 답이 딱 떨어지니 AI가 헷갈리지 않습니다.

저는 안드로이드도 playwright 코드로 했지만, 코드를 직접 짜지 않는 방법도 있습니다. Playwright MCP를 --cdp-endpoint http://127.0.0.1:9333 옵션으로 띄우면, AI가 adb로 열어 둔 포트에 바로 붙어 데스크톱 브라우저를 다루듯 휴대폰 웹뷰를 다룹니다.

주의할 점

웹뷰에서 JS를 실행하면 그 앱의 세션과 저장소에도 그대로 손이 닿습니다. AI에게 맡길 때는 더 조심해야 해서 두 가지를 지킵니다.

정리

안드로이드 실기기 아이폰 실기기
통로 adb forward ios_webkit_debug_proxy
붙는 도구 playwright connectOverCDP 직접 만든 스크립트
할 수 있는 것 웹 화면 조작, 측정, 스크린샷 JS 실행 (그걸로 조작과 측정)
키보드 확인 AI가 직접 (dumpsys input_method) AI가 알림 → 사람이 확인
신경 쓸 것 앱을 다시 켜면 포트를 다시 연결 키보드는 코드로 못 띄움, 클릭은 JS 클릭

키보드처럼 실기기에서만 드러나는 문제가 있다면 휴대폰을 케이블로 꽂아 두고 AI에게 맡겨 보세요.