브라우저 확장 아키텍처 가이드
작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Chrome, Firefox, Edge (Manifest V3)
공통 문서 참조: 프로젝트 구조, 인증, 보안 등은
../common/참조
목차
- Manifest V3 아키텍처 개요
- 프로젝트 구조 (WXT + TypeScript)
- Service Worker (Background)
- 메시징 패턴
- 저장소 전략
- 권한 모델
- Side Panel API
- Offscreen Documents
- 아키텍처 체크리스트
1. Manifest V3 아키텍처 개요
컴포넌트 관계도
+---------------------------+
| Browser (Host) |
| |
| +---------------------+ | +-------------------+
| | Content Script | |<--->| Web Page (DOM) |
| | (격리된 JS 세계) | | +-------------------+
| +----------+-----------+ |
| | chrome.runtime.sendMessage / Port
| +----------+-----------+ |
| | Service Worker | | +-------------------+
| | (Background) | |<--->| External API |
| | - 이벤트 구동 | | +-------------------+
| | - 상태 비지속 | |
| +----------+-----------+ |
| | | |
| +----+--+ +---+------+ |
| | Popup | | Side | |
| | (UI) | | Panel | |
| +--------+ +----------+ |
| | |
| +----+------+ |
| | Options | |
| | Page | |
| +-----------+ |
+---------------------------+
컴포넌트별 역할
| 컴포넌트 | 역할 | 라이프사이클 | DOM 접근 |
|---|---|---|---|
| Service Worker | 이벤트 처리, API 호출, 알람 | 이벤트 구동 (휴면/기동) | X |
| Content Script | 웹 페이지 DOM 조작 | 페이지와 함께 | O (격리) |
| Popup | 사용자 인터랙션 UI | 열림/닫힘 시 | 자체 DOM |
| Side Panel | 지속형 보조 UI | 패널 열림/닫힘 | 자체 DOM |
| Options Page | 설정 UI | 페이지 열림 시 | 자체 DOM |
| Offscreen Document | DOM API 필요한 백그라운드 작업 | 생성/파괴 | 자체 DOM |
manifest.json 핵심 구조 (Manifest V3)
{
"manifest_version": 3,
"name": "__MSG_extensionName__",
"version": "1.0.0",
"description": "__MSG_extensionDescription__",
"default_locale": "en",
"background": {
"service_worker": "src/background/index.ts",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["src/content/index.ts"],
"css": ["src/content/styles.css"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "src/popup/index.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"side_panel": {
"default_path": "src/sidepanel/index.html"
},
"options_ui": {
"page": "src/options/index.html",
"open_in_tab": true
},
"permissions": ["storage", "alarms", "sidePanel"],
"host_permissions": ["https://api.example.com/*"],
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
2. 프로젝트 구조 (WXT + TypeScript)
WXT 프레임워크 권장 구조
WXT는 Vite 기반 브라우저 확장 프레임워크. 크로스 브라우저 빌드, HMR, 자동 매니페스트 생성 지원.
browser-ext/
+-- wxt.config.ts # WXT 설정 (빌드, 브라우저, 모듈)
+-- package.json
+-- tsconfig.json
+-- .env # 환경변수 (API URL 등)
+-- .env.production
+--
+-- assets/ # 정적 리소스
| +-- icons/ # 아이콘 (16, 32, 48, 96, 128)
| +-- images/
+--
+-- entrypoints/ # WXT 진입점 (자동 매니페스트 생성)
| +-- background.ts # Service Worker
| +-- content.ts # Content Script (또는 content/)
| +-- popup/ # Popup UI
| | +-- index.html
| | +-- App.tsx
| | +-- main.tsx
| +-- sidepanel/ # Side Panel UI
| | +-- index.html
| | +-- App.tsx
| | +-- main.tsx
| +-- options/ # Options 페이지
| | +-- index.html
| | +-- App.tsx
| | +-- main.tsx
+--
+-- components/ # 공유 UI 컴포넌트
| +-- ui/ # 기본 UI (Button, Input 등)
| +-- layout/ # 레이아웃 (Header, Sidebar)
+--
+-- lib/ # 핵심 비즈니스 로직
| +-- api/ # API 클라이언트
| +-- messaging/ # 메시징 유틸리티
| +-- storage/ # 저장소 추상화
| +-- hooks/ # React/Vue 훅
| +-- utils/ # 유틸리티 함수
+--
+-- public/ # 정적 파일 (그대로 복사)
| +-- _locales/ # i18n 메시지
| +-- en/messages.json
| +-- ko/messages.json
+--
+-- tests/ # 테스트
| +-- unit/
| +-- integration/
| +-- e2e/
| +-- setup.ts # 테스트 환경 설정
wxt.config.ts 기본 설정
import { defineConfig } from 'wxt';
export default defineConfig({
// UI 프레임워크
modules: ['@wxt-dev/module-react'],
// 매니페스트 설정
manifest: {
name: '__MSG_extensionName__',
description: '__MSG_extensionDescription__',
default_locale: 'en',
permissions: ['storage', 'alarms'],
host_permissions: ['https://api.example.com/*'],
},
// 빌드 설정
runner: {
startUrls: ['https://example.com'],
},
});
TypeScript 설정
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"paths": {
"@/*": ["./src/*"],
"@lib/*": ["./lib/*"],
"@components/*": ["./components/*"]
},
"types": ["wxt/client"]
},
"include": ["entrypoints/**/*", "lib/**/*", "components/**/*"]
}
3. Service Worker (Background)
라이프사이클
이벤트 발생
|
+--------+ +---v---+ +---------+
| 휴면 |--->| 기동 |--->| 이벤트 |
| (Sleep)| | (Wake)| | 처리 |
+--------+ +-------+ +----+----+
^ |
| 30초 유휴 시 |
+--------------------------+
Service Worker는 이벤트가 없으면 30초 후 종료. 5분 이상 단일 작업 불가. 전역 변수는 기동 시마다 초기화됨.
상태 비지속 패턴
// entrypoints/background.ts
// ❌ 잘못된 패턴: 전역 변수에 상태 보관
let cachedData: UserData | null = null;
// ✅ 올바른 패턴: chrome.storage.session 사용 (메모리 내, 세션 지속)
async function getCachedData(): Promise<UserData | null> {
const result = await chrome.storage.session.get('cachedData');
return result.cachedData ?? null;
}
async function setCachedData(data: UserData): Promise<void> {
await chrome.storage.session.set({ cachedData: data });
}
Alarm 패턴 (주기적 작업)
// entrypoints/background.ts
// 알람 등록 (설치 시 1회)
export default defineBackground(() => {
chrome.runtime.onInstalled.addListener(() => {
// 최소 간격: 1분 (개발), 프로덕션에서는 적절한 간격 설정
chrome.alarms.create('syncData', {
periodInMinutes: 15,
});
chrome.alarms.create('refreshToken', {
periodInMinutes: 50, // 토큰 만료 전 갱신
});
});
// 알람 리스너
chrome.alarms.onAlarm.addListener(async (alarm) => {
switch (alarm.name) {
case 'syncData':
await syncDataWithServer();
break;
case 'refreshToken':
await refreshAuthToken();
break;
}
});
});
장기 실행 작업 처리
// Service Worker 5분 제한 대응: 작업 분할
async function processLargeDataset(items: Item[]): Promise<void> {
const BATCH_SIZE = 50;
const batches = chunk(items, BATCH_SIZE);
for (let i = 0; i < batches.length; i++) {
await processBatch(batches[i]);
// 진행 상태 저장 (중단 대비)
await chrome.storage.session.set({
processingState: { batchIndex: i + 1, total: batches.length },
});
}
// 완료 후 상태 정리
await chrome.storage.session.remove('processingState');
}
// Service Worker 재시작 시 미완료 작업 재개
chrome.runtime.onStartup.addListener(async () => {
const result = await chrome.storage.session.get('processingState');
if (result.processingState) {
await resumeProcessing(result.processingState);
}
});
Keep-Alive 패턴 (필요 시에만 사용)
// ⚠️ 심사에서 거절 사유가 될 수 있음. 정당한 사유 필요.
// 예: WebSocket 연결 유지, 실시간 알림
// 방법 1: chrome.runtime 포트 연결 유지
function keepAlive(): void {
// Popup/Side Panel이 열려 있는 동안만 Service Worker 유지
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'keepAlive') {
// 포트 연결 해제 시 자동으로 Service Worker 해제됨
port.onDisconnect.addListener(() => {
// 정리 작업
});
}
});
}
// 방법 2: Offscreen Document + WebSocket (권장)
// → Offscreen Documents 섹션 참조
4. 메시징 패턴
4.1 단발성 메시지 (One-time Message)
// lib/messaging/types.ts
// 메시지 타입 정의 (타입 안전)
interface MessageMap {
'auth:login': { payload: { token: string }; response: { success: boolean } };
'data:fetch': { payload: { key: string }; response: { data: unknown } };
'tab:capture': { payload: undefined; response: { imageUri: string } };
}
type MessageType = keyof MessageMap;
interface Message<T extends MessageType> {
type: T;
payload: MessageMap[T]['payload'];
}
// lib/messaging/sender.ts
async function sendMessage<T extends MessageType>(
type: T,
payload: MessageMap[T]['payload']
): Promise<MessageMap[T]['response']> {
const response = await chrome.runtime.sendMessage({ type, payload });
if (chrome.runtime.lastError) {
throw new Error(chrome.runtime.lastError.message);
}
return response;
}
// Content Script → Background
const result = await sendMessage('data:fetch', { key: 'settings' });
// lib/messaging/handler.ts (Background에서 수신)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
const handler = messageHandlers[message.type as MessageType];
if (handler) {
// 비동기 응답을 위해 true 반환 필수
handler(message.payload, sender).then(sendResponse);
return true;
}
});
const messageHandlers: Record<MessageType, Function> = {
'auth:login': async (payload: { token: string }) => {
await chrome.storage.local.set({ authToken: payload.token });
return { success: true };
},
'data:fetch': async (payload: { key: string }) => {
const data = await fetchFromStorage(payload.key);
return { data };
},
'tab:capture': async (_: undefined, sender: chrome.runtime.MessageSender) => {
const imageUri = await chrome.tabs.captureVisibleTab(sender.tab!.windowId);
return { imageUri };
},
};
4.2 장기 연결 (Port 기반)
// Popup/Side Panel → Background (스트리밍, 진행 상태 등)
// Popup 측
const port = chrome.runtime.connect({ name: 'streaming' });
port.postMessage({ type: 'start', query: 'user query' });
port.onMessage.addListener((msg) => {
switch (msg.type) {
case 'chunk':
appendToUI(msg.data);
break;
case 'done':
port.disconnect();
break;
case 'error':
showError(msg.error);
port.disconnect();
break;
}
});
// Background 측
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'streaming') {
port.onMessage.addListener(async (msg) => {
if (msg.type === 'start') {
try {
for await (const chunk of streamFromAPI(msg.query)) {
port.postMessage({ type: 'chunk', data: chunk });
}
port.postMessage({ type: 'done' });
} catch (error) {
port.postMessage({ type: 'error', error: String(error) });
}
}
});
}
});
4.3 외부 페이지 통신 (externally_connectable)
// manifest.json
{
"externally_connectable": {
"matches": ["https://app.example.com/*"]
}
}
// 웹 앱 측 (app.example.com)
const EXTENSION_ID = 'abcdefghijklmnop...';
// 확장 설치 여부 확인
async function isExtensionInstalled(): Promise<boolean> {
return new Promise((resolve) => {
try {
chrome.runtime.sendMessage(EXTENSION_ID, { type: 'ping' }, (response) => {
resolve(!!response?.pong);
});
} catch {
resolve(false);
}
});
}
// 확장에 메시지 전송
chrome.runtime.sendMessage(
EXTENSION_ID,
{ type: 'auth:token', token: 'jwt-token' },
(response) => {
console.log('Extension response:', response);
}
);
// Background에서 외부 메시지 수신
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
// sender.url 검증 필수
if (!sender.url?.startsWith('https://app.example.com')) {
sendResponse({ error: 'Unauthorized origin' });
return;
}
if (message.type === 'ping') {
sendResponse({ pong: true, version: chrome.runtime.getManifest().version });
}
});
4.4 Content Script ↔ 웹 페이지 통신
상세는 content-script.md 참조
// 간략한 패턴만 제시
// Content Script에서 window.postMessage로 페이지와 통신
window.postMessage({ source: 'my-extension', type: 'getData' }, '*');
window.addEventListener('message', (event) => {
if (event.data?.source === 'my-extension-page') {
// 페이지로부터 응답 처리
}
});
5. 저장소 전략
저장소 유형 비교
| 저장소 | 용량 | 동기화 | 지속성 | 용도 |
|---|---|---|---|---|
chrome.storage.local | 10MB (unlimitedStorage 시 무제한) | X | 영구 | 사용자 데이터, 캐시 |
chrome.storage.sync | 100KB (항목당 8KB) | O (Google 계정) | 영구 | 설정, 환경설정 |
chrome.storage.session | 10MB | X | 세션 (브라우저 재시작 시 소멸) | 임시 상태, 토큰 |
IndexedDB | 무제한 | X | 영구 | 대용량 구조화 데이터 |
chrome.storage.managed | 읽기 전용 | 기업 정책 | 영구 | 기업 관리 설정 |
저장소 추상화 레이어
// lib/storage/base.ts
interface StorageAdapter<T> {
get(): Promise<T | null>;
set(value: T): Promise<void>;
remove(): Promise<void>;
watch(callback: (newValue: T | null, oldValue: T | null) => void): () => void;
}
// lib/storage/chrome-storage.ts
function createStorageItem<T>(
key: string,
area: 'local' | 'sync' | 'session' = 'local',
defaultValue?: T
): StorageAdapter<T> {
const storage = chrome.storage[area];
return {
async get(): Promise<T | null> {
const result = await storage.get(key);
return (result[key] as T) ?? defaultValue ?? null;
},
async set(value: T): Promise<void> {
await storage.set({ [key]: value });
},
async remove(): Promise<void> {
await storage.remove(key);
},
watch(callback: (newValue: T | null, oldValue: T | null) => void): () => void {
const listener = (
changes: Record<string, chrome.storage.StorageChange>,
areaName: string
) => {
if (areaName === area && key in changes) {
callback(
(changes[key].newValue as T) ?? null,
(changes[key].oldValue as T) ?? null
);
}
};
chrome.storage.onChanged.addListener(listener);
return () => chrome.storage.onChanged.removeListener(listener);
},
};
}
// 사용 예시
const authToken = createStorageItem<string>('authToken', 'session');
const userSettings = createStorageItem<UserSettings>('settings', 'sync', defaultSettings);
const cachedData = createStorageItem<CachedData>('cache', 'local');
// React Hook으로 래핑
function useStorageItem<T>(adapter: StorageAdapter<T>) {
const [value, setValue] = useState<T | null>(null);
useEffect(() => {
adapter.get().then(setValue);
const unwatch = adapter.watch((newVal) => setValue(newVal));
return unwatch;
}, []);
const update = useCallback(async (newValue: T) => {
await adapter.set(newValue);
}, []);
return [value, update] as const;
}
저장소 마이그레이션
// lib/storage/migration.ts
interface Migration {
version: number;
migrate: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
}
const migrations: Migration[] = [
{
version: 2,
migrate: async (data) => {
// v1 → v2: settings 구조 변경
if (data.settings && typeof data.settings === 'string') {
data.settings = JSON.parse(data.settings as string);
}
return data;
},
},
{
version: 3,
migrate: async (data) => {
// v2 → v3: 키 이름 변경
if (data.auth_token) {
data.authToken = data.auth_token;
delete data.auth_token;
}
return data;
},
},
];
async function runMigrations(): Promise<void> {
const { storageVersion = 1 } = await chrome.storage.local.get('storageVersion');
const pendingMigrations = migrations.filter((m) => m.version > storageVersion);
if (pendingMigrations.length === 0) return;
let data = await chrome.storage.local.get(null);
for (const migration of pendingMigrations) {
data = await migration.migrate(data);
}
await chrome.storage.local.set({
...data,
storageVersion: pendingMigrations.at(-1)!.version,
});
}
// onInstalled에서 실행
chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason === 'update') {
await runMigrations();
}
});
6. 권한 모델
권한 분류
| 분류 | 선언 위치 | 사용자 동의 | 예시 |
|---|---|---|---|
| permissions | permissions | 설치 시 | storage, alarms, tabs, activeTab |
| host_permissions | host_permissions | 설치 시 | https://api.example.com/* |
| optional_permissions | optional_permissions | 런타임 요청 | bookmarks, history |
| optional_host_permissions | optional_host_permissions | 런타임 요청 | https://*/* |
최소 권한 원칙
// ❌ 과도한 권한: 심사 거절 사유
{
"permissions": ["tabs", "history", "bookmarks", "<all_urls>"],
"host_permissions": ["<all_urls>"]
}
// ✅ 최소 권한 + 선택적 확장
{
"permissions": ["storage", "activeTab"],
"optional_permissions": ["tabs"],
"host_permissions": ["https://api.example.com/*"],
"optional_host_permissions": ["https://*.target-site.com/*"]
}
런타임 권한 요청
// lib/permissions.ts
async function requestHostPermission(origin: string): Promise<boolean> {
const granted = await chrome.permissions.request({
origins: [`${origin}/*`],
});
return granted;
}
async function checkPermission(permission: string): Promise<boolean> {
return chrome.permissions.contains({ permissions: [permission] });
}
// UI에서 사용
async function onEnableFeature(): Promise<void> {
const hasPermission = await checkPermission('bookmarks');
if (!hasPermission) {
const granted = await requestHostPermission('https://target-site.com');
if (!granted) {
showMessage('이 기능을 사용하려면 권한이 필요합니다.');
return;
}
}
// 기능 활성화
}
activeTab 패턴 (권장)
// activeTab: 사용자가 클릭한 탭에만 일시적으로 접근
// host_permissions 없이 현재 탭 접근 가능
// manifest.json
// "permissions": ["activeTab", "scripting"]
// 사용자 클릭 시에만 Content Script 주입
chrome.action.onClicked.addListener(async (tab) => {
if (!tab.id) return;
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content-scripts/on-demand.js'],
});
});
7. Side Panel API
설정
// manifest.json
{
"permissions": ["sidePanel"],
"side_panel": {
"default_path": "src/sidepanel/index.html"
}
}
패널 제어
// entrypoints/background.ts
// 특정 탭에서만 Side Panel 활성화
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
if (!tab.url) return;
const url = new URL(tab.url);
if (url.hostname === 'target-site.com') {
await chrome.sidePanel.setOptions({
tabId,
path: 'src/sidepanel/index.html',
enabled: true,
});
} else {
await chrome.sidePanel.setOptions({
tabId,
enabled: false,
});
}
});
// 프로그래밍적으로 열기 (사용자 제스처 필요)
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ tabId: tab.id! });
});
Side Panel vs Popup 선택 기준
| 기준 | Side Panel | Popup |
|---|---|---|
| 지속적 UI 필요 | O | X (닫으면 상태 소멸) |
| 페이지와 나란히 작업 | O | X |
| 빠른 액션 (1-2클릭) | X | O |
| 복잡한 폼/목록 | O | X (공간 부족) |
| 모든 브라우저 지원 | X (Chrome/Edge) | O |
8. Offscreen Documents
DOM API가 필요하지만 UI가 아닌 작업에 사용. Service Worker에서는 DOM API 사용 불가.
주요 용도
| 용도 | Reason | 설명 |
|---|---|---|
| 클립보드 | CLIPBOARD | document.execCommand('copy') |
| 오디오 재생 | AUDIO_PLAYBACK | <audio> 태그 |
| DOM 파싱 | DOM_PARSER | DOMParser, document.createElement |
| WebRTC | USER_MEDIA | 미디어 캡처 |
| WebSocket | WEB_RTC | 장기 연결 유지 |
구현 패턴
// entrypoints/background.ts
// Offscreen Document 생성 (전역에서 1개만 존재 가능)
async function ensureOffscreenDocument(): Promise<void> {
const existingContexts = await chrome.runtime.getContexts({
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
});
if (existingContexts.length > 0) return;
await chrome.offscreen.createDocument({
url: 'src/offscreen/index.html',
reasons: [chrome.offscreen.Reason.DOM_PARSER],
justification: 'HTML 콘텐츠를 안전하게 파싱하기 위해 DOM API가 필요합니다.',
});
}
// Background → Offscreen 메시지
async function parseHTML(html: string): Promise<string> {
await ensureOffscreenDocument();
const response = await chrome.runtime.sendMessage({
target: 'offscreen',
type: 'parseHTML',
data: html,
});
return response.result;
}
// src/offscreen/index.ts (Offscreen Document 측)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.target !== 'offscreen') return;
if (message.type === 'parseHTML') {
const parser = new DOMParser();
const doc = parser.parseFromString(message.data, 'text/html');
const text = doc.body.textContent ?? '';
sendResponse({ result: text });
}
return true;
});
9. 아키텍처 체크리스트
프로젝트 초기 설정
- WXT 프로젝트 초기화 (
npx wxt@latest init) - TypeScript strict 모드 활성화
- 경로 별칭(alias) 설정 (
@/,@lib/,@components/) - ESLint + Prettier 설정
- 환경변수 파일 구성 (
.env,.env.production)
Manifest 설정
-
manifest_version: 3확인 - 최소 권한 원칙 적용 (
activeTab우선) -
host_permissions는 필요한 도메인만 명시 - 국제화 적용 (
__MSG_*__) - 아이콘 모든 사이즈 준비 (16, 32, 48, 96, 128)
Service Worker
- 전역 변수에 상태 보관하지 않음
-
chrome.storage.session또는chrome.storage.local로 상태 관리 - 장기 작업은 배치 분할 또는 Offscreen Document 사용
-
chrome.alarms로 주기적 작업 처리 -
onInstalled리스너에서 초기화/마이그레이션 수행
메시징
- TypeScript 타입 정의로 메시지 안전성 보장
- 비동기 응답 시
return true확인 -
chrome.runtime.lastError에러 처리 - 외부 메시지 수신 시
sender.url검증
저장소
- 용도에 맞는 저장소 유형 선택 (local/sync/session)
- 저장소 추상화 레이어 구현
- 버전 업데이트 시 마이그레이션 처리
-
chrome.storage.sync용량 제한 (100KB) 준수
관련 문서
| 문서 | 내용 |
|---|---|
| content-script.md | Content Script 상세 패턴 |
| cross-browser.md | 크로스 브라우저 호환성 |
| chrome-store.md | Chrome Web Store 배포 |
| firefox-addon.md | Firefox Add-on 배포 |
| /platform/client/common/security | 공통 보안 가이드 |
| /platform/client/common/auth | 공통 인증 패턴 |
| /platform/client/common/project-structure | 공통 프로젝트 구조 |
Last Updated: 2026-04-06