본문으로 건너뛰기

크로스 브라우저 호환성 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Chrome, Firefox, Edge, Safari 확장 프로그램 호환성

공통 문서 참조: 테스트는 ../common/testing.md, CI/CD는 ../common/ci-cd.md 참조


목차

  1. 브라우저 호환성 매트릭스
  2. WXT 프레임워크 활용
  3. webextension-polyfill
  4. API 차이 대응 패턴
  5. 브라우저별 매니페스트 생성
  6. 테스트 매트릭스
  7. 스토어별 배포 파이프라인
  8. 체크리스트

1. 브라우저 호환성 매트릭스

Manifest V3 지원 현황

기능ChromeFirefoxEdgeSafari
Manifest V3O (88+)O (109+)O (88+)O (15.4+)
Service WorkerOO (120+)OO
chrome.actionOOOO
chrome.storageOOOO
chrome.scriptingOO (102+)OO
chrome.sidePanelO (114+)O (125+)O (114+)X
chrome.offscreenO (109+)XO (109+)X
chrome.declarativeNetRequestOO (113+)OO (제한적)
chrome.userScriptsO (120+)O (123+)O (120+)X
chrome.tabGroupsOXOX

확장 스토어 비교

항목Chrome Web StoreFirefox AMOEdge Add-onsSafari Extensions
등록비$5 (1회)무료무료$99/년 (Apple Dev)
매니페스트MV3 필수MV2/MV3MV3 (CWS 호환)MV3 (Xcode 변환)
심사 속도수 시간-수 일수 일-수 주수 시간-수 일수 일
소스 제출X필수 (빌드 시)XX (App Review)
자동 업데이트OOOO (App Store)
사용자 수최대중간중간

브라우저 엔진 관계

+-- Chromium --+
| |
+-- Chrome | Firefox (Gecko) Safari (WebKit)
+-- Edge |
+-- Opera |
+-- Brave |
+-- Vivaldi |
+--------------+

Edge: Chrome과 거의 동일 (Chromium 기반). CWS에서 직접 설치도 가능. Safari: Xcode 변환 필요. 별도 개발 비용 고려.


2. WXT 프레임워크 활용

WXT 소개

WXT는 Vite 기반 브라우저 확장 개발 프레임워크. 크로스 브라우저 빌드, HMR, 자동 매니페스트 생성을 지원.

프로젝트 초기화

# 새 프로젝트
npx wxt@latest init my-extension
cd my-extension
npm install

# 개발 모드 (Chrome)
npm run dev

# 개발 모드 (Firefox)
npm run dev:firefox

WXT 크로스 브라우저 설정

// wxt.config.ts
import { defineConfig } from 'wxt';

export default defineConfig({
modules: ['@wxt-dev/module-react'],

manifest: {
name: '__MSG_extensionName__',
description: '__MSG_extensionDescription__',
default_locale: 'en',
permissions: ['storage', 'activeTab'],
},

// 브라우저별 추가 설정
// WXT가 빌드 시 자동으로 브라우저별 매니페스트 생성
});

WXT 브라우저 분기 패턴

// WXT 내장 유틸리티 (권장)
import { browser } from 'wxt/browser';

// browser 객체는 자동으로 적절한 네임스페이스 사용
const data = await browser.storage.local.get('key');

// 현재 브라우저 감지
import { getBrowser } from 'wxt/utils';

const currentBrowser = getBrowser(); // 'chrome' | 'firefox' | 'edge' | 'safari'

if (currentBrowser === 'firefox') {
// Firefox 전용 처리
}

WXT 빌드 명령

# 브라우저별 빌드
npm run build # Chrome (기본)
npm run build:firefox # Firefox

# 또는 직접 지정
wxt build --browser chrome
wxt build --browser firefox
wxt build --browser edge
wxt build --browser safari

# 모든 브라우저 빌드 (CI 용)
wxt build --browser chrome && \
wxt build --browser firefox && \
wxt build --browser edge

# ZIP 패키징 (스토어 업로드용)
wxt zip --browser chrome
wxt zip --browser firefox

WXT 빌드 출력

.output/
+-- chrome-mv3/ # Chrome/Edge 빌드
| +-- manifest.json # Chrome 형식 MV3
| +-- ...
+-- firefox-mv3/ # Firefox 빌드
| +-- manifest.json # Firefox 형식 MV3
| +-- ...

WXT vs 기타 도구 비교

기능WXTPlasmo직접 Vite
HMROO수동 설정
자동 매니페스트OOX
크로스 브라우저 빌드OO수동
React/Vue/SvelteOOO
TypeScriptOOO
Content Script HMRO제한적X
자체 테스트 유틸OXX
러닝커브낮음낮음높음
프로젝트 권장OOX

3. webextension-polyfill

WXT를 사용하지 않는 경우

npm install webextension-polyfill
npm install -D @types/webextension-polyfill
// lib/browser.ts
import browser from 'webextension-polyfill';

// Chrome에서도 Promise 기반 API 사용 가능
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
const data = await browser.storage.local.get('key');

polyfill이 처리하는 것

기능Chrome (기본)polyfill 적용 후
네임스페이스chrome.*browser.*
반환 방식콜백 기반Promise 기반
runtime.lastError수동 확인Promise reject로 변환
이벤트 리스너동일동일 (변경 없음)

polyfill 주의사항

// ⚠️ 이벤트 리스너의 sendResponse 패턴은 polyfill이 처리하지 않음
// Chrome의 sendResponse 콜백 패턴 대신 Promise 반환 사용

// ❌ Chrome 전용 패턴 (polyfill에서 동작 불안정)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
fetchData().then(sendResponse);
return true; // 비동기 응답
});

// ✅ polyfill 호환 패턴 (Promise 반환)
browser.runtime.onMessage.addListener(async (msg, sender) => {
const data = await fetchData();
return data; // Promise 반환이 응답이 됨
});

4. API 차이 대응 패턴

Feature Detection 패턴

// lib/compat/feature-detect.ts

interface BrowserFeatures {
sidePanel: boolean;
offscreen: boolean;
tabGroups: boolean;
userScripts: boolean;
declarativeNetRequest: boolean;
}

function detectFeatures(): BrowserFeatures {
return {
sidePanel: 'sidePanel' in chrome,
offscreen: 'offscreen' in chrome,
tabGroups: 'tabGroups' in chrome,
userScripts: 'userScripts' in chrome,
declarativeNetRequest: 'declarativeNetRequest' in chrome,
};
}

const features = detectFeatures();

// 사용
if (features.sidePanel) {
await chrome.sidePanel.open({ tabId });
} else {
// Popup fallback
await chrome.action.openPopup();
}

Offscreen Document 대체 패턴

// Chrome/Edge: Offscreen Document
// Firefox/Safari: 대안 방법

async function parseHTMLSafely(html: string): Promise<string> {
if ('offscreen' in chrome) {
// Chrome/Edge: Offscreen Document 사용
return await parseViaOffscreen(html);
} else {
// Firefox: Content Script에서 DOMParser 직접 사용 가능
// 또는 Background에서 직접 처리 (Firefox는 Background에서 DOM API 일부 지원)
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return doc.body.textContent ?? '';
}
}

Side Panel 대체 패턴

// Chrome/Edge: Side Panel API
// Firefox: sidebar_action (MV2) 또는 Side Panel (125+)
// Safari: 미지원

async function openAuxiliaryUI(): Promise<void> {
if ('sidePanel' in chrome) {
// Chrome 114+ / Edge / Firefox 125+
await chrome.sidePanel.open({ tabId: (await getActiveTab()).id! });
} else {
// Fallback: 새 탭에서 옵션 페이지 열기
await chrome.tabs.create({
url: chrome.runtime.getURL('src/sidepanel/index.html'),
});
}
}

브라우저별 매니페스트 키 처리

// wxt.config.ts에서 브라우저별 매니페스트 분기

import { defineConfig } from 'wxt';

export default defineConfig({
manifest: ({ browser }) => ({
name: '__MSG_extensionName__',
permissions: ['storage', 'activeTab'],

// Chrome/Edge 전용
...(browser === 'chrome' || browser === 'edge'
? {
side_panel: {
default_path: 'sidepanel.html',
},
permissions: ['storage', 'activeTab', 'sidePanel'],
}
: {}),

// Firefox 전용
...(browser === 'firefox'
? {
browser_specific_settings: {
gecko: {
id: 'extension@example.com',
strict_min_version: '120.0',
},
},
}
: {}),
}),
});

브라우저별 코드 분기 패턴 (조건부 import)

// lib/compat/clipboard.ts
// 브라우저별 클립보드 구현 분기

export async function copyToClipboard(text: string): Promise<void> {
if ('offscreen' in chrome) {
// Chrome: Offscreen Document 경유
await copyViaOffscreen(text);
} else if (typeof navigator.clipboard?.writeText === 'function') {
// Firefox/Safari: Clipboard API 직접 사용
await navigator.clipboard.writeText(text);
} else {
// Fallback: Content Script에서 execCommand
throw new Error('Clipboard API not available in this context');
}
}

5. 브라우저별 매니페스트 생성

WXT 자동 생성 (권장)

WXT 사용 시 wxt build --browser <target>으로 자동 생성. 수동 관리 불필요.

수동 관리 시 빌드 스크립트

// scripts/build-manifest.ts
// WXT를 사용하지 않는 경우

interface ManifestBase {
manifest_version: 3;
name: string;
version: string;
description: string;
permissions: string[];
[key: string]: unknown;
}

function buildManifest(browser: 'chrome' | 'firefox' | 'edge'): ManifestBase {
const base: ManifestBase = {
manifest_version: 3,
name: '__MSG_extensionName__',
version: '1.0.0',
description: '__MSG_extensionDescription__',
default_locale: 'en',
permissions: ['storage', 'activeTab'],
host_permissions: ['https://api.example.com/*'],
action: {
default_popup: 'popup.html',
default_icon: { '16': 'icons/16.png', '48': 'icons/48.png', '128': 'icons/128.png' },
},
icons: { '16': 'icons/16.png', '48': 'icons/48.png', '128': 'icons/128.png' },
};

switch (browser) {
case 'chrome':
case 'edge':
return {
...base,
background: { service_worker: 'background.js', type: 'module' },
side_panel: { default_path: 'sidepanel.html' },
permissions: [...base.permissions, 'sidePanel'],
};

case 'firefox':
return {
...base,
background: { scripts: ['background.js'], type: 'module' },
browser_specific_settings: {
gecko: { id: 'extension@example.com', strict_min_version: '120.0' },
},
};
}
}
// package.json scripts
{
"scripts": {
"build:chrome": "BUILD_TARGET=chrome vite build && node scripts/build-manifest.js chrome",
"build:firefox": "BUILD_TARGET=firefox vite build && node scripts/build-manifest.js firefox",
"build:edge": "BUILD_TARGET=edge vite build && node scripts/build-manifest.js edge",
"build:all": "npm run build:chrome && npm run build:firefox && npm run build:edge"
}
}

6. 테스트 매트릭스

단위 테스트 (Vitest)

// tests/setup.ts
// chrome.* API 모킹

import { vi } from 'vitest';

// 기본 chrome API 모킹
const chromeMock = {
storage: {
local: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
},
sync: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
},
session: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
},
onChanged: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
runtime: {
sendMessage: vi.fn(),
onMessage: { addListener: vi.fn(), removeListener: vi.fn() },
onInstalled: { addListener: vi.fn() },
getURL: vi.fn((path: string) => `chrome-extension://mock-id/${path}`),
getManifest: vi.fn(() => ({ version: '1.0.0' })),
lastError: null as chrome.runtime.LastError | null,
},
tabs: {
query: vi.fn().mockResolvedValue([]),
sendMessage: vi.fn(),
},
alarms: {
create: vi.fn(),
onAlarm: { addListener: vi.fn() },
},
};

vi.stubGlobal('chrome', chromeMock);
vi.stubGlobal('browser', chromeMock); // Firefox 호환

export { chromeMock };
// tests/unit/storage.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { chromeMock } from '../setup';
import { createStorageItem } from '@lib/storage/chrome-storage';

describe('StorageAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should get stored value', async () => {
chromeMock.storage.local.get.mockResolvedValue({ key: 'value' });
const item = createStorageItem<string>('key');
const result = await item.get();
expect(result).toBe('value');
});

it('should return default value when not found', async () => {
chromeMock.storage.local.get.mockResolvedValue({});
const item = createStorageItem<string>('key', 'local', 'default');
const result = await item.get();
expect(result).toBe('default');
});
});

E2E 테스트 (Playwright)

// tests/e2e/extension.spec.ts
import { test, expect, chromium, type BrowserContext } from '@playwright/test';
import path from 'path';

const EXTENSION_PATH = path.resolve(__dirname, '../../.output/chrome-mv3');

async function createContextWithExtension(): Promise<BrowserContext> {
return chromium.launchPersistentContext('', {
headless: false, // 확장은 headless에서 동작 제한
args: [
`--disable-extensions-except=${EXTENSION_PATH}`,
`--load-extension=${EXTENSION_PATH}`,
],
});
}

let context: BrowserContext;

test.beforeAll(async () => {
context = await createContextWithExtension();
});

test.afterAll(async () => {
await context.close();
});

test('extension popup shows correct title', async () => {
// 확장 ID 가져오기
let extensionId = '';
const serviceWorkers = context.serviceWorkers();
if (serviceWorkers.length > 0) {
extensionId = serviceWorkers[0].url().split('/')[2];
} else {
const sw = await context.waitForEvent('serviceworker');
extensionId = sw.url().split('/')[2];
}

// Popup 페이지 열기
const popupPage = await context.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);

// 제목 확인
await expect(popupPage.locator('h1')).toHaveText('My Extension');
});

test('content script injects UI on target site', async () => {
const page = await context.newPage();
await page.goto('https://example.com');

// Content Script가 주입한 요소 확인
const extensionUI = page.locator('#my-extension-root');
await expect(extensionUI).toBeVisible({ timeout: 5000 });
});

크로스 브라우저 E2E 테스트

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
testDir: './tests/e2e',
timeout: 30000,
projects: [
{
name: 'chrome',
use: {
// Chrome 확장 테스트는 Chromium 기반
browserName: 'chromium',
},
},
{
name: 'firefox',
use: {
browserName: 'firefox',
// Firefox 확장 테스트는 web-ext + Playwright 조합
// 또는 별도 스크립트로 처리
},
},
],
});

Firefox E2E (web-ext 활용)

# Firefox 테스트는 web-ext run + Playwright 조합
# 1. web-ext로 Firefox에 확장 로드
web-ext run --source-dir .output/firefox-mv3 --no-reload &

# 2. Playwright로 Firefox 인스턴스에 연결
# (별도 스크립트)

CI 테스트 매트릭스

# .github/workflows/test.yml
name: Cross-Browser Tests

on: [push, pull_request]

jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:unit
- run: npm run test:coverage

e2e-test:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chrome, firefox]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps ${{ matrix.browser == 'chrome' && 'chromium' || 'firefox' }}
- run: npm run build -- --browser ${{ matrix.browser }}
- run: npm run test:e2e -- --project=${{ matrix.browser }}

lint:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chrome, firefox]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build -- --browser ${{ matrix.browser }}
- if: matrix.browser == 'firefox'
run: npx web-ext lint --source-dir .output/firefox-mv3

7. 스토어별 배포 파이프라인

통합 릴리즈 워크플로우

# .github/workflows/release.yml
name: Release to All Stores

on:
push:
tags:
- 'v*'

permissions:
contents: write

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chrome, firefox, edge]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build -- --browser ${{ matrix.browser }}
- run: |
cd .output/${{ matrix.browser }}-mv3
zip -r ../../${{ matrix.browser }}-extension.zip .
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.browser }}-build
path: ${{ matrix.browser }}-extension.zip

# Firefox: 소스코드도 패키징
package-source:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
zip -r source-code.zip . \
-x "node_modules/*" ".output/*" ".git/*" ".env*"
- uses: actions/upload-artifact@v4
with:
name: source-code
path: source-code.zip

deploy-chrome:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: chrome-build
- uses: mnao305/chrome-extension-upload@v5.0.0
with:
file-path: chrome-extension.zip
extension-id: ${{ secrets.CWS_EXTENSION_ID }}
client-id: ${{ secrets.CWS_CLIENT_ID }}
client-secret: ${{ secrets.CWS_CLIENT_SECRET }}
refresh-token: ${{ secrets.CWS_REFRESH_TOKEN }}

deploy-firefox:
needs: [build, package-source]
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/download-artifact@v4
with:
name: firefox-build
- uses: actions/download-artifact@v4
with:
name: source-code
- run: |
npx web-ext sign \
--channel listed \
--source-dir . \
--api-key ${{ secrets.AMO_JWT_ISSUER }} \
--api-secret ${{ secrets.AMO_JWT_SECRET }} \
--upload-source-code source-code.zip

deploy-edge:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: edge-build
# Edge Add-ons API (Partner Center)
- name: Upload to Edge Add-ons
run: |
# Edge는 Partner Center API 사용
# https://learn.microsoft.com/en-us/microsoft-edge/extensions-chromium/publish/api/using-addons-api

# 1. Access Token 발급
ACCESS_TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/${{ secrets.EDGE_TENANT_ID }}/oauth2/v2.0/token" \
-d "client_id=${{ secrets.EDGE_CLIENT_ID }}" \
-d "scope=https://api.addons.microsoftedge.microsoft.com/.default" \
-d "client_secret=${{ secrets.EDGE_CLIENT_SECRET }}" \
-d "grant_type=client_credentials" | jq -r '.access_token')

# 2. 패키지 업로드
curl -s -X POST \
"https://api.addons.microsoftedge.microsoft.com/v1/products/${{ secrets.EDGE_PRODUCT_ID }}/submissions/draft/package" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/zip" \
--data-binary @edge-extension.zip

# 3. 게시
curl -s -X POST \
"https://api.addons.microsoftedge.microsoft.com/v1/products/${{ secrets.EDGE_PRODUCT_ID }}/submissions" \
-H "Authorization: Bearer $ACCESS_TOKEN"

create-release:
needs: [deploy-chrome, deploy-firefox, deploy-edge]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- uses: softprops/action-gh-release@v2
with:
files: |
chrome-build/chrome-extension.zip
firefox-build/firefox-extension.zip
edge-build/edge-extension.zip

스토어별 필수 시크릿

시크릿ChromeFirefoxEdge
CWS_CLIENT_IDO
CWS_CLIENT_SECRETO
CWS_REFRESH_TOKENO
CWS_EXTENSION_IDO
AMO_JWT_ISSUERO
AMO_JWT_SECRETO
EDGE_TENANT_IDO
EDGE_CLIENT_IDO
EDGE_CLIENT_SECRETO
EDGE_PRODUCT_IDO

Edge Add-ons 배포 참고

Edge는 Chrome 빌드를 거의 그대로 사용 가능 (Chromium 기반). Edge Add-ons Partner Center에서 CWS의 확장을 직접 가져오는 옵션도 존재.


8. 체크리스트

크로스 브라우저 개발 체크리스트

프로젝트 설정:

  • WXT 프레임워크 사용 (권장)
  • TypeScript strict 모드
  • 브라우저별 빌드 스크립트 준비 (build:chrome, build:firefox, build:edge)
  • 환경변수로 브라우저 감지 가능

API 호환성:

  • Chrome 전용 API 사용 시 Feature Detection 적용
  • chrome.offscreen 사용 시 Firefox 대체 구현 준비
  • chrome.sidePanel 사용 시 Fallback UI 준비
  • browser / chrome 네임스페이스 통일 (WXT 또는 polyfill)

매니페스트:

  • Chrome: service_worker 형식
  • Firefox: scripts 배열 형식 + browser_specific_settings.gecko
  • Edge: Chrome 매니페스트와 동일
  • 브라우저별 매니페스트 자동 생성 (WXT 또는 빌드 스크립트)

테스트:

  • 단위 테스트: chrome API 모킹 설정
  • E2E 테스트: Chrome (Playwright + 확장 로드)
  • E2E 테스트: Firefox (web-ext + Playwright)
  • CI에서 크로스 브라우저 테스트 매트릭스 구성
  • web-ext lint 통과 (Firefox)

배포:

  • Chrome Web Store API 키 발급 + CI 시크릿 등록
  • Firefox AMO JWT 키 발급 + CI 시크릿 등록
  • Edge Partner Center API 설정 + CI 시크릿 등록
  • GitHub Actions 통합 릴리즈 워크플로우 구성
  • 태그 기반 자동 배포 파이프라인
  • Firefox 소스코드 ZIP 자동 생성

품질:

  • 모든 대상 브라우저에서 수동 테스트 1회 이상
  • Popup, Options, Content Script 모든 컴포넌트 브라우저별 확인
  • 에러/폴백 상태가 각 브라우저에서 정상 표시

관련 문서

문서내용
architecture.mdManifest V3 아키텍처, WXT 프로젝트 구조
chrome-store.mdChrome Web Store 배포 상세
firefox-addon.mdFirefox Add-on 배포 상세
safari-extension.mdSafari Web Extension 배포 상세
content-script.mdContent Script 개발 패턴
../common/ci-cd.md공통 CI/CD 파이프라인
../common/testing.md공통 테스트 전략

Last Updated: 2026-04-06