🍀Gyuri's Devlog
FE브라우저 익스텐션

브라우저 확장 프로그램의 구성요소

file 이미지

브라우저는 여러 구성 요소로 이뤄져 있습니다. 또한 같은 스크립트여도 실행 환경이 조금씩 다른데요. 같은 브라우저에서 실행되긴 하지만, 사용할 수 있는 api와 실행 컨텍스트가 다를 수 있습니다. 따라서 이벤트 기반으로 메세지를 주고받거나, 공유 storage를 통한 데이터 공유가 필요할 수 있습니다.

file 이미지
  • Content script: 사용자가 보는 탭의 DOM에 직접 접근할 수 있는 환경에서 실행되는 코드
  • Background(service worker): 브라우저가 확장 프로그램에게만 열어주는 특권 API인 chrome.* API 전체를 사용할 수 있는 환경에서 실행되는 코드 (단, DOM 접근은 불가)
  • Popup: 툴바 아이콘을 클릭하면 뜨는 작은 창
  • options 페이지: 툴바의 확장 아이콘 우클릭 → "옵션”을 클릭하면 나오는 페이지

이제 각 구성요소에 대해 자세히 알아봅시다.

Content script

유일하게 웹 페이지 에 들어가는 코드로, 사용자가 보는 탭의 DOM에 직접 접근할 수 있어서, 웹 페이지에 DOM을 변경하거나, 추가하는 작업을 할 수 있습니다.

다만 페이지 안에 있어도, 웹 페이지의 JS와는 다른 컨텍스트에서 실행됩니다. 처음에 content script는 페이지 내부에 <script> 태그로 주입되는 것으로 알고 있었지만, 이는 사실이 아닙니다.

웹 페이지의 js를 메인 월드라 한다면, content script는 이와 별도로 분리된 격리된 세계에서 실행됩니다.
file 이미지

DOM 트리는 JS가 직접 가지고 있는 객체라기 보단, 브라우저 엔진(Blink)이 C++ 레벨에서 들고 있는 데이터 구조입니다. 따라서 JS가 가지고 있는 document 객체나 요소 객체들은 브라우저 엔진이 들고 있는 데이터를 가리키는 래퍼(참조)입니다.

또한 V8(js 엔진)은 하나의 문서에 대해 JS 실행 컨텍스트를 여러 개 만들 수 있습니다. 컨텍스트마다 자기만의 전역 객체, 자기만의 변수 스코프, 심지어 자기만의 Array.prototype 같은 내장 객체 세트를 갖습니다.

크롬이 content script를 실행할 때, 같은 문서에 대해 두 번째 컨텍스트(isolated world)를 만들고, 거기서 확장 코드를 실행시킵니다. 두 컨텍스트 모두 동일한 밑단의 DOM 트리를 가리키는 래퍼를 받기 때문에 한쪽에서 document.body를 바꾸면 같은 DOM 트리를 가리키는 다른 컨텍스트에서도 보입니다.

단, 위의 경우 document는 공유하지만, 전역 객체 등 변수는 공유하지 않기 때문에, window는 공유하지 않습니다.

직접 확인해볼 수 있는 실험이 있습니다. 아래 코드를 DevTools 콘솔에서 각각 실행 시켜보세요. DevTools 콘솔의 컨텍스트 드롭다운에 페이지와 확장이 따로 뜬다는 것 자체가 두 세계가 실재한다는 증거이기도 합니다.

devtools 콘솔에서 이렇게 extension 마다 격리된 환경의 콘솔을 볼 수 있다.
devtools 콘솔에서 이렇게 extension 마다 격리된 환경의 콘솔을 볼 수 있다.
// 페이지 콘솔에서 (DevTools 콘솔 상단 드롭다운이 'top'일 때)
window.secret = 123;
document.body.dataset.marker = 'hello';

// content script에서 (드롭다운을 확장 이름으로 바꾸면 격리된 세계 콘솔)
console.log(window.secret);              // undefined — 전역 객체가 다름
console.log(document.body.dataset.marker); // 'hello' — DOM은 공유

정리하자면, 일반 페이지의 js 실행 컨텍스트와 확장 프로그램의 실행 컨텍스트에서 모두 공유되는 것은 실제 브라우저 엔진 기반의 DOM이고, 객체 등 변수는 공유되지 않습니다. 이렇기 때문에 주의해해야하는 사례가 있습니다.

element.setAttribute('data-x', '1') // 진짜 DOM의 속성을 바꾸는 것이라 변경이 공유된다.

// 이건 DOM의 js 래퍼(참조)인 js 객체의 값을 바꾸는 것이기 때문에
// 현재 실행 컨텍스트에만 반영된다. 
element.myCustomProp = 1 

위와 같이, 실제 DOM 자체가 바뀌는 것인지, 아니면 JS 객체가 바뀌는 것인지에 따라, 각 스크립트가 보는 DOM 요소의 변경사항을 볼 수 있는지 아닌지가 달라집니다.

content script의 world

content script은 일반적으로 위와 같이 웹 페이지의 js와 격리된 환경에서 실행되지만, 웹 페이지와 동일한 환경에서 실행되도록 실행할 수도 있긴 합니다. 이렇게 실행 환경(world)이 두 가지로 나누는데, 격리된 환경을 뜻하는 ISOLATED world와 웹 페이지와 동일한 환경을 의미하는 MAIN world가 있습니다.

  • ISOLATED world: content script의 기본 실행 환경. 페이지와 같은 DOM을 보지만, JavaScript 환경은 분리되어 있습니다. 페이지의 전역 변수나 페이지가 로드한 라이브러리에 접근할 수 없고, 반대로 페이지도 content script의 변수를 볼 수 없어요. 악의적인 페이지가 확장 코드를 건드리지 못하게 하는 보안 장치입니다. chrome.scripting.executeScript({ world: 'MAIN', ... }) 외에 chrome.scripting.registerContentScripts()에도 world를 줄 수 있습니다.
// background에서 주입할 때 world 선택
await browser.scripting.executeScript({
  target: { tabId },
  world: 'MAIN',   // 기본값은 'ISOLATED'
  func: () => {
    // 페이지의 전역에 직접 접근 가능
    console.log(window.__NEXT_DATA__); // 예: Next.js 페이지의 초기 데이터
  },
});
  • MAIN world : 페이지 자신의 JavaScript 환경. chrome.scripting.executeScriptworld: 'MAIN'을 줘 동적으로 'MAIN'으로 주입하거나, manifest.json에 특정 스크립트의 world를 설정하여 페이지의 js와 같은 실행 환경에서 실행하도록 할 수 있습니다.
    페이지의
    window 객체와 전역 변수를 직접 만질 수 있지만, 대신 확장 API(chrome.runtime 등)는 못 씁니다.

manifest.json에서는 content_scripts 항목에 world 필드를 추가하면 됩니다 (Chrome 111+):

{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["main-world-script.js"],
      "world": "MAIN",
      "run_at": "document_start"
    },
    {
      "matches": ["<all_urls>"],
      "js": ["isolated-script.js"],
      "world": "ISOLATED"
    }
  ]
}
  • world"ISOLATED"(기본값) 또는 "MAIN" 두 가지입니다.
  • world는 content_scripts 배열의 항목 단위로 지정되기 때문에, 위처럼 MAIN용과 ISOLATED용을 별도 항목으로 나눠 선언합니다. 같은 항목 안의 js 파일들은 전부 같은 world에서 실행돼요.

Background (service worker)

content script와 달리 DOM(document 없음)이 아예 없고 window 전역 객체도 존재하지 않는 별도의 환경에서 실행되는 코드입니다. Background 스크립트는 브라우저가 확장 프로그램에게만 열어주는 특권 API인 chrome.* API 전체를 쓸 수 있어서 API 호출, 탭 관리, 컨텍스트 메뉴, 이벤트 리스닝 같은 일을 담당합니다.

content script와의 차이점

content script탭마다(정확히는 프레임마다) 새 사본이 실행됩니다. 탭 10개를 열면 여러분의 content.js가 10번 따로 로드되고, 각 사본은 자기만의 격리된 세계에서 돌기때문에, 서로의 존재를 알 수 없습니다.

반면 background는 그 10개 사본이 전부 바라보는 단 하나의 수신처입니다. 여러 탭의 메시지가 한 워커의 onMessage로 모이고, 어느 탭에서 왔는지는 리스너의 두 번째 인자 sender(안에 sender.tab.id 등)로 구분합니다.

file 이미지

이 구조가 background를 자연스러운 중앙 코디네이터로 만듭니다. 따라서, 여러 탭이 같은 데이터를 캐싱해서 사용할 때, API 호출 rate limit 관리 등을 할 때 사용할 수 있습니다.

단, 다른 확장이나 크롬 프로필이 다르면 background가 새롭게 만들어집니다. 각자 하나씩 background를 가지게 됩니다. 시크릿 모드인 경우는 특별한데, 일반적으로 시크릿 창에서는 확장 프로그램이 동작하지 않습니다.

이 경우는 사용자가 확장 관리 페이지에서 명시적으로 허용해야하며, 허용된 경우 기본 동작은 spanning 모드입니다. 이 모드에서는 같은 워커(background) 하나가 일반 창과 시크릿 창을 모두 담당하게 됩니다. manifest"incognito": "split"을 선언하면 시크릿용 인스턴스가 따로 뜨는 모드도 있지만, 특별한 이유가 없으면 기본값으로 두면 됩니다.

그럼 background는 도대체 어떤 origin을 가질까?

한 사이트에 하나씩 실행되는 스크립트가 아니고, 위와 같이 하나의 브라우저 프로필 당 하나씩 생긴다면, 정확히 어떤 범위 안에서 실행되는 것 일까요?

background는 웹 origin에 속하지 않지만, origin을 가지고 있지 않는 다는 말은 아닙니다. 확장은 설치되는 순간 chrome-extension://<확장ID>라는 자기만의 오리진을 부여받고, background는 그 오리진에서 돕니다.

origin이 chrome-extension://<확장ID>라면, 만약 확장 프로그램의 구성요소 어디선가 api 요청을 하면 CORS 에러가 나지는 않을까요? 정답은 api url을 host_permissions에 등록한다면 CORS 에러가 나지 않습니다.

브라우저가 CORS 검사를 하는 방식과 확장 프로그램이 이를 무효화하는 방식

브라우저는 어떻게 CORS 검사를 하는 것일까요? CORS 에러가 나면, api 요청은 일어나지 않는 것일까요? CORS가 애초에 왜 있는지를 보면 설계가 이해됩니다. CORS(정확히는 그 기반인 same-origin policy)가 막으려는 위협은 사용자가 그냥 방문한 악성 웹페이지가, 사용자의 쿠키를 업고 은행 API를 몰래 읽는 것이에요. 웹페이지는 링크 한 번에 아무 코드나 실행되는 환경이라, 기본 불신 + 서버의 명시적 허락(Access-Control-Allow-Origin)이라는 모델이 필요했죠.

cross-origin fetch가 막힐 때 실제로 벌어지는 일을 보면, 서버는 대부분 요청을 받고 응답까지 보냅니다. 그 응답을 받아든 브라우저가 응답 헤더인 Access-Control-Allow-Origin 헤더를 확인합니다. 여기서 Access-Control-Allow-Origin 응답 헤더는 서버의 명시적 허락을 의미합니다. 없으면 JS에게 응답을 안 전달해주는 것입니다. 즉 차단의 주체는 서버도 네트워크도 아닌 브라우저 자신입니다.

확장 프로그램에서는 host_permissions를 통해 일부 url에 대해서 이런 검사를 무효화해줍니다. manifest에 https://api.mytranslator.com/*와 같이 api url를 선언해주면, 브라우저는 그 확장의 컨텍스트에서 그 호스트로 나가는 요청에 대해 "이 코드는 사용자가 이 호스트 접근을 허락한 코드"라고 판단하고 CORS 검사를 적용하지 않습니다.

즉, host_permissions로 선언한 호스트에 한해 브라우저가 CORS 검사를 면제해주는 것입니다.

브라우저 확장 프로그램에서 api 요청을 할 수 있는 구성요소는 많은데, 만약 content script에서 fetch를 하게 된다면 어떻게 될까요? content script는 확장 코드지만 페이지 안에서 돌기 때문에, 최신 크롬에서는 fetch가 페이지의 오리진 기준으로 CORS 검사를 받습니다. 격리된 세계라도 네트워크 관점에서는 그 웹사이트의 일부로 취급됩니다. 그래서 뉴스 사이트에 주입된 content script가 번역 API를 직접 부르면, 번역 API 서버가 그 뉴스 사이트 오리진을 허락하지 않는 한 CORS 에러가 발생합니다. 따라서 API 요청이 필요하다면, 반드시 background로 모아서 요청을 하고 응답을 받아야 합니다.

이렇게 읽으면 host_permissions이 의미하는게 무엇인지에 대해 하나 헷갈리는 부분이 생길 수 있습니다. 그 이유는 host_permission이 두가지 용도로 사용되기 때문입니다.

  • [용도 1] 네트워크 목적지에 대한 CORS 면제: background(확장 오리진)에서 나가는 fetch의 목적지가 선언에 매칭되면 CORS 검사를 면제해줍니다. 위 상황은 1번 용도로 사용되는 것이고, 사용자가 어떤 탭에 확장 프로그램을 사용할 수 있는 허용하는 용도는 아닙니다.
  • [용도 2] 방문 사이트에 대한 접근권: content script를 어떤 사이트에 주입할 수 있는지, chrome.scripting으로 어느 탭에 코드를 넣을 수 있는지, tabs에서 어느 탭의 url을 읽을 수 있는지 등을 허용하는 용도

생명 주기 (MV3 기준)

MV3의 background는 서비스 워커이고 항상 켜져 있는 서버가 아니라 필요할 때만 깨어나는 구조로 되어 있습니다.

file 이미지
  1. 설치 시점에 chrome.runtime.onInstalled가 한 번 실행되고(초기 설정을 여기서 함), 그 후로는 철저히 이벤트 기반으로 움직입니다.
  2. onMessage로 메시지가 오거나, chrome.alarms가 울리거나, 탭 이벤트가 발생하면 브라우저가 워커를 메모리에 올려서 실행해요.
  3. 그리고 약 30초간 처리할 이벤트가 없으면 브라우저가 워커를 그냥 죽입니다. 새 이벤트나 메시지를 받으면 유휴 타이머가 리셋되고, 열려 있는 포트(runtime.connect)로 메시지가 오가는 동안에도 연장됩니다.

종료되면 전역 변수, 클로저, setTimeout 전부 사라지고, 다음 기동 때는 스크립트가 처음부터 다시 평가되어 실행됩니다.

Background 환경의 제약사항

DOM이 없습니다. window, document가 없으니 localStorage도 못 씁니다. DOM 파싱(DOMParser)이나 오디오 재생, 클립보드 같은 작업이 필요하면 Offscreen Document API로 보이지 않는 문서를 하나 띄워서 거기서 처리합니다.

setTimeoutsetInterval을 믿으면 안 됩니다. 워커가 죽으면 타이머도 같이 죽기 때문에, 30초를 넘길 수 있는 예약 작업은 전부 chrome.alarms로 대체해야 합니다. 알람은 워커가 죽어 있어도 시간이 되면 워커를 깨워서 실행됩니다.

상태는 chrome.storage에 저장합니다. 재시작되어도 기억해야하는 데이터는 chrome.storage.local에, 브라우저를 끄면 사라져도 되는 임시 상태(진행 중인 요청 추적 같은 것)는 chrome.storage.session이 적절합니다. 메모리 기반이라 빠르고, 워커가 재시작이 되어도 유지되는 공간입니다.

이벤트 리스너는 반드시 최상위 스코프에서, 동기적으로 등록해야 합니다. 이게 제일 많이 실수하는 부분인데요. 워커가 이벤트 때문에 깨어나면 브라우저는 스크립트를 평가한 뒤 동기적으로 이벤트 핸들러가 등록되지 않으면, 그 사이에 도착한 이벤트들이 제대로 처리되지 않을 수 있습니다.

// ❌ 워커가 재기동될 때 이 리스너는 제때 등록되지 못해서 이벤트를 놓침
chrome.storage.local.get('config').then((config) => {
  chrome.runtime.onMessage.addListener(handler);
});

// ✅ 리스너는 동기적으로 등록하고, 필요한 데이터는 핸들러 안에서 로드
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  chrome.storage.local.get('config').then((config) => {
    // 처리
    sendResponse(result);
  });
  return true;
});

마지막으로 디버깅 시 주의할 사항이 있습니다. chrome://extensions에서 서비스 워커의 DevTools를 열어두면 Background 워커가 종료되지 않아요. 그래서 개발 중엔 잘 동작하는 것처럼 보이다가 배포하니까 워커가 죽어서 상태가 날아가는 버그가 나타날 수 있습니다. 따라서Background의 종료, 재실행 시나리오는 DevTools를 닫고 테스트해 보는 게 좋습니다.

Popup

툴바 아이콘을 클릭하면 뜨는 작은 창인데, 정체는 그냥 독립된 HTML 페이지입니다. 자기만의 DOM과 JS 컨텍스트를 갖고, chrome.* API도 대부분 쓸 수 있어요. 대신 닫히는 순간 그 컨텍스트가 없어지고, 다시 열면 완전히 새로 시작합니다.

위와 같이 확장 프로그램을 클릭했을 때 나오는 페이지를 말한다.
위와 같이 확장 프로그램을 클릭했을 때 나오는 페이지를 말한다.

options 페이지

file 이미지

일반적으로, 사용자가 옵션 버튼을 클릭하면 열리는 페이지 입니다. 더 구체적으로는 아래 상황에서 모두 옵션 페이지가 열립니다.

  1. 툴바의 확장 아이콘 우클릭 → "옵션"
  2. chrome://extensions에서 확장의 "세부정보" → "확장 프로그램 옵션"
  3. 코드에서 chrome.runtime.openOptionsPage() 호출 — 보통 popup에 "설정" 버튼을 두고 이걸 부릅니다

확장 오리진(chrome-extension://<id>)에서 열리는 평범한 HTML 페이지고, 자기만의 JS 컨텍스트를 가지며, chrome.* API를 거의 다 쓸 수 있습니다. Popup과 같이 탭이 열려 있는 동안만 살아 있고 닫으면 컨텍스트가 소멸됩니다. popup과의 차이는 형태와 용도뿐이에요. options는 전체 페이지로 열려, 설정 페이지로 알맞습니다.

manifest.json

manifest는 브라우저가 확장을 설치/로드할 때 이 파일을 읽고 "이 확장은 무엇으로 구성되고, 어디서 돌고, 무슨 권한이 필요한가"를 파악할 수 있는 파일입니다. 아래는 manifest.json의 예시 입니다.

{
  "manifest_version": 3,
  "name": "AI Translator",
  "version": "0.1.0",
  "description": "페이지를 번역하는 확장 프로그램",
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" },

  "background": { "service_worker": "background.js" },

  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "run_at": "document_idle"
  }],

  "action": { "default_popup": "popup.html" },
  "options_ui": { "page": "options.html", "open_in_tab": true },

  "permissions": ["storage", "activeTab", "contextMenus"],
  "host_permissions": ["https://api.mytranslator.com/*"]
}

backgroundcontent_scriptsaction(popup)이 위에서 말한 구성요소의 스크립트를 등록하는 구간입니다. content_scripts에는 몇가지 옵션을 사용할 수 있는데, 좀 더 자세히 알아봅시다.

 "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "run_at": "document_idle",
    "world": "ISOLATED" // 기본값
  }],
  • matches : content scripts를 어떤 URL에 주입할지(모든 사이트에 넣을지, 특정 도메인만인지)
  • run_at : content scripts의 주입 시점. 가능한 값은 아래 세가지이다.
      • document_start : CSS는 로드됐지만 DOM 구성이 시작되기 전. 즉 페이지의 어떤 스크립트도 아직 실행되지 않은 시점이에요.
      • document_end : DOM 파싱이 끝난 직후(DOMContentLoaded 무렵). 이미지 같은 서브리소스는 아직 로딩 중일 수 있어요.
      • document_idle : 기본값. document_endwindow.onload 사이 어딘가에서 브라우저가 알아서 적절한 타이밍에 실행해줍니다.
  • world: content_scripts가 실행될 환경 (격리된 환경 혹은 웹 페이지 js와 같은 환경)
  "permissions": ["storage", "activeTab", "contextMenus"],
  "host_permissions": ["https://api.mytranslator.com/*"]
  • permissions : 필요한 브라우저 네임스페이스 API에 대한 권한을 줄 수 있습니다. 아래 세가지 말고도 다양한 내용을 추가할 수 있습니다.
      • activeTab: <all_urls> 같은 광범위한 host 권한 대신 사용자가 아이콘을 클릭한 그 탭에만, 그 순간만 접근 권한을 얻는 절제된 권한으로 유용하게 쓰일 수 있습니다.
      • storage: 확장 전용 키-값 저장소입니다. 페이지의 localStorage와는 완전히 분리되어 있어서 웹 페이지 스크립트가 읽을 수 없고, content scriptbackground가 같은 저장소를 공유한다는 게 핵심입니다. 영역이 세 가지 있습니다.
          • storage.local: 이 기기에만 저장
          • storage.sync: 브라우저 계정으로 기기 간 동기화(용량 제한이 훨씬 작음)
          • storage.session: 브라우저 세션 동안만 유지
      • contextMenus: 우클릭 메뉴 추가
  • host_permissions : 확장 프로그램 사용을 허용할 url 혹은 확장 프로그램이 허용할 외부 API url 목록을 적습니다.
{
  "web_accessible_resources": [
    {
      "resources": ["injected.js", "images/*.png", "fonts/custom.woff2"],
      "matches": ["https://*.example.com/*"]
    },
    {
      "resources": ["overlay.html"],
      "matches": ["<all_urls>"]
    }
  ]
}
  • web_accessible_resources : 확장 패키지 안의 파일들은 chrome-extension://<확장ID>/파일경로 형태의 URL을 갖는데, 기본적으로 웹페이지 컨텍스트에서 이 URL로 접근하면 차단됩니다. 웹페이지가 사용자의 설치된 확장을 마음대로 탐지하거나(핑거프린팅) 확장 내부 리소스를 읽는 걸 막기 위한 보안 조치예요.

      그런데 확장 프로그램이 의도적으로 페이지 js와 같은 실행환경에서 실행되는 경우가 있습니다. world: "MAIN" 인 스크립트는 이렇게 실행되는데, 이럴 경우는 확장 프로그램의 js가 결국 페이지 컨텍스트에서 로드되기 때문에, 확장 프로그램의 js임에도 불구하고 리소스에 접근이 차단될 수 있습니다. 따라서 "이 파일들은 웹에서 접근해도 된다"고 명시적으로 선언해야 합니다. 그게 web_accessible_resources입니다. 아래 상황에서 유용하게 사용됩니다.

      • <script src> 태그로 MAIN world에 주입할 injected.js (manifest의 world: "MAIN"을 안 쓰고 수동 주입하는 고전적 방식)
      • 콘텐츠 스크립트가 페이지 DOM에 삽입하는 <img>, 폰트, CSS의 리소스가 chrome-extension://<확장ID>/* 에 있는 경우
      • 페이지에 iframe으로 띄우는 확장 내부 HTML의 리소스가 확장 프로그램 안에 있는경우

      콘텐츠 스크립트에서 리소스 URL을 얻을 때는 경로를 하드코딩하지 않고 chrome.runtime.getURL()을 씁니다:

      // 콘텐츠 스크립트 (ISOLATED world)
      const script = document.createElement('script');
      script.src = chrome.runtime.getURL('injected.js');
      document.documentElement.appendChild(script);
      
      const img = document.createElement('img');
      img.src = chrome.runtime.getURL('images/icon.png');

마치며

이번 글에서는 브라우저 확장 프로그램의 기본적인 구성요소에 대해 알아보았습니다. 각 구성요소는 각각 격리된 실행 환경에서 실행되고 다른 실행 컨텍스트에서 실행된다는 것을 알 수 있었습니다. 이렇게 분리되어 있기 때문에, 각 실행 컨텍스트가 소통하고 데이터를 주고 받기 위해서 메세징 방식이 필요합니다.

다음 글에서는 이런 메세지를 어떻게 보내고 소통할 수 있을지 알아보겠습니다.

참고자료