브라우저 확장프로그램 시리즈 (3/5)
1편에서 생태계를 보고, 2편에서 뭘 만들지 정했다면, 이번 편에서는 구조를 해부합니다. manifest.json이 왜 “설명서”인지, popup·background·content script가 왜 서로 직접 대화할 수 없는지, 메시지 패싱은 어떻게 하는지. 이 편을 이해하면 공식 문서를 봤을 때 길을 잃지 않는 지도를 얻게 됩니다.
들어가며 — 왜 아키텍처부터인가
보통 개발 튜토리얼은 “일단 따라 쳐봐라”로 시작해요. 그런데 확장프로그램은 그렇게 시작하면 100% 헷갈립니다. 왜냐하면 일반 웹앱과 구조가 근본적으로 다르거든요.
일반 웹앱은 하나의 페이지에서 모든 코드가 같은 환경에 살아요. JS 파일이 몇 개든, 결국 같은 window 객체를 공유하죠.
확장프로그램은 여러 개의 독립된 실행 환경으로 쪼개져 있어요. 각 환경은 서로 다른 메모리 공간, 다른 생명주기, 다른 권한을 가져요. 이 사실을 모르고 코드를 짜면:
- “아까 팝업에서 저장한 값이 왜 콘텐츠 스크립트에선 안 보이지?”
- “background.js에서
alert()를 호출했는데 왜 안 뜨지?” - “페이지의
window.jQuery에 접근했는데undefined가 나와?”
이런 당혹감이 줄줄이 생겨요. 반대로 이 구조를 한번 이해하면, 나머지 공부가 훨씬 수월해져요. 그래서 이 편이 중요한 겁니다.
1부. manifest.json — 확장프로그램의 설계도
모든 확장프로그램은 manifest.json 이라는 파일 하나로 시작해요. 브라우저는 이 파일을 보고 “아, 이 확장은 이런 일을 하겠다는 거구나” 하고 이해해요.
최소한의 manifest.json
가장 심플한 확장의 설계도예요.
{
"manifest_version": 3,
"name": "내 첫 확장프로그램",
"version": "1.0",
"description": "안녕하세요를 보여주는 확장입니다"
}
이 세 줄짜리 파일만 있어도 확장은 “존재”할 수 있어요. 아무 일도 안 하지만요. 이제 기능을 붙여볼게요.
실제로 쓰는 manifest.json
조금 더 실용적인 예시예요.
{
"manifest_version": 3,
"name": "웹페이지 분석기",
"version": "1.0.0",
"description": "현재 웹페이지의 정보를 분석해서 보여줍니다",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "popup.html",
"default_icon": "icons/icon48.png"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
],
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://api.example.com/*"]
}
각 필드가 무슨 역할인지 하나씩 볼게요.
필수 필드 3인방
manifest_version — 현재는 무조건 3이에요. V2는 끝났어요.
name — 확장프로그램 이름. 툴바 아이콘에 호버하면 뜨고, 스토어에 검색될 때 쓰여요.
version — 버전 번호. 업데이트할 때마다 올려야 해요. 시맨틱 버저닝 (1.0.0 → 1.0.1) 권장.
선택 필드들 — 진짜 중요한 것들
icons — 브라우저 여러 곳에서 쓰이는 아이콘. 16, 48, 128 픽셀 세 개는 꼭 만들어두세요.
action — 툴바 아이콘을 만드는 필드. default_popup을 지정하면 클릭 시 그 HTML이 팝업으로 뜨고, 지정 안 하면 그냥 아이콘만 생겨요 (클릭 이벤트는 코드로 처리).
background — 백그라운드 서비스 워커 파일 지정. 아래에서 자세히 다룰게요.
content_scripts — 웹페이지에 주입할 스크립트 지정. matches로 어떤 URL에 주입할지 결정해요. <all_urls>는 “전부”이고, "https://*.google.com/*" 같은 패턴도 가능해요.
permissions — 확장이 사용할 Chrome API 권한 목록. 사용자가 설치할 때 “이 확장은 다음 권한을 요구합니다” 목록으로 보여져요. 그래서 최소한만 요구해야 해요.
host_permissions — 확장이 접근할 외부 도메인. fetch로 외부 API 호출할 때 필요해요.
권한의 세계 — 조심해야 할 것들
권한 하나하나가 사용자에게는 “이 확장이 내 뭘 볼 수 있는가”의 신호예요. 몇 가지 주요 권한과 그 의미를 표로 정리했어요.
| 권한 | 의미 | 사용자가 보는 경고 |
|---|---|---|
storage | 확장 자체 저장소 사용 | 없음 (가벼움) |
activeTab | 현재 탭만 일시적 접근 | 없음 (권장) |
tabs | 모든 탭 정보 접근 | “방문 기록을 읽을 수 있음” |
cookies | 쿠키 읽기·쓰기 | “쿠키를 읽고 수정할 수 있음” |
<all_urls> | 모든 사이트 접근 | “모든 웹사이트 데이터를 읽고 변경” |
history | 방문 기록 접근 | “방문 기록을 읽고 변경” |
꿀팁: 가능하면 tabs 대신 activeTab을 쓰세요. 사용자 경고가 없어서 설치율이 훨씬 높아져요. activeTab은 “사용자가 확장을 직접 클릭했을 때 현재 탭만” 접근하는 권한인데, 많은 기능에 충분해요.
2부. 네 가지 실행 환경 — 각자의 세계
이제 핵심이에요. 확장프로그램은 네 가지 실행 환경으로 구성돼요.
각 환경의 특성을 표로 먼저 요약할게요. 이 표가 시리즈 전체에서 가장 중요한 표예요.
| 환경 | 언제 살아있나 | 뭘 볼 수 있나 | 주 용도 |
|---|---|---|---|
| Popup | 사용자가 툴바 아이콘 클릭 → 닫을 때까지 | Chrome API, 확장 파일들 | 사용자 UI |
| Background Service Worker | 이벤트 발생 시 깨어남 → 일정 시간 후 종료 | Chrome API, 확장 파일들 | 중앙 제어 로직 |
| Content Script | 매칭되는 페이지 로드 시 | 페이지 DOM, 제한된 Chrome API | 페이지 조작 |
| Options Page | 사용자가 옵션 페이지 열 때 | Chrome API, 확장 파일들 | 설정 UI |
이제 하나씩 자세히 볼게요.
① Popup — 사용자가 만나는 얼굴
Popup은 툴바 아이콘을 클릭했을 때 뜨는 작은 창이에요. HTML/CSS/JS로 만든 미니 웹페이지라고 보면 돼요.
<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { width: 300px; padding: 16px; font-family: sans-serif; }
button { width: 100%; padding: 8px; }
</style>
</head>
<body>
<h3>웹페이지 분석기</h3>
<button id="analyze">현재 페이지 분석</button>
<div id="result"></div>
<script src="popup.js"></script>
</body>
</html>
// popup.js
document.getElementById('analyze').addEventListener('click', async () => {
const resultDiv = document.getElementById('result');
resultDiv.textContent = '분석 중...';
// ... 분석 로직
});
Popup의 중요한 특성들
- 닫히면 죽는다. 팝업을 닫는 순간 JS 변수, 상태, 타이머가 전부 날아가요. 지속되어야 할 데이터는
chrome.storage에 저장해야 해요. - 크기 제한. 최대 800x600px 정도. 그보다 크면 스크롤이 생겨요.
- Chrome API 대부분 사용 가능. 확장 환경에서 돌아가니까요.
② Background Service Worker — 중앙 관제탑
Background Service Worker(줄여서 “서비스 워커” 또는 “백그라운드”)는 확장의 중앙 제어 로직이 사는 곳이에요. 이벤트에 반응해서 깨어나고, 할 일이 끝나면 잠드는 구조예요.
// background.js
console.log('서비스 워커가 깨어났어요');
// 확장이 설치되거나 업데이트될 때 한 번 실행
chrome.runtime.onInstalled.addListener(() => {
console.log('설치 완료!');
chrome.storage.local.set({ installedAt: Date.now() });
});
// 탭이 업데이트될 때마다
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete') {
console.log('페이지 로드 완료:', tab.url);
}
});
// 메시지 수신
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('메시지 받음:', message);
sendResponse({ reply: '확인했어요' });
});
Service Worker의 까다로운 특성들
- 수명이 짧다. 약 30초 idle하면 종료돼요. 긴 작업은
alarms로 쪼개거나 중간 상태를 저장해야 해요. - 전역 변수가 휘발된다. 서비스 워커가 종료되면 변수가 다 사라지고, 다시 깨어날 때 코드가 처음부터 실행돼요. 중요한 상태는
chrome.storage에 저장하세요. - DOM이 없다.
window,document같은 게 없어요.alert()도 안 돼요 (알림이 필요하면chrome.notifications사용). - 이벤트 기반으로 설계해야 해요. 일반 JS처럼 “변수에 상태를 넣고 계속 지켜본다”는 방식은 안 맞아요.
흔한 실수 예시:
// ❌ 이렇게 하면 안 됨
let clickCount = 0;
chrome.action.onClicked.addListener(() => {
clickCount++;
console.log(`클릭 ${clickCount}번`);
});
// → 서비스 워커가 재시작되면 clickCount가 다시 0이 됨!
// ✅ 이렇게 해야 함
chrome.action.onClicked.addListener(async () => {
const { clickCount = 0 } = await chrome.storage.local.get('clickCount');
const newCount = clickCount + 1;
await chrome.storage.local.set({ clickCount: newCount });
console.log(`클릭 ${newCount}번`);
});
③ Content Script — 웹페이지 속의 스파이
Content Script는 웹페이지 안에 주입되는 자바스크립트예요. manifest에서 “이 URL 패턴과 매칭되는 페이지에 주입하라”고 선언하면 브라우저가 그 페이지를 열 때마다 스크립트를 넣어줘요.
// content.js - 모든 페이지에 주입되는 스크립트
console.log('이 페이지에 content script가 주입됐어요');
console.log('현재 페이지 제목:', document.title);
// DOM 조작 가능
const banner = document.createElement('div');
banner.textContent = '확장프로그램이 이 페이지를 감시 중입니다';
banner.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
background: #667eea;
color: white;
padding: 8px;
text-align: center;
z-index: 999999;
font-family: sans-serif;
`;
document.body.prepend(banner);
Content Script의 독특한 특성들 — 이게 제일 헷갈려요
- 페이지 DOM은 볼 수 있다.
document, 페이지의 HTML 요소에 자유롭게 접근 가능. - 페이지의 JS 전역 변수는 못 본다. 페이지에
window.myApp = {...}이 있어도 content script에서는 접근 불가. 격리된 실행 환경(isolated world)에서 돌기 때문이에요. - Chrome API는 제한적.
chrome.storage,chrome.runtime은 쓸 수 있지만,chrome.tabs같은 건 못 써요. 필요하면 background에 메시지를 보내서 대신 실행하게 해야 해요. - 페이지별로 독립적. 탭 A의 content script와 탭 B의 content script는 서로 모르는 사이예요.
격리 환경 이해하기
페이지에 이런 JS가 있다고 해볼게요.
<!-- example.com의 페이지 -->
<script>
window.myApp = { version: '2.0', user: 'john' };
</script>
// content.js
console.log(window.myApp); // undefined! 격리되어 있음
// ✅ DOM은 공유됨
console.log(document.querySelector('h1').textContent); // 가능
이 격리는 보안을 위한 것이에요. 페이지의 악의적 스크립트가 확장의 권한을 훔칠 수 없도록 막아줘요.
④ Options Page — 설정의 집
Options Page는 사용자가 확장 설정을 바꾸는 전용 페이지예요. 팝업보다 크고 상세한 설정 UI가 필요할 때 써요.
// manifest.json에 추가
{
"options_page": "options.html"
}
<!-- options.html -->
<!DOCTYPE html>
<html>
<body>
<h1>설정</h1>
<label>
<input type="checkbox" id="darkMode"> 다크 모드 활성화
</label>
<label>
알림 주기 (분):
<input type="number" id="interval" value="25">
</label>
<button id="save">저장</button>
<script src="options.js"></script>
</body>
</html>
사용자가 chrome://extensions → 확장 → “세부정보” → “확장 프로그램 옵션”에서 열 수 있어요.
3부. 메시지 패싱 — 환경 간 대화의 기술
이제 네 환경이 서로 어떻게 대화하는지 볼 차례예요. 확장프로그램 아키텍처의 진짜 핵심이에요.

왜 메시지가 필요한가
각 환경이 격리돼 있다고 했죠? 서로의 변수나 함수를 직접 호출할 수 없어요. 대신 chrome.runtime.sendMessage 와 chrome.runtime.onMessage 를 통해 메시지를 주고받아요.
이건 서로 다른 방에 있는 사람들이 쪽지로 대화하는 것과 같아요. 직접 얼굴을 볼 순 없지만, 쪽지 하나면 뭐든 전달할 수 있죠.
패턴 1: Popup → Background
가장 흔한 패턴이에요. 팝업에서 버튼을 누르면 background에게 “이거 해줘”라고 요청해요.
// popup.js - 메시지 보내기
document.getElementById('myButton').addEventListener('click', async () => {
const response = await chrome.runtime.sendMessage({
action: 'getData',
payload: { userId: 123 }
});
console.log('받은 응답:', response);
});
// background.js - 메시지 받기
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'getData') {
// 비동기 작업
fetch(`https://api.example.com/user/${message.payload.userId}`)
.then(r => r.json())
.then(data => sendResponse({ success: true, data }));
return true; // ⚠️ 비동기 응답을 보낼 거면 반드시 true 반환!
}
});
중요한 함정: sendResponse를 비동기적으로 호출할 거면 리스너 함수가 반드시 true를 반환해야 해요. 안 그러면 응답 채널이 닫혀서 응답이 안 와요.
패턴 2: Popup → Content Script (특정 탭)
팝업에서 현재 보고 있는 탭의 content script에게 메시지 보내기예요.
// popup.js
async function sendToContent() {
// 1. 현재 활성 탭 찾기
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// 2. 그 탭의 content script에게 메시지 보내기
const response = await chrome.tabs.sendMessage(tab.id, {
action: 'highlightText',
color: 'yellow'
});
console.log('content script 응답:', response);
}
// content.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'highlightText') {
// 페이지의 모든 텍스트 하이라이트
document.body.style.backgroundColor = message.color;
sendResponse({ done: true });
}
});
여기서 중요한 차이: Popup → Background는 chrome.runtime.sendMessage, Popup → Content Script는 chrome.tabs.sendMessage를 써요. 탭 대상이냐 아니냐에 따라 API가 달라요.
패턴 3: Content Script → Background
Content script가 권한이 제한적이라서, 뭔가 하고 싶으면 background에게 부탁하는 경우가 많아요.
// content.js
// 페이지에서 뭔가 감지했을 때
document.addEventListener('click', async (e) => {
if (e.target.tagName === 'IMG') {
// background에게 이미지 URL 전달
const result = await chrome.runtime.sendMessage({
action: 'saveImage',
url: e.target.src
});
console.log(result);
}
});
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'saveImage') {
chrome.downloads.download({
url: message.url,
filename: 'saved-image.png'
});
sendResponse({ saved: true });
}
});
패턴 4: 지속적 연결 (Port)
단발성 메시지가 아니라 긴 대화를 하고 싶을 때는 Port를 써요.
// popup.js
const port = chrome.runtime.connect({ name: 'chat' });
port.postMessage({ greeting: '안녕' });
port.onMessage.addListener((msg) => {
console.log('받음:', msg);
});
// background.js
chrome.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((msg) => {
console.log('팝업 메시지:', msg);
port.postMessage({ reply: '나도 안녕' });
});
});
대부분의 경우는 단발성 sendMessage로 충분해요. Port는 스트리밍이나 장시간 대화가 필요할 때만 쓰면 돼요.
4부. 공유 저장소로 소통하기
메시지 말고 chrome.storage 를 공유 상태처럼 쓰는 방법도 흔해요. 어떤 환경에서 값을 바꾸면 다른 환경에서 감지할 수 있어요.
모든 환경에서 같은 저장소 접근
// popup.js에서 저장
await chrome.storage.local.set({ theme: 'dark' });
// content.js에서 읽기
const { theme } = await chrome.storage.local.get('theme');
console.log(theme); // 'dark'
// background.js에서도 읽기
const data = await chrome.storage.local.get(null); // null = 전체
console.log(data); // { theme: 'dark' }
변화 감지 — storage.onChanged
저장소가 변하면 모든 환경이 동시에 감지할 수 있어요.
// 어느 환경에서든 가능
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local' && changes.theme) {
console.log(`테마 바뀜: ${changes.theme.oldValue} → ${changes.theme.newValue}`);
// UI 업데이트 등
}
});
이 패턴의 장점은 브로드캐스트가 된다는 거예요. 한 곳에서 값을 바꾸면, 살아있는 모든 환경(여러 탭의 content script 포함)이 알아챌 수 있어요.
5부. 전체 구조 한눈에 보기
지금까지 배운 걸 하나의 그림으로 정리할게요.

데이터 흐름을 요약하면:
- manifest.json이 전체 설계도. 어떤 파일이 어디서 실행될지 선언.
- Popup은 사용자의 직접 입력을 받는 창구.
- Background Service Worker는 중앙 로직. Chrome API와 통신.
- Content Script는 웹페이지 안의 대리인.
- Storage는 모두가 공유하는 저장 공간.
- 환경끼리는 메시지로 통신, 또는 Storage 공유로 상태 동기화.
실전 체크리스트
새 확장을 기획할 때 이 순서로 생각해보세요.
1. 뭘 하는 확장인가? 한 문장으로 설명. 2. 어떤 환경이 필요한가?
- 사용자 UI 필요 → Popup
- 웹페이지 건드려야 함 → Content Script
- 이벤트 처리·API 호출·중앙 로직 → Background
- 복잡한 설정 화면 → Options Page
3. 어떤 권한이 필요한가? 최소한으로.
4. 환경끼리 어떻게 소통하나? 메시지? 스토리지 공유?
5. 상태는 어디에 저장하나? 휘발돼도 되는 건 변수, 영속 필요하면 storage.
마무리하며
오늘 다룬 내용을 한 줄로 요약할게요.
확장프로그램은 manifest.json이라는 설계도로 시작한다. 그 안에서 Popup, Background Service Worker, Content Script, Options Page라는 네 환경이 각자의 수명과 권한을 가지고 살아간다. 이들은 격리되어 있으며, 메시지나 공유 저장소를 통해 소통한다.
이 구조만 머릿속에 있으면 확장프로그램 공식 문서를 봤을 때 더 이상 헷갈리지 않아요. “아, 이 API는 어느 환경에서 쓰는 거구나”가 바로 보이거든요.
다음 편에서는 드디어 직접 손으로 만들어봐요. “웹페이지 분석기 + 다크 모드 토글” 확장을 처음부터 끝까지 만들면서, 오늘 배운 네 환경과 메시지 패싱이 실제 코드에서 어떻게 쓰이는지 체감하게 될 거예요. 30분이면 돼요.
그럼 다음 편에서 만나요. 👋