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

chrome.storage — Eviction 견디는 state layer

~12 min · chrome.storage, state, async, service-worker, json

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Service worker가 깼다 잠들었다 하는 함수라면, chrome.storage는 그게 쓰는 기억이야. Lesson 4는 *중요한 건 다 storage에 살아야 한다* 는 사실이랑 화해하는 시간이고."

두 storage 영역: local과 sync

chrome.storage는 하위 namespace를 여럿 갖고 있어. 그중 제일 자주 쓸 둘부터:

  • chrome.storage.local — 머신 하나에 묶이고, extension을 지울 때까지 남아. MV3 기준 quota는 10 MB 쯤 되고, API는 Promise 기반이야. ClipDeck의 clip, 방문 카운트, 쌓이는 data는 전부 여기가 기본값이야.
  • chrome.storage.sync — 로그인한 Chrome 끼리 동기화돼. 전체 100 KB, 항목 하나당 8 KB 쯤에서 막혀. 사용자 취향 (theme, hotkey 설정) 담는 데 쓰지, 쌓이는 data 담는 데는 아니야.

덜 쓰지만 알아 둘 것도 둘 있어. chrome.storage.session (2023에 추가)은 worker eviction은 견디는데 browser session이 끝나면 같이 죽어. chrome.storage.managed는 읽기 전용이고, 기업 정책이 대신 채워 줘.

전부 async 야

chrome.storage 작업은 하나도 빠짐없이 async 야. MV3 부터는 Promise를 돌려줘 (옛날 callback 방식도 나란히 살아 있고). 메서드 여섯 개면 거의 다 해결돼:

  • get(key | keys[] | undefined) — 하나, 여럿, 아니면 전부 읽기
  • set(object) — 넘긴 object를 기존 데이터에 합쳐 (몇 번 불러도 같고, 상관없는 key는 안 건드려)
  • remove(key | keys[]) — key 지우기
  • clear() — 이 영역 통째로 비우기
  • getBytesInUse(key | keys[] | undefined) — quota 확인
  • onChanged.addListener(fn) — 어느 context에서 쓰든 write가 나면 반응

set에 object를 넘기면 key를 합쳐. storage 전체를 갈아엎는 게 아니야. 그래서 { visitCount: 5 }를 써도 { clips: [...] }는 멀쩡해. storage 층이 기능끼리 안 부딪히고 조립되는 이유가 이거야.

JSON으로 되는 것만

chrome.storage는 값을 JSON으로 직렬화해. 그래서 실제로 이런 일이 생겨:

  • 평범한 object, 배열, 문자열, 숫자, boolean, null — 다 괜찮아.
  • Date object → 들어갈 때 ISO 문자열이 되고, 꺼내면 문자열로 나와. 타입은 잃어버려.
  • Map, Set{} 나 빈 배열이 돼 버려. 넣기 전에 Object.fromEntries(map) 이나 [...set]로 직접 바꿔.
  • 함수, 메서드 달린 class instance, 순환 참조 — 조용히 망가지거나 대놓고 실패해.

JSON으로 옮길 수 있는 평범한 모양만 써. 특이한 타입은 넣을 때 평범한 값으로 바꾸고, 꺼낼 때 다시 조립하는 게 맞아.

변경 지켜보기

chrome.storage.onChanged는 popup 이든 side panel 이든 service worker 든 content script 든, 어디서 write가 나도 전부 fire 해. 그래서 popup이 worker가 쓴 값에 바로 반응할 수 있어. message를 주고받을 필요가 없지.

읽기 상태를 갱신하는 데는 이게 chrome.runtime.sendMessage 대신 쓸 수 있는 길이야. popup은 onChanged만 구독해 두고, SW가 storage에 쓰면, popup이 알아서 다시 그려. 왕복도 없고, "지금 worker 깨어 있나" 를 물어볼 일도 없어.

ClipDeck의 첫 storage 왕복

Lesson 6에서 ClipDeck 방문 카운터에 storage를 쓸 거야. 아래 두 번째 code block이 그 뼈대고. 읽고-처리하고-쓰고-다시-읽는 패턴의 가장 순수한 형태야. Module 수준에서는 아무 state도 안 들고 있고. Track 3부터 나오는 ClipDeck의 clip 목록도 같은 모양이야 — storage에서 배열 읽고, 뒤에 붙이고, 다시 쓰기. Track 7의 edit과 delete도 똑같아 — 읽고, 고치고, 쓰기.

이 패턴 하나가 몸에 붙으면 ClipDeck의 나머지는 거의 다 그 변주야.

chrome.storage는 ClipDeck 이라는 정체성이 worker eviction도, browser 재시작도, Mac 재부팅도 넘어서 살아남는 층이야. 중요한 건 전부 여길 거쳐 가. 잠깐 쓰고 버릴 건 scope 안에 남아서 예정대로 죽고.
디버깅할 땐 이렇게: chrome://extensions → ClipDeck → 'Inspect views: service worker' → Application 탭 → Storage → Extension Storage → 'local'. key/value가 실시간으로 다 보이고, 그 자리에서 고칠 수도 있어. onChanged listener 랑 같이 쓰면 state가 어딘가 걸려 있는 버그를 잡는 제일 빠른 길이야.
Compound storage update는 원자적이지 않아. get → modify → set을 surface 둘이 동시에 돌리면 한쪽 변경이 사라질 수 있어. Service worker를 sole writer로 두고 mutation queue 하나로 직렬화하거나 record를 독립 key로 저장해. 한 번의 set은 그 안의 key를 함께 적용하지만, 앞뒤 read-modify-write 전체가 atomic인 건 아니야.

Code

chrome.storage.local — 기본 get/set/remove/quota·javascript
// Basic chrome.storage.local usage (run from SW DevTools console)

// Read one key with default
const { visitCount = 0 } = await chrome.storage.local.get("visitCount");

// Read several keys
const { foo, bar } = await chrome.storage.local.get(["foo", "bar"]);

// Read everything
const all = await chrome.storage.local.get();

// Write — merges into existing data
await chrome.storage.local.set({
  visitCount: visitCount + 1,
  lastVisitAt: new Date().toISOString(),
});

// Remove a key
await chrome.storage.local.remove("lastVisitAt");

// Quota check
const bytes = await chrome.storage.local.getBytesInUse();
console.log("[ClipDeck SW] storage bytes in use:", bytes);
background.js — canonical serialized mutation queue, popup은 reader·javascript
// background.js — serialize compound mutations through the service worker
let localMutation = Promise.resolve();

function mutateLocal(keys, reducer) {
  const run = async () => {
    const current = await chrome.storage.local.get(keys);
    const next = await reducer(current);
    await chrome.storage.local.set(next);
    return next;
  };
  const result = localMutation.then(run, run);
  localMutation = result.then(() => undefined, () => undefined);
  return result;
}

// Example: a completed navigation cannot lose a simultaneous increment.
chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
  if (info.status !== "complete" || !tab.url) return;
  void mutateLocal(["visitCount"], ({ visitCount = 0 }) => ({
    visitCount: visitCount + 1,
  }));
});

// popup.js remains a reader and subscribes to storage.onChanged.

External links

Exercise

clipdeck/manifest.json에 "permissions": ["storage"] 를 추가해 (storage는 선언해야 쓰는 permission 이야). Reload 하고 SW DevTools를 열어 (chrome://extensions → ClipDeck → Inspect views: service worker). Console에 이걸 붙여 넣어 봐: await chrome.storage.local.set({ greeting: 'hello from ClipDeck SW', when: new Date().toISOString() }). console.log(await chrome.storage.local.get());. 그다음 Application 탭 → Storage → Extension Storage → 'local' 로 가. key 두 개가 보일 거야. 표에서 'greeting' 값을 직접 고쳐 봐. 다시 console로 와서 chrome.storage.onChanged.addListener((changes, area) => console.log('[change]', area, changes)) 를 걸고, 표에서 값을 또 고쳐 봐 — listener가 fire 해? (변경 내용까지 같이 떠야 정상이야.)Increment 둘을 연달아 trigger해서 serialized helper가 둘 다 보존하는지 확인해. 뒤 lesson의 clip mutation도 이 worker-owned helper를 재사용해.
Hint
Console에서 await가 'await is only valid in async functions' 로 터지면 한 줄로 감싸: (async () => { await chrome.storage.local.set({...}). console.log(await chrome.storage.local.get()). })(). 요즘 Chrome DevTools는 top-level await를 대체로 받아 주는데 옛날 flag 걸린 console은 안 받아 줘. Application 탭에서 값을 고쳤는데 onChanged가 안 울리면, 대개 listener를 이전 SW 실행 때 걸어 놨고 그게 그 사이 evict 된 거야. 지금 console session에서 다시 걸어 봐.

Progress

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

댓글 0

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

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