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

Message passing — Popup과 SW 사이의 짧고 팽팽한 줄

~12 min · messaging, runtime, sendMessage, onMessage, async, service-worker

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Lesson 4의 storage는 worker가 적어 두는 수동적 memory 였어. Message는 context 간 능동적 wake-up signal. Lesson 5는 popup과 service worker 사이의 줄 — 짧고 팽팽하고 한 turn에 정확히 한 번만 당겨지는."

같은 channel의 두 반쪽

모든 Chrome extension이 모든 context에서 같은 runtime channel 노출. 보내려면: chrome.runtime.sendMessage. 받으려면: chrome.runtime.onMessage.addListener. "popup messaging" 따로, "SW messaging" 따로 — 그런 API 없어. 두 context가 같은 두 반쪽 사용.

  • sendMessage(message, callback?) — runtime으로 메시지를 쏴. MV3 에서는 callback을 안 주면 Promise를 돌려주고. 이 extension 안에 걸려 있는 다른 listener 들이 전부 받아 봐.
  • onMessage.addListener((message, sender, sendResponse) => boolean | undefined) — handler 등록, 들어오는 모든 message에 호출. sendResponse를 async로 부를 거면 true 반환. 동기 응답 (또는 무응답) 이면 undefined.

sender argument는 message가 popup, content script, side panel, options page 중 어디서 왔는지 알려줘. 이 정보로 routing하고, 허용하지 않은 context의 요청을 거절할 수 있어.

return true 불변식

listener 안에서 응답 전에 async 작업 — 예: chrome.storage read — 하면 listener body에서 await가 suspend 되기 전에 동기적으로 true 반환 필수:

Chrome runtime은 listener가 stack에 있는 동안만 message channel 열어 둠. return true가 runtime 한테 "sendResponse 나중에 부를게, channel 살려 둬" 라고 말해 줘. 잊으면 async path의 sendResponse는 silent no-op — caller가 영원히 기다리다 한 5 분 뒤 timeout. MV3 message-passing의 가장 흔한 버그 한 가지.

두 방향, 두 패턴

Popup → SW. popup은 잠깐 살다 가. 그래서 SW 한테 뭘 해 달라고 부탁해 놓고 답을 기다리는 쪽이야. 흔한 부탁은 이런 것들이지 — "지금 clip 목록 줘", "이 clip 저장해 줘", "이 id 지워 줘". SW가 storage를 읽거나 쓰고 답을 돌려줘.

SW → popup. 이쪽은 흔치 않아. popup이 열려 있다는 보장이 없거든. 듣고 있는 popup이 없는데 보내면 "Receiving end does not exist" 같은 에러가 돌아와. SW가 popup에 뭔가 알려야 한다면 chrome.storage.onChanged (Lesson 4)가 훨씬 자연스러워. popup은 열릴 때 구독만 해 두고, SW는 storage에 쓰기만 하면, 양쪽이 알아서 반응하거든. "popup 열려 있나?" 를 확인하는 코드가 아예 필요 없어져.

Message와 storage 결합

등장하는 실용 ClipDeck 패턴:

  • Action은 message로. "clip 저장", "clip 삭제", "전체 비우기". popup이 보내고, SW가 storage mutate, 새 state와 함께 응답.
  • State update는 storage로. popup이 뜰 때 storage를 한 번 읽고, 그 뒤 onChanged를 구독. 이후 SW 쪽 mutation이 자동으로 re-render trigger.

이 분리가 codebase를 정직하게 유지: message는 동사, storage는 명사. 섞으면 — message로 전체 state 전송, message로 변경 broadcast — 작은 extension 에선 돌아가지만 clip 쌓이는 순간 spaghetti.

ClipDeck preview: popup이 SW ping

아래 exercise가 ClipDeck popup에 "Ping SW" 버튼 추가. { type: "ping" } 보내고, SW가 { ok: true, at: Date.now() } 응답, popup이 timestamp 표시. Lesson 6가 이 scaffolding을 진짜 방문 카운터 feature로 발전. Track 3 부터는 같은 channel을 ClipDeck의 실제 CRUD-C 작업 — "선택 텍스트를 clip으로 저장" 에 사용.

Message는 동사 (action). Storage는 명사 (state). Message 보내서 SW 한테 뭔가 하라 고 부탁. storage.onChanged 구독해서 무슨 일이 생겼는지 봄.
Silent timeout 함정. listener에서 sendResponse 전에 async 작업 하면 동기적으로 true 반환. 잊으면 listener return 순간 message channel 닫혀. async path의 sendResponse는 no-op. caller 영원히 대기, 결국 timeout. 증상: popup spinner가 안 풀려, 양쪽 DevTools console에 에러 안 보여.
Compound storage update는 원자적이지 않아. get → modify → set을 surface 둘이 동시에 돌리면 한쪽 변경이 사라질 수 있어. Service worker를 sole writer로 두고 mutation queue 하나로 직렬화하거나 record를 독립 key로 저장해. 한 번의 set은 그 안의 key를 함께 적용하지만, 앞뒤 read-modify-write 전체가 atomic인 건 아니야.

Listener 호환성. Promise를 반환하는 onMessage listener는 Chrome 148부터 지원해. 더 오래된 Chrome도 받으려면 listener 자체는 async로 만들지 말고, 내부 async IIFE에서 sendResponse를 부른 다음 바깥 listener가 literal true를 반환하게 해. async listener와 return true를 섞지 마.

Code

동기 round-trip — popup 보내고, SW가 같은 tick에 응답·javascript
// popup.js — ping 보내고 응답 await
async function pingSw() {
  const response = await chrome.runtime.sendMessage({ type: "ping" });
  console.log("[ClipDeck popup] response:", response);
  return response;
}

// background.js — 동기 응답, `return true` 안 필요
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "ping") {
    sendResponse({ ok: true, at: Date.now() });
    return; // 동기; channel 바로 닫혀도 OK
  }
});
Async round-trip — listener 안에서 storage read/write, 동기적으로 return true·javascript
// background.js — async 응답은 `return true` 필수
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "getClips") {
    (async () => {
      const { clips = [] } = await chrome.storage.local.get("clips");
      sendResponse({ ok: true, clips });
    })();
    return true; // async sendResponse 위해 channel 열어 둠
  }
  if (message?.type === "saveClip") {
    (async () => {
      const { clips = [] } = await chrome.storage.local.get("clips");
      const next = [...clips, message.payload];
      await chrome.storage.local.set({ clips: next });
      sendResponse({ ok: true, count: next.length });
    })();
    return true;
  }
});

External links

Exercise

clipdeck/popup.html에 <button id="pingBtn">Ping SW</button><div id="pingResult"></div> 추가. clipdeck/popup.js에서 버튼이 chrome.runtime.sendMessage({type:'ping'}) 호출하고 JSON-stringify 된 응답을 pingResult에 적도록 연결. clipdeck/background.js에 onMessage listener 등록, {type:'ping'}{ok:true, at:Date.now()} 로 동기 응답. extension reload, popup 열기, Ping SW 클릭 — div에 timestamp 떨어져야 함. 다음 popup 닫고 약 35 초 기다려 SW가 evict 되게 한 다음, ClipDeck toolbar icon 다시 클릭, Ping SW 클릭 — 여전히 동작해? (동작해야 함 — incoming message가 evict 된 SW의 wake-up trigger 중 하나.)
Hint
popup 응답이 undefined로 돌아오면 popup DevTools console에서 Receiving end does not exist 확인. 대개 메시지가 도착한 시점에 SW 쪽 listener가 아직 안 걸려 있어서야. 등록하는 코드가 cold start 때 아직 실행 안 된 async 함수 안에 묻혀 있거나, background.js 자체가 안 올라갔거나 (service worker DevTools에서 문법 오류를 확인해 봐). onMessage 등록은 반드시 background.js 맨 바깥에서 해. async 함수 안이나 다른 event handler 안은 절대 안 돼. 그리고 sendResponse 는 message 당 한 번만 동작. 두 번 부르면 두 번째는 silent no-op.

Progress

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

댓글 0

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

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