본문 바로가기
C.W.K.
Stream
Lesson 03 of 06 · published

Isolated world — 같은 DOM, 다른 JavaScript

~12 min · isolated-world, security, content-script, scripting, world-main

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"같은 DOM, 다른 JavaScript. 이 한 문장만 받아들이면 다른 모든 content-script 퍼즐이 알아서 풀려. Lesson 3는 뭐가 공유되고, 뭐가 안 공유되고, 정말 page의 world가 필요할 때 Chrome이 주는 escape hatch의 깊은 dive."

Two-world 모델

Chrome이 tab에 content script inject 할 때, 그것 위한 새 JavaScript context 만들어 — 분리된 heap, 분리된 global object, 분리된 class prototype set. 스크립트의 window와 page의 window는 같은 underlying browser primitive를 감싸지만 서로 다른 object.

실용적으로 의미하는 것:

  • DOM은 공유. 두 world 다 같은 document, 같은 element, 같은 attribute 봄.
  • JavaScript identity는 안 공유. Page의 window.fetch가 monkey-patch 된 버전일 수 있고. content script의 window.fetch는 깨끗한 browser 거.
  • Custom global 안 건너감. Page가 window.gtag = ... 하면, content script는 undefined 봄. Content script가 window.clipdeckHook = ... 하면, page는 아무것도 안 봄.
  • class의 prototype도 따로야. document.querySelector('div') instanceof HTMLElement는 양쪽에서 다 true로 나오는데, 사실 각 세계가 자기만의 HTMLElement를 쥐고 있는 거야.

왜 존재해

보안과 안정성, 양방향:

  • Extension 으로부터 page 보호. window.fetch를 덮어쓴 buggy extension이 page 깨뜨릴 텐데, isolation이 그걸 막아.
  • 페이지로부터 extension을 지켜. 못된 페이지가 자기 세계에 function chrome() { … }를 만들어 둬도, extension의 chrome.runtime.sendMessage 호출을 거기로 빼돌릴 수 없어. 격리가 그걸 막아 주거든.
  • extension 끼리도 안 부딪혀. 두 extension이 같은 페이지에 들어가도 각자 자기 세계를 받아. 서로의 전역이 충돌할 일이 없어.

이 isolation이 content script를 쓸 만한 security primitive로 만드는 핵심 — 그게 없으면 extension과 page는 끊임없는 군비 경쟁.

두 world가 대화하는 법

선호 순서로 세 가지 정당한 bridge:

  1. DOM event. Page가 알려진 element에서 CustomEvent dispatch. content script가 listen. Content script가 dispatch. page가 listen. 가장 isolated 하고 가장 explicit 한 패턴.
  2. window.postMessage. 두 세계가 같은 window 하나를 같이 봐 (이건 DOM에 매인 물건이거든). 그래서 거기에 메시지를 던질 수 있어. 넘어가는 data는 복제돼서 건너가. 숫자나 문자열, 평범한 object와 배열은 무사히 넘어가고, 함수나 class instance는 못 넘어가.
  3. Script tag inject. Pre-Chrome-111 경로: extension 안에 파일 작성, web_accessible_resources에 list, content script에서 <script src="chrome-extension://.../injected.js"> 생성. Inject 된 파일이 page world에서 돔. DOM event 나 postMessage와 결합해 결과 받기.

Lesson 5에서 페이지와 다리를 놓는 방법을 돌아가는 코드로 다룰 거야. 다만 ClipDeck이 필요로 하는 일 대부분 — 선택 영역 읽기, 페이지 정보 읽기, clip 저장 — 에는 다리가 필요 없어. content script 자기 세계 안에 이미 다 있거든.

world: 'MAIN' escape hatch

Chrome 95+ 가 chrome.scripting.executeScriptworld: 'MAIN' 추가. Chrome 111+ 는 declarative manifest entry 에도 같은 option 확장. 이걸 주면 스크립트가 페이지 자기 JavaScript 세계에서 돌아. window.gtagwindow.React 든 페이지가 만든 건 뭐든 다 만질 수 있게 되지. 대신 치를 값이 커. 격리를 통째로 잃고, chrome.* API잃음 (chrome.runtime만 남음), page가 스크립트 global 보고 수정 가능.

진짜로 page world 필요할 때 — page-side library 통합, framework 함수 monkey-patch, custom window.__store read — MAIN 사용. 다른 모든 건 ISOLATED 유지.

같은 DOM, 다른 JavaScript. Isolation이 default. MAIN world는 explicit 한 좁은 escape hatch. ISOLATED에서 DOM event 나 postMessage로 풀 수 있으면 그렇게 — MAIN world는 보안과 편의를 거래.
chrome ambiguity 함정. ISOLATED-world content script에서 chrome은 extension API. MAIN-world content script에서 chrome은 page의 window.chrome — 훨씬 작은 browser-defined object (주로 chrome.webstore, installed app의 chrome.runtime.id). MAIN script가 chrome.runtime.sendMessage 부르면, 그 호출은 silent fail 하거나 misleading error throw. Extension API 호출은 ISOLATED partner script에 두든가, 결과를 postMessage / DOM event로 다시 보내.
MAIN에는 extension API가 없어. world: "MAIN"으로 넣은 코드는 page code로 돌아. chrome.runtime, storage, messaging은 isolated partner에 두고 좁게 검증한 payload만 bridge해.

Code

ISOLATED-world content script — DOM yes, page global no·javascript
// content.js (ISOLATED world — default)
// Page 가 정의한 게 우리한테 invisible, 반대도 마찬가지.

window.clipdeckMarker = "hello from ClipDeck content script";

// 가상의 page global read 시도. 거의 항상 undefined.
console.log("[ClipDeck content] window.gtag is:", window.gtag);

// DOM 공유 확인.
const h1 = document.querySelector("h1");
if (h1) console.log("[ClipDeck content] first h1 text:", h1.textContent);

// Prototype identity 가 world 별인 거 시연.
console.log("[ClipDeck content] HTMLElement is:", HTMLElement);
MAIN-world injection — SW에서 page-defined global read·javascript
// background.js — MAIN world 에 inject 해서 page global read
chrome.action.onClicked.addListener(async (tab) => {
  if (!tab.id) return;
  const [result] = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    world: "MAIN",
    func: () => {
      // 이제 page 자체 JS world 안에 있음.
      // window.gtag (있다면) 여기서 닿음.
      return {
        hasGtag: typeof window.gtag === "function",
        hasReact: typeof window.React !== "undefined",
        documentTitle: document.title,
      };
    },
  });
  console.log("[ClipDeck SW] page-world snapshot:", result.result);
});
ISOLATED ↔ MAIN bridge via window.postMessage with sentinel·javascript
// content.js (ISOLATED) — window.postMessage 로 MAIN-world helper 와 대화
window.addEventListener("message", (event) => {
  // 항상 origin 과 sentinel field 검증 후에 payload 신뢰.
  if (event.source !== window || event.origin !== location.origin) return;
  if (event.data?.source !== "clipdeck-page-script") return;

  console.log("[ClipDeck content] received from page:", event.data.payload);
});

// Page-world 스크립트에 요청 보냄
window.postMessage(
  { source: "clipdeck-content-script", type: "giveMeReactVersion" },
  location.origin
);

External links

Exercise

clipdeck/content.js의 body를 첫 번째 code block으로 교체. window.gtag 정의된 page 아무거나 (대부분의 news site와 대형 retailer가 그래 — cnn.com 이나 amazon.com 시도) 열고, page DevTools 열고, Console context를 'ClipDeck' 으로 전환, window.gtag is: undefined 줄 확인. 이제 Console context를 'top' 으로 전환하고 window.gtag 타이핑 — function 이나 object. 같은 DOM, 다른 JS. 다음 두 번째 code block을 clipdeck/background.js에 추가 (Lesson 2 exercise 처럼 popup 잠깐 다시 제거 필요할 수도). Toolbar icon 클릭 — SW console에 hasGtag: true 보고돼야 함. MAIN-world injection이 page의 실제 gtag에 닿았고. ISOLATED-world content script는 못 닿았어.
Hint
두 context 다 gtag 보이면 실수로 Wikipedia 등 gtag 없는 page 열린 거 — commerce 나 news site 시도. MAIN-world injection이 world is not a valid option throw 하면 Chrome이 95 보다 옛 거 (현대 install 에선 매우 드묾 — chrome://version 확인). 분명 analytics 있는 site에서 SW console이 hasGtag: false 보고하면, 그 site가 클릭 시점에 아직 fire 안 한 방식으로 lazy-load 중 — navigation 후 몇 초 기다리고 다시 클릭.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.