WXT로 브라우저 확장 만들기: Manifest V3 생성, 엔트리포인트 구조, Storage·메시징, 크로스 브라우저

이 글의 핵심

브라우저 확장을 직접 구성하면 manifest.json과 번들 설정을 손으로 맞추느라 시간이 들고, MV3에서 백그라운드가 주기적으로 종료되는 서비스 워커로 바뀌면서 이벤트 등록 위치를 잘못 잡는 실수도 생깁니다. Wxt는 Vite 위에서 이런 설정을 파일 구조 규칙으로 대신합니다. 백그라운드와 콘텐츠 스크립트의 역할 분담과 자주 하는 실수도 짚습니다.

WXT는 브라우저 확장 프로그램을 Vite 기반으로 개발하게 해 주는 프레임워크입니다. manifest.json을 손으로 맞추고, 콘텐츠 스크립트 경로를 일일이 연결하고, 번들러 설정을 확장 전용으로 늘리던 작업을 entrypoints/ 디렉터리 규칙과 wxt.config.ts 한 곳으로 모읍니다.

이 글은 Manifest V3(MV3)를 전제로, 매니페스트 생성 방식, 엔트리포인트 구조, 콘텐츠 스크립트와 백그라운드(서비스 워커)의 역할 분리, 스토리지와 메시징, 크로스 브라우저 빌드를 설명하고, 현재 탭 정보를 보여 주는 작은 확장을 예제로 만듭니다. npm과 TypeScript 기본기, 그리고 권한이나 격리된 월드 같은 확장 개념을 대략 안다고 가정합니다.


WXT가 해결하는 문제

확장 프로그램은 백그라운드, 팝업, 옵션 페이지, 콘텐츠 스크립트, 사이드 패널처럼 진입점이 여러 개이고, 각각 다른 실행 환경에서 돕니다. 웹 페이지와 달리 CSP, 권한, 웹 접근 가능 리소스 선언이 빌드 산출물의 파일 경로와 정확히 일치해야 하고, MV3부터는 백그라운드가 상주 페이지가 아니라 필요할 때만 깨어나는 서비스 워커로 바뀌었습니다.

WXT는 이 복잡도를 파일 이름 규칙과 선언적 설정으로 흡수합니다. 개발자는 기능 단위 파일을 작성하고, 각 파일에서 defineBackground, defineContentScript, HTML의 <meta name="manifest.*"> 태그로 매니페스트에 들어갈 정보를 선언합니다. 빌드 시 Vite가 진입점마다 번들을 만들고, WXT가 이 선언들과 wxt.config.ts를 합쳐 최종 manifest.json을 생성합니다.

엔트리포인트는 entrypoints/ 아래의 파일이나 index 파일을 가진 디렉터리이며, 이름이 종류를 결정합니다. background.ts는 백그라운드, popup/은 팝업, *.content.ts는 콘텐츠 스크립트가 됩니다. 매니페스트에 등록되는 엔트리 외에, runtime.getURL로만 참조하는 비등록(unlisted) 페이지와 스크립트도 만들 수 있습니다.

엔트리에 딸린 보조 파일은 반드시 해당 엔트리 디렉터리 안에 두어야 합니다. entrypoints/ 바로 아래에 공용 .ts 파일을 두면 WXT가 이를 별도의 엔트리로 해석하려 해 빌드 오류가 납니다. 공용 코드는 utils/나 lib/ 같은 다른 디렉터리에 둡니다.


Manifest V3와 매니페스트 생성

MV3에서는 백그라운드가 service_worker 하나로 정해지고, 원격에서 받은 코드 실행이 금지되며, 네트워크 요청 차단은 선언적 규칙(declarativeNetRequest)으로 바뀌었습니다. WXT는 대상 브라우저와 매니페스트 버전에 맞춰 산출물을 조정합니다. 기본값으로 Chrome 계열은 MV3, Firefox는 MV2로 빌드하며, 옵션으로 바꿀 수 있습니다(버전에 따라 기본값이 다를 수 있으니 문서를 확인하세요).

서비스 워커는 유휴 상태가 되면 브라우저가 종료시키므로, 메모리의 전역 변수에 상태를 두면 다음 이벤트 때 사라져 있습니다. 유지할 상태는 스토리지 API에 저장하고, 주기 작업은 setInterval 대신 워커를 다시 깨워 주는 alarms API로 처리합니다. 또 defineBackground의 main 함수는 비동기 함수일 수 없습니다. 리스너는 main에서 동기적으로 등록하고, 비동기 작업은 리스너 안에서 처리해야 합니다. 서비스 워커가 깨어날 때 리스너가 첫 번째 이벤트 루프 턴 안에 등록되어 있어야 그 이벤트를 받을 수 있기 때문입니다.

wxt.config.ts의 manifest는 객체로 두거나, 대상 브라우저와 모드에 따라 다른 값을 돌려주는 함수로 쓸 수 있습니다. 아이콘은 public/icon-16.png 같은 이름 규칙으로 자동 인식되고, 이름과 버전은 기본적으로 package.json에서 가져옵니다.

import { defineConfig } from 'wxt';

export default defineConfig({
  manifest: {
    name: 'MV3 예제',
    permissions: ['storage', 'activeTab'],
    host_permissions: ['https://example.com/*'],
  },
  // 필요 시: manifest: ({ browser, manifestVersion, mode, command }) => ({ ... })
});

확장 전체에 필요한 권한과 메타데이터는 이렇게 설정 파일에 모으고, 특정 사이트에만 주입하는 콘텐츠 스크립트의 matches는 해당 엔트리 파일에 둡니다. 그러면 소스 트리만 읽어도 어떤 스크립트가 어디에 주입되는지 알 수 있습니다. 권한은 실제로 쓰는 API에 맞춰 최소한으로 요청해야 스토어 심사도 쉽고, 설치 시 사용자에게 보이는 경고도 줄어듭니다.


TypeScript와 Vite 통합

WXT는 내부적으로 Vite를 쓰므로 경로 별칭, Vue·React·Svelte 같은 UI 프레임워크, CSS 전처리기, 환경 변수는 Vite 관례를 따릅니다. 여기에 여러 진입점, 서비스 워커, 콘텐츠 스크립트에 맞는 빌드 규칙을 WXT가 추가합니다.

wxt CLI로 개발 서버를 띄우면 확장이 설치된 브라우저가 열리고 코드 변경 시 자동으로 다시 로드됩니다. wxt build의 산출물은 .output/<대상>/에 생기고, wxt zip으로 스토어 제출용 압축 파일을 만듭니다. Firefox용 빌드는 -b firefox 옵션으로 만듭니다. postinstall에 wxt prepare를 넣어 두는 것이 일반적인데, 이 명령이 .wxt/ 아래에 타입 정의와 tsconfig를 생성해 defineBackground, defineContentScript, browser 같은 자동 임포트 심볼을 TypeScript가 인식하게 합니다. 아래 예제는 이 자동 임포트를 전제로 import 문을 생략합니다.

메시지 페이로드와 스토리지 구조를 타입으로 정의해 두면 콘텐츠 스크립트, 팝업, 백그라운드 사이의 계약이 코드로 고정되어, 한쪽에서 필드 이름을 바꿨을 때 다른 쪽에서 컴파일 오류로 드러납니다.


프로젝트 구조와 엔트리포인트

프로젝트/
  entrypoints/
    background.ts          → 서비스 워커(MV3)
    popup/index.html       → 툴바 팝업
    example.content.ts     → 콘텐츠 스크립트
  public/                  → 아이콘 등 정적 자산
  wxt.config.ts
  package.json

엔트리 탐색은 entrypoints/ 아래 0~1단계 깊이에서만 이루어집니다. 사이트별로 여러 콘텐츠 스크립트가 필요하면 youtube.content/index.ts, github.content/index.ts처럼 .content 접미사를 붙인 디렉터리로 나눕니다.

백그라운드: defineBackground

export default defineBackground(() => {
  browser.runtime.onInstalled.addListener(() => {
    console.log('확장 설치 또는 업데이트');
  });
});

모듈 최상단에서 브라우저 API를 호출하면 안 됩니다. WXT는 빌드 중에 엔트리 파일을 Node.js 환경에서 불러와 옵션을 읽는데, 이때 browser API가 없어 오류가 나기 때문입니다. 런타임 코드는 모두 main 콜백 안에 둡니다.

콘텐츠 스크립트: defineContentScript

export default defineContentScript({
  matches: ['https://example.com/*'],
  runAt: 'document_idle',
  main(ctx) {
    const el = document.querySelector('h1');
    if (el) el.setAttribute('data-wxt', '1');
  },
});

matches로 주입할 URL을 제한하고 runAt으로 실행 시점을 정합니다. 기본은 격리된 월드(ISOLATED)라서 DOM은 공유하지만 페이지의 자바스크립트 변수나 window에 추가된 속성은 보이지 않습니다. 페이지 스크립트와 직접 상호작용해야 한다면 world: 'MAIN' 옵션이나 페이지에 스크립트를 주입하는 방식을 써야 하며, 이때는 확장 API를 쓸 수 없습니다. 백그라운드와 달리 콘텐츠 스크립트의 main은 비동기 함수로 선언할 수 있습니다. ctx 객체는 확장이 업데이트되거나 비활성화되어 콘텐츠 스크립트가 무효화될 때 타이머와 리스너를 정리하는 데 씁니다.


콘텐츠 스크립트와 백그라운드의 역할 분담

역할콘텐츠 스크립트백그라운드(서비스 워커)
DOM 접근직접 가능불가(DOM이 필요하면 오프스크린 문서 사용)
탭·알람 같은 브라우저 API대부분 쓸 수 없어 메시지로 위임tabs, alarms 등을 직접 호출
수명페이지가 열려 있는 동안이벤트마다 깨어났다가 유휴 시 종료

콘텐츠 스크립트는 매칭되는 모든 페이지에 주입되므로 가볍게 유지하고, 권한이 필요한 작업, 여러 탭에 걸친 집계, 스케줄링은 백그라운드로 옮깁니다. 콘텐츠 스크립트에 무거운 로직을 넣으면 사용자가 여는 모든 페이지의 로딩이 느려지고, 넓은 사이트 접근 권한이 필요해져 심사에서도 불리합니다.


스토리지와 메시징

스토리지

storage.local과 storage.sync는 용량과 동기화 정책이 다릅니다. Chrome 기준으로 sync는 전체 약 100KB, 항목당 약 8KB로 작고 쓰기 횟수 제한도 있어 사용자 설정처럼 작은 값을 기기 간에 동기화할 때 쓰고, 그 외 데이터는 local(기본 10MB, unlimitedStorage 권한으로 확장 가능)에 둡니다. 두 저장소 모두 콘텐츠 스크립트, 팝업, 백그라운드에서 똑같이 접근할 수 있고, 서비스 워커가 종료되어도 유지됩니다.

키와 기본값은 한 모듈에 모아 두고 모든 진입점에서 같은 함수로 읽게 하면 키 이름 오타 같은 실수를 막을 수 있습니다. WXT는 키 정의, 기본값, 변경 감지를 타입과 함께 제공하는 스토리지 유틸리티도 제공하므로 이를 써도 됩니다.

export const STORAGE_KEYS = {
  enabled: 'featureEnabled',
} as const;

export type Options = {
  enabled: boolean;
};

export async function loadOptions(): Promise<Options> {
  const { [STORAGE_KEYS.enabled]: enabled = true } =
    await browser.storage.sync.get(STORAGE_KEYS.enabled);
  return { enabled: Boolean(enabled) };
}

메시징

기본 API는 runtime.sendMessage, tabs.sendMessage, runtime.onMessage입니다. 방향에 따라 쓰는 함수가 다릅니다. 콘텐츠 스크립트나 팝업에서 백그라운드로 보낼 때는 runtime.sendMessage를, 백그라운드나 팝업에서 특정 탭의 콘텐츠 스크립트로 보낼 때는 탭 ID를 지정하는 tabs.sendMessage를 씁니다. 콘텐츠 스크립트는 tabs API를 쓸 수 없으므로 다른 탭으로 직접 메시지를 보낼 수 없습니다.

// 백그라운드
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === 'GET_TAB_ID') {
    sendResponse({ tabId: sender.tab?.id }); // 동기 응답이므로 true를 돌려줄 필요 없음
  }
});

응답을 비동기로 보내야 한다면 리스너에서 true를 돌려줘 응답 채널을 열어 두고 나중에 sendResponse를 호출해야 합니다. 이를 빠뜨리면 보내는 쪽은 undefined 응답을 받거나 “The message port closed before a response was received” 오류를 봅니다. Firefox와 폴리필 환경에서는 리스너가 Promise를 돌려주는 방식도 쓸 수 있지만, Chrome에서는 이 방식이 오랫동안 지원되지 않았으므로 크로스 브라우저 코드라면 sendResponse와 return true 조합이 안전합니다.

메시지 종류가 많아지면 타입과 오류 처리가 번거로우므로 @webext-core/messaging(가볍고 타입 안전한 래퍼), webext-bridge(채널 기반 라우팅), trpc-chrome(tRPC 스타일 RPC) 같은 래퍼를 고려할 수 있습니다. 직접 구현한다면 type 문자열과 함께 버전 필드를 두어, 확장 업데이트 직후 옛 콘텐츠 스크립트와 새 백그라운드가 섞여 있는 시점에도 메시지를 구분할 수 있게 합니다.


크로스 브라우저 지원

WXT는 Chrome, Firefox, Edge, Safari 대상으로 빌드할 수 있습니다. 다만 Safari는 Xcode로 앱에 감싸 서명하는 별도 절차가 필요하고, 브라우저마다 API와 매니페스트 키 이름이 다른 경우가 있습니다. 예를 들어 사이드바는 Chrome의 side_panel과 Firefox의 sidebar_action으로 다르고, 툴바 버튼은 MV3의 action과 MV2의 browser_action으로 다릅니다. WXT는 이런 차이 중 일부를 엔트리 종류와 빌드 대상에 따라 자동으로 매핑합니다.

API 호출 방식도 차이가 있습니다. Firefox의 browser.* API는 Promise를 돌려주고, Chrome도 MV3에서는 대부분의 chrome.* API가 Promise를 지원합니다. 예전에는 둘의 차이를 webextension-polyfill로 맞췄는데, WXT 최신 버전은 이 폴리필을 기본으로 포함하지 않으므로 사용하는 버전의 문서를 확인합니다. 특정 브라우저에만 넣을 엔트리는 엔트리 옵션의 include/exclude로 지정할 수 있습니다.

스토어마다 권한 사용 이유와 개인정보 처리에 대한 설명 요구사항이 다르므로, Chrome 웹 스토어, Firefox AMO, Edge 애드온에 각각 맞춰 준비합니다.


예제: 현재 탭 정보를 보여 주는 팝업

팝업을 열면 활성 탭의 제목과 URL을 보여 주는 확장을 만들어 봅니다.

권한

다른 탭의 URL과 제목을 읽으려면 tabs 권한이 필요하지만, 이 권한은 설치 시 “방문 기록 읽기” 경고를 띄웁니다. 사용자가 확장 아이콘을 눌러 팝업을 연 순간의 현재 탭만 필요하다면 activeTab 권한으로 충분합니다. activeTab은 사용자가 확장을 호출한 탭에 대해서만 일시적으로 접근을 허용하고 설치 경고를 띄우지 않습니다. 앞의 wxt.config.ts처럼 permissions에 activeTab을 넣습니다.

팝업에서 직접 조회

팝업은 확장 페이지라서 tabs API를 직접 호출할 수 있습니다. 백그라운드를 거칠 필요가 없습니다.

<!-- entrypoints/popup/index.html -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>현재 탭</title>
  </head>
  <body>
    <pre id="out"></pre>
    <script type="module" src="./main.ts"></script>
  </body>
</html>
// entrypoints/popup/main.ts
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
document.getElementById('out')!.textContent = tab
  ? `${tab.title ?? ''}\n${tab.url ?? ''}`
  : '활성 탭을 찾지 못했습니다';

activeTab 없이 이 코드를 실행하면 tab 객체는 받지만 url과 title이 비어 있습니다. 권한 문제를 오류 없이 빈 값으로 돌려주므로, 값이 비어 있으면 권한부터 확인합니다.

백그라운드를 거치는 방식은 콘텐츠 스크립트처럼 tabs API를 쓸 수 없는 곳에서 탭 정보가 필요할 때 씁니다. 그 경우 백그라운드 리스너에서 tabs.query의 결과를 비동기로 보내므로 return true가 필요합니다.

export default defineBackground(() => {
  browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
    if (msg?.type !== 'GET_ACTIVE_TAB') return;
    browser.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
      sendResponse({ title: tab?.title, url: tab?.url });
    });
    return true; // 비동기 응답을 위해 채널 유지
  });
});

기능을 늘릴 때

콘텐츠 스크립트에서 선택한 텍스트를 백그라운드로 보내 번역 API를 호출하거나, 스토리지에 저장한 옵션에 따라 콘텐츠 스크립트 동작을 켜고 끄거나, alarms로 주기 작업을 추가하는 식으로 확장할 수 있습니다. 권한을 추가할 때마다 그 이유를 스토어 설명에 적어 두면 심사 대응이 수월합니다. 콘텐츠 스크립트가 페이지에 넣는 JS·CSS·이미지 파일은 web_accessible_resources에 선언되어 있어야 하므로, 빌드된 매니페스트에서 누락이 없는지 확인합니다.

참고


같이 보면 좋은 글