Published on

BabyAGI+Playwright로 로그인봇 안정화 전략

Authors

서버 사이드 자동화로 로그인봇을 만들다 보면, 대부분의 실패는 "CAPTCHA를 어떻게 뚫을까"가 아니라 "CAPTCHA가 뜨지 않게, 혹은 떠도 안전하게 멈추게"에서 갈립니다. CAPTCHA는 방어 체인의 일부일 뿐이고, 실제 운영에서 더 자주 마주치는 문제는 다음입니다.

  • 로그인 폼이 A/B 테스트로 바뀌어 셀렉터가 깨짐
  • 리다이렉트 체인 중간에 추가 동의/약관 화면이 끼어듦
  • 2FA, 이메일 인증, 기기 등록 등 "사람 확인" 단계가 간헐적으로 발생
  • 레이트 리밋, WAF, 봇 탐지로 인해 특정 IP/UA 조합이 차단
  • 쿠키/스토리지 불일치로 세션이 꼬여 무한 루프

이 글은 BabyAGI 스타일의 에이전트(작업 분해·계획·재시도)와 Playwright(브라우저 자동화)를 결합해, CAPTCHA 우회 없이도 로그인 자동화를 "운영 가능한 수준"으로 안정화하는 방법을 다룹니다. 핵심은 우회가 아니라 탐지 신호를 줄이는 브라우저 전략 + 상태 머신 기반의 복구 + 안전한 중단입니다.

아키텍처 개요: 에이전트는 "결정", Playwright는 "실행"

구성은 단순하게 가져가는 편이 안정적입니다.

  • BabyAGI 역할
    • 로그인 작업을 작은 태스크로 쪼개고(예: 페이지 진입, 폼 입력, 제출, 후처리)
    • 각 태스크의 성공 조건/실패 조건을 정의하고
    • 실패 시 재시도 정책(백오프, 다른 경로, 중단)을 선택
  • Playwright 역할
    • 실제 브라우저 컨텍스트/세션을 관리
    • 네트워크/DOM/스토리지 상태를 관측
    • 스크린샷, 트레이스, HAR 등 디버깅 아티팩트 수집

이 조합의 장점은, UI가 조금 변해도 "어느 단계에서 실패했는지"가 명확하고, 실패를 무작정 반복하지 않고 정책 기반으로 복구할 수 있다는 점입니다.

CAPTCHA 우회 대신 "CAPTCHA가 덜 뜨는" 조건 만들기

CAPTCHA는 보통 리스크 스코어가 특정 임계치를 넘으면 등장합니다. 즉, 우회하지 않아도 리스크를 낮추면 빈도가 크게 줄어듭니다.

1) 헤드리스/자동화 흔적 최소화

Playwright는 기본값만 써도 충분히 안정적이지만, 아래는 흔히 리스크를 올리는 요인입니다.

  • 매 실행마다 새 컨텍스트(쿠키 없음)로 로그인 시도
  • 너무 빠른 타이핑/클릭(인간 행동과 괴리)
  • 동일 IP에서 과도한 로그인 시도
  • 비정상적인 viewport, timezone, locale 조합

다만, "스텔스 플러그인"류로 탐지를 속이려는 접근은 서비스 약관/정책 위반 소지가 크고, 장기적으로도 깨지기 쉽습니다. 여기서는 정상 사용자에 가까운 행동세션 재사용 중심으로 접근합니다.

2) 세션 재사용이 가장 큰 효과를 냄

CAPTCHA는 "로그인 시도" 자체보다 "낯선 환경에서의 로그인 시도"에서 더 자주 뜹니다. 따라서 가능하면:

  • 최초 1회 사람이 로그인하고
  • 해당 상태를 Playwright storageState로 저장한 뒤
  • 이후 자동화는 저장된 상태를 로드해서 "로그인 과정"을 최소화

이 전략이 CAPTCHA 노출을 가장 크게 줄입니다.

// playwright-session.ts
import { chromium, type BrowserContext } from 'playwright';
import fs from 'node:fs';

const STATE_PATH = 'storage-state.json';

export async function getContext(): Promise<BrowserContext> {
  const browser = await chromium.launch({ headless: true });

  // 세션이 있으면 재사용
  if (fs.existsSync(STATE_PATH)) {
    return await browser.newContext({ storageState: STATE_PATH });
  }

  // 없으면 최초 부트스트랩(이 단계는 사람이 수행하도록 설계하는 편이 안전)
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto('https://example.com/login', { waitUntil: 'domcontentloaded' });

  // TODO: 여기서는 실제 계정 입력/2FA 등을 사람이 처리하고,
  // 완료 후 아래처럼 저장만 하도록 운영 플로우를 만든다.

  await context.storageState({ path: STATE_PATH });
  return context;
}

운영에서는 이 STATE_PATH를 계정 단위로 분리하고, 만료/회수 로직을 둬야 합니다.

상태 머신으로 로그인 플로우를 "관측 가능"하게 만들기

로그인 자동화가 불안정한 가장 큰 이유는, 코드가 보통 이렇게 생기기 때문입니다.

  • 로그인 페이지로 이동
  • 셀렉터 기다림
  • 입력
  • 제출
  • 다음 페이지로 이동했겠지? 라는 가정

현실은 중간에 약관 동의, 이메일 인증, 기기 등록, 보안 경고 등이 끼어듭니다. 따라서 "현재 화면이 무엇인지"를 판별하고 다음 액션을 선택하는 상태 머신이 필요합니다.

화면 판별(Detection) 레이어

DOM 셀렉터 하나에 의존하지 말고, 여러 신호를 합쳐 판별합니다.

  • URL 패턴
  • 특정 텍스트(에러 메시지, 페이지 타이틀)
  • 폼 존재 여부
  • 응답 코드/리다이렉트
// detect.ts
import type { Page } from 'playwright';

export type Screen =
  | 'LOGIN_FORM'
  | 'LOGGED_IN'
  | 'CAPTCHA'
  | 'TWO_FACTOR'
  | 'CONSENT'
  | 'ERROR';

export async function detectScreen(page: Page): Promise<Screen> {
  const url = page.url();

  if (url.includes('/dashboard')) return 'LOGGED_IN';
  if (url.includes('/2fa')) return 'TWO_FACTOR';

  const hasPassword = await page.locator('input[type="password"]').count();
  if (hasPassword) return 'LOGIN_FORM';

  // CAPTCHA는 vendor마다 다르므로 "의심" 신호를 폭넓게 잡고,
  // 실제 처리는 안전 중단하도록 한다.
  const captchaSignals = [
    'captcha',
    'recaptcha',
    'hcaptcha',
    'are you human',
    'robot'
  ];

  const bodyText = (await page.locator('body').innerText().catch(() => ''))
    .toLowerCase();
  if (captchaSignals.some(s => bodyText.includes(s))) return 'CAPTCHA';

  const consentSignals = ['terms', 'privacy', 'consent', '동의'];
  if (consentSignals.some(s => bodyText.includes(s))) return 'CONSENT';

  return 'ERROR';
}

핵심은 CAPTCHA를 "풀어버리는" 게 아니라, CAPTCHA 상태를 감지하면 즉시 안전 중단하고 운영자 개입 또는 다음 시나리오로 넘어가게 만드는 것입니다.

BabyAGI 스타일로 태스크 분해와 재시도 정책 붙이기

BabyAGI의 요지는 "목표를 달성하기 위해 다음 할 일을 생성하고, 결과를 메모리에 쌓고, 우선순위를 조정"하는 루프입니다. 로그인봇에서는 거창한 자율 계획보다, 아래처럼 정해진 태스크 세트 + 조건부 분기 + 재시도 정책을 붙이는 방식이 실용적입니다.

  • Task 1: 세션 로드 후 홈 진입
  • Task 2: 로그인 필요 여부 판별
  • Task 3: 로그인 폼 입력/제출
  • Task 4: 후처리(프로필 페이지 접근 등)로 로그인 검증
  • Task 5: 실패 시 원인 분류(네트워크, 셀렉터 변경, CAPTCHA, 2FA)
  • Task 6: 정책 실행(백오프 재시도, 상태 초기화, 중단/알림)

간단한 에이전트 루프 예시

// agent.ts
import { getContext } from './playwright-session';
import { detectScreen } from './detect';
import { setTimeout as sleep } from 'node:timers/promises';

type Result = { ok: true } | { ok: false; reason: string };

async function loginFlow(): Promise<Result> {
  const context = await getContext();
  const page = await context.newPage();

  // 트래픽 패턴을 너무 공격적으로 만들지 않기
  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
  await sleep(300 + Math.random() * 700);

  const screen = await detectScreen(page);

  if (screen === 'LOGGED_IN') return { ok: true };

  if (screen === 'LOGIN_FORM') {
    await page.fill('input[name="email"]', process.env.LOGIN_EMAIL ?? '');
    await sleep(150 + Math.random() * 250);
    await page.fill('input[type="password"]', process.env.LOGIN_PASSWORD ?? '');
    await sleep(150 + Math.random() * 250);
    await page.click('button[type="submit"]');

    await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
    const after = await detectScreen(page);

    if (after === 'LOGGED_IN') {
      await context.storageState({ path: 'storage-state.json' });
      return { ok: true };
    }

    if (after === 'CAPTCHA') return { ok: false, reason: 'CAPTCHA' };
    if (after === 'TWO_FACTOR') return { ok: false, reason: 'TWO_FACTOR' };
    return { ok: false, reason: `LOGIN_FAILED:${after}` };
  }

  if (screen === 'CAPTCHA') return { ok: false, reason: 'CAPTCHA' };
  if (screen === 'TWO_FACTOR') return { ok: false, reason: 'TWO_FACTOR' };
  if (screen === 'CONSENT') return { ok: false, reason: 'CONSENT' };

  return { ok: false, reason: `UNKNOWN_SCREEN:${screen}` };
}

export async function runAgent() {
  const maxAttempts = 3;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const result = await loginFlow();
    if (result.ok) return;

    // 실패 원인별 정책
    if (result.reason === 'CAPTCHA' || result.reason === 'TWO_FACTOR') {
      // 우회 금지: 즉시 중단 + 알림 대상으로 넘기는 게 운영 안정성에 유리
      throw new Error(`HUMAN_REQUIRED:${result.reason}`);
    }

    // 네트워크/일시 장애로 간주하고 지수 백오프
    const backoff = Math.min(10_000, 500 * 2 ** (attempt - 1));
    await sleep(backoff + Math.random() * 300);
  }

  throw new Error('LOGIN_BOT_FAILED:MAX_ATTEMPTS');
}

여기서 BabyAGI의 "메모리"를 붙이면, 실패 사유와 화면 스냅샷을 축적해 다음 실행에서 우선순위(예: 세션 초기화 먼저, 다른 프록시 풀 사용 등)를 조정할 수 있습니다.

안정화의 핵심 1: 재시도는 "멱등"이어야 한다

로그인 자동화에서 재시도는 쉽게 계정을 잠그거나(잠금 정책), 세션을 꼬이게 하거나, WAF 점수를 올립니다. 따라서 재시도는 다음 원칙을 지키는 편이 좋습니다.

  • 동일 세션에서 무한 반복하지 않기
  • 실패 원인별로 다른 재시도 전략 적용
  • "제출 버튼 클릭" 같은 비멱등 액션은 횟수 제한
  • 재시도 전에 상태를 재검증(이미 로그인 되었는지) 후 진행

이 관점은 메시징/워크플로우에서의 Exactly-Once와도 비슷합니다. 로그인봇이 단순 스크립트에서 운영 시스템으로 바뀌는 순간, 멱등성 설계가 중요해집니다. 이벤트 기반 안정화 감각이 필요하다면 Kafka Exactly-Once 실패? 멱등키·Outbox 실전에서 다루는 "중복 실행을 전제로 한 설계"가 큰 힌트를 줍니다.

안정화의 핵심 2: 관측 가능성(Observability)을 먼저 깔기

"가끔 실패"를 잡으려면 로그만으로는 부족합니다. Playwright가 제공하는 트레이스/스크린샷/네트워크 로그를 반드시 남기세요.

  • 시도 단위 correlation id
  • 단계별 스크린샷
  • Playwright trace zip
  • 실패 시 마지막 URL, 주요 DOM 텍스트 일부
// observability.ts
import type { Page, BrowserContext } from 'playwright';

export async function enableTracing(context: BrowserContext, runId: string) {
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
  return async () => {
    await context.tracing.stop({ path: `trace-${runId}.zip` });
  };
}

export async function captureFailure(page: Page, runId: string) {
  await page.screenshot({ path: `fail-${runId}.png`, fullPage: true }).catch(() => {});
}

실패 분석이 빨라지면, "CAPTCHA가 떴다"라는 결과만 보지 않고 그 직전에 어떤 리다이렉트가 있었는지, 어떤 에러 메시지가 있었는지까지 추적할 수 있습니다.

안정화의 핵심 3: 레이트 리밋과 백오프는 제품 기능이다

로그인봇은 외부 서비스에 부하를 주는 순간 바로 차단의 대상이 됩니다. 따라서 백오프/재시도는 선택이 아니라 필수입니다.

  • 계정 단위 동시성 제한(한 계정은 한 번에 한 세션)
  • IP 단위 시도 제한
  • 지수 백오프 + 지터
  • 특정 실패 코드에서 즉시 중단

LLM API를 붙여 BabyAGI를 운용한다면, 모델 호출도 429가 자주 납니다. 자동화 전체가 불안정해지지 않도록 LLM 호출에 대해서도 동일한 재시도 원칙이 필요합니다. 관련해서는 Gemini API 429 쿼터 초과 대응 - 재시도·백오프 같은 패턴을 그대로 가져와 적용할 수 있습니다.

CAPTCHA/2FA가 뜨면 어떻게 할 것인가: "안전 중단"과 "사람 개입" 설계

우회하지 않겠다고 결정했다면, 남는 선택지는 두 가지입니다.

  1. 안전 중단
  • CAPTCHA/2FA 감지 즉시 종료
  • 계정 상태를 "HUMAN_REQUIRED"로 마킹
  • 알림 발송(슬랙, 이메일, 티켓)
  1. 사람 개입 후 재개
  • 운영자가 별도 UI에서 해당 세션을 이어받아 처리
  • 처리 완료 후 storageState 갱신
  • 봇은 다음 작업부터 재개

여기서 중요한 것은 "사람 개입"이 들어가도 전체 파이프라인이 멈추지 않게 만드는 것입니다. 예를 들어 계정 풀을 운영한다면, 특정 계정이 HUMAN_REQUIRED로 빠지면 다른 계정으로 작업을 이어가고, 해당 계정은 큐에서 제외합니다.

Playwright 구현 팁: 안정성을 올리는 디테일

1) networkidle만 믿지 말고, 도메인별 대기 조건을 둔다

SPA는 백그라운드 요청이 계속 발생해 networkidle이 잘 안 끝날 수 있습니다. 다음처럼 "로그인 성공을 증명하는 요소"를 기다리는 편이 더 안정적입니다.

await page.waitForURL(/\/dashboard/, { timeout: 15_000 });
await page.locator('[data-testid="user-menu"]').waitFor({ timeout: 15_000 });

2) 셀렉터는 data-testid 우선, 없으면 "의미 기반"으로

  • 가능하면 대상 서비스(자사 서비스라면)에서 data-testid를 심습니다.
  • 외부 서비스라면, 텍스트/role 기반으로 다중 후보를 둡니다.
const submit = page.getByRole('button', { name: /sign in|login|로그인/i });
await submit.click();

3) 컨텍스트 격리: 계정마다 별도 storage state

한 파일에 모든 계정 상태를 섞으면, 쿠키 충돌로 "가끔"이 아니라 "항상" 불안정해집니다.

  • storage-state-{accountId}.json
  • 계정별 디렉터리로 trace/screenshot 분리

BabyAGI를 붙일 때의 실전 포인트: "자율"보다 "정책"이 이긴다

로그인봇에서 LLM 기반 에이전트가 빛을 보는 지점은 다음입니다.

  • 셀렉터가 깨졌을 때, DOM 스냅샷을 요약하고 "어떤 화면인지" 추론
  • 에러 메시지(다국어 포함)를 분류해 원인 라벨링
  • 새로 등장한 동의/공지 화면에서 "동의" 버튼 후보를 제시

반대로, 아래는 LLM에게 맡기면 비용/불확실성만 늘어납니다.

  • 무작위 클릭으로 돌파 시도
  • CAPTCHA를 풀려고 시도
  • 계정 잠금 정책을 무시한 반복 로그인

즉, BabyAGI는 "지능"이라기보다 런북 자동화 엔진으로 쓰는 게 운영 친화적입니다.

운영 환경 배포: 콜드스타트/브라우저 실행 지연도 실패 원인이다

Playwright는 런타임에서 브라우저를 띄우기 때문에, 서버리스 환경에서 콜드스타트 영향을 크게 받습니다. 콜드스타트가 길어지면 로그인 타임아웃과 겹쳐 실패로 보일 수 있습니다.

  • 컨테이너 기반 런타임에서 브라우저 의존성 캐시
  • 워커를 항상 일정 개수 warm 유지
  • 타임아웃을 "단계별"로 분리(전체 30초가 아니라 단계별 10초, 15초)

Cloud Run 계열에서 지연이 크다면 Cloud Run 503·콜드스타트 7분 지연 해결 가이드의 접근처럼, 런타임 준비 시간을 관측하고 워밍 전략을 세우는 것이 좋습니다.

체크리스트: CAPTCHA 우회 없이 안정화하려면

  • 로그인 과정을 최소화하기 위해 storageState 기반 세션 재사용
  • 화면 판별 레이어로 LOGIN_FORM, LOGGED_IN, CAPTCHA, 2FA 등을 명확히 분리
  • CAPTCHA/2FA 감지 시 즉시 중단하고 HUMAN_REQUIRED로 라벨링
  • 재시도는 멱등하게, 실패 원인별 정책(백오프/세션 초기화/중단)
  • trace/스크린샷/HAR로 관측 가능성 확보
  • 계정 단위 동시성 제한과 레이트 리밋 준수

마무리

CAPTCHA를 우회하지 않는다는 제약은 오히려 설계를 건강하게 만듭니다. 로그인봇을 "스크립트"가 아니라 "운영 가능한 시스템"으로 만들려면, BabyAGI로 태스크를 구조화하고 Playwright로 상태를 정확히 관측하며, 실패를 복구하거나 안전하게 멈추는 정책을 먼저 세워야 합니다.

정리하면, 안정화의 80퍼센트는 우회 기술이 아니라 세션 재사용, 상태 머신, 멱등 재시도, 관측 가능성에서 나옵니다. 이 네 가지를 갖추면 CAPTCHA는 "가끔 등장하는 예외"가 되고, 로그인봇은 "매일 돌아가는 서비스"가 됩니다.