AI 브라우저 에이전트가 잘못된 버튼을 클릭하는 것을 막은 방법
요약
AI 브라우저 에이전트가 모호한 텍스트 설명 대신 Playwright의 접근성 스냅샷(accessibility snapshot)을 사용하여 정확한 요소를 클릭하도록 만드는 방법을 설명합니다. 모델이 생성한 자연어 설명의 불확실성을 해결하기 위해 접근성 트리 기반의 고유 참조(ref)를 활용하는 것이 핵심입니다.
핵심 포인트
- 자연어 설명 기반 셀렉터는 텍스트 변경이나 구조적 모호성으로 인해 실패할 확률이 높음
- 접근성 트리(Accessibility Tree)를 압축한 스냅샷을 AI에게 제공하여 정확도 향상
- Playwright의 aria-ref를 사용하여 모델과 실행 엔진 간의 명확한 참조 계약 구축
- 모델이 픽셀이나 추측이 아닌 구조화된 참조를 바탕으로 동작하도록 유도
Smoketest.sh에서는 문장으로 흐름(
- "Sign in"과 일치하는 요소가 세 가지 있습니다: 내비게이션 링크(nav link), 푸터 링크(footer link), 그리고 실제 버튼입니다. 로케이터(locator)가 리스트로 해석되면서 Playwright가 그중 첫 번째 요소를 클릭하게 되고, 결과적으로 예상치 못한 곳으로 이동하게 됩니다.
- 이번 주에는 버튼 텍스트가 "Sign In"이었으나, 카피(copy) 변경 후에는 "Log in"이 되었습니다. 모델이 지난 실행에서 작성한 설명이 더 이상 일치하지 않게 된 것입니다.
- 모델은 실행 사이에 자신의 설명을 스스로 재구성합니다. "the Sign in button"이 "the blue login button at the top right"로 바뀌면서, 기존의 역할 및 이름 조회(role-and-name lookup)가 완전히 실패하게 됩니다.
이 중 어느 것도 모델의 버그가 아닙니다. 이는 재생성된 영어 구절을 셀렉터(selector)로 사용한 결과입니다. 해당 구절은 구조적으로 모호(fuzzy)하며, 복잡한 페이지에서의 모호한 셀렉터는 단 하나의 요소로 결정되지 않습니다.
접근성 트리(Accessibility tree)는 에이전트의 진실의 원천(source of truth)입니다
해결책은 모델이 무엇을 보는지 변경하는 것에서 시작됩니다. 모델이 스크린샷이나 가공되지 않은 HTML을 보고 추측하게 하는 대신, 페이지의 접근성 트리 (accessibility tree)를 압축하여 보여주는 Playwright의 접근성 스냅샷 (accessibility snapshot)을 AI 모드로 전달합니다. 이것이 하나의 도구입니다:
{
name: 'getAccessibilityTree',
description:
...
}
page.ariaSnapshot({ mode: 'ai' })는 페이지를 역할(role) 기반의 압축된 트리 형태로 반환합니다. AI 모드의 중요한 점은 모든 상호작용 가능한 요소에 [ref=eN] 태그가 붙는다는 것입니다. 로그인 페이지는 대략 다음과 같은 모습으로 반환됩니다:
- heading "Welcome back" [level=1]
- textbox "Email" [ref=e4]
- textbox "Password" [ref=e5]
...
이제 모델은 버튼을 설명할 필요가 없습니다. 대신 e6를 참조하면 됩니다. 이 참조(ref)는 "모델이 본 것"과 "Playwright가 클릭하는 것" 사이의 계약이며, 이것이 핵심입니다.
이는 Microsoft의 Playwright MCP server가 취하는 것과 동일한 구조화된 스냅샷(structured-snapshot) 방식입니다. 모델이 픽셀이나 추측이 아닌, 접근성 참조(accessibility refs)를 바탕으로 동작하게 만드는 것입니다.
aria-ref는 Playwright의 일급 객체 로케이터(first-class locator)입니다
ref가 작동하는 이유는 Playwright가 이를 직접 해결(resolve)하기 때문입니다. aria-ref=e6는 우리가 만든 것이 아니라 실제 로케이터 엔진(locator engine)입니다. 따라서 click 도구는 ref를 우선시하며, ref가 없는 경우에만 설명(description)으로 대체합니다.
execute: async ({ ref, description }) => {
const refStr = ref?.trim() || null;
const text = description?.trim() || null;
...
ref 경로는 모델이 방금 읽은 정확한 스냅샷(snapshot)을 기준으로 해결되기 때문에, 특정 문구로부터 다시 유도되는 것이 아니라 안정적입니다. fill, select, getText도 동일한 개념입니다. 모든 상호작용 도구는 ref를 첫 번째로, description을 두 번째로 받습니다.
모델에는 도구뿐만 아니라 규칙이 필요합니다
ref를 수용하는 도구만으로는 충분하지 않습니다. 모델에게 허용한다면 모델은 여전히 설명(description)을 찾으려 할 것입니다. 왜냐하면 영어를 사용하여 무언가를 설명하는 것이 언어 모델(language model)이 가장 잘하는 일이기 때문입니다. 따라서 모델에게 부여하는 규칙은 순서가 협상 불가능하도록 만들어야 합니다.
- 아직 보지 못한 페이지를 건드리기 전에 접근성 트리(accessibility tree)를 읽을 것.
- 모든 동작에서 설명(description)보다 ref를 우선할 것.
- 동작에 실패하면 설명을 다시 작성하는 대신, 새로운 ref를 찾기 위해 트리를 다시 읽을 것.
마지막 규칙이 핵심적인 역할을 합니다. 동작에 실패한 후 언어 모델의 본능은 더 정교한 설명을 시도하는 것입니다. 하지만 설명은 결코 신뢰할 수 있는 경로가 아니었기 때문에, 그것은 정확히 잘못된 움직임입니다. 트리를 다시 읽으면 실제로 변경된 현재의 DOM과 일치하는 새로운 ref를 얻을 수 있습니다.
ref가 없는 경우, 의도적으로 대체(fallback)하십시오
ref가 항상 사용 가능한 것은 아닙니다. 모델은 마지막 스냅샷에 있는 것이 아니라 자신이 추론한 무언가를 바탕으로 동작할 수도 있습니다. 따라서 resolveLocator는 단일 추측이 아니라 의도적인 단계(ladder)입니다. 모델은 각 후보 문구에 대해 역할(role), 그다음 레이블(label), 그다음 플레이스홀더(placeholder), 그다음 텍스트(text) 순서로 시도하며, 실제로 보이는 첫 번째 것을 선택합니다.
for (const phrase of phrases) {
if (roleHint) {
const roleLocator = page.getByRole(roleHint, { name: phrase, exact: false });
...
isVisible은 try/catch로 감싸진 5초간의 waitFor({ state: 'visible' })이므로, 존재하지만 숨겨져 있는 후보는 선택되지 않습니다. 구문 추출(phrase extraction) 과정에서 설명(description)으로부터 인용된 하위 문자열을 먼저 추출하기 때문에 (예: "click the button labeled "Place order""는 Place order를 반환), 모델의 장황함(verbosity)이 매칭을 방해하지 않습니다.
이것은 퍼지 경로(fuzzy path)이며, 우리는 이를 그에 맞게 취급합니다. 이는 복구(recover)하기에 충분히 훌륭하며, 우리가 가능한 한 모델을 참조(refs)에 두고 싶어 하는 바로 그 이유이기도 합니다.
"element not found"로 실패하지 마세요
폴백(fallback)조차 실패했을 때, 가장 좋지 않은 반환값은 아무런 정보 없는 "element not found"입니다. 모델은 행동할 근거가 없으므로 갈팡질팡하게 됩니다. 따라서 클릭 실패 시에는 페이지에 실제로 무엇이 포함되어 있는지에 대한 진단 정보(diagnostics)를 수집하여 에러와 함께 반환합니다:
const diagnostics = await collectClickDiagnostics(page, text!);
throw new Error(`${getErrorMessage(error)}. Diagnostics: ${JSON.stringify(diagnostics)}`);
collectClickDiagnostics는 역할(role), 레이블(label), 텍스트(text)별로 일치하는 요소가 몇 개인지 계산하고, 페이지 링크의 샘플을 포함합니다:
return {
description,
roleHint: roleMatch?.role ?? null,
...
이제 실패 원인을 읽을 수 있습니다. textCount: 3, roleCount: 0은 모델에게(그리고 트레이스 상의 우리에게) 모델이 버튼이라고 불렀던 것이 실제로는 세 개의 텍스트 조각임을 알려주며, 따라서 트리를 다시 읽고 실제 상호작용 가능한 요소(interactive element)를 타겟팅해야 함을 시사합니다. 에러가 행동에 필요한 충분한 정보를 담고 있기 때문에 복구 루프(recovery loop)가 완성됩니다.
링크를 위한 작은 특수화(specialization)도 있습니다. 만약 모델이 링크를 클릭하려고 의도했으나 로케이터(locator)가 놓친 경우, 링크 텍스트나 aria-label을 매칭하여 href를 찾아 직접 탐색(navigate)합니다. 이는 오버레이 및 가로채기(overlay-and-intercept) 클릭이라는 범주 전체를 우회할 수 있게 해줍니다.
솔직한 트레이드오프 (trade-offs)
이것은 신뢰할 수 있는 요소 타겟팅(element targeting)이지, 결정론적인 에이전트(deterministic agent)가 아닙니다. 명확히 밝혀둘 만한 두 가지 한계는 다음과 같습니다:
- 참조(Refs)는 촬영한 스냅샷에 대해서만 유효합니다. 페이지 탐색(navigation)이나 DOM 변경이 발생한 후에는
e6가 아무것도 가리키지 않거나 잘못된 노드를 가리킬 수 있습니다. 이것이 바로 실패 시나 새로운 페이지에서 프롬프트가 새로운getAccessibilityTree호출을 강제하는 이유입니다. 참조를 지속 가능한 것이 아니라 스냅샷별로 유효한 것으로 취급하십시오. - 스냅샷은 토큰 비용을 발생시킵니다. 콘텐츠가 많은 페이지의 접근성 트리(accessibility tree)는 매우 클 수 있으며, 모든 탐색 후에 이를 모델에 입력하는 것은 비용이 빠르게 누적됩니다. 우리는 What Works (and What Breaks) Running Playwright MCP in Claude Code에서 이 비용에 대해 자세히 다루었는데, 단일 스냅샷이 수만 개의 토큰에 달할 수 있습니다. 우리에게는 그 신뢰성이 비용을 지불할 가치가 있지만, 이는 실제 청구서에 기록되는 비용이며, 우리가 모든 단계마다 스냅샷을 다시 찍지 않고 페이지가 새로 바뀌거나 무언가 실패했을 때만 다시 찍는 이유이기도 합니다.
그리고 모델은 여전히 무엇을 할지 결정합니다. 참조(Refs)는 모델이 '로그인(Sign in)' 버튼을 클릭하기로 결정했을 때, 이름이 같은 푸터(footer) 링크가 아니라 정확히 그 버튼을 클릭하도록 보장합니다. 참조가 모델이 애초에 잘못된 것을 클릭하기로 결정하는 것 자체를 막아주지는 않습니다. 그것은 별도의 평가 단계(evaluation pass)를 통해 해결해야 하는 다른 문제입니다.
LLM 브라우저 에이전트를 구축하고 있다면
한 가지 핵심 아이디어는 다음과 같습니다: 모델이 셀렉터(selector)를 생성하게 하지 마십시오. 영어 문구로부터 만들어진 셀렉터는 실행할 때마다 재생성되며, 단 하나의 요소로 정확히 연결되는 경우가 드뭅니다. 대신 다음과 같이 하십시오:
- 모델에게 안정적인 ID가 포함된 페이지의 구조화된 스냅샷을 제공하십시오 (
page.ariaSnapshot({ mode: 'ai' })). - 모든 액션 도구(action tool)가 ID를 우선적으로 받고, 설명(description)은 보조 수단으로만 사용하게 하십시오 (
page.locator('aria-ref=eN')). - 시스템 프롬프트에서 '스냅샷 촬영 후 행동(snapshot-then-act)'을 강제하고, 실패 시 문구를 수정하는 대신 스냅샷을 다시 찍으십시오.
- 요소를 찾지 못했을 경우 풍부한 진단 정보(rich diagnostics)를 반환하여 모델이 추측하는 대신 복구할 수 있도록 하십시오.
이러한 시퀀스가 우리 에이전트를 "데모만 통과하는 수준"에서 "두 번째 실행, 그리고 백 번째 실행에서도 통과하는 수준"으로 끌어올렸습니다.
여러분의 앱에서 직접 시도해 보세요
우리는 Smoketest에서 이를 프로덕션(Production) 환경에서 실행하고 있습니다. 여러분은 중요한 흐름(로그인, 결제, 온보딩, 빌링 등)을 설명하기만 하면 됩니다. 그러면 우리는 매 배포(Deploy) 후에 실제 브라우저에서 해당 흐름을 실행하고 무엇이 고장 났는지 알려줍니다. 여러분이 직접 소유하거나 유지 관리해야 하는 Playwright 스위트(Suite)는 필요 없습니다. smoketest.sh를 확인해 보세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기