본문 바로가기

프로그래밍

Playwright + browser-use로 웹 태스크 LLM 자동화: 셀프힐링 로케이터와 검증 루프 패턴 시작 가이드

반응형

왜 이 조합이 필요한가

 

웹 업무를 LLM으로 자동화하면 가장 먼저 부딪히는 문제가 잦은 UI 변경과 예외 상황입니다. 버튼 텍스트나 DOM 구조가 조금만 바뀌어도 스크립트가 멈추는 일이 반복됩니다.

 

Playwright는 안정적인 브라우저 제어와 테스트 친화적 로케이터를 제공합니다. 여기에 browser-use 같은 LLM 에이전트를 얹으면 “사람처럼” 탐색하고 선택지를 판단하는 흐름을 만들 수 있습니다.

 

이 글은 두 도구로 초보자도 적용하기 쉬운 최소 패턴을 제안합니다. 셀프힐링 로케이터로 선택 실패를 줄이고, 짧은 검증 루프로 결과를 즉시 확인해 실패를 빠르게 되돌리는 방식을 다룹니다.

 

> 안티패턴: 처음부터 모든 과정을 LLM에게 완전 위임하면 디버깅이 어려워집니다. 기본 경로는 Playwright로 고정하고, LLM은 모호한 선택지 해석·대체 로케이터 제안처럼 불확실한 구간에만 쓰는 편이 안전합니다.

 

셀프힐링 로케이터와 검증 루프, 무엇을 뜻하나

 

셀프힐링 로케이터는 요소가 조금 바뀌어도 대체 단서를 조합해 다시 찾는 방식입니다. 텍스트, 역할(role), 레이블, 근접 관계 같은 힌트를 순서대로 시도해 실패를 줄이는 아이디어입니다.

 

왜 필요한가를 간단히 보겠습니다. 로그인 버튼이 “Sign in”에서 “Log in”으로 바뀌거나 div 구조가 한 겹 더 감싸져도, 하나의 CSS 셀렉터에만 의존하면 바로 깨집니다.

 

실무에서는 다음처럼 단서를 계층화해 사용합니다.

 

- 우선 순위 1: 접근성 정보(ARIA role, name)
- 우선 순위 2: 눈에 보이는 텍스트의 유사도 매칭
- 우선 순위 3: 레이블-입력 쌍, placeholder
- 우선 순위 4: 부모/형제 관계, test-id

 

검증 루프는 액션 후 바로 결과를 확인하고, 불일치하면 즉시 보정하는 짧은 반복입니다. 클릭 -> 기대 상태 확인 -> 불일치 시 대안 로케이터 시도 -> 그래도 실패면 스크린샷·로그 수집으로 이어지는 흐름입니다.

 

간단한 예로, “검색어 입력 -> 검색 버튼 클릭 -> 결과 카드가 최소 1개 보임”을 한 루프로 묶습니다. 실패 시 browser-use에게 “동의 배너 닫기” 같은 차단 요인을 설명하게 하여 재시도하게 합니다.

 

> 팁: 검증은 “페이지 전체 스냅샷 비교”보다 “작은 조건 하나”를 빠르게 체크하는 편이 안정적입니다. 네트워크 지연으로 생기는 일시적 실패를 줄일 수 있습니다.

 

최소 패턴, 바로 적용

 

처음 흐름은 Playwright로 고정하고, 실패한 부분만 LLM에 맡기세요. 액션-검증-보정을 한 루프로 묶으면 유지가 단순해집니다.

 

- 1단계: 우선순위 로케이터 묶음을 정합니다(ARIA 역할/이름 → 텍스트 유사 → 레이블/placeholder → 근접).
- 2단계: 액션 전 대기 조건을 적습니다(waitForURL, toBeVisible 등).
- 3단계: 액션 뒤 기대 상태를 한 가지로 검증합니다(URL, 토스트, DOM 스냅샷 중 택1).
- 4단계: 불일치면 대체 로케이터를 순서대로 시도하고, 끝에 LLM 제안을 받습니다.
- 5단계: 모두 실패하면 스크린샷·콘솔 로그를 저장하고 루프를 멈춥니다.

 

검증 신호는 하나만 쓰세요. 예: 로그인은 “프로필 아바타 노출” 또는 “/dashboard 경로” 중 한 가지로 고정합니다.

 

> 실무 팁: LLM은 실패 시에만 호출하고, 호출 횟수와 토큰 상한을 제한하세요. 불필요한 호출은 지연과 비용을 늘립니다.

 

아래 예시는 버튼 클릭을 셀프 힐링 로케이터와 검증 루프로 감싼 구조입니다. 코드 뒤에 실패 원인을 남겨 다음 실행에 반영합니다.

 

// TypeScript (Playwright + browser-use 예시 구조)
import { test, expect } from '@playwright/test';
import { createAgent } from 'browser-use';

const candidates = [
  (p) => p.getByRole('button', { name: /sign in|log in/i }),
  (p) => p.getByText(/sign in|log in/i),
  (p) => p.locator('button:has-text("Sign in"), button:has-text("Log in")'),
  (p) => p.getByLabel('Password').locator('..').getByRole('button')
];

test('login with self-healing + verify loop', async ({ page }) => {
  const agent = createAgent({ page });
  await page.goto('https://example.com');

let clicked = false;
  for (const make of candidates) {
    const btn = make(page);
    if (await btn.first().isVisible()) {
      await btn.first().click();
      clicked = true;
      break;
    }
  }

if (!clicked) {
    const suggestion = await agent.suggestLocator({ intent: 'login button' });
    await page.locator(suggestion.selector).first().click();
  }

await page.waitForLoadState('networkidle');
  const ok = page.url().includes('/dashboard') ||
             (await page.getByRole('img', { name: /profile/i }).isVisible().catch(() => false));

if (!ok) {
    await page.screenshot({ path: 'fail.png' });
    console.error(await page.evaluate(() => console.log));
    throw new Error('Verification failed');
  }
});

 

요점은 로케이터 후보를 배열로 관리하고, 실패했을 때만 LLM을 호출하는 것입니다. 검증은 단일 신호로 단순화하고, 로그와 스크린샷으로 다음 시도에 근거를 남깁니다.

 

흔한 실수와 안전한 선택 기준

 

셀프힐링과 검증 루프는 만능이 아닙니다. 초반 설정이 흐트러지면 복구 비용이 커집니다.

 

자주 발생하는 실수는 아래와 같습니다.

 

- 하나의 CSS/XPath에만 의존해 변경에 취약해짐
- 검증 신호를 여러 개 섞어 오탐·미탐이 빈번해짐
- LLM 호출을 기본 경로에 넣어 지연과 비용이 커짐
- 대체 로케이터 우선순위를 문서화하지 않아 재현이 어려움

 

안전하게 적용하려면 선택 기준을 명확히 두는 편이 좋습니다.

 

- 로케이터는 접근성 정보(ARIA role/name) → 텍스트 유사 → 레이블/placeholder → 근접 순으로 고정
- 검증은 화면 상태 신호 1개만 채택(URL, 특정 요소, 토스트 중 택1)
- LLM은 실패 분기에서만 호출하고 호출 횟수·토큰 상한을 설정
- 각 단계의 타임아웃과 재시도 횟수를 환경별로 분리(dev/stage/prod)

 

> 안티패턴: “강한” 텍스트 유사도 매칭으로 전역을 뒤지는 설정은 과적합과 오클릭을 유발합니다. 스코프를 화면 섹션 단위로 제한하고, 테스트 ID가 있으면 그 경로를 최우선으로 두세요.

 

검증 루프의 트레이드오프는 빠른 회복 대신 로그가 늘어난다는 점입니다. 운영에서는 실패 루프의 마지막 스크린샷과 콘솔 로그만 보관하고, 경고 레벨을 분리하는 방식으로 노이즈를 줄이세요.

 

작은 루프부터 고정하기

 

웹 자동화는 한 화면에서 끝나는 작은 액션-검증-보정 루프를 먼저 만드세요. 로그인, 검색, 폼 제출 중 하나로 시작하면 흐름이 단순해집니다.

 

아래 체크리스트로 초반 품질을 일정하게 맞춥니다.

 

- 로케이터 우선순위: ARIA role/name → 텍스트 유사 → 레이블/placeholder → 근접. 이 순서를 설정 파일로 고정
- 대기·검증 신호는 각각 1개만 사용
- 실패 시 대체 로케이터 2~3개 재시도 후에만 LLM 호출, 호출/토큰 상한 설정
- 최종 실패는 스크린샷·콘솔 로그를 경로·타임스탬프 규칙으로 저장

 

먼저 루프의 뼈대를 만든 뒤, 검증 기준을 한 가지로 고정합니다. 코드는 구조를 보여주기 위한 단순 예시입니다.

 

// Playwright + 실패 시 LLM 제안 훅의 뼈대 예시
async function actWithVerify(page, tryLocators, verify) {
  for (const loc of tryLocators) {
    const el = page.locator(loc);
    if (await el.first().isVisible({ timeout: 2000 })) {
      await el.first().click();
      if (await verify()) return { ok: true, used: loc };
    }
  }
  const llmLoc = await suggestLocatorWithLLM(page.url(), await page.content());
  if (llmLoc) return actWithVerify(page, [llmLoc], verify);
  await page.screenshot({ path: `fails/${Date.now()}.png` });
  return { ok: false };
}

 

검증은 한 신호로만 결정하고, 실패 경로에서만 LLM을 쓰면 지연과 변경 비용을 함께 줄일 수 있습니다. 이렇게 작게 묶은 뒤 범위를 천천히 늘리세요.

 

> 팁: 첫 주는 한 시나리오만 운영해 성공률과 평균 재시도 횟수를 기록하세요. 수치가 안정되면 다음 화면으로 확장합니다.

 

로그인 루프를 짧게 묶는 방법

 

작은 로그인 예제로 셀프힐링 로케이터와 검증 루프를 함께 만듭니다. 핵심은 우선순위 단서를 돌며 클릭하고, 직후 한 신호로 성공을 확인하는 것입니다.

 

코드는 Playwright 기본 흐름 위에 실패 시 대체 로케이터를 차례로 시도합니다. LLM이 보조 단서를 제안할 위치도 주석으로 남깁니다.

 

// 예시는 구조를 보여주기 위한 형태입니다.
type LocatorHint = { by: 'role'|'text'|'label'|'near'; value: string };

async function clickWithHeal(page, hints: LocatorHint[]) {
  for (const h of hints) {
    try {
      const el =
        h.by === 'role' ? page.getByRole('button', { name: h.value }) :
        h.by === 'text' ? page.getByText(h.value, { exact: false }) :
        h.by === 'label' ? page.getByLabel(h.value) :
        page.getByRole('button').filter({ hasText: h.value });
      await el.first().click({ timeout: 2000 });
      return true;
    } catch {}
  }
  // TODO: 실패 시 LLM에게 대체 단서 제안 요청
  return false;
}

export async function loginFlow(page) {
  await page.goto('https://example.com/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('secret');

const ok = await clickWithHeal(page, [
    { by: 'role', value: 'Log in' },
    { by: 'text', value: 'Sign in' },
    { by: 'near', value: 'Password' },
  ]);

await page.waitForLoadState('networkidle');
  const verified = page.url().includes('/dashboard') ||
    await page.getByRole('img', { name: /profile/i }).isVisible().catch(() => false);

if (!ok || !verified) throw new Error('login verification failed');
}

 

요지는 로케이터 힌트를 배열로 관리하고, 클릭 뒤 URL 또는 특정 요소 하나로만 검증하는 것입니다. 실패분에는 스크린샷과 LLM 호출을 연결해 보정 루프를 닫습니다.

 

> 실무 팁
> - 실패가 2~3회 누적될 때만 LLM을 호출해 지연과 비용을 줄이세요.
> - 타임아웃, 재시도, 스크린샷 경로는 환경 변수로 빼서 과한 로그를 막으세요.

반응형