Skip to main content

브라우저 확장 아키텍처 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Chrome, Firefox, Edge (Manifest V3)

공통 문서 참조: 프로젝트 구조, 인증, 보안 등은 ../common/ 참조


목차

  1. Manifest V3 아키텍처 개요
  2. 프로젝트 구조 (WXT + TypeScript)
  3. Service Worker (Background)
  4. 메시징 패턴
  5. 저장소 전략
  6. 권한 모델
  7. Side Panel API
  8. Offscreen Documents
  9. 아키텍처 체크리스트

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 DocumentDOM 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.local10MB (unlimitedStorage 시 무제한)X영구사용자 데이터, 캐시
chrome.storage.sync100KB (항목당 8KB)O (Google 계정)영구설정, 환경설정
chrome.storage.session10MBX세션 (브라우저 재시작 시 소멸)임시 상태, 토큰
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. 권한 모델

권한 분류

분류선언 위치사용자 동의예시
permissionspermissions설치 시storage, alarms, tabs, activeTab
host_permissionshost_permissions설치 시https://api.example.com/*
optional_permissionsoptional_permissions런타임 요청bookmarks, history
optional_host_permissionsoptional_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 PanelPopup
지속적 UI 필요OX (닫으면 상태 소멸)
페이지와 나란히 작업OX
빠른 액션 (1-2클릭)XO
복잡한 폼/목록OX (공간 부족)
모든 브라우저 지원X (Chrome/Edge)O

8. Offscreen Documents

DOM API가 필요하지만 UI가 아닌 작업에 사용. Service Worker에서는 DOM API 사용 불가.

주요 용도

용도Reason설명
클립보드CLIPBOARDdocument.execCommand('copy')
오디오 재생AUDIO_PLAYBACK<audio> 태그
DOM 파싱DOM_PARSERDOMParser, document.createElement
WebRTCUSER_MEDIA미디어 캡처
WebSocketWEB_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.mdContent Script 상세 패턴
cross-browser.md크로스 브라우저 호환성
chrome-store.mdChrome Web Store 배포
firefox-addon.mdFirefox Add-on 배포
/platform/client/common/security공통 보안 가이드
/platform/client/common/auth공통 인증 패턴
/platform/client/common/project-structure공통 프로젝트 구조

Last Updated: 2026-04-06