Kotlin Multiplatform용 WebView 자동화
요약
Kotlin Multiplatform을 활용하여 Android, iOS, 데스크톱에서 작동하는 통합 WebView 자동화 솔루션인 Vitre를 소개합니다. 이 시스템은 선언적 워크플로우와 단일 코드베이스로 페이지 상호작용의 일관성을 보장하며, LLM 에이전트가 실행할 수 있는 구조화된 단계 어휘를 제공합니다.
핵심 포인트
- Kotlin DSL을 사용하여 플랫폼 독립적인 자동화 워크플로우를 정의합니다.
- 선언적 단계(Navigate, Click 등)와 `runIf` 같은 조건부 로직을 지원합니다.
- LLM 에이전트가 이해할 수 있도록 MCP/Koog와 같은 표준화된 방식으로 기능을 노출합니다.
- CSS, XPath 외에 페이지 스냅샷 핸들을 이용한 요소 주소 지정 방식을 제공합니다.
Kotlin Multiplatform을 위한 WebView 자동화. 페이지에 수행하길 원하는 작업을 일련의 단계 목록으로 설명한 다음, Android, iOS 및 데스크톱에서 임베디드 WebView 내부에서 실행합니다. 앱에서, 테스트에서, 또는 Model Context Protocol(MCP)이나 Koog를 통해 LLM 에이전트로부터 실행할 수 있습니다.
val workflow = workflow("hn-top-story", "Hacker News top story") {
navigate("https://news.ycombinator.com/")
waitFor(".titleline > a", timeoutMs = 15_000)
...
동일한 어휘가 네 가지 방식으로 사용 가능합니다: 위의 Kotlin DSL로, 화면에 드롭할 Compose composable로, 그리고 페이지를 한 번도 본 적 없는 에이전트의 경우 MCP 도구 또는 Koog 도구로 사용됩니다. 후자 두 가지는 구현체라기보다는 하나의 의미론(semantics) 세트를 전달하는 방식입니다.
이 블록은 스크립터라기보다는 빌더(builder)입니다. 이는 한 번, 초기에 실행되어 WorkflowEngine가 순차적으로 따라갈 단계 목록을 조립합니다. 따라서 일반적인 Kotlin의 if 문은 워크플로우가 무엇을 포함하는지를 선택합니다.
만약 결정이 실행(run)에 속한다면 — 즉, 때때로만 존재하는 요소이거나 이전 단계에서 추출된 값이라면 — runIf는 엔진이 페이지와 비교하여 평가하는 단계를 추가합니다. WorkflowStep 생성자는 여전히 공개적이며 동등합니다. 이는 에이전트의 단계가 Kotlin이 아닌 JSON으로 도착하기 때문에 vitre-mcp가 사용하는 방식입니다.
모바일 앱은 이미 WebView를 임베디드하고 있으며, 사람들이 그것들로 하고 싶어 하는 모든 것(페이지 읽기, 양식 채우기, 페이지 자체 스크립트와 대화하기, 테이블 스크래핑, 네 개의 사이트를 한 번에 실행하기)은 콜백에 붙은 일회성 evaluateJavascript 호출로 끝납니다. 이러한 호출들은 서로 경쟁하고, UI와 경쟁하며, 이 모든 것이 Android, iOS 및 데스크톱 간에 공유되지 않습니다.
Vitre는 페이지를 구동할 수 있는 무언가로 만듭니다: 하나의 순서 보장(ordering guarantee), 하나의 단계 어휘(step vocabulary), 모든 플랫폼을 위한 단일 코드베이스, 그리고 에이전트가 읽을 수 있는 스냅샷 형식입니다.
- 단계들은 선언적입니다:
Navigate,LoadHtml,WaitFor,Click,Input,Extract,ExtractRows,Snapshot,EvaluateJs,PostMessage,AwaitMessage,If
브랜치(branch)의 경우 빌더가 아닌 페이지가 결정하며, ForEach를 사용합니다. 이전 단계에서 찾은 모든 페이지—예: 검색 결과 목록에 있는 각 제품 페이지—를 방문할 수 있습니다. 요소는 세 가지 방식으로 주소 지정됩니다: CSS, XPath, 그리고 페이지 스냅샷이 발행하는 핸들입니다.
evaluateJs는 모든 플랫폼에서 스크립트의 결과를 JSON으로 인코딩하여 반환합니다. 따라서 controller.evaluate<Boolean>(…)는 "true"와 비교하는 대신 이를 디코드(decode)합니다.
postMessage는 양방향으로 작동하며, 수신함(inbox)이 있어 기다리는 동안 도착하는 메시지가 손실되지 않습니다. 페이로드(Payloads)는 양쪽 끝에서 타입 지정됩니다: 왕복 요청을 위해 bridge.request<Ack, Token>(…)를 사용하고, 워크플로우의 경우 decodePayload<Token>(…)를 사용합니다.
controller.cookies는 WebView의 쿠키 저장소(cookie jar)를 읽고, 쓰고, 지웁니다. 여기에는 사이트가 세션을 유지하는 데 사용하는 HttpOnly 쿠키도 포함되며, document.cookie로는 볼 수 없습니다. 호스트/경로/Secure/SameSite 범위 지정은 요청이 적용되는 방식과 동일하게 적용됩니다. Android와 iOS는 현재 이를 지원하지만, 데스크톱은 CEF의 두 저장소가 조정될 때까지 null을 보고합니다.
레인 풀(lane pool)은 최대 4개의 사이트를 동시에 구동할 수 있습니다: 각 레인은 하나의 WebView이며, 다음 페이지가 필요한 워크플로우에 의해 빌려집니다. 따라서 2레인 장치에서 6개 워크플로우는 4개를 놓치는 대신 3단계로 실행되며, 분기(fan out)하는 워크플로우는 모든 레인에 걸쳐 페이지를 전파합니다.
vitre-mcp는 전체 어휘(vocabulary)를 인프로세스 트랜스포트(in-process transport)를 통해 MCP 도구로 노출하며, vitre-koog는 이를 플러그인 형태로 네이티브 Koog 도구로 노출합니다. 둘 다 vitre-agent로부터 동일한 세맨틱(semantics) 세트를 읽기 때문에, 어느 쪽도 모델에게 다른 쪽이 알지 못하는 것을 알려주는 방향으로 벗어날 수 없습니다.
모든 플랫폼 호출은 WebView 스레드에 국한되며 완전히 순서가 지정되므로, 엔진, UI, 에이전트가 서로를 위해 특별히 처리할 필요 없이 동일한 페이지를 구동할 수 있습니다.
웹(Web)은 목표 범위(target)에서 제외됩니다. 브라우저 CORS 규칙 때문에 범용 웹 자동화 프레임워크는 그곳에서 비실용적입니다. (참고: docs/PLAN.md 참조).
Android의 샘플 갤러리입니다. 동일한 composeApp을 사용하여 iOS와 데스크톱에서도 같은 화면이 실행됩니다.
; 너비가 720dp를 초과하면 리스트와 러너가 번갈아 가며 배치되는 대신 나란히 배치됩니다.
각 항목이 무엇을 시연하는지는 '샘플 갤러리에서 확인하기'에 명시되어 있습니다. 직접 실행하려면 '샘플 실행하기'를 참조하세요.
| Kotlin | 2.3.10 (Multiplatform) |
| Android | minSdk 24, compileSdk 36 |
| iOS | 15.0+ (WKWebView ) |
| ... |
Published to Maven Central에 배포되었으므로, mavenCentral()를 리포지토리에서 설정하는 것이 전부입니다:
// build.gradle.kts
kotlin {
sourceSets {
...
또는 소스 의존성으로 사용합니다: 레포지토리를 프로젝트 옆에 복제하고, settings.gradle.kts에 includeBuild("../vitre")를 추가한 다음, 위에 언급된 버전을 넣습니다. 대신 소스를 벤더링(vendor)하려면, 모듈을 직접 include(":vitre-core")로 추가합니다.
WebView를 마운트하고, 컨트롤러를 가져와서 워크플로우를 실행합니다:
@Composable
fun Screen() {
val state = rememberVitreWebViewState("https://example.com")
...
state.controller는 WebView가 마운트되기 전에는 null이고, 컴포지션(composition)을 벗어난 후에도 다시 null이 됩니다. 따라서 이 속성에 키를 둔 효과(effect)는 페이지가 도착할 때 시작하고 사라질 때 해제되며, 죽은 WebView에 대해서는 아무것도 실행되지 않습니다.
시도해 볼 만한 가장 작은 작업: 탐색(navigate)하고, 페이지가 준비되었다는 것을 의미하는 요소를 기다린 다음, 찾으러 온 내용을 읽습니다. 텍스트는 textContent이며; href는 진정한 속성이므로 그렇게 읽힙니다.
workflow("hn-top-story", "Hacker News top story") {
navigate("https://news.ycombinator.com/")
waitFor(".titleline > a", timeoutMs = 15_000)
...
ExtractRows는 일치하는 행마다 하나의 JSON 레코드를 반환하며, 각 열은 해당 행 내에서 해결됩니다. 이 범위 지정(scoping)이 핵심입니다: 가격 정보가 누락된 행은 나중에 오는 모든 기록을 잘못된 제품으로 밀어내는 대신 그 레코드에 빈 문자열을 제공합니다.
상품 목록 페이지에서 제품명을 가져오고; 실제 정보는 한 페이지 더 뒤에 있으며, 행마다 하나씩 존재합니다.
forEach
배열 ExtractRows의 각 요소마다 본문을 실행합니다.
이때 각 요소를 product라는 이름으로 바인딩하여,
template("{product.url}")는 해당 행이 가지고 있는 주소입니다. 항목들은 엔진의 LaneSource에서 빌린 레인을 사용하는데,
이는 단일 WebView에서 순차적으로, 또는 풀(pool)에서는 여러 개가 동시에 작동하며,
각 요소는 추출한 내용을 details에 행 순서대로 남기고, 실패하더라도 치명적 오류(fatal)로 기록되는 대신 처리합니다.
forEach(over = "results", item = "product", into = "details", limit = 4) {
navigate(template("{product.url}"))
waitFor("#productTitle", timeoutMs = 25_000)
...
팬아웃(fan-out)은 페이지 장벽입니다: 워크플로우는 첫 번째 항목이 시작하기 전에 레인을 반환하고,
그 후에 새로운 레인을 빌려오기 때문에, forEach 이후의 단계는 변수들은 그대로 유지된 빈 페이지에서 시작합니다. 이것이 어떤 크기의 풀도 부모가 자식들이 기다리는 레인을 붙잡고 있는 상황으로부터 안전하게 만드는 이유입니다 — PARALLEL-LANES.md를 참조하세요.
페이지가 사용자의 것이라면, 브릿지(bridge)가 스크래핑보다 우수합니다. PostMessage는 페이지 내부로 MessageEvent('vitre')를 전송하고; AwaitMessage는 window.vitre.postMessage가 돌아오기를 기다립니다. 인박스(inbox)는 버퍼링하기 때문에, 클릭 시 동기적으로 포스트하는 핸들러라도 나중에 시작되는 AwaitMessage에 의해 여전히 일치하며, 이는 다른 곳에서 메시지를 조용히 손실시키는 경우와 다릅니다.
@Serializable data class Ack(val seen: Boolean)
@Serializable data class Token(val value: String, val expiresAt: Long)
loadHtml(html = checkoutHtml, baseUrl = "https://app.example.com")
...
click
오직 연결되고 활성화되었으며 보이는 레이아웃을 가진 하나의 대상을 요구하며, 숨겨지거나 비활성(inert)인 대상은 거부합니다. 유효성 검사 및 디스패치는 단일 JavaScript 턴에 실행됩니다. 이는 합성 DOM 클릭을 디스패치할 뿐이며, 스크롤하거나, 뷰포트 교차 또는 가려짐을 확인하거나, 신뢰할 수 있는 사용자 입력을 생성하거나, 사이트 작동 완료를 증명하지는 않습니다. 결과를 검증하려면 waitFor , 추출(extraction) 또는 브릿지 승인(bridge acknowledgement)을 사용하십시오.
WorkflowEvent.Failed.kind와 PageDriverException.kind는 ActionRejected(유효성 검사로 인해 디스패치 중단), OutcomeUnknown(스크립트가 이미 효과를 가져왔을 수 있음), 그리고 일반적인 Failure를 구별합니다.
알려지지 않은 결과에 대해 재시도하기 전에 상태를 검사하십시오. 스크립트는 절대 자동으로 재생되지 않습니다.
페이로드(Payloads)는 손으로 작성한 엔벨로프 문자열이 아니라 클래스입니다. id와 type은 프로토콜이기 때문에 인자(arguments)로 유지됩니다. 응답은 변수에서 도착하며, 타이핑은 값이 있는 곳에서 다시 시작합니다:
`if (event is WorkflowEvent.Completed) use(event.decodePayload<Token>(
쿠키 배너, 인터스티셜(interstitial), 세션 만료 시 나타나는 로그인 폼 등. waitFor는 이 모든 경우에 적절한 도구가 아닙니다. 해당 요소가 실제로 존재하지 않으면 실행에 실패하기 때문입니다. 또한 빌더 내의 Kotlin if 문도 도움이 되지 못하는데, 이는 페이지가 로드될 때쯤이면 이미 실행이 완료되었기 때문입니다.
runIf는 WorkflowStep.If를 추가하며 엔진은 이를 제자리에 평가합니다. 조건은 JS 문자열이 아니라 값(values)이기 때문에 실패 시 무엇이 잘못되었는지 이름으로 알려줄 수 있습니다: exists, variableEquals, variableMatches, jsTruthy 등이 and/or/not과 결합될 수 있습니다.
navigate("https://shop.example.com/cart")
// 어떤 결과도 실패가 아닙니다. 배너가 있으면 닫고, 없으면 건너뜁니다.
runIf(exists("#cookie-banner")) {
...
브랜치(Branches)는 중첩되고 단계 번호 매김(step numbering)도 그에 따라 중첩됩니다. 내부에서 실패가 발생하면 단순히 실패한 If 문을 가리키는 평면적인 인덱스 대신 3.then.1과 같은 StepPath를 보고합니다.
Exists는 누락된 요소가 오류가 아니라 답변이 되어야 하는 의도적인 조건이며, 모든 다른 단계에서 전적으로 거부하는 오래된 스냅샷 핸들(stale snapshot handle)에도 적용됩니다.
레인(lane)당 하나의 WebView를 두고, 각 사이트를 최상위 문서로 로드하여 세션이 퍼스트파티(first-party) 상태를 유지하고 X-Frame-Options가 문제 되는 것을 방지합니다. 풀(pool)을 모든 워크플로우에 전달하면 장치가 감당할 수 있는 만큼의 레인으로 소진됩니다.
var pool by remember { mutableStateOf<FramePool?>(null) }
VitreFrameHost(
laneCount = 4,
...
샘플의 가격 탐색기(Price scout)는 정확히 이 작업을 수행합니다. 네 개의 고유 출처를 가진 네 개의 합성 상점을 만들고, 전달된 가격으로 병합 및 순위를 매기는데, 이는 카탈로그 대부분에서 가장 저렴한 스티커 가격과 다른 상점인 경우가 많습니다.
관찰자(observer)가 연결을 끊는 상황에서도 살아남아야 하는 작업은 한 번 WorkflowQueue에 제출해야 합니다.
그 역할은 핫 상태(hot state)를 노출하는 것입니다: 또 다른 컬렉터가 동일한 실행을 관찰합니다. 진입은 제한되며, 마감 시간에는 대기하는 시간이 포함됩니다.
val queue = WorkflowQueue(hostScope, readyPool, capacity = 32)
val job = queue.submit(shop.workflow(query), timeoutMs = 60_000)
hostJobs[job.id] = job // UI 또는 에이전트 세션을 위해 핸들 유지
...
A RequestHandler는 메모리에서 요청에 응답하므로, 테스트는 네트워크 연결이나 불안정성 없이 실제 Origin을 가진 실제 WebView를 대상으로 실행됩니다. 이것이 병렬 레인 데모가 사용 가능한 스모크 테스트로 유지되는 방식입니다.
val fixtures = RequestHandler { request ->
when (request.host) {
"shop-a.test" -> InterceptedResponse(body = shopAHtml.encodeToByteArray())
...
핸들러는 기본적으로 활성화되어 있는 인터셉션의 유일한 부분이며, 피처가 필요로 하는 유일한 부분입니다: 기본 생성된 InterceptionPolicy는 그로부터 응답하고 그렇지 않으면 네트워크를 그대로 두어, 실제 사이트를 가리키는 레인은 이 라이브러리가 다시 가져온 문서 대신 브라우저가 가져왔을 문서를 얻게 됩니다. InterceptionPolicy.AUTOMATION
AI 자동 생성 콘텐츠
본 콘텐츠는 GitHub Claude Ecosystem의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기