브라우저 확장 아키텍처 가이드
작성일: 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();
}
});