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. 참고 자료
시리즈 목차
- JavaScript URL 비교와 정규화
- Web Crypto API로 안전한 해싱 구현하기
- CSS 변수와 다크 모드 구현하기
- 크롬 확장 프로젝트 구조 정리하기
- 크롬 확장 다국어(i18n) 구현하기
- 크롬 확장 공유 모듈 설계
- Chrome Storage로 실시간 상태 동기화 ← 현재 글