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

Per-tab vs global side panel — 두 scoping 모델

~12 min · side-panel, setOptions, per-tab, global, tabs

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"같은 panel 인데 tab 마다 다른 내용 — 그게 per-tab. 같은 panel에 어느 tab 이든 같은 내용 — 그게 global. ClipDeck은 둘 다 동시에 원하는데, 어느 호출이 이기는지만 알면 API가 깔끔하게 허락해 줘."

층이 둘이야

chrome.sidePanel.setOptions를 부를 때마다 둘 중 한 층에 state가 박혀:

  • Per-tab: { tabId: T, ... }로 불러. path 든 enabled 든 그 설정은 tab T가 활성일 때만 먹어. tab 마다 자기 state를 따로 들고 가는 거야.
  • Global: tabId 없이 불러. per-tab 설정을 따로 안 받은 tab 전부의 기본값이 돼.

싸우면 per-tab이 이겨. tab T에 setOptions({ tabId: T, enabled: false })를 걸어 놓고 global은 enabled: true 라면, panel은 T 에서만 꺼지고 나머지에선 켜져 있어.

ClipDeck은 뭘 쓸까

ClipDeck은 clip 창고를 하나만 갖고 (global storage), panel 내용도 tab 마다 크게 다르지 않아. 그래도 per-tab이 필요한 동작이 둘 있어:

  • site 별로 걸러 보여 주기. user가 github.com에 있으면 panel이 github.com에서 저장한 clip만 미리 걸러서 보여 줄 수 있어. SW의 tab-update handler에서 tab.url을 읽고 panel path에 다른 query string을 실어 주면 돼 — panel.html?host=github.com 이렇게.
  • 민감한 site 에선 꺼 두기. 은행, 비밀번호 관리자, 아니면 user가 직접 막아 둔 URL 에서는 그 tab만 enabled: false로 해서 panel 목록에서 ClipDeck을 숨겨. user가 그런 자리에서 기대하는 프라이버시를 존중해 주는 거야.

기본값 (global)은 enabled: true, path: 'panel.html'로 두면 돼. 그래야 per-tab으로 따로 손대지 않은 tab 에서도 panel이 그냥 열려.

setOptions는 어떻게 겹쳐지나

path는 tab 마다 다르게 줄 수 있어. 위에서 말한 site 별 필터가 그걸 써먹는 거고. Chrome은 panel.html?host=github.com을 그냥 이동으로 처리해. 그래서 panel JS가 뜰 때 new URLSearchParams(location.search)를 읽으면 목록을 거기 맞춰 거를 수 있어. path가 바뀌면 panel은 다시 로드돼.

enabled는 목록에 보일지 말지만 정해. 어떤 tab의 panel을 꺼도 이미 열려 있는 다른 tab의 panel이 닫히진 않아. 꺼 둔 tab으로 넘어가면 목록에서 ClipDeck이 사라지고, 돌아오면 다시 나타나.

언제 다시 걸어 줘야 하나

setOptions를 다시 불러야 하는 tab event는 이것들이야:

  • chrome.tabs.onActivated — user가 이 tab으로 넘어왔을 때.
  • chrome.tabs.onUpdated에서 status === 'complete' — 페이지가 다 떴을 때. 이때부터 URL을 믿고 읽을 수 있어.
  • chrome.tabs.onCreated — 새 tab이 열렸을 때. global 기본값을 그대로 물려주고 싶다면 여기서 명시해도 돼.

chrome.runtime.onInstalled에서 기존 tab 전부를 돌면서 setOptions를 부를 필요는 없어. global 호출 하나가 다 덮어 주거든. 대신 그 global 호출 한 번은 꼭 해 줘야 해. 아직 개별로 손대지 않은 tab도 그럴듯한 기본값을 갖고 있어야 하니까.

Race 안 나는 패턴

onActivated 랑 onUpdated를 같이 쓰는 이 조합이 실전에서 race가 안 나:

  1. 설치하거나 시작할 때 global로 setOptions({ path: 'panel.html', enabled: true }) 한 번.
  2. onActivated가 올 때마다, 그리고 onUpdated(complete)가 올 때마다 tab.url로 그 tab에 원하는 state를 계산해서 setOptions({ tabId, path, enabled }).
  3. 어떤 tab 이든 첫 event가 오는 순간 global 기본값이 그 tab 전용 설정으로 승격돼. 그 뒤 event는 같은 값을 몇 번 다시 발라도 그만이고.

몇 번 다시 불러도 같다는 게 중요해. 두 event가 거의 붙어서 올 수 있거든 — 새 tab을 열고 바로 이동한다든지, 다른 tab 갔다가 돌아온다든지 하면 SW가 순식간에 여러 개를 받아. 같은 값으로 setOptions를 다시 부르는 건 싸니까 그냥 부르면 돼.

싸우면 per-tab이 이겨. "기본적으로 항상 켜 둔다" 는 global로, 필터·차단·path 교체 같은 특수 케이스는 per-tab으로. race 안 나게 하려면 onActivated와 onUpdated 양쪽에서 다시 발라 줘.
이유도 없이 panel을 꺼 버리지 마. 특정 site에서 목록에 ClipDeck이 조용히 사라지면 user는 당황해. 은행 URL 같은 데서 꼭 꺼야 한다면 작은 popup 이나 한 번짜리 toast로 설명해 줘 — "ClipDeck은 프라이버시 때문에 이 site에서 잠깐 쉬어" 정도로. 안 그러면 user는 ClipDeck이 고장 난 줄 알고 지워 버려.

Code

background.js — global + per-tab side-panel 구성·javascript
// background.js — global default + per-tab 특화
const BLOCKLIST = [
  /^https:\/\/.*\.bank\.com\//i,
  /^https:\/\/accounts\.google\.com\//i,
];

async function configurePanelForTab(tab) {
  if (!tab?.id || !tab.url) return;
  const blocked = BLOCKLIST.some((re) => re.test(tab.url));
  if (blocked) {
    await chrome.sidePanel.setOptions({
      tabId: tab.id,
      enabled: false,
    });
    return;
  }
  const host = new URL(tab.url).host;
  await chrome.sidePanel.setOptions({
    tabId: tab.id,
    path: `panel.html?host=${encodeURIComponent(host)}`,
    enabled: true,
  });
}

chrome.runtime.onInstalled.addListener(async () => {
  // Global default — 아직 per-tab 호출 안 받은 tab cover.
  await chrome.sidePanel.setOptions({ path: "panel.html", enabled: true });
  await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
});

chrome.tabs.onActivated.addListener(async ({ tabId }) => {
  const tab = await chrome.tabs.get(tabId);
  await configurePanelForTab(tab);
});

chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
  if (info.status !== "complete") return;
  await configurePanelForTab(tab);
});
panel.js — path query string에서 뽑아낸 host filter·javascript
// panel.js — mount 시 URL 에서 host filter read
async function getFilteredClips() {
  const params = new URLSearchParams(location.search);
  const host = params.get("host");
  const { clips = [] } = await chrome.storage.local.get("clips");
  if (!host) return clips;
  return clips.filter((c) => {
    try {
      return new URL(c.url).host === host;
    } catch {
      return false;
    }
  });
}

async function render() {
  const clips = await getFilteredClips();
  const params = new URLSearchParams(location.search);
  const host = params.get("host");
  document.getElementById("filterLabel").textContent = host
    ? `Showing clips from ${host}`
    : "All clips";
  // ...전과 같이 DOM 에 `clips` 렌더...
}

chrome.storage.onChanged.addListener((c, a) => a === "local" && "clips" in c && render());
render();

External links

Exercise

clipdeck/background.js의 panel 등록 부분을 첫 번째 code block으로 갈아. clipdeck/panel.html의 body 맨 위에 <div id="filterLabel"></div> 를 넣고, panel.js는 두 번째 code block의 필터 로직으로 고쳐. Reload 하고, 서로 다른 site 셋 (wikipedia, github, news.ycombinator.com)에서 clip을 저장해 봐. 이제 tab을 옮겨 다니면 panel 머리말이 Showing clips from wikipedia.org 처럼 바뀌고, 그 host에서 저장한 clip만 보여야 해. 막아 둔 URL (https://accounts.google.com/로 가 봐)로 넘어가면 side-panel 목록에서 ClipDeck이 사라져야 하고, 평범한 페이지로 돌아오면 다시 나타나야 해.
Hint
tab을 옮겼는데 panel이 안 바뀌면 path query string이 실제로는 안 바뀌고 있는 거야. configurePanelForTab 맨 위에 console.log('reconfiguring', tab.id, tab.url) 을 넣어서 onActivated에 진짜 도는지 확인해 봐. 맞는 site 인데도 clip이 하나도 안 보이면 clip의 urltab.url 의 host가 subdomain 때문에 다를 수 있어. 좀 더 무던하게 가고 싶으면 second-level domain 까지만 비교하도록 필터를 느슨하게 해. 차단 URL 테스트는 이미 열려 있는 panel 말고 목록에서 항목이 사라지는지를 봐 — 이미 열린 panel은 per-tab으로 꺼도 저절로 닫히진 않거든.

Progress

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

댓글 0

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

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