Skip to main content

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

작성일: 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: '[email protected]',
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: '[email protected]', 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-[email protected]
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