새 탭 감지 및 자동 전환: 브라우저 자동화의 까다로운 문제¶
개요¶
브라우저 자동화에서 "새 탭"은 예상보다 까다로운 문제를 만든다. 사용자가 target="_blank" 링크를 클릭하면 새 탭이 열리는데, Playwright의 기본 컨텍스트는 여전히 원래 탭을 가리킨다. 캡처 스크립트도 원래 탭에만 주입되어 있으므로, 새 탭에서의 사용자 조작은 아무것도 기록되지 않는다.
문제는 새 탭이 열리는 시점을 직접 감지할 방법이 제한적이라는 점이다. Playwright MCP를 통한 간접 제어 환경에서는 page.on('popup') 같은 이벤트 리스너를 실시간으로 수신할 수 없다. MCP 호출은 요청-응답 방식이므로, "방금 새 탭이 열렸습니다" 같은 push 알림을 받을 수 없다.
XGEN 2.0에서는 이 문제를 "빈 폴링 휴리스틱"으로 해결했다. 캡처 스크립트의 폴링이 연속으로 빈 결과를 반환하면, 사용자가 새 탭으로 이동했을 가능성을 추론하고, context.pages()로 확인 후 자동 전환한다.
문제 분석¶
새 탭이 만드는 문제들¶
flowchart LR
subgraph Before[클릭 전]
Tab1A[탭 1: 메인 페이지<br/>캡처 스크립트 주입됨]
end
subgraph After[클릭 후]
Tab1B[탭 1: 메인 페이지<br/>캡처 스크립트 유지]
Tab2[탭 2: 새 페이지<br/>캡처 스크립트 없음]
User[사용자 포커스<br/>→ 탭 2]
end
Tab1A -->|target=_blank 클릭| Tab1B
Tab1A -->|새 탭 열림| Tab2
Tab2 --> User
새 탭이 열리면 3가지 문제가 동시에 발생한다.
| 문제 | 원인 | 영향 |
|---|---|---|
| 캡처 스크립트 미주입 | 새 탭은 별도의 page 객체 | 새 탭에서의 조작 미기록 |
| 폴링 대상 불일치 | evaluate가 원래 탭에서 실행 | 빈 결과만 반환 |
| URL 추적 불일치 | currentUrl이 원래 탭 URL | 네비게이션 기록 오류 |
이벤트 기반 감지가 불가능한 이유¶
일반적인 Playwright 코드에서는 page.on('popup') 이벤트로 새 탭을 즉시 감지할 수 있다. 하지만 XGEN의 구조에서는 이 방식이 작동하지 않는다.
MCP(Model Context Protocol)는 요청-응답 기반이다. 프론트엔드가 browser_evaluate를 호출하면 결과를 받고, 다음 호출을 보내는 구조다. MCP 서버 측에서 "새 탭이 열렸다"는 이벤트를 클라이언트에 push할 수 있는 채널이 없다. JSON-RPC over stdin/stdout 통신이므로, 서버 → 클라이언트 방향의 비동기 알림이 지원되지 않는다.
따라서 폴링 기반의 간접 감지가 유일한 방법이다.
아키텍처¶
감지 → 전환 → 재주입 흐름¶
sequenceDiagram
participant Poll as 폴링 루프
participant BM as BrowserManager
participant PW as Playwright MCP
participant T1 as 탭 1
participant T2 as 탭 2
Note over Poll,T2: 사용자가 target=_blank 클릭
T1->>T2: 새 탭 열림
loop 빈 폴링 감지
Poll->>BM: evaluate(getActions)
BM->>PW: browser_evaluate
PW->>T1: 실행 (탭 1)
T1-->>PW: 빈 결과 (사용자는 탭 2)
PW-->>Poll: count: 0
Poll->>Poll: emptyPolls++
end
Note over Poll: emptyPolls === 10
Poll->>BM: runCode(context.pages)
BM->>PW: browser_run_code
PW-->>Poll: pages: 2, switched: true
Poll->>BM: injectCaptureScript()
BM->>PW: browser_evaluate
PW->>T2: 캡처 스크립트 주입
Poll->>Poll: emptyPolls = 0, lastUrl = 탭2 URL
핵심 구현¶
checkAndSwitchToNewTab 함수¶
Playwright의 context.pages()로 열린 탭 목록을 조회하고, 2개 이상이면 마지막 탭으로 전환한다.
// useTauriMCPRecording.ts
const checkAndSwitchToNewTab = useCallback(async (): Promise<boolean> => {
try {
const code = `async (page) => {
const context = page.context();
const pages = context.pages();
if (pages.length > 1) {
const newPage = pages[pages.length - 1];
await newPage.bringToFront();
return JSON.stringify({
switched: true,
url: newPage.url(),
tabCount: pages.length
});
}
return JSON.stringify({
switched: false,
tabCount: pages.length
});
}`;
const result = await browserManager.runCode(code);
const parsed = parseTabResult(result);
if (parsed?.switched) {
lastUrlRef.current = parsed.url;
setCurrentUrl(parsed.url);
// 새 탭에 캡처 스크립트 주입
await injectCaptureScript();
return true;
}
} catch (err) {
console.warn('[TauriMCPRecording] Tab check error:', err);
}
return false;
}, [injectCaptureScript]);
pages[pages.length - 1]로 마지막에 열린 탭을 선택하는 이유는, target="_blank"로 열린 탭이 항상 pages 배열의 끝에 추가되기 때문이다.
bringToFront()는 해당 탭을 활성 탭으로 만드는 Playwright API다. 이후 MCP의 도구 호출(evaluate, click 등)이 이 탭을 대상으로 실행된다.
빈 폴링 휴리스틱¶
폴링 루프에서 캡처 스크립트의 getActions() 호출이 연속으로 빈 결과를 반환하면, 새 탭이 열렸을 가능성을 의심한다.
let emptyPolls = 0;
const EMPTY_POLLS_BEFORE_TAB_CHECK = 10;
while (active) {
const result = await collectActions();
if (result.count === 0) {
emptyPolls++;
if (emptyPolls === EMPTY_POLLS_BEFORE_TAB_CHECK) {
const switched = await checkAndSwitchToNewTab();
if (switched) {
emptyPolls = 0;
continue;
}
}
} else {
emptyPolls = 0;
}
await sleep(500);
}
임계값 10은 500ms 폴링 기준으로 약 5초에 해당한다. 이 시간은 다음을 고려해서 결정했다.
- 너무 짧으면: 사용자가 잠시 멈춘 것(화면 읽기, 생각 중)을 새 탭으로 오인
- 너무 길면: 새 탭에서의 조작이 오래 기록되지 않음
- 5초: 사용자가 새 탭을 열고 첫 조작을 시작하기까지의 평균 시간
JSON 이중 이스케이프 파싱¶
browser_run_code의 반환값이 MCP 레이어를 거치면서 이중 또는 삼중으로 JSON 이스케이프되는 경우가 있었다. 이를 처리하기 위해 3단계 파싱 전략을 사용한다.
const parseTabResult = (rawResult: any): TabCheckResult | null => {
let dataStr = typeof rawResult === 'string'
? rawResult
: rawResult?.data || rawResult?.content || '';
// 1단계: 외부 따옴표 제거
if (dataStr.startsWith('"') && dataStr.endsWith('"')) {
try { dataStr = JSON.parse(dataStr); } catch (_) {}
}
// 2단계: 일반 JSON 파싱
try { return JSON.parse(dataStr); } catch (_) {}
// 3단계: 이스케이프 문자 정리 후 재시도
if (typeof dataStr === 'string' && dataStr.startsWith('{')) {
try {
return JSON.parse(dataStr.replace(/\\"/g, '"'));
} catch (_) {}
}
// 4단계: regex로 직접 추출 (최후 수단)
const switchedMatch = dataStr.match(/switched["\s:]+(\w+)/);
const urlMatch = dataStr.match(/url["\s:]+["']([^"']+)["']/);
const tabCountMatch = dataStr.match(/tabCount["\s:]+(\d+)/);
if (switchedMatch) {
return {
switched: switchedMatch[1] === 'true',
url: urlMatch?.[1] || '',
tabCount: parseInt(tabCountMatch?.[1] || '1'),
};
}
return null;
};
# 커밋: fix: robust JSON parsing for new tab detection + use mappedType for check/radio
# 날짜: 2026-02-08 14:34
이 4단계 파싱이 필요했던 이유는 MCP 응답 형식이 도구마다 다르기 때문이다. browser_evaluate는 JavaScript 값을 직접 반환하지만, browser_run_code는 문자열로 직렬화된 결과를 반환한다. Rust IPC 레이어를 한 번 더 거치면서 추가 이스케이프가 발생하는 경우도 있었다.
Agent 모드에서의 탭 관리¶
시나리오 녹화 모드와 별개로, Agent 모드에서도 탭 관리가 필요했다. Agent가 클릭한 링크가 새 탭을 여는 경우, Agent의 후속 액션이 원래 탭에서 실행되면서 의도한 요소를 찾지 못하는 문제가 발생했다.
// Agent 모드: 클릭/navigate 후 팝업 탭 감지
case 'click': {
result = await browserManager.clickByRef(action.args.ref);
// 클릭 후 새 탭이 열렸는지 확인
const tabCheck = await checkAndSwitchToNewTab();
if (tabCheck) {
// 새 탭으로 전환 완료 → 스크롤바 CSS 재주입
await injectScrollbarCSS();
}
break;
}
팝업 탭 자동 닫기¶
일부 사이트에서는 팝업 형태로 새 탭이 열리고, 작업 완료 후 자동으로 닫히지 않는다. 이런 경우 탭이 계속 누적되어 context.pages()의 결과가 혼란스러워진다.
Agent 모드에서는 "0번 탭(메인 탭) 유지" 전략을 적용했다. 새 탭에서 작업이 완료되면 해당 탭을 닫고 메인 탭으로 돌아온다.
// 팝업 탭 닫기 + 메인 탭 복귀
const closePopupTab = async (): Promise<void> => {
const code = `async (page) => {
const context = page.context();
const pages = context.pages();
if (pages.length > 1) {
// 현재 탭(마지막 탭) 닫기
await pages[pages.length - 1].close();
// 메인 탭(0번)으로 포커스
await pages[0].bringToFront();
return JSON.stringify({ closed: true, remaining: pages.length - 1 });
}
return JSON.stringify({ closed: false, remaining: pages.length });
}`;
await browserManager.runCode(code);
};
Lazy Tab Management¶
MCP 호출을 줄이기 위해, 매 클릭마다 탭을 확인하는 대신, 후속 액션이 실패했을 때만 탭 전환을 시도하는 lazy 전략도 도입했다.
이전에는 모든 클릭 후에 checkAndSwitchToNewTab을 호출했는데, 대부분의 클릭은 새 탭을 열지 않으므로 불필요한 MCP 호출이었다. 후속 snapshot이나 click이 "요소를 찾을 수 없음" 에러를 반환했을 때만 탭 전환을 시도하는 것이 더 효율적이다.
트러블슈팅¶
Agent의 검색엔진 이동¶
Agent가 "Google에서 검색해보겠습니다"라고 판단하고 검색엔진으로 navigate하는 문제가 있었다. 이건 새 탭 문제는 아니지만, 불필요한 네비게이션이라는 점에서 관련이 있다.
시스템 프롬프트에 "검색엔진 이동 금지" 규칙을 추가하고, navigate 도구에 URL 블랙리스트 가드를 넣어서 해결했다.
# 커밋: fix: prevent agent from navigating to search engines - use current page
# 날짜: 2026-02-08 17:53 (xgen-app)
탭 전환 후 캡처 스크립트 주입 실패¶
새 탭으로 전환한 직후에 injectCaptureScript()를 호출하면, 페이지가 아직 로드 중이어서 주입이 실패하는 경우가 있었다.
waitForLoadState('domcontentloaded') 호출 후 주입하도록 순서를 조정했다.
if (parsed?.switched) {
// 새 탭 페이지 로드 대기
await browserManager.waitForLoadState('domcontentloaded', 5000);
// 캡처 스크립트 주입
await injectCaptureScript();
}
addInitScript의 탭 간 공유 한계¶
addInitScript로 등록한 스크립트는 해당 BrowserContext의 모든 페이지에 적용된다고 Playwright 문서에 나와 있다. 하지만 실제로는 target="_blank"로 열린 새 탭에 항상 적용되지는 않았다. 특히 크로스 오리진 탭에서 적용되지 않는 경우가 있었다.
이 때문에 addInitScript에만 의존하지 않고, 탭 전환 시 수동으로 캡처 스크립트를 주입하는 방식을 병행한다.
macOS 빌드에서의 탭 관리¶
macOS에서 Tauri 빌드 시 Playwright Chromium의 탭 동작이 Windows/Linux와 약간 달랐다. bringToFront()가 macOS에서는 창 포커스까지 변경해서, Tauri 앱 자체의 포커스가 빠져나가는 문제가 있었다.
결과 및 회고¶
탭 관리 시나리오별 대응¶
| 시나리오 | 감지 방식 | 대응 |
|---|---|---|
target="_blank" 클릭 |
빈 폴링 10회 | 자동 전환 + 스크립트 주입 |
window.open() |
빈 폴링 10회 | 자동 전환 + 스크립트 주입 |
| 팝업 후 자동 닫힘 | pages.length 감소 | 메인 탭으로 복귀 |
| Agent 클릭 → 새 탭 | 후속 액션 실패 시 | lazy 감지 + 전환 |
설계 원칙¶
"push가 없으면 poll로 해결하라": MCP의 요청-응답 구조에서 이벤트 push가 불가능하다면, 폴링으로 상태 변화를 감지하는 것이 현실적이다. 폴링 간격과 임계값을 적절히 조정하면 실시간에 가까운 반응 속도를 달성할 수 있다.
"휴리스틱은 명시적이어야 한다": "빈 폴링 10회 = 새 탭 가능성"이라는 휴리스틱을 코드에 상수로 명시하고, 왜 그 값인지 주석으로 설명했다. 매직 넘버를 숨기지 않고 드러내면 나중에 튜닝할 때 쉽다.
"파싱은 방어적으로": MCP 레이어를 거치면서 JSON이 이중/삼중 이스케이프될 수 있다. 4단계 파싱(직접 → 언이스케이프 → replace → regex)으로 모든 경우를 커버한다. 가장 바깥 레이어부터 벗기는 순서가 중요하다.
"lazy가 eager보다 낫다": 매 클릭 후 탭을 확인하는 대신, 후속 액션이 실패했을 때만 확인하는 lazy 전략이 MCP 호출을 크게 줄인다. 대부분의 클릭은 새 탭을 열지 않기 때문이다.
관련 글
- CSS 셀렉터 대체 전략: selector_alternatives로 안정성 확보
AI AgentBrowser AutomationCSS Selector - 브라우저 자동화 시 페이지 네비게이션 생존 전략
AI AgentBrowser AutomationNavigation - Playwright 스크롤바 강제 표시: headless 환경의 UI 트릭
AI AgentBrowser AutomationCSS - 시나리오 레코더: 사용자 행동 녹화 및 재생 엔진
AI AgentBrowser AutomationPlaywright - AI Agent 기반 브라우저 자동화 시스템 구축기
AI AgentBrowser AutomationMCP