Skip to main content

Content Script 개발 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Chrome, Firefox, Edge Content Script 개발

공통 문서 참조: 보안은 ../common/security.md, 아키텍처 개요는 architecture.md 참조


목차

  1. Content Script 개요
  2. 주입 패턴
  3. DOM 조작 안전 패턴
  4. 페이지와의 통신
  5. CSS 격리
  6. CSP 제약과 대응
  7. 성능 최적화
  8. 사이트별 호환성 처리
  9. 체크리스트

1. Content Script 개요

Content Script의 실행 환경

+--------------------------------------+
| 웹 페이지 |
| |
| +-------------+ +---------------+ |
| | Page Script | | Content Script| |
| | (페이지 JS) | | (확장 JS) | |
| | | | | |
| | - 페이지 DOM | | - 페이지 DOM | |
| | 직접 접근 | | 직접 접근 | |
| | - window | | - 격리된 | |
| | (공유) | | window | |
| | - 페이지 | | - chrome.* | |
| | 전역변수 | | API 접근 | |
| +-------------+ +---------------+ |
| ↕ window.postMessage |
+--------------------------------------+

핵심 특징

특성설명
DOM 접근호스트 페이지 DOM에 직접 접근 가능
JS 격리페이지 JS와 격리된 별도 JS 세계 (Isolated World)
API 접근chrome.runtime, chrome.storage 등 제한된 API
CSP호스트 페이지 CSP 영향 받음 (일부)
라이프사이클페이지 로드 시 주입, 네비게이션 시 소멸

2. 주입 패턴

2.1 선언적 주입 (Manifest)

// manifest.json
{
"content_scripts": [
{
"matches": ["https://*.github.com/*"],
"exclude_matches": ["https://github.com/settings/*"],
"js": ["content-scripts/main.js"],
"css": ["content-scripts/styles.css"],
"run_at": "document_idle",
"all_frames": false,
"match_about_blank": false
}
]
}

run_at 타이밍

타이밍사용 시나리오
document_startDOM 파싱 시작 전페이지 스크립트 차단, 초기 스타일 주입
document_endDOM 파싱 완료, 리소스 로드 전DOM 요소 접근 필요 시
document_idle (기본)모든 로드 완료 또는 DOMContentLoaded대부분의 경우 (권장)

2.2 프로그래밍적 주입 (Scripting API)

// entrypoints/background.ts

// 특정 이벤트 시 주입 (사용자 클릭 등)
chrome.action.onClicked.addListener(async (tab) => {
if (!tab.id) return;

// JS 파일 주입
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content-scripts/on-demand.js'],
});

// CSS 주입
await chrome.scripting.insertCSS({
target: { tabId: tab.id },
files: ['content-scripts/on-demand.css'],
});
});

// 인라인 함수 주입 (간단한 작업)
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (param) => {
// 이 함수는 Content Script 환경에서 실행됨
// 외부 변수 접근 불가 (직렬화됨)
document.title = `Modified: ${param}`;
},
args: ['hello'],
});

2.3 선언적 + 동적 조합 (Registered Content Scripts)

// 동적으로 Content Script 등록/해제
// 사용자 설정에 따라 특정 사이트에서만 동작

async function registerContentScript(sites: string[]): Promise<void> {
// 기존 등록 해제
try {
await chrome.scripting.unregisterContentScripts({ ids: ['dynamic-script'] });
} catch {
// 미등록 상태면 무시
}

if (sites.length === 0) return;

await chrome.scripting.registerContentScripts([
{
id: 'dynamic-script',
matches: sites.map((s) => `https://${s}/*`),
js: ['content-scripts/dynamic.js'],
css: ['content-scripts/dynamic.css'],
runAt: 'document_idle',
},
]);
}

// 설정 변경 시 재등록
chrome.storage.onChanged.addListener(async (changes) => {
if (changes.enabledSites) {
await registerContentScript(changes.enabledSites.newValue);
}
});

주입 패턴 선택 가이드

패턴적합한 상황권한
선언적항상 특정 사이트에서 동작host_permissions
프로그래밍적사용자 액션 시에만 동작activeTab + scripting
동적 등록사용자 설정에 따라 사이트 변경host_permissions + scripting

3. DOM 조작 안전 패턴

3.1 안전한 요소 삽입

// ❌ 위험: innerHTML로 사용자 입력 삽입 (XSS)
element.innerHTML = `<div>${userInput}</div>`;

// ✅ 안전: DOM API로 요소 생성
function createSafeElement(tag: string, text: string, attrs?: Record<string, string>): HTMLElement {
const el = document.createElement(tag);
el.textContent = text; // HTML 이스케이프 자동 처리
if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
el.setAttribute(key, value);
}
}
return el;
}

// ✅ 안전: 템플릿 리터럴 + sanitize (HTML 필요 시)
import DOMPurify from 'dompurify';

function setInnerHTML(element: HTMLElement, html: string): void {
element.innerHTML = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'br', 'p', 'span'],
ALLOWED_ATTR: ['href', 'class', 'title'],
});
}

3.2 MutationObserver (동적 콘텐츠 감지)

// SPA에서 동적으로 추가되는 요소 감지

class DOMWatcher {
private observer: MutationObserver;
private selectors: Map<string, (elements: Element[]) => void> = new Map();

constructor() {
this.observer = new MutationObserver((mutations) => {
this.processMutations(mutations);
});
}

/**
* 특정 셀렉터와 일치하는 요소가 추가되면 콜백 실행
*/
watch(selector: string, callback: (elements: Element[]) => void): void {
this.selectors.set(selector, callback);
}

start(root: Element = document.body): void {
// 이미 존재하는 요소 처리
for (const [selector, callback] of this.selectors) {
const existing = Array.from(root.querySelectorAll(selector));
if (existing.length > 0) callback(existing);
}

this.observer.observe(root, {
childList: true,
subtree: true,
});
}

stop(): void {
this.observer.disconnect();
}

private processMutations(mutations: MutationRecord[]): void {
const addedNodes: Node[] = [];
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
addedNodes.push(node);
}
}
}

if (addedNodes.length === 0) return;

for (const [selector, callback] of this.selectors) {
const matched: Element[] = [];
for (const node of addedNodes) {
const el = node as Element;
if (el.matches?.(selector)) matched.push(el);
matched.push(...Array.from(el.querySelectorAll?.(selector) ?? []));
}
if (matched.length > 0) callback(matched);
}
}
}

// 사용 예시
const watcher = new DOMWatcher();

watcher.watch('[data-testid="tweet"]', (tweets) => {
tweets.forEach((tweet) => addTranslateButton(tweet));
});

watcher.watch('.comment-body', (comments) => {
comments.forEach((comment) => enhanceComment(comment));
});

watcher.start();

3.3 Shadow DOM을 이용한 안전한 UI 삽입

// 확장 UI를 Shadow DOM으로 격리하여 페이지 스타일 영향 차단

function createExtensionUI(): HTMLElement {
const host = document.createElement('div');
host.id = 'my-extension-root';

const shadow = host.attachShadow({ mode: 'closed' });

// 스타일 격리
const style = document.createElement('style');
style.textContent = `
:host {
all: initial;
position: fixed;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

.container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 16px;
width: 320px;
}

.title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 12px;
}

.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}

.btn-primary {
background: #0066ff;
color: white;
}

.btn-primary:hover {
background: #0052cc;
}
`;

const container = document.createElement('div');
container.className = 'container';
container.innerHTML = `
<h3 class="title">My Extension</h3>
<p>Extension UI content here</p>
<button class="btn btn-primary" id="action-btn">Action</button>
`;

shadow.appendChild(style);
shadow.appendChild(container);

// Shadow DOM 내부 이벤트
shadow.getElementById('action-btn')?.addEventListener('click', () => {
handleAction();
});

return host;
}

// 페이지에 추가
document.body.appendChild(createExtensionUI());

4. 페이지와의 통신

4.1 window.postMessage 패턴

// Content Script 측
// lib/content/page-bridge.ts

interface PageMessage {
source: 'my-extension-content';
type: string;
payload: unknown;
id: string;
}

interface PageResponse {
source: 'my-extension-page';
type: string;
payload: unknown;
id: string;
}

class PageBridge {
private pendingRequests = new Map<string, {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}>();

constructor() {
window.addEventListener('message', this.handleMessage.bind(this));
}

/**
* 페이지에 메시지 전송 (응답 대기)
*/
async send(type: string, payload: unknown, timeoutMs = 5000): Promise<unknown> {
const id = crypto.randomUUID();

return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`PageBridge timeout: ${type}`));
}, timeoutMs);

this.pendingRequests.set(id, { resolve, reject, timeout });

window.postMessage(
{ source: 'my-extension-content', type, payload, id } satisfies PageMessage,
'*'
);
});
}

private handleMessage(event: MessageEvent): void {
if (event.source !== window) return;
const data = event.data as PageResponse;
if (data?.source !== 'my-extension-page') return;

const pending = this.pendingRequests.get(data.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(data.id);
pending.resolve(data.payload);
}
}

destroy(): void {
window.removeEventListener('message', this.handleMessage.bind(this));
for (const { reject, timeout } of this.pendingRequests.values()) {
clearTimeout(timeout);
reject(new Error('PageBridge destroyed'));
}
this.pendingRequests.clear();
}
}

4.2 Injected Script 패턴

// 페이지 JS 세계에 스크립트 주입이 필요한 경우
// (페이지의 전역 변수/함수 접근)

// entrypoints/content.ts
function injectPageScript(): void {
const script = document.createElement('script');
script.src = chrome.runtime.getURL('injected.js');
script.onload = () => script.remove();
(document.head || document.documentElement).appendChild(script);
}

// public/injected.js (패키지에 포함된 파일)
// ⚠️ 이 스크립트는 페이지 JS 세계에서 실행됨
// chrome.* API 접근 불가
(function () {
// 페이지 전역 변수 접근
const appState = window.__APP_STATE__;

// Content Script에 결과 전달
window.postMessage(
{
source: 'my-extension-page',
type: 'appState',
payload: appState,
id: 'init',
},
'*'
);
})();
// manifest.json - injected.js를 web_accessible_resources로 등록
{
"web_accessible_resources": [
{
"resources": ["injected.js"],
"matches": ["https://*.target-site.com/*"]
}
]
}

4.3 CustomEvent 패턴

// CustomEvent (동일 페이지 내에서만)
// window.postMessage보다 가벼움, iframe 통신 불필요 시

// Content Script → Page
document.dispatchEvent(
new CustomEvent('my-extension:request', {
detail: { type: 'getData', key: 'user' },
})
);

// Page → Content Script
document.addEventListener('my-extension:response', (event: CustomEvent) => {
const data = event.detail;
// 데이터 처리
});

통신 방법 비교

방법방향iframe 지원보안용도
window.postMessage양방향Oorigin 검증 필요범용 (권장)
CustomEvent양방향X같은 문서 내간단한 통신
Injected Script단방향 (주입)X페이지 세계 노출전역변수 접근
chrome.runtime.sendMessageContent → BackgroundX확장 내부확장 컴포넌트 간

5. CSS 격리

5.1 Shadow DOM (권장)

DOM 조작 안전 패턴 섹션 참조

5.2 CSS Modules + 고유 프리픽스

// 빌드 시 CSS Modules 사용
// wxt.config.ts (Vite 기본 지원)

// content.module.css
// 자동으로 고유 클래스명 생성: .container → .content_container_a1b2c
.container {
background: white;
padding: 16px;
}

5.3 CSS Reset + Namespace

/* 확장 UI에만 적용되는 CSS reset */
#my-extension-root,
#my-extension-root * {
all: revert;
box-sizing: border-box;
}

/* 네임스페이스로 충돌 방지 */
.myext-container {
position: fixed;
z-index: 2147483647; /* 최상위 */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.5;
color: #333;
}

.myext-btn {
/* 페이지의 .btn과 충돌하지 않음 */
padding: 8px 16px;
border-radius: 4px;
}

CSS 격리 전략 비교

방법격리 수준복잡도권장
Shadow DOM (closed)완전 격리중간O (복잡한 UI)
CSS Modules클래스 격리낮음O (간단한 UI)
네임스페이스 프리픽스부분 격리낮음X (레거시)
iframe완전 격리높음X (과도함)

6. CSP 제약과 대응

Content Script의 CSP 관계

구분CSP 적용
Content Script JS 실행확장 자체 CSP (영향 없음)
페이지에 <script> 삽입호스트 페이지 CSP 적용
페이지에 <style> 삽입호스트 페이지 CSP 적용
Shadow DOM 내 style확장 CSP (영향 적음)
chrome.scripting.insertCSS영향 없음 (확장 API)
fetch() from Content ScriptContent Script origin (확장 CSP)

인라인 스크립트 제한 대응

// ❌ CSP 위반: 인라인 스크립트 삽입
const script = document.createElement('script');
script.textContent = 'console.log("hello")';
document.head.appendChild(script);
// → Refused to execute inline script (CSP)

// ✅ 대응 1: 파일로 분리하여 주입
const script = document.createElement('script');
script.src = chrome.runtime.getURL('injected.js');
document.head.appendChild(script);

// ✅ 대응 2: chrome.scripting API 사용 (MV3)
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => { console.log('hello'); },
});

인라인 스타일 제한 대응

// ❌ CSP 위반 가능: 인라인 style 속성
element.setAttribute('style', 'color: red');

// ✅ 안전: JS로 스타일 직접 설정
element.style.color = 'red';

// ✅ 안전: chrome.scripting.insertCSS
await chrome.scripting.insertCSS({
target: { tabId: tab.id },
css: '.my-class { color: red; }',
});

// ✅ 안전: Shadow DOM 내부 style
const shadow = host.attachShadow({ mode: 'closed' });
const style = document.createElement('style');
style.textContent = '.container { color: red; }';
shadow.appendChild(style);

web_accessible_resources 보안

// ⚠️ 주의: 지정된 리소스는 웹 페이지에서 접근 가능
{
"web_accessible_resources": [
{
"resources": ["injected.js", "images/icon.png"],
"matches": ["https://*.target-site.com/*"],
"use_dynamic_url": true
}
]
}
// use_dynamic_url: true → 리소스 URL이 세션마다 변경
// 확장 ID 기반 핑거프린팅 방지
const url = chrome.runtime.getURL('images/icon.png');
// → chrome-extension://[dynamic-id]/images/icon.png

7. 성능 최적화

7.1 Lazy Injection

// 필요할 때만 Content Script 로드

// entrypoints/content.ts
// 최소 진입점: 조건 확인만 수행
function shouldActivate(): boolean {
// 현재 페이지에서 동작해야 하는지 확인
return document.querySelector('[data-target-element]') !== null;
}

if (shouldActivate()) {
// 무거운 모듈은 동적 import
import('./heavy-module').then((module) => {
module.init();
});
}

7.2 requestIdleCallback 활용

// 비핵심 작업은 유휴 시간에 실행

function runWhenIdle(callback: () => void, timeout = 2000): void {
if ('requestIdleCallback' in window) {
requestIdleCallback(callback, { timeout });
} else {
setTimeout(callback, 100);
}
}

// DOM 분석 등 비긴급 작업
runWhenIdle(() => {
analyzePage();
collectMetadata();
});

7.3 Debounce/Throttle DOM 이벤트

// MutationObserver 콜백 최적화

function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout>;
return function (this: unknown, ...args: unknown[]) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
} as T;
}

const observer = new MutationObserver(
debounce((mutations: MutationRecord[]) => {
processMutations(mutations);
}, 100) // 100ms 내 연속 변경은 1회로 처리
);

7.4 메모리 관리

// Content Script 해제 시 정리

class ContentScriptManager {
private observers: MutationObserver[] = [];
private listeners: Array<{ target: EventTarget; type: string; listener: EventListener }> = [];
private intervals: ReturnType<typeof setInterval>[] = [];

addObserver(observer: MutationObserver): void {
this.observers.push(observer);
}

addListener(target: EventTarget, type: string, listener: EventListener): void {
target.addEventListener(type, listener);
this.listeners.push({ target, type, listener });
}

addInterval(id: ReturnType<typeof setInterval>): void {
this.intervals.push(id);
}

destroy(): void {
this.observers.forEach((o) => o.disconnect());
this.listeners.forEach(({ target, type, listener }) =>
target.removeEventListener(type, listener)
);
this.intervals.forEach(clearInterval);

// 삽입한 DOM 요소 제거
document.getElementById('my-extension-root')?.remove();
}
}

const manager = new ContentScriptManager();

// 페이지 이탈 시 정리
window.addEventListener('unload', () => manager.destroy());

성능 가이드라인

지표목표측정 방법
Content Script 로드 시간< 50msperformance.now()
DOM 조작 빈도초당 10회 이하Performance Monitor
메모리 사용량< 10MBDevTools Memory
MutationObserver 콜백< 16ms (60fps)Performance Profiler

8. 사이트별 호환성 처리

SPA 네비게이션 감지

// SPA (React Router, Next.js 등)에서 URL 변경 감지

function watchNavigation(callback: (url: string) => void): void {
let lastUrl = location.href;

// History API 감지
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);

history.pushState = function (...args) {
originalPushState(...args);
checkUrlChange();
};

history.replaceState = function (...args) {
originalReplaceState(...args);
checkUrlChange();
};

window.addEventListener('popstate', checkUrlChange);

function checkUrlChange(): void {
if (location.href !== lastUrl) {
lastUrl = location.href;
callback(lastUrl);
}
}
}

// 사용
watchNavigation((url) => {
// URL 변경 시 Content Script 재초기화
reinitialize(url);
});

사이트별 셀렉터 관리

// lib/content/site-adapters.ts

interface SiteAdapter {
hostname: string;
selectors: {
container: string;
title: string;
content: string;
actions?: string;
};
init?: () => void;
}

const adapters: SiteAdapter[] = [
{
hostname: 'github.com',
selectors: {
container: '.js-discussion',
title: '.js-issue-title',
content: '.comment-body',
actions: '.timeline-comment-actions',
},
},
{
hostname: 'stackoverflow.com',
selectors: {
container: '#answers',
title: '#question-header h1',
content: '.js-post-body',
actions: '.js-post-menu',
},
},
];

function getAdapter(): SiteAdapter | null {
return adapters.find((a) => location.hostname.includes(a.hostname)) ?? null;
}

// 사용
const adapter = getAdapter();
if (adapter) {
const elements = document.querySelectorAll(adapter.selectors.content);
elements.forEach((el) => processElement(el));
}

동적 로딩 사이트 대응

// 무한 스크롤, lazy loading 등 동적 콘텐츠 사이트

function waitForElement(
selector: string,
timeout = 10000
): Promise<Element | null> {
return new Promise((resolve) => {
// 이미 존재하면 즉시 반환
const existing = document.querySelector(selector);
if (existing) {
resolve(existing);
return;
}

const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
resolve(el);
}
});

observer.observe(document.body, { childList: true, subtree: true });

// 타임아웃
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}

// 사용
const targetElement = await waitForElement('.dynamic-content');
if (targetElement) {
enhanceElement(targetElement);
}

9. 체크리스트

Content Script 개발 체크리스트

주입:

  • 적절한 주입 패턴 선택 (선언적/프로그래밍적/동적)
  • run_at 타이밍 적절히 설정
  • matches 패턴 최소화 (필요한 사이트만)
  • all_frames 기본값 false 유지 (필요 시에만 true)

DOM 조작:

  • innerHTML에 사용자/외부 입력 직접 할당 금지
  • DOMPurify 등 sanitizer 사용 (HTML 삽입 시)
  • Shadow DOM으로 확장 UI 격리
  • MutationObserver로 동적 콘텐츠 대응

통신:

  • window.postMessage source 필드로 메시지 필터링
  • 외부 메시지 origin 검증
  • web_accessible_resources 최소화
  • use_dynamic_url: true 설정 (핑거프린팅 방지)

CSS:

  • Shadow DOM 또는 CSS Modules로 스타일 격리
  • 고유 프리픽스 사용 (클래스명, ID)
  • z-index: 2147483647 최상위 레이어
  • all: initial 또는 all: revert로 상속 차단

성능:

  • 조건 확인 후 필요 시에만 초기화
  • 무거운 모듈은 동적 import
  • MutationObserver 콜백 debounce 적용
  • 메모리 누수 방지 (리스너 정리, Observer 해제)
  • Content Script 로드 시간 50ms 이내

호환성:

  • SPA 네비게이션 감지 구현
  • 사이트별 셀렉터 분리 관리
  • 동적 콘텐츠 로딩 대기 처리
  • 다크 모드/테마 대응

관련 문서

문서내용
architecture.md전체 아키텍처, 메시징 패턴
cross-browser.md크로스 브라우저 호환성
chrome-store.mdChrome Web Store CSP 요구사항
../common/security.md공통 보안 가이드
../common/testing.md공통 테스트 전략

Last Updated: 2026-04-06