Chrome Storage로 실시간 상태 동기화 구현하기

popup에서 잠금 해제했는데 options 페이지는 여전히 잠금 화면? chrome.storage.onChanged 이벤트로 여러 페이지 간 상태를 실시간 동기화하는 패턴을 정리했습니다.

1. 문제 상황

증상: 페이지 간 상태 불일치

크롬 확장에서 여러 페이지(popup, options, manager)를 운영하고 있습니다. 문제는 한 페이지에서 상태를 변경하면 다른 페이지에는 반영되지 않는다는 것입니다.

[Popup 페이지]              [Options 페이지]
     |                           |
  PIN 입력                       |
     |                           |
  잠금 해제됨 ✓                   | ← 여전히 잠금 화면 표시
     |                           |
  북마크 목록 표시                | ← 새로고침해야 반영

구체적인 문제 상황들

1. 잠금 상태 동기화

  • Popup에서 PIN을 입력해 잠금 해제
  • Options 페이지는 여전히 잠금 화면 표시
  • 사용자가 혼란스러워함

2. 테마 동기화

  • Options에서 다크 모드로 변경
  • Popup은 여전히 라이트 모드
  • 페이지를 닫았다 열어야 반영

3. 언어 설정 동기화

  • Options에서 한국어로 변경
  • 다른 페이지는 영어 유지

기존 접근 방식의 한계

// 초기 로드 시에만 상태 확인
async function init() {
  const settings = await chrome.storage.local.get('settings');
  applySettings(settings);
}

// 문제: 다른 페이지에서 변경해도 감지하지 못함

2. 해결 방법: chrome.storage.onChanged

핵심 아이디어

chrome.storage.onChanged 이벤트를 활용하면 storage가 변경될 때마다 모든 페이지에서 알림을 받을 수 있습니다.

[Popup]                    [Storage]                  [Options]
   |                          |                          |
   |  storage.set({locked})   |                          |
   | -----------------------> |                          |
   |                          |   onChanged 이벤트       |
   |                          | -----------------------> |
   |                          |                          |
   |                          |                    UI 자동 업데이트

기본 API

// 이벤트 리스너 등록
chrome.storage.onChanged.addListener((changes, areaName) => {
  // changes: { key: { oldValue, newValue } }
  // areaName: 'local' | 'sync' | 'session'

  console.log('Storage changed:', changes);
  console.log('Area:', areaName);
});

changes 객체 구조

// storage.local.set({ theme: 'dark', language: 'ko' }) 실행 시
// onChanged 콜백에서 받는 changes:
{
  "theme": {
    "oldValue": "light",
    "newValue": "dark"
  },
  "language": {
    "oldValue": "en",
    "newValue": "ko"
  }
}

3. 실전 구현: 잠금 상태 동기화

3.1 Background에서 잠금 상태 저장

// background.js

const PIN_UNLOCKED_KEY = 'pinUnlocked';

// 잠금 해제 상태 저장 (session storage 사용)
async function setPinUnlocked(unlocked) {
  await chrome.storage.session.set({ [PIN_UNLOCKED_KEY]: unlocked });
}

// 잠금 해제 상태 확인
async function isPinUnlocked() {
  const data = await chrome.storage.session.get(PIN_UNLOCKED_KEY);
  return data[PIN_UNLOCKED_KEY] === true;
}

// PIN 검증 성공 시
async function handleUnlockPin(pin) {
  const isValid = await verifyPin(pin);
  if (isValid) {
    await setPinUnlocked(true);  // ← 여기서 storage 변경 발생
    return { ok: true };
  }
  return { ok: false, reason: 'invalid-pin' };
}

// 잠금 시
async function handleLockPin() {
  await setPinUnlocked(false);  // ← 여기서 storage 변경 발생
  return { ok: true };
}

session vs local storage:

  • session: 브라우저 종료 시 삭제 (잠금 상태에 적합)
  • local: 영구 저장 (설정에 적합)

3.2 LockScreen 컴포넌트에서 동기화

// shared/lock-screen.js

const LockScreen = (function () {
  'use strict';

  let isLocked = false;
  let onUnlockCallback = null;

  /**
   * 초기화
   */
  function init(elements, onUnlock) {
    // ... DOM 요소 설정 ...
    onUnlockCallback = onUnlock;

    // 핵심: 다른 페이지에서의 잠금 상태 변경 감지
    listenForLockStateChanges();
  }

  /**
   * 잠금 상태 변경 리스너
   */
  function listenForLockStateChanges() {
    chrome.storage.onChanged.addListener((changes, area) => {
      // session storage의 pinUnlocked 키 변경 감지
      if (area === 'session' && changes['pinUnlocked']) {
        const isUnlocked = changes['pinUnlocked'].newValue;

        if (!isUnlocked && !isLocked) {
          // 다른 페이지에서 잠금됨 → 잠금 화면 표시
          checkLockState();
        } else if (isUnlocked && isLocked) {
          // 다른 페이지에서 잠금 해제됨 → 잠금 화면 숨김
          hideLockScreen();
          if (onUnlockCallback) {
            onUnlockCallback();
          }
        }
      }
    });
  }

  /**
   * 잠금 상태 확인 및 화면 전환
   */
  async function checkLockState() {
    const settings = await chrome.storage.local.get(['settings', 'pinMeta']);
    const pinEnabled = settings.settings?.pinEnabled;
    const hasPin = settings.pinMeta?.hashB64;

    if (!pinEnabled || !hasPin) {
      hideLockScreen();
      return false;
    }

    // Background에 현재 잠금 상태 확인
    const response = await chrome.runtime.sendMessage({ type: 'isPinUnlocked' });
    if (response?.unlocked) {
      hideLockScreen();
      return false;
    } else {
      showLockScreen();
      return true;
    }
  }

  function showLockScreen() {
    isLocked = true;
    // ... UI 업데이트 ...
  }

  function hideLockScreen() {
    isLocked = false;
    // ... UI 업데이트 ...
  }

  return { init, checkLockState, showLockScreen, hideLockScreen };
})();

3.3 Popup에서 간단하게 사용

// popup.js

function listenForLockStateChanges() {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'session' && changes['pinUnlocked']) {
      // 상태 변경 시 전체 새로고침
      refresh();
    }
  });
}

async function refresh() {
  const state = await sendMessage({ type: 'getState' });
  if (state.locked) {
    showLockedUI();
  } else {
    showUnlockedUI();
    await loadBookmarks();
  }
}

// 초기화
document.addEventListener('DOMContentLoaded', () => {
  listenForLockStateChanges();
  refresh();
});

4. 실전 구현: 테마 동기화

4.1 테마 변경 시 Storage에 저장

// shared/theme.js

const THEME_KEY = 'userTheme';

/**
 * 테마 초기화
 */
async function initTheme() {
  // 저장된 테마 로드
  const data = await chrome.storage.local.get(THEME_KEY);
  const theme = data[THEME_KEY] || getSystemTheme();

  applyTheme(theme);

  // 다른 페이지에서 테마 변경 시 동기화
  listenForThemeChanges();
}

/**
 * 테마 적용
 */
function applyTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
}

/**
 * 테마 토글
 */
async function toggleTheme() {
  const current = document.documentElement.getAttribute('data-theme') || 'light';
  const next = current === 'dark' ? 'light' : 'dark';

  applyTheme(next);
  await chrome.storage.local.set({ [THEME_KEY]: next });  // ← Storage 변경

  return next;
}

/**
 * 테마 변경 리스너
 */
function listenForThemeChanges() {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'local' && changes[THEME_KEY]) {
      // 다른 페이지에서 테마 변경 시 즉시 적용
      applyTheme(changes[THEME_KEY].newValue);
    }
  });
}

4.2 동작 흐름

[Options 페이지]                              [Popup 페이지]
      |                                            |
  다크 모드 토글                                    |
      |                                            |
  toggleTheme()                                    |
      |                                            |
  storage.local.set({userTheme: 'dark'})           |
      |                                            |
      +-------------- onChanged ------------------>|
                                                   |
                                            applyTheme('dark')
                                                   |
                                            즉시 다크 모드 적용 ✓

5. 실전 구현: 언어 설정 동기화

5.1 i18n 모듈에서 동기화

// shared/i18n.js

const SETTINGS_KEY = 'settings';

const i18n = (function () {
  let currentLang = 'en';

  async function init() {
    // 저장된 언어 로드
    currentLang = await getLanguage();
    applyTranslations();

    // 다른 페이지에서 언어 변경 시 동기화
    chrome.storage.onChanged.addListener((changes, area) => {
      if (area === 'local' && changes[SETTINGS_KEY]) {
        const newSettings = changes[SETTINGS_KEY].newValue || {};
        if (newSettings.language) {
          setLanguage(newSettings.language);
        }
      }
    });

    return currentLang;
  }

  async function setLanguage(lang) {
    if (lang === 'auto') {
      currentLang = detectBrowserLanguage();
    } else {
      currentLang = lang;
    }
    applyTranslations();  // 즉시 UI 업데이트
  }

  function applyTranslations() {
    document.querySelectorAll('[data-i18n]').forEach(el => {
      const key = el.getAttribute('data-i18n');
      el.textContent = t(key);
    });
  }

  function t(key) {
    return messages[currentLang]?.[key] || messages.en[key] || key;
  }

  return { init, setLanguage, t };
})();

6. 패턴 정리: onChanged 활용 패턴

6.1 기본 패턴

function listenForChanges(key, callback) {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (changes[key]) {
      callback(changes[key].newValue, changes[key].oldValue);
    }
  });
}

// 사용
listenForChanges('theme', (newTheme, oldTheme) => {
  console.log(`Theme changed: ${oldTheme} → ${newTheme}`);
  applyTheme(newTheme);
});

6.2 특정 Storage Area 필터링

function listenForLocalChanges(key, callback) {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'local' && changes[key]) {
      callback(changes[key].newValue, changes[key].oldValue);
    }
  });
}

function listenForSessionChanges(key, callback) {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'session' && changes[key]) {
      callback(changes[key].newValue, changes[key].oldValue);
    }
  });
}

// 사용
listenForLocalChanges('settings', handleSettingsChange);
listenForSessionChanges('pinUnlocked', handleLockStateChange);

6.3 여러 키 동시 감지

function listenForMultipleChanges(keys, callback) {
  chrome.storage.onChanged.addListener((changes, area) => {
    const relevantChanges = {};
    let hasRelevant = false;

    keys.forEach(key => {
      if (changes[key]) {
        relevantChanges[key] = changes[key];
        hasRelevant = true;
      }
    });

    if (hasRelevant) {
      callback(relevantChanges, area);
    }
  });
}

// 사용
listenForMultipleChanges(['theme', 'language', 'fontSize'], (changes, area) => {
  if (changes.theme) applyTheme(changes.theme.newValue);
  if (changes.language) applyLanguage(changes.language.newValue);
  if (changes.fontSize) applyFontSize(changes.fontSize.newValue);
});

6.4 Debounced 리스너 (빈번한 변경 처리)

function listenWithDebounce(key, callback, delay = 100) {
  let timer = null;
  let latestValue = null;

  chrome.storage.onChanged.addListener((changes, area) => {
    if (changes[key]) {
      latestValue = changes[key].newValue;

      clearTimeout(timer);
      timer = setTimeout(() => {
        callback(latestValue);
      }, delay);
    }
  });
}

// 사용 (빈번하게 변경되는 설정)
listenWithDebounce('searchQuery', (query) => {
  performSearch(query);
}, 150);

7. Storage Area 선택 가이드

local vs session vs sync

특성 local session sync
지속성 영구 브라우저 종료 시 삭제 영구 + 동기화
용량 ~5MB ~1MB ~100KB
동기화 X X 계정 간 동기화
용도 설정, 데이터 임시 상태, 세션 사용자 설정

적합한 사용처

// local: 영구 설정
await chrome.storage.local.set({
  settings: { theme: 'dark', language: 'ko' },
  bookmarks: [...],
  pinMeta: { saltB64, hashB64 }
});

// session: 임시 상태 (브라우저 종료 시 초기화됨)
await chrome.storage.session.set({
  pinUnlocked: true,       // 잠금 해제 상태
  searchQuery: 'test',     // 현재 검색어
  lastViewedTab: 'recent'  // 마지막 본 탭
});

// sync: 계정 간 동기화 필요한 설정
await chrome.storage.sync.set({
  preferences: { showNotifications: true }
});

8. 주의사항 및 트러블슈팅

8.1 무한 루프 방지

// 잘못된 예: 무한 루프 발생 가능
chrome.storage.onChanged.addListener((changes, area) => {
  if (changes['counter']) {
    const newValue = changes['counter'].newValue + 1;
    chrome.storage.local.set({ counter: newValue });  // ← 다시 onChanged 트리거!
  }
});

// 올바른 예: 값 비교로 무한 루프 방지
chrome.storage.onChanged.addListener((changes, area) => {
  if (changes['counter']) {
    const newValue = changes['counter'].newValue;
    const oldValue = changes['counter'].oldValue;

    // 실제로 변경되었을 때만 처리
    if (newValue !== oldValue) {
      updateUI(newValue);
      // storage.set 하지 않음!
    }
  }
});

8.2 리스너 중복 등록 방지

// 잘못된 예: 여러 번 호출 시 리스너 중복
function init() {
  chrome.storage.onChanged.addListener(handleChange);
}

// 올바른 예: 플래그로 중복 방지
let listenerRegistered = false;

function init() {
  if (!listenerRegistered) {
    chrome.storage.onChanged.addListener(handleChange);
    listenerRegistered = true;
  }
}

8.3 초기 상태 처리

// 문제: 페이지 로드 시 onChanged는 발생하지 않음
function init() {
  listenForThemeChanges();  // 이후 변경만 감지
  // 초기 테마는 적용되지 않음!
}

// 해결: 초기 로드 + 변경 감지 모두 처리
async function init() {
  // 1. 초기 상태 로드
  const data = await chrome.storage.local.get('theme');
  applyTheme(data.theme || 'light');

  // 2. 이후 변경 감지
  listenForThemeChanges();
}

8.4 area 필터링 잊지 않기

// 잘못된 예: area 확인 안 함
chrome.storage.onChanged.addListener((changes) => {
  if (changes['pinUnlocked']) {
    // local과 session 둘 다 반응할 수 있음!
    handleLockChange(changes['pinUnlocked'].newValue);
  }
});

// 올바른 예: area 명시적 확인
chrome.storage.onChanged.addListener((changes, area) => {
  if (area === 'session' && changes['pinUnlocked']) {
    handleLockChange(changes['pinUnlocked'].newValue);
  }
});

9. 완전한 구현 예시

9.1 통합 상태 동기화 모듈

// shared/state-sync.js

const StateSync = (function () {
  'use strict';

  const listeners = new Map();

  /**
   * 초기화 및 통합 리스너 등록
   */
  function init() {
    chrome.storage.onChanged.addListener((changes, area) => {
      listeners.forEach((config, key) => {
        if (config.area === area && changes[key]) {
          config.callback(changes[key].newValue, changes[key].oldValue);
        }
      });
    });
  }

  /**
   * 특정 키의 변경 구독
   * @param {string} key - Storage 키
   * @param {string} area - 'local' | 'session' | 'sync'
   * @param {Function} callback - (newValue, oldValue) => void
   */
  function subscribe(key, area, callback) {
    listeners.set(key, { area, callback });
  }

  /**
   * 구독 해제
   */
  function unsubscribe(key) {
    listeners.delete(key);
  }

  /**
   * 값 설정 (자동으로 onChanged 트리거)
   */
  async function set(area, key, value) {
    const storage = chrome.storage[area];
    await storage.set({ [key]: value });
  }

  /**
   * 값 조회
   */
  async function get(area, key) {
    const storage = chrome.storage[area];
    const data = await storage.get(key);
    return data[key];
  }

  return { init, subscribe, unsubscribe, set, get };
})();

// 사용 예시
StateSync.init();

StateSync.subscribe('pinUnlocked', 'session', (unlocked) => {
  if (unlocked) {
    hideLockScreen();
  } else {
    showLockScreen();
  }
});

StateSync.subscribe('userTheme', 'local', (theme) => {
  document.documentElement.setAttribute('data-theme', theme);
});

10. 베스트 프랙티스

DO (해야 할 것)

  • [x] 초기 로드 + onChanged 리스너 모두 구현
  • [x] area 파라미터 명시적 체크
  • [x] 적절한 storage area 선택 (local/session/sync)
  • [x] 리스너 중복 등록 방지
  • [x] 무한 루프 가능성 검토

DON'T (하지 말아야 할 것)

  • [ ] onChanged 콜백에서 같은 키 다시 set
  • [ ] area 체크 없이 모든 storage 변경에 반응
  • [ ] 페이지 로드 때마다 리스너 중복 등록
  • [ ] 초기 상태 로드 생략

11. FAQ

Q: onChanged는 같은 페이지에서 변경해도 발생하나요?

A: 네. 같은 페이지에서 storage.set을 호출해도 onChanged 이벤트가 발생합니다. 따라서 이미 로컬에서 처리한 변경을 또 처리하지 않도록 주의해야 합니다.

Q: Background script에서도 onChanged를 받을 수 있나요?

A: 네. Background service worker에서도 chrome.storage.onChanged를 구독할 수 있습니다. 모든 extension 컨텍스트(popup, options, background, content script)에서 동일하게 동작합니다.

Q: 여러 키를 한 번에 set하면 onChanged는 몇 번 발생하나요?

A: 한 번만 발생합니다. changes 객체에 모든 변경된 키가 포함됩니다.

// 한 번의 set 호출
await chrome.storage.local.set({ theme: 'dark', language: 'ko' });

// onChanged는 1번 발생, changes 객체에 2개 키 포함
// { theme: {...}, language: {...} }

Q: incognito 모드에서도 동기화되나요?

A: manifest의 incognito 설정에 따라 다릅니다:

  • "spanning": 일반/시크릿 창이 같은 storage 공유
  • "split": 일반/시크릿 창이 별도 storage 사용

12. 참고 자료


시리즈 목차

  1. JavaScript URL 비교와 정규화
  2. Web Crypto API로 안전한 해싱 구현하기
  3. CSS 변수와 다크 모드 구현하기
  4. 크롬 확장 프로젝트 구조 정리하기
  5. 크롬 확장 다국어(i18n) 구현하기
  6. 크롬 확장 공유 모듈 설계
  7. Chrome Storage로 실시간 상태 동기화 ← 현재 글