C.W.K.
Stream
Lesson 06 of 06 · published

ClipDeck 방문 카운터 — Storage + Message + Tabs 한 loop

~15 min · tabs, onUpdated, storage, messaging, service-worker, permissions

Level 0Extension 입덕
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Lesson 4 가 명사. Lesson 5 가 동사. Lesson 6 은 그 둘을 함께 쓰는 작고 진짜인 한 가지 — 실시간으로 올라가는 걸 볼 수 있는 방문 카운터. Track 2 가 여기서 끝나. popup 닫혀 있고 laptop 잠들어 있어도 도는 feature 하나로."

뭘 만들고 있는 거?

방문 카운터는 ClipDeck 의 가장 단순한 R — storage 에서 state read, render. tab load 끝날 때마다 service worker 가 카운터 증가. popup 열리면 total 과 URL 별 breakdown 표시. popup 닫고, SW 잠들고, Chrome 재시작해도 카운트는 살아 — 다 chrome.storage.local 안에 있어서.

이게 Track 2 의 마지막 ClipDeck slice. Track 3 부터는 같은 stack — storage 가 state, message 가 action, event 가 trigger — 이 clip CRUD 자체를 호스팅. Lesson 6 은 stack 이 end-to-end 로 제대로 연결된 걸 증명하는 worked example.

chrome.tabs.onUpdated event

Tab 은 모든 state 변화 — favicon load, title swap, navigation, completion — 마다 onUpdated fire. 우리는 completion 만 관심, 그래서 changeInfo.status === "complete"tab.url 존재 여부로 guard:

  • tabId — 변화 일어난 tab 의 id.
  • changeInfo — 이번 fire 에서 변한 것. status (loading / complete) 포함, 가끔 url 도.
  • tab — 변화 후 전체 tab object. changeInfo.url 보다 tab.url 사용 — 후자가 실제 URL transition 때만 있음.

onUpdated listen 하려면 "tabs" permission (URL read 위해) 또는 "activeTab" permission (user 가 action 부를 때만) 필요. 항상 도는 방문 카운터엔 "tabs" 가 맞아 — manifest.json"storage" 옆에 추가.

System page 거르기

금방 알아챌 거야 — chrome://, about:, chrome-extension:// URL 이 카운트 오염시킴. 건너뛰기 — user 가 의미 있게 page 로 browse 한 게 아냐. 한 줄짜리 guard 가 noise 의 99% 처리:

빈 / null URL 도 건너뛰기 (실제 navigation 도착 전 new-tab transition 에서 나타나). 그 guard 이후 모든 증가는 user 가 시작한 실제 page load 와 mapping.

Schema

chrome.storage.local 의 두 key:

  • totalVisits: number — monotonic 카운터, user action 외엔 reset 안 됨.
  • urlCounts: Record<string, number> — URL 별 카운트. popup 에서 top-N list 보여 줄 수 있게 해 줘.

이 schema 가 flat scalar 와 structured object 둘 다 storage 에서 시연하는 가장 작은 것. ClipDeck 의 clip list (Track 3) 도 같은 패턴 — 배열용 key 하나, 파생 카운트 / metadata 용 key 하나.

Popup 쪽

popup 이 load, 두 key read, render. chrome.storage.onChanged 도 구독; SW 가 새 카운트 write 하면 message passing 없이 popup re-render. "Reset counters" 버튼은 { type: "resetVisitCounts" } 를 SW 로; SW 가 storage 에 0 write 하고 {ok:true} 응답. Reset 은 action 이라 message 로 통과 (Lesson 5 의 동사/명사 split).

한 feature 안의 Track 2 전체 stack: event 가 SW 깨우고, SW 가 storage mutate, popup 이 onChanged 로 re-render, user action 이 message 로 돌아옴. ClipDeck 이 이후 모든 feature 에 타고 다닐 loop.
하필 왜 방문 카운터? 새 domain 만들지 안 하고 Track 2 의 모든 개념을 행사하는 가장 작은 feature 라서. 원하면 Track 3 에서 카운터 버려도 됨 — point 는 loop 지 카운트 자체 아냐. 어떤 학생은 ClipDeck 의 영구 "이 사이트 얼마나 자주 가?" widget 으로 유지; 둘 다 괜찮은 선택.
실시간으로 올라가는 거 봐 봐. chrome://extensions → ClipDeck → 'Inspect views: service worker' 로 SW DevTools 열기. Application 탭 → Storage → Extension Storage → 'local'. 이제 page 몇 개 navigate. urlCounts 가 refresh 없이 실시간으로 자라. 전체 pipeline 이 제대로 연결됐는지 확인하는 가장 빠른 sanity check.

Code

background.js — listener, storage write, reset action·javascript
// background.js — tab completion 시 방문 카운터
function isSkippableUrl(url) {
  if (!url) return true;
  return (
    url.startsWith("chrome://") ||
    url.startsWith("chrome-extension://") ||
    url.startsWith("about:") ||
    url.startsWith("edge://") ||
    url.startsWith("devtools://")
  );
}

chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  if (changeInfo.status !== "complete") return;
  if (isSkippableUrl(tab.url)) return;

  const { totalVisits = 0, urlCounts = {} } =
    await chrome.storage.local.get(["totalVisits", "urlCounts"]);

  const next = {
    totalVisits: totalVisits + 1,
    urlCounts: { ...urlCounts, [tab.url]: (urlCounts[tab.url] || 0) + 1 },
  };
  await chrome.storage.local.set(next);
});

// Reset action — popup 이 {type:'resetVisitCounts'} 보내면 SW 가 store 0 으로
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "resetVisitCounts") {
    (async () => {
      await chrome.storage.local.set({ totalVisits: 0, urlCounts: {} });
      sendResponse({ ok: true });
    })();
    return true;
  }
});
popup.js — 첫 read, onChanged 구독, reset 버튼·javascript
// popup.js — 한 번 read, 이후 구독; reset 은 message 로
async function render() {
  const { totalVisits = 0, urlCounts = {} } =
    await chrome.storage.local.get(["totalVisits", "urlCounts"]);

  document.getElementById("totalVisits").textContent = String(totalVisits);

  const top = Object.entries(urlCounts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5);

  const list = document.getElementById("topUrls");
  list.innerHTML = "";
  for (const [url, count] of top) {
    const li = document.createElement("li");
    li.textContent = `${count} \u2022 ${url}`;
    list.appendChild(li);
  }
}

chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName !== "local") return;
  if ("totalVisits" in changes || "urlCounts" in changes) render();
});

document.getElementById("resetBtn").addEventListener("click", async () => {
  await chrome.runtime.sendMessage({ type: "resetVisitCounts" });
  // 수동 render 안 함 — storage.onChanged 가 fire 해서 re-render 시켜 줌.
});

render();
clipdeck/manifest.json — 0.3.0, tabs + storage permission·json
{
  "manifest_version": 3,
  "name": "ClipDeck",
  "version": "0.3.0",
  "description": "Lightweight clipboard helper — visit counter scaffolding for Track 2.",
  "action": {
    "default_title": "ClipDeck",
    "default_popup": "popup.html"
  },
  "background": {
    "service_worker": "background.js"
  },
  "permissions": ["storage", "tabs"],
  "icons": {
    "16": "icons/16.png",
    "48": "icons/48.png",
    "128": "icons/128.png"
  }
}

External links

Exercise

clipdeck/manifest.json 을 version 0.3.0 으로 올리고 "permissions": ["storage", "tabs"] 로 설정. 첫 번째 code block 의 chrome.tabs.onUpdated listener 를 clipdeck/background.js 에 (기존 message listener 옆에) 추가. clipdeck/popup.html 에 <div>Total visits: <span id="totalVisits">0</span></div>, <ol id="topUrls"></ol>, <button id="resetBtn">Reset counters</button> 추가. clipdeck/popup.js 의 body 를 두 번째 code block 으로 교체. extension reload. popup 열기 — 카운터 0 이어야 함. popup 닫기. 실제 web page 네다섯 개 (평소 쓰는 사이트들) 열기. popup 다시 열기 — totalVisits 가 page load 수와 맞아야 하고 topUrls 가 그것들 listing. Reset 클릭 — 두 카운터 0, top-URL list 비어, popup 다시 열 필요 없어.
Hint
카운트가 안 움직이면 가장 흔한 원인은 "tabs" permission 누락 — 그게 없으면 tab.url 이 undefined 라 isSkippableUrl 이 true 반환. manifest 편집 후 extension reload. popup 이 0 렌더하는데 SW DevTools 의 Application → Extension Storage 엔 실제 카운트 보이면, popup 의 chrome.storage.local.get 호출이 storage write 와 race — destructure key 이름이 정확히 일치하는지 확인 (totalVisit vs totalVisits 같은 typo 는 조용히 0 으로 default). onChanged 가 fire 하는데 popup 이 re-render 안 하면 listener 가 popup DOM ready 전에 등록된 거 — popup.js 에서 onChanged 먼저 등록, 그 다음 마지막에 render() 호출.

Progress

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

댓글 0

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

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