홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

Playwright MCP 로 크롬 익스텐션 popup UI 평가하기

정기창·2026년 5월 12일

이번에 제가 만든 작은 크롬 익스텐션의 popup UI 를 AI 평가 하네스에 맡겨보려다가 작은 벽에 부딪혔습니다. Playwright MCP 가 평범한 웹페이지는 잘 다루지만, chrome-extension:// 컨텍스트로 직접 붙어주지는 않는다는 사실이었습니다.

처음에는 빌드해서 unpacked 익스텐션을 로드하는 방식을 떠올렸지만, MCP 인터페이스를 통해 그 흐름을 짜는 게 생각보다 번거롭다는 결론에 다다랐습니다. 그러다 문득, popup 도 결국 HTML + JS + CSS 일 뿐이라는 사실이 떠올랐습니다. 그 안에서 chrome.* API 호출과 인증이 필요한 fetch 만 어떻게든 떼어낼 수 있다면, 평범한 정적 페이지처럼 띄워서 평가할 수 있겠다는 가설이 생겼습니다.

왜 Playwright MCP 는 익스텐션을 직접 다루지 못하는가

익스텐션 popup 을 진짜 그대로 띄우려면 두 가지 조건이 필요합니다. 첫째, 빌드된 unpacked 디렉터리를 --load-extension 플래그로 미리 로드해야 합니다. 둘째, 그렇게 로드된 익스텐션의 내부 URL — chrome-extension://<id>/popup.html — 을 알아내서 거기로 이동해야 합니다.

Playwright 라이브러리 자체로는 가능한 일입니다. 다만 MCP 라는 추상화 계층은 일반적인 URL 탐색·클릭·스크린샷 위주로 다듬어져 있다 보니, 익스텐션 로드 같은 부가 옵션을 표현하기에는 결이 잘 맞지 않는다는 생각이 들었습니다. 평가만을 위해 MCP 외부의 Playwright 환경을 따로 세팅하는 것도 가능하겠지만, 그 정도 인프라를 들이기에는 평가 한 사이클이 너무 짧다는 판단이었습니다.

분장(harness) 으로 풀어낸 네 가지 조각

결국 익스텐션 popup 을 "분장" 시키기로 했습니다. popup 의 본체 코드는 그대로 두고, 그 주위의 런타임 환경만 가짜로 깔아주는 방식입니다. 평가 산출물은 .ui-eval/ 디렉터리에 모았고, 평가용 자산만 그 안에 격리했습니다.

1. popup.html 변종 사본 — 시나리오 주입

원본 popup.html 을 4 변종으로 복사했습니다. 영어/한국어 × Used/Remaining 표시 모드의 조합입니다. 변종마다 한 줄짜리 시나리오 객체를 전역에 박아두는 것이 핵심이었습니다.

<!-- .ui-eval/popup-en-used.html 의 마지막 부분 -->
<script>window.__MOCK__={ lang: "en", displayMode: "used" };</script>
<script src="mock.js"></script>
<script src="../popup.js"></script>

중요한 결정은 원본 popup.js 를 한 줄도 건드리지 않았다는 점입니다. 평가 대상 코드는 익스텐션이 실제로 배포하는 그 파일 그대로이며, 분장은 그 바깥에만 존재합니다. 그래야 평가 결과를 "현재 출시본" 의 결과라고 부를 수 있겠다는 생각이 들었습니다.

2. chrome.* API 의 메모리 mock

popup.js 안에서 호출되는 chrome.* 네임스페이스는 다섯 군데였습니다. chrome.runtime, chrome.storage.local, chrome.i18n, chrome.action, chrome.alarms 입니다. 이 다섯을 통째로 가짜로 깔았습니다.

// .ui-eval/mock.js (요약)
const localStore = {
  uiLanguage: scenario.uiLanguage,
  badgeMode: scenario.badgeMode || 'session',
  displayMode: scenario.displayMode || 'used',
};

window.chrome = {
  runtime: {
    getURL: (path) => {
      if (path === '_locales/en/messages.json') return './en.json';
      if (path === '_locales/ko/messages.json') return './ko.json';
      return path;
    },
    sendMessage: () => {},
    onMessage: { addListener: () => {} },
  },
  storage: {
    local: {
      get(keys, cb) { /* localStore 에서 동기/Promise 양쪽 모두 응답 */ },
      set(items, cb) { Object.assign(localStore, items); /* ... */ },
    },
    onChanged: { addListener: () => {} },
  },
  i18n: {
    getUILanguage: () => (lang === 'ko' ? 'ko-KR' : 'en-US'),
    getMessage: (key) => key,
  },
  action: { setBadgeText: () => {}, setBadgeBackgroundColor: () => {} },
  alarms: { create: () => {}, onAlarm: { addListener: () => {} } },
};

가장 신경 쓴 부분은 chrome.storage.local.get 이었습니다. popup.js 가 콜백 스타일과 Promise 스타일 양쪽으로 storage 를 호출했기 때문에, mock 도 두 형태 모두에 동일한 결과를 돌려줘야 했습니다. 콜백 인자가 있으면 호출 후 undefined 반환, 없으면 Promise 반환하는 패턴으로 정리했습니다.

3. window.fetch 가로채기 — 인증 우회

popup.js 는 Anthropic Console API 두 엔드포인트를 호출합니다. 조직 목록과 사용량 조회입니다. 익스텐션 환경에서는 사용자가 로그인된 claude.ai 의 쿠키가 자동으로 따라붙지만, 정적 HTML 에서는 그 쿠키를 흉내 낼 수 없다는 점이 다음 고민이었습니다.

const realFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
  const url = typeof input === 'string' ? input : input.url;
  if (url.endsWith('/api/organizations')) {
    return new Response(JSON.stringify(orgPayload), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  }
  if (url.includes('/api/organizations/') && url.endsWith('/usage')) {
    return new Response(JSON.stringify(usage), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  }
  // 로케일 JSON 같은 상대 경로는 진짜 fetch 로 통과
  return realFetch(input, init);
};

이 단계에서 한 가지 작은 발견이 있었습니다. Console API 만 가로채고 나머지는 그대로 통과시키니, 로케일 JSON 같은 정적 파일은 별다른 처리 없이 자연스럽게 로드되었습니다. 결국 가짜로 흉내 낼 부분과 진짜로 둘 부분의 경계를 명확히 그은 셈입니다.

4. 데이터 freezing — 평가의 결정성 확보

마지막 조각은 데이터를 결정적(deterministic) 으로 고정하는 일이었습니다. 평가 리포트가 "주간 3%, 5시간 12%, Sonnet 8%" 같은 구체 수치를 근거로 이슈를 짚는 만큼, 다음번에 다시 돌렸을 때 같은 화면이 나와야 비교가 가능하다는 생각이 들었습니다.

const usage = scenario.usage || {
  seven_day: {
    utilization: 3,
    resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString(),
  },
  five_hour: {
    utilization: 12,
    resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000 + 15 * 60 * 1000).toISOString(),
  },
  seven_day_sonnet: {
    utilization: 8,
    resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
  },
};

_locales/{en,ko}/messages.json 도 .ui-eval/en.json, ko.json 으로 사본을 떠두었습니다. 본 익스텐션 코드 안의 메시지가 갱신되어도 평가 결과는 직전 시점의 카피본을 기준으로 평가되므로, 이상하게 들릴 수 있지만 평가 시점의 동결된 컨텍스트를 의도적으로 유지한 셈입니다.

Playwright MCP 가 실제로 한 일

여기까지 분장하고 나면 Playwright MCP 의 역할은 단순해집니다. file://.ui-eval/popup-en-used.html 같은 로컬 정적 HTML 4 개를 차례로 열어 스크린샷을 찍는 것뿐입니다. 익스텐션 호스트 권한, MV3 service worker, chrome.runtime 메시징, 어느 것도 거치지 않습니다.

.ui-eval/
├─ popup-en-used.html       ← 시나리오 주입된 변종
├─ popup-en-remaining.html
├─ popup-ko-used.html
├─ popup-ko-remaining.html
├─ mock.js                  ← chrome.* + fetch 분장
├─ en.json / ko.json        ← 로케일 freezing
├─ popup-*.png              ← MCP 가 찍은 4 장
└─ report.md / report.html  ← AI 평가자 산출물

그렇게 모인 4 장의 PNG 와 동결된 시나리오 데이터는 그대로 AI UI/UX 평가 스킬의 입력이 되었습니다. AI 평가자는 일반 웹페이지를 평가할 때와 동일한 흐름으로 점수표, 이슈 카드, Nielsen 휴리스틱 평가를 만들어주었습니다.

얻은 것과 놓치는 것

이 접근은 만능이 아닙니다. 명백히 평가 범위 바깥으로 밀어낸 영역이 있다는 점을 솔직히 적어두는 편이 좋겠습니다.

얻은 것 놓치는 것
익스텐션 빌드·설치 없이 4 변종 시각 비교 익스텐션 lifecycle (popup open/close 시 상태 동기)
결정적 데이터로 이슈 재현 가능 실제 badge 페인트, chrome.alarms 트리거
원본 popup.js / popup.html 미수정 chrome.storage.onChanged 리스너 동작
AI 평가 하네스의 일반 흐름 그대로 적용 백그라운드 service worker 의 fetch interval

다시 말해 이 분장은 "popup 의 정적 렌더 + 시나리오 분기 + i18n" 까지만 평가 대상으로 좁혔습니다. popup 이 다시 열렸을 때 상태를 어떻게 보존하는지 같은 동적 시나리오는 의도적으로 평가 범위에서 빼낸 결정입니다.

돌이켜본 교훈

처음에는 "익스텐션 직접 평가가 안 된다" 는 사실이 작은 좌절이었습니다. 다만 곰곰이 생각해보니, 평가 도구의 한계를 우회하는 일과 평가 범위를 의도적으로 좁히는 일은 결국 같은 결정이라는 사실에 다다랐습니다.

분장은 "이 도구가 못하는 걸 억지로 시키는" 우회가 아니라, "이 도구가 잘하는 영역만 남기고 나머지는 잘라낸" 범위 결정에 가까웠다는 생각이 들었습니다. AI 평가 하네스가 잘 다루는 시각 휴리스틱·라벨 일관성·i18n 부정합 같은 정적 품질은 충분히 살아남았고, 실제로 그 평가가 곧장 다음 핫픽스 PR 의 우선순위 큐가 되어주었습니다.

그럼에도 불구하고 여전히 빈 공간은 남습니다. 동적 시나리오, lifecycle, 백그라운드 동작 같은 영역은 별도의 검증 경로가 필요하다는 사실을 잊지 않으려 합니다. 결국 도구 하나로 모든 걸 덮으려 하지 말고, 도구마다 잘하는 자리에 두는 것이 평가 인프라를 오래 유지하는 길이라는 결론입니다.

Chrome ExtensionPlaywright MCPUI/UXClaude CodeTesting

관련 글

같은 Chromium 엔진, 다른 자동화 — Playwright / MCP / 브라우저 확장 해부

헤드리스 Playwright, Playwright MCP, Claude-in-Chrome 확장이 모두 같은 Chromium 엔진을 쓰는데 왜 쓰임새가 다른지, 레이어와 쿠키 수명 주기 관점에서 정리했습니다.

관련도 91%

Playwright E2E 테스트: 프론트엔드와 백엔드를 동시에 검증하는 실전 가이드

단위 테스트만으로는 실제 사용자 경험을 보장할 수 없다는 것을 깨달았습니다. Playwright E2E 테스트를 통해 프론트엔드와 백엔드를 동시에 구동하고, 실제 사용자 시나리오를 검증한 경험을 정리했습니다.

관련도 89%

Claude Code에서 블로그 글 작성하기: MCP를 직접 만들어 활용한 경험

Claude Code로 개발하면서 얻은 지식을 블로그에 정리하고 싶었습니다. 하지만 AI에게 블로그 관리자 권한을 모두 주기엔 불안했고, 필요한 API만 분기 처리하기엔 번거로웠습니다. MCP(Model Context Protocol)를 직접 만들어서 권한을 명확히 분리하고, Few-shot 예시와 SEO 가이드라인을 1회 호출로 제공하도록 개선한 경험을 공유합니다.

관련도 88%