Chrome 확장 프로그램 가이드 — Manifest V3·서비스 워커·메시징·콘텐츠 스크립트·프로덕션 패턴

이 글의 핵심

이 글은 Chrome 확장 프로그램을 “API 나열”이 아니라 실행 모델과 격리 경계를 기준으로 설명합니다. Manifest V3의 구성 요소, 서비스 워커 수명, 메시지 전달 경로, 콘텐츠 스크립트의 격리, 스토어·운영 관점의 프로덕션 패턴을 한 흐름으로 연결합니다.

이 글의 범위

Chrome 확장 프로그램은 단일 웹앱이 아니라 여러 실행 컨텍스트(서비스 워커, 확장 페이지, 콘텐츠 스크립트, 필요 시 오프스크린 문서)가 chrome.* API와 메시징으로 묶인 분산 시스템에 가깝습니다. Manifest V3(MV3) 는 보안·성능·스토어 정책을 이 모델에 맞추기 위해 백그라운드를 지속 페이지에서 서비스 워커로 바꾸고, 네트워크 조작·원격 코드 등 위험한 패턴을 제한했습니다.

이 문서는 입문용 체크리스트를 넘어 내부 경계(권한, 격리 월드, 메시지 라우팅)와 수명(서비스 워커 종료, 탭별 콘텐츠 스크립트)을 기준으로 설계 판단을 내릴 수 있게 정리합니다. 프로덕션에서는 이 이론이 곧 장애 대응(메시지 유실, 상태 불일치, 스토어 거절 사유)과 직결됩니다.


Manifest V3 아키텍처 심화

MV3의 핵심은 “권한·엔트리·네트워크·자산 노출”을 선언적으로 고정하며, 백그라운드를 이벤트 기반 서비스 워커로 통일하는 것입니다.

구성 요소와 책임

구성 요소실행 환경대표 역할
서비스 워커 (background.service_worker)Worker 글로벌, DOM 없음이벤트 구독, 탭·스토리지·알람, 메시지 허브
확장 페이지 (팝업, 옵션, 전체 탭 페이지, 사이드 패널)확장 origin의 문서, DOM 있음UI, 사용자 설정, 단기 상태 표시
콘텐츠 스크립트웹 페이지에 주입, 격리 월드DOM 읽기/조작, 페이지와의 브리지
오프스크린 문서 (Chrome 109+, offscreen 권한)숨은 페이지 컨텍스트, chrome.* 중 runtime만 사용 가능DOM 파싱, 오디오 재생, 클립보드처럼 DOM이 필요한 백그라운드 작업

manifest.json 은 이들의 스크립트 경로, permissions / host_permissions, content_scripts 매치, web_accessible_resources, declarative_net_request 규칙 등을 한데 묶는 계약서입니다. MV3에서는 host_permissions와 permissions를 분리해 “어떤 API”와 “어떤 호스트”를 쓰는지 심사·사용자 이해에 맞게 드러냅니다.

MV2와 달라진 결정적 제약

  • 백그라운드: 영구 백그라운드 페이지가 사라지고 단일 서비스 워커가 이벤트를 받습니다. “항상 켜진 데몬”처럼 무한 루프를 돌리는 설계는 플랫폼과 맞지 않습니다.
  • 원격 코드: 원격에서 내려받은 JS를 실행하는 것(remotely hosted code)은 MV3에서 금지됩니다. eval과 외부 <script src>는 확장의 CSP에서 막히고, 스토어 심사에서도 거절 사유가 됩니다. 모든 로직은 번들에 포함하고, 원격으로는 설정·실험 플래그 같은 데이터만 받습니다.
  • 네트워크 차단/수정: 요청을 가로채 동기적으로 막거나 바꾸던 webRequestBlocking은 MV3에서 정책으로 설치된(enterprise) 확장에만 허용되고, 일반 확장은 declarativeNetRequest(DNR)의 선언적 규칙을 씁니다. 관찰용 webRequest 이벤트는 여전히 쓸 수 있지만 요청을 막지는 못합니다.

action과 UI 엔트리

툴바 아이콘은 MV3에서 MV2의 browser_action과 page_action을 합친 action 키 하나로 통일되었습니다. default_popup 이 있으면 클릭 시 팝업 문서가 뜨고, 없으면 클릭 이벤트만 서비스 워커에서 처리할 수 있습니다. 사이드 패널 등 별도 엔트리는 매니페스트와 권한 조합이 다르므로, “어디에 상태를 둘지” 를 먼저 정한 뒤 엔트리를 고르는 것이 좋습니다.


서비스 워커 수명 주기

MV3 백그라운드는 브라우저가 유휴 상태로 판단하면 종료할 수 있는 서비스 워커입니다. 웹의 서비스 워커와 유사하게 이벤트가 올 때 깨어나고, 작업이 끝나면 다시 잠들 수 있습니다. Chrome 문서 기준으로 이벤트나 확장 API 호출 없이 30초가 지나면 종료되며, 이벤트 하나의 처리가 5분을 넘겨도 종료될 수 있습니다.

시작·이벤트·종료

  1. 등록: 확장이 로드되면 서비스 워커 스크립트가 등록되고, 최초 실행 시 최상위 코드가 한 번 평가됩니다.
  2. 리스너 등록: chrome.runtime.onInstalled, onMessage, alarms.onAlarm, tabs.onUpdated 등 리스너는 최상위에서 동기적으로 연결하는 것이 안전합니다. 비동기 async 최상위에서만 리스너를 달면, 초기 깨어남 타이밍에 따라 이벤트를 놓칠 수 있습니다.
  3. 유휴 종료: 오래 실행을 붙잡아 두지 않으면 브라우저가 워커를 종료합니다. 메모리상 전역 변수는 다음 깨어남에서 초기화된 상태일 수 있습니다.
  4. 상태 영속: 세션 간 유지가 필요한 값은 chrome.storage(로컬/동기/세션 스코프)나 서버 동기화로 옮깁니다. chrome.storage.session은 메모리에만 있어 브라우저를 재시작하면 사라지고, 대용량 캐시는 서비스 워커에서도 직접 쓸 수 있는 IndexedDB가 적합합니다. localStorage는 서비스 워커에 없으므로 쓸 수 없습니다.

주기 작업과 장시간 작업

  • chrome.alarms: setTimeout/setInterval은 워커가 종료되면 함께 사라지므로, 주기 작업은 알람 API로 깨우는 패턴이 일반적입니다. Chrome 120부터 최소 주기는 30초입니다.
  • 비동기 응답: onMessage에서 sendResponse를 비동기로 쓸 경우 문서에 명시된 패턴(예: return true로 비동기 응답 유지)을 지키지 않으면 호출 측에서 응답이 끊긴 것처럼 보입니다.
  • 오프스크린: DOM이 꼭 필요한 작업은 오프스크린 문서로 옮기고, 서비스 워커는 조율만 하는 식으로 역할을 쪼갭니다.

실무에서는 “워커가 살아 있다”는 가정을 하지 않는 것이 첫 번째 원칙입니다. 깨어날 때마다 storage에서 복원 → 리스너는 항상 동일하게 등록하는 구조가 예측 가능합니다.


메시지 전달 메커니즘

확장의 각 부분은 서로 다른 자바스크립트 컨텍스트에 있으므로, 명시적 메시지로만 안전하게 통신합니다.

일회성 메시지

  • chrome.runtime.sendMessage: 콘텐츠 스크립트나 확장 페이지에서 확장 쪽 컨텍스트(서비스 워커, 열려 있는 팝업·옵션 페이지 등)로 보냅니다. 수신은 chrome.runtime.onMessage이며, 콘텐츠 스크립트는 이 경로로는 메시지를 받지 않습니다.
  • chrome.tabs.sendMessage: 특정 탭의 콘텐츠 스크립트로 보냅니다. 별도 권한은 필요 없지만 해당 탭에 콘텐츠 스크립트가 이미 주입되어 있어야 합니다.

페이로드는 JSON으로 직렬화 가능한 값이어야 합니다. 함수와 DOM 노드는 전달되지 않고, Map·Set·Date 같은 객체도 그대로 넘어가지 않으므로 평범한 객체나 문자열로 바꿔 보냅니다. 버전 필드를 넣어 메시지 스키마를 진화시키면 롤백·호환 처리가 쉬워집니다.

포트 기반 연결

chrome.runtime.connect / tabs.connect 는 장수명 채널을 엽니다. 팝업 ↔ 백그라운드처럼 여러 이벤트를 주고받는 UI에 적합합니다. 포트가 끊기면 onDisconnect 로 정리하며, 재연결 전략을 두는 것이 좋습니다.

라우팅 패턴

규모가 커지면 메시지 타입 문자열 + 페이로드 검증(런타임 스키마)을 두어 한 곳에서 분기합니다. 그렇지 않으면 onMessage 핸들러가 비대해지고, 권한이 다른 코드 경로가 섞여 보안 검토가 어려워집니다.

코드로 보는 핵심: 비동기 응답과 포트

서비스 워커에서 onMessage로 비동기 작업 끝에 sendResponse 를 호출하려면, Chrome 문서가 요구하는 대로 return true 로 “응답이 아직이다”를 알려야 합니다. 그렇지 않으면 채널이 먼저 닫혀 호출부 runtime.sendMessage의 콜백/Promise가 빈 결과를 받을 수 있습니다.

// background.js (service worker)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type !== 'FETCH_AND_RETURN') {
    return; // 동기 응답 없음
  }
  (async () => {
    try {
      const data = await chrome.storage.local.get('key');
      sendResponse({ ok: true, data });
    } catch (e) {
      sendResponse({ ok: false, error: String(e) });
    }
  })();
  return true; // 비동기 sendResponse 허용
});

장수명 UI(팝업·옵션)와 백그라운드가 스트림처럼 이야기해야 할 때는 포트가 적합합니다. 한쪽이 사라지면 onDisconnect가 호출되므로, 탭이 닫히거나 확장이 리로드된 뒤에도 좀비 리스너가 남지 않게 정리합니다.

// popup.js — 백그라운드와 포트 연결
const port = chrome.runtime.connect({ name: 'ui-sync' });
port.postMessage({ type: 'SUBSCRIBE' });
port.onMessage.addListener((msg) => {
  /* UI 갱신 */
});
port.onDisconnect.addListener(() => {
  /* 재연결 또는 안내 */
});

콘텐츠 스크립트로 보낼 때는 탭 ID가 필요합니다. 백그라운드는 sender.tab?.id로 응답 경로를 알 수 있으며, UI에서 특정 탭으로 보낼 때는 chrome.tabs.sendMessage(tabId, payload)를 사용합니다. 주입 여부(해당 URL에 content_scripts가 매치되는지, 또는 scripting.executeScript로 이미 주입했는지)를 전제로 해야 하며, 그렇지 않으면 수신자 없음 오류가 납니다.


콘텐츠 스크립트 격리

콘텐츠 스크립트는 웹 페이지와 같은 DOM 트리를 보되, 자바스크립트 환경은 분리됩니다. 이를 격리된 월드(isolated world) 라고 부릅니다.

격리 월드가 의미하는 것

  • 페이지가 정의한 window.MyApp 같은 전역과 콘텐츠 스크립트의 전역은 다릅니다. 따라서 라이브러리가 페이지에 로드되었다고 해서 콘텐츠 스크립트에서 같은 심볼을 볼 수 없습니다.
  • DOM 조작은 가능합니다. 스크립트가 만든 노드를 읽으며, 속성·이벤트를 다룰 수 있습니다. 다만 페이지 스크립트가 기대하는 이벤트 순서와 충돌하지 않도록 설계해야 합니다.

메인 월드 주입이 필요한 경우

페이지 객체와 직접 같은 JS 힙을 공유해야 할 때는 chrome.scripting.executeScript에 world: "MAIN"을 주거나, 매니페스트의 content_scripts 항목에 "world": "MAIN"(Chrome 111+)을 지정해 메인 월드에 주입합니다. 메인 월드에서는 chrome.* 확장 API를 쓸 수 없으므로, 확장 쪽 기능이 필요하면 격리 월드 스크립트와 window.postMessage로 연결합니다. 이 경로는 페이지 권한으로 실행되는 코드에 가깝기 때문에 신뢰 경계를 엄격히 두며, 최소한의 브리지만 두는 것이 좋습니다.

run_at과 타이밍

document_start, document_end, document_idle 은 주입 시점을 바꿉니다. 광고 차단·스타일 조기 삽입 등은 document_start 가 필요할 수 있으며, DOM이 어느 정도 준비된 뒤가 안전한 로직은 document_idle 이 흔합니다. 레이스 컨디션(SPA 전환, 동적 로딩)은 MutationObserver 와 메시징으로 보완하는 경우가 많습니다.

스타일 충돌

콘텐츠 스크립트가 삽입한 스타일은 페이지 CSS와 전역적으로 경쟁합니다. Shadow DOM, 고유한 클래스 접두사, CSS 변수 스코프 등으로 누수와 깨짐을 줄입니다.


프로덕션 확장 패턴

스토어 배포와 장기 운영을 전제로 한 패턴을 정리합니다.

권한 최소화와 단계적 요청

초기 설치 시 optional_permissions 또는 필요 시점에만 host_permissions 를 요청하면 거부율과 리뷰 리스크를 줄일 수 있습니다. 권한 설명 문자열(스토어 제출용)과 인앱 설명을 일치시키는 것이 중요합니다.

오류·로깅·관측

  • 서비스 워커에서 잡히지 않은 예외는 조용히 실패할 수 있습니다. 중앙 try/catch, 구조화 로그, 필요 시 외부 로깅(개인정보·URL 마스킹)을 고려합니다.
  • 콘텐츠 스크립트는 사이트마다 예외가 다르므로 도메인별 가드와 기능 플래그로 폭을 제한합니다.

성능

  • 대용량 DOM 순회·무거운 리스너는 메인 스레드와 페이지 성능에 악영향을 줍니다. 디바운스·스로틀, IntersectionObserver 등 웹 성능 기법을 그대로 적용합니다.
  • DNR 규칙 수·콘텐츠 스크립트 주입 범위가 넓을수록 브라우저 부담이 커집니다. 매치 패턴을 좁히고, 불필요한 탭에서는 주입하지 않습니다.

보안

  • 메시지 페이로드 검증: 신뢰할 수 없는 출처를 가정하고 타입·길이·필드를 검사합니다.
  • XSS: 콘텐츠 스크립트가 innerHTML에 사용자 입력을 넣으면 확장 컨텍스트에서도 위험합니다. 텍스트 노드·이스케이프·DOMPurify 등 웹과 동일한 위생을 유지합니다.
  • 업데이트: 확장은 자동 업데이트되므로 마이그레이션(onInstalled의 reason)으로 스토리지 스키마를 버전업합니다.

팀 개발

  • TypeScript로 메시지·스토리지 스키마를 공유하며, 빌드(Vite, WXT, Plasmo 등)로 매니페스트와 번들을 일치시킵니다.
  • E2E는 실제 브라우저 드라이버로 “확장 로드 → 시나리오”를 검증하는 편이 신뢰도가 높습니다.

참고


같이 보면 좋은 글