확장프로그램의 해부학 — 4가지 구성 요소와 메시지 통신




브라우저 확장프로그램 시리즈 (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.sendMessagechrome.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부. 전체 구조 한눈에 보기

지금까지 배운 걸 하나의 그림으로 정리할게요.

데이터 흐름을 요약하면:

  1. manifest.json이 전체 설계도. 어떤 파일이 어디서 실행될지 선언.
  2. Popup은 사용자의 직접 입력을 받는 창구.
  3. Background Service Worker는 중앙 로직. Chrome API와 통신.
  4. Content Script는 웹페이지 안의 대리인.
  5. Storage는 모두가 공유하는 저장 공간.
  6. 환경끼리는 메시지로 통신, 또는 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분이면 돼요.

그럼 다음 편에서 만나요. 👋




댓글 남기기