본문 바로가기
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/56 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은 상태가 조금이라도 바뀔 때마다 onUpdated를 울려. favicon이 뜨든, 제목이 바뀌든, 다른 주소로 가든, 로딩이 끝나든. 우리가 볼 건 로딩이 끝난 순간뿐이니까 changeInfo.status === "complete"tab.url이 있는지로 걸러:

  • tabId — 변화가 일어난 tab의 id 야.
  • changeInfo — 이번에 바뀐 것만 담겨 와. status (loading 이나 complete)가 들어 있고 가끔 url도 따라와.
  • tab — 바뀐 뒤의 tab 전체야. changeInfo.url 말고 tab.url을 써. 앞의 것은 주소가 진짜로 바뀔 때만 들어오거든.

onUpdated를 들으려면 주소를 읽기 위한 "tabs" 권한이 필요해. 아니면 user가 불렀을 때만 도는 "activeTab" 이든지. 계속 세고 있어야 하는 방문 카운터라면 "tabs"가 맞아. manifest.json"storage" 옆에 나란히 적어.

System page 거르기

돌려 보면 금방 눈에 띌 거야. chrome://, about:, chrome-extension:// 주소들이 카운트를 더럽혀. 이런 건 건너뛰어. user가 마음먹고 찾아간 페이지가 아니잖아. 한 줄짜리 거름망이면 잡음의 99% 는 걸러져:

비어 있거나 null 인 주소도 같이 건너뛰어. 새 tab이 실제 주소에 닿기 전 단계에서 그런 게 들어오거든. 이 거름망을 지나고 나면, 올라가는 숫자 하나하나가 user가 직접 연 진짜 페이지랑 딱 맞아떨어져.

Schema

chrome.storage.local의 두 key:

  • totalVisits: number — 올라가기만 하는 카운터야. user가 직접 누르지 않는 한 안 돌아가.
  • urlCounts: Record<string, number> — 주소별 횟수고. 덕분에 popup에서 상위 몇 개를 뽑아 보여 줄 수 있어.

단순한 숫자 하나랑 구조를 가진 object 하나를 storage에서 같이 보여 주는 가장 작은 schema 야. Track 3의 clip 목록도 결국 같은 모양이야 — 배열을 담을 key 하나, 거기서 파생되는 개수나 부가 정보를 담을 key 하나.

Popup 쪽

popup은 뜨자마자 두 key를 읽고 그려. 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).

기능 하나에 Track 2가 통째로 담겨 있어. event가 SW를 깨우고, SW가 storage를 고치고, popup이 onChanged를 받아 다시 그리고, user가 누른 건 메시지로 되돌아와. ClipDeck이 앞으로 모든 기능에서 타고 다닐 고리가 이거야.
하필 왜 방문 카운터냐면. 새로운 개념을 하나도 안 들이면서 Track 2에서 배운 걸 전부 써먹는 가장 작은 기능이라서 그래. Track 3에 가서 카운터를 버려도 괜찮아. 요점은 고리지 숫자가 아니거든. 그냥 두고 "이 사이트 얼마나 자주 오지?" 위젯으로 키우는 사람도 있고. 둘 다 괜찮은 선택이야.
실시간으로 올라가는 거 봐 봐. chrome://extensions → ClipDeck → 'Inspect views: service worker' 로 SW DevTools 열기. Application 탭 → Storage → Extension Storage → 'local'. 이제 page 몇 개 navigate. urlCounts가 refresh 없이 실시간으로 자라. 전체 pipeline이 제대로 연결됐는지 확인하는 가장 빠른 sanity check.
Compound storage update는 원자적이지 않아. get → modify → set을 surface 둘이 동시에 돌리면 한쪽 변경이 사라질 수 있어. Service worker를 sole writer로 두고 mutation queue 하나로 직렬화하거나 record를 독립 key로 저장해. 한 번의 set은 그 안의 key를 함께 적용하지만, 앞뒤 read-modify-write 전체가 atomic인 건 아니야.

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((tabId, changeInfo, tab) => {
  if (changeInfo.status !== "complete" || isSkippableUrl(tab.url)) return;
  void mutateLocal(["totalVisits", "urlCounts"],
    ({ totalVisits = 0, urlCounts = {} }) => ({
      totalVisits: totalVisits + 1,
      urlCounts: { ...urlCounts, [tab.url]: (urlCounts[tab.url] || 0) + 1 },
    }));
});

// 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 — 처음 읽고 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

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

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