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

MV3 Gotchas — MV2에서 뭐가 깨졌어

~10 min · mv3, migration, gotchas, service-worker, deprecations

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"인터넷에 떠 있는 모든 MV2-시대 튜토리얼이 지뢰밭이야. 패턴은 여전히 자명해 보여 — 그저 더 이상 load가 안 될 뿐."

Background page → Service worker

가장 큰 단절. MV2의 persistent background page는 전체 browser session 동안 메모리에 살아 있었어 — global에 state 박아두고, long-lived event listener 잡고, cache 쌓고, 다 그대로 유지. MV3의 service worker는 event-driven 이고 inactivity 약 30 초 후 evict 돼. Event (message / alarm / tab change / install)로 깨우면 cold 시작 — global 초기화, in-memory cache 비움, listener도 파일 위에서부터 재등록.

Migration 패턴: eviction 견뎌야 하는 건 chrome.storage.local로 (synchronous 느낌, worker 재시작 across 영속). MV2에서 in-memory state로 들고 있던 건 storage round-trip으로 바뀌거나, 매 wake 때 죽는 걸 받아들여.

Blocking webRequest → declarativeNetRequest

MV2의 chrome.webRequest는 extension이 모든 request intercept, header 검사, 동기적으로 block 또는 modify 가능. 그게 ad blocker의 전체 메커니즘이었어. MV3가 비-enterprise extension에 대해 blocking 모드 deprecate 하고 declarativeNetRequest 도입 — rule 기반 시스템, 앞으로 패턴 선언하면 Chrome이 코드 없이 enforce.

Tradeoff: rule 기반은 더 빠르고 privacy-preserving (extension이 자기가 안 다루는 request 자체를 못 봄), 근데 덜 유연. 복잡한 ad blocker 들은 matching logic을 static rule로 재설계해야 했어. ClipDeck 한테 이 절은 그냥 참고용이야. network를 가로챌 일이 없거든.

chrome.extension.* → chrome.runtime.*

여러 MV2 API가 MV3에서 이름 바뀌거나 chrome.runtime으로 통합:

  • chrome.extension.getBackgroundPage() → 대체 없음. service worker를 직접 가져올 수 없으니 chrome.runtime.sendMessage로 메시지를 보내.
  • chrome.extension.getViews() → popup/options page 용은 여전히 동작, service worker는 "볼 수" 없음.
  • chrome.extension.sendMessagechrome.runtime.sendMessage.
  • chrome.extension.onMessagechrome.runtime.onMessage.

chrome.extension 쓰는 MV2 스니펫을 복사해 붙이면, 절반은 동작하고 절반은 안 동작. Errors panel이 빠진 거 한 chrome.extension.X is not a function으로 보여줘. 보통 find-and-replace 면 충분. Chrome의 migration 문서에서 symbol 별 mapping 확인.

Persistent state 손실

모든 MV2 dev가 딱 한 번씩 걸리는 gotcha. 코드는 멀쩡해 보이고, event listening도 잘 되는데, state가 랜덤하게 reset 되는 것처럼 보임. 실제로 일어난 일: service worker가 evict 되고, 다음 event에 재시작, 파일 위에서부터 다시 실행 — 그래서 아까 채워둔 module-level const cache = new Map()이 새 빈 Map.

해결: service worker를 stateless로 취급하고 chrome.storage.local을 state layer로 사용. 각 event handler 위에서 read, 아래에서 write. Track 2에서 이 패턴을 깊게 다룰 거야. 지금은 이 gotcha 자체가 lesson 이고.

Migration 체크리스트

혹시 MV2 extension을 MV3로 migrate 해야 한다면 이 list 따라가:

  1. manifest_version: 2 → 3.
  2. background.scripts + persistent: truebackground.service_worker (단일 파일).
  3. Extension HTML page의 inline <script> 태그나 onclick= handler 다 제거.
  4. 모든 chrome.extension.* 호출 찾아서 chrome.runtime.*로 매핑 (문서 보고).
  5. Blocking webRequestdeclarativeNetRequest rule (또는 rule 기반이 불가능하면 enterprise policy로 escalate).
  6. 모든 module-level state를 chrome.storage.local로 이동.
  7. permissions 감사: permissions (API)와 host_permissions (URL pattern)으로 분리.
  8. web_accessible_resources 형태 확인 — MV3가 배열을 resources / matches 가진 object로 감싸.

이 순서가 MV2 → MV3 migration의 약 95% 잡아. 남은 5% 는 자기만의 Chrome 문서 deep-dive 가치 있는 edge case.

MV3는 feature를 추가한 게 아냐 — footgun을 제거한 거. 모든 gotcha는 의도된 cut: 더 적은 메모리, 더 적은 attack surface, 더 적은 디버깅할 state. 제약 자체가 설계.
옛 Stack Overflow 답변이 거짓말함. MV2 패턴이 10 년간 Chrome extension 콘텐츠를 지배했어. 의심스러우면 2023 년 이전 자료보다 현재 Chrome Developers 문서를 우선. 문서 URL에 /develop/ 들어가. MV2-시대 가이드는 /extensions/ 였어.
Compound storage update는 원자적이지 않아. get → modify → set을 surface 둘이 동시에 돌리면 한쪽 변경이 사라질 수 있어. Service worker를 sole writer로 두고 mutation queue 하나로 직렬화하거나 record를 독립 key로 저장해. 한 번의 set은 그 안의 key를 함께 적용하지만, 앞뒤 read-modify-write 전체가 atomic인 건 아니야.

Code

Cross-context 호출: MV2 직접 reach vs MV3 message passing·javascript
// MV2 — gone. Service worker can't be "gotten" directly.
const bg = chrome.extension.getBackgroundPage();
bg.someSharedFunction();

// MV3 — message the worker instead.
const response = await chrome.runtime.sendMessage({
  type: "do-the-thing",
  payload: { x: 1 },
});

// And in background.js:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "do-the-thing") {
    sendResponse({ ok: true, result: doIt(message.payload) });
  }
  return false; // synchronous response
});
Module-level state: MV2 Set vs MV3 chrome.storage.local round-trip·javascript
// MV2 — synchronous in-memory state survives forever
let visitedTabs = new Set();
chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
  if (info.status === "complete") visitedTabs.add(tab.url);
});
// later... visitedTabs.size is the running count

// MV3 — state must survive worker eviction
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
  if (info.status !== "complete") return;
  const { visitedUrls = [] } = await chrome.storage.local.get("visitedUrls");
  if (!visitedUrls.includes(tab.url)) {
    visitedUrls.push(tab.url);
    await chrome.storage.local.set({ visitedUrls });
  }
});

External links

Exercise

GitHub에서 MV2-시대 Chrome extension 아무거나 찾기 (github.com에서 "manifest_version: 2 chrome extension" 검색 — 버려진 hobby project 많아). manifest.json과 JavaScript 파일 하나 읽기. MV3로 migration 시 변경 필요한 MV2 idiom 셋 짚기: 어떤 manifest field, 어떤 chrome.* API 호출, 어떤 background 패턴. Migration 직접 안 해도 됨 — 뭘 바꿀지 + 왜 짧은 bullet 셋으로.
Hint
결정적 신호: manifest.json의 background.scripts + persistent: true, JS 안의 chrome.extension.* 호출, HTML 파일의 inline event handler. Extension이 webRequest blocking 쓰면 더 까다로운 migration (rule 기반 재설계). 빠른 win 위해 — Tampermonkey 스타일 ad-hoc script를 extension으로 옮긴 거 찾아봐, 거의 다 MV3 CSP 위반하는 inline JS 갖고 있어.

Progress

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

댓글 0

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

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