단일 크롬 확장에서 멀티테넌트 구현하기: Space Isolation 패턴과 30개 메시지 핸들러 스코핑기

하나의 크롬 확장 안에 여러 격리된 데이터 공간을 두려면 메시지 핸들러마다 "현재 공간" 개념을 주입해야 합니다. 30개 가까운 핸들러에 Space Isolation 패턴을 녹이고, cross-page sync를 깨지 않으면서, 리팩토링 부수 효과로 dead code까지 정리한 경험을 정리했습니다.

단일 크롬 확장에서 멀티테넌트 구현하기: Space Isolation 패턴과 30개 메시지 핸들러 스코핑기

1. 문제 상황: 하나의 확장, 여러 개의 격리된 세계

사이드 프로젝트로 운영 중인 크롬 확장에 디코이 스페이스라는 기능을 붙였습니다. 같은 확장 안에 PIN으로 구분되는 여러 개의 데이터 공간이 존재하고, 각각 자기만의 북마크, 설정, 라이선스, 테마, 언어를 가집니다. 이전 글(크롬 확장에 위장 공간 만들기: AES-GCM과 티어드 패딩)에서 암호화 레이어를 다뤘다면, 이 글은 그 위에서 생긴 애플리케이션 레이어의 이야기입니다.

암호화까지는 모듈 하나로 정리됐습니다. 그런데 암호화된 공간이 "생기고" 난 뒤에, 기존 확장의 모든 곳이 새 질문을 던집니다.

  • 북마크 한 공간에서 추가했는데, 다른 공간에서 보이면 안 됩니다 — 그럼 addBookmark 핸들러는 어느 스토리지를 건드려야 하나요?
  • 설정을 공간 A에서 변경했는데, 공간 B에 새어 나가면 안 됩니다 — saveSettings는?
  • 자동 잠금 타이머는 공간 A의 설정을 따라야 합니다 — getAutoLockSettings는?
  • Pro 라이선스가 공간 A에만 활성되어 있을 수도, 모든 공간에 상속될 수도 있습니다 — getProStatus는?
  • 테마와 언어도 공간별로 다를 수 있습니다. 그런데 옵션 페이지와 팝업은 chrome.storage.local 을 직접 읽어서 cross-page sync를 구현하고 있습니다 — 이건 어떻게 덧붙이죠?

1.1 세어 보면 30개 가까운 핸들러

background.js 하나 안에서 chrome.runtime.onMessage 리스너가 처리하는 메시지 타입을 세어 보면 다음과 같습니다.

분류 핸들러 예시
상태 조회 5개 getState, listBookmarks, listRecent, getSettings, getAutoLockSettings
북마크 CRUD 6개 addBookmark, removeBookmark, clearAllBookmarks, saveAllTabs, restoreBookmark, updateBookmark
최근 탭 3개 restoreUrl, removeFromRecent, clearAllRecent
설정 4개 togglePersist, setExpiryDays, setAutoLock*, setSpaceSetting
라이선스 5개 activateLicense, deactivateLicense, checkProStatus, getProStatus, getDeviceId
공간 제어 4개 enterSpace, exitSpace, registerSpace, deleteSpace

총 27개. 이 모두가 "공간 진입 상태"에 따라 다르게 동작해야 합니다. 한두 개 빼먹으면 데이터가 공간 경계를 넘어 유출됩니다. 그리고 그 버그는 유닛 테스트로 잡기 거의 불가능합니다 — 사용자가 공간 A에서 북마크를 추가한 뒤 공간 B로 옮겨서야 발견되는 종류입니다.

1.2 기존 아키텍처가 가정한 것

Chrome 확장의 기본 스토리지 모델은 단순합니다.

chrome.storage.session   ← 휘발성 (창 닫히면 소멸)
chrome.storage.local     ← 영구 (설치 기간 유지)

기존 확장은 인코그니토 Split Mode를 쓰고 있어서 실행 컨텍스트는 2개(일반/인코그니토)였지만, 각 컨텍스트 안에서는 여전히 "하나의 storage, 하나의 전역 상태" 라는 가정 위에 있었습니다. 스페이스는 이 가정을 깹니다 — 같은 인코그니토 컨텍스트 안에서 여러 개의 "world"가 병존해야 합니다.


2. 원인 분석: 왜 모든 핸들러를 고쳐야 하는가

세 가지 이유가 있습니다.

2.1 스페이스 데이터는 스토리지가 아니라 메모리에 산다

암호화 레이어 설계상, 활성 공간의 복호화된 데이터는 절대 디스크에 있어선 안 됩니다. 디스크에 있는 건 항상 AES-GCM 암호문입니다. 복호화된 상태는 SpaceManager 내부의 변수에만 존재하고, 사용자가 공간에서 나가거나 타임아웃이 발동되면 null로 돌아갑니다.

// extension/shared/space-manager.js (요약)
const SpaceManager = (function () {
  let activeSpace = null;        // ← 복호화된 데이터 (메모리 온리)
  let activeSlotIndex = -1;
  let activePin = null;
  let activeCryptoKey = null;    // ← 캐시된 AES 키 (메모리 온리)
  // ...
})();

즉 공간 안의 상태를 건드리는 코드는 chrome.storage가 아니라 SpaceManager의 메모리를 건드려야 합니다. 그리고 변경 후엔 반드시 saveActiveSpace() 를 호출해서 메모리→암호화→디스크로 이어지는 파이프라인을 돌려야 합니다. 이걸 빼먹으면 공간이 나가자마자 변경 내역이 사라집니다.

2.2 둘 다 지원해야 한다 — 기본 공간과 PIN 공간

디코이 스페이스는 옵션입니다. 사용자가 공간을 한 번도 만들지 않으면 확장은 기존처럼 동작해야 합니다. 즉 모든 핸들러는:

  1. 공간 안이면SpaceManager 메모리 경유
  2. 공간 밖(기본 공간)이면chrome.storage 직접 경유

이 두 분기를 모두 가져야 합니다. 새 시스템이 기존 시스템을 대체하는 게 아니라 공존합니다.

2.3 공간 간 누출은 보안 사고

데이터 유출이 보안 기능의 핵심입니다. 공간 A의 북마크가 공간 B에 한 번이라도 보이면 플라우저블 디나이빌리티(plausible deniability)가 깨집니다. 이 맥락에서 "그냥 버그"는 없습니다 — 모든 누출은 곧 기능 실패입니다.


3. 해결 방법: Space Isolation 패턴

패턴 자체는 단순합니다. 모든 메시지 핸들러에 isInSpace() 분기를 넣는 것. 단순한 만큼 꼼꼼함이 전부입니다.

3.1 Active Space Context: 메모리 기반 컨텍스트

SpaceManager는 활성 공간의 상태를 IIFE 안에서 싱글톤으로 유지합니다. 공간 전환은 이 싱글톤의 activeSpace, activeSlotIndex, activePin, activeCryptoKey 네 필드를 교체하는 것뿐입니다.

// 공간 진입
async function enterSpace(pin) {
  const result = await findSpace(pin);
  if (result) {
    activeSpace = result.data;
    activeSlotIndex = result.index;
    activePin = pin;
    activeCryptoKey = result.key;
    return { found: true, data: result.data };
  }
  return { found: false };
}

// 공간 퇴장 — 메모리 상 모든 민감 상태 클리어
function exitToDefault() {
  activeSpace = null;
  activeSlotIndex = -1;
  activePin = null;
  activeCryptoKey = null;
}

function isInSpace() {
  return activeSlotIndex >= 0;
}

이 상태는 오직 진입·퇴장 함수에서만 변경됩니다. 메시지 핸들러는 읽기만 합니다. 이 규칙을 깨는 건 버그로 취급합니다.

3.2 공통 패턴: if (isInSpace()) / else

거의 모든 핸들러는 이 구조를 따릅니다.

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  (async () => {
    try {
      if (msg.type === 'listBookmarks') {
        if (SpaceManager.isInSpace()) {
          // ─── 공간 안 ───
          const space = SpaceManager.getActiveSpace();
          let list = space.bookmarks || [];
          // ... 만료 필터링, 정렬 등 ...
          sendResponse({ list, persist: true });
        } else {
          // ─── 공간 밖 ───
          const settings = await getSettings();
          const area = getArea(settings.persistSecretBookmarks);
          const list = (await area.get(BOOKMARKS_KEY))[BOOKMARKS_KEY] || [];
          sendResponse({ list, persist: settings.persistSecretBookmarks });
        }
      }
      // ... 다른 핸들러들도 동일한 분기 ...
    } catch (e) {
      console.error('[Ext] Message handler error:', msg?.type, e);
      sendResponse({ ok: false, reason: 'internal-error' });
    }
  })();
  return true; // async response
});

쓰기 핸들러는 여기에 saveActiveSpace() 호출이 덧붙습니다.

} else if (msg.type === 'addBookmark') {
  const validation = validateBookmarkInput(msg.url, msg.title);
  if (!validation.valid) {
    sendResponse({ ok: false, reason: validation.reason });
    return;
  }

  if (SpaceManager.isInSpace()) {
    const space = SpaceManager.getActiveSpace();
    const list = space.bookmarks || [];
    if (!list.some((b) => urlsMatch(b.url, msg.url))) {
      const spaceSettings = space.settings || {};
      list.unshift(createBookmarkEntry(msg.url, msg.title, spaceSettings.expiryDays));
      space.bookmarks = list;
      await SpaceManager.saveActiveSpace(); // ← 이것 없으면 메모리만 갱신됨
    }
  } else {
    // 기본 공간 — 기존 로직
    const settings = await getSettings();
    const area = getArea(settings.persistSecretBookmarks);
    const data = await area.get(BOOKMARKS_KEY);
    const list = Array.isArray(data[BOOKMARKS_KEY]) ? data[BOOKMARKS_KEY] : [];
    if (!list.some((b) => urlsMatch(b.url, msg.url))) {
      list.unshift(createBookmarkEntry(msg.url, msg.title, settings.expiryDays));
      await area.set({ [BOOKMARKS_KEY]: list });
    }
  }
  sendResponse({ ok: true });
}

핸들러 한 개는 길이가 2배가 됩니다. 27개를 이렇게 고치면 background.js가 전체적으로 40~50% 늘어납니다. 가독성 저하는 불가피하지만, 중간에 한 핸들러라도 빠뜨리면 데이터가 샙니다. 이게 Space Isolation 설계의 비용입니다.

3.3 Cross-Cutting 문제: 테마와 언어

여기서 가장 까다로운 문제가 등장합니다. 대부분의 설정은 "background.js 안의 메시지 핸들러"가 처리합니다. 그런데 테마와 언어는 페이지들이 직접 chrome.storage.local을 구독합니다. theme.jsi18n.jschrome.storage.onChanged로 모든 열린 페이지(팝업, 옵션, 매니저)가 실시간으로 동기화되도록 만들어져 있었습니다.

// extension/shared/theme.js (핵심만)
chrome.storage.onChanged.addListener((changes, area) => {
  if (area === 'local' && changes.theme) {
    applyTheme(changes.theme.newValue);
  }
});

이 구조 덕분에 "옵션 페이지에서 테마를 바꾸면 팝업도 자동으로 바뀌는" cross-page sync가 공짜로 얻어집니다. 문제는 이 cross-page sync가 공간 시스템과 충돌한다는 점입니다.

공간 A의 테마가 "light"이고 공간 B의 테마가 "dark"인데, 페이지는 chrome.storage.local을 본다면 어떻게 둘을 오가야 할까요?

3.3.1 나이브한 시도 — 페이지를 SpaceManager 참조로 바꾸기

첫 번째 시도는 theme.jsi18n.jsSpaceManager.getActiveSpace().settings.theme을 읽도록 바꾸는 것이었습니다. 실패했습니다. SpaceManagerbackground.js에만 존재하고, 페이지 스크립트에선 chrome.runtime.sendMessage로 물어봐야 합니다. 동기적으로 읽던 코드가 전부 async가 되고, 그러면 페이지 렌더링 파이프라인이 줄줄이 망가집니다.

3.3.2 해결 — 스토리지를 경유하는 "임시 덮어쓰기"

두 번째 시도는 "공간 진입/퇴장 시에 chrome.storage.local의 테마·언어 값을 덮어쓰기" 였습니다. 페이지는 여전히 chrome.storage.local만 봅니다. 공간 진입 로직이 그 값을 임시로 바꿉니다. 그러면 storage.onChanged가 자동으로 모든 페이지에 전파됩니다.

// extension/options/options.js — 공간 진입 시점
async function enterSpaceUI(pin) {
  // 1. 공간 진입 전에 글로벌 테마/언어를 백업 (세션 스토리지에 임시 저장)
  const themeData = await chrome.storage.local.get(THEME_KEY);
  const settingsData = await chrome.storage.local.get('settings');
  sessionStorage.setItem('_sgGlobalTheme', themeData[THEME_KEY] || 'dark');
  sessionStorage.setItem('_sgGlobalLang', settingsData.settings?.language || 'auto');

  // 2. 백엔드에 공간 진입 요청
  const result = await sendMessage({ type: 'enterSpace', pin });
  if (!result.ok) return;

  // 3. 공간 설정 가져와서 chrome.storage.local에 덮어쓰기 — cross-page sync 자동 발동
  const stateRes = await sendMessage({ type: 'getState' });
  const settings = stateRes.settings || {};
  if (settings.theme) {
    await chrome.storage.local.set({ [THEME_KEY]: settings.theme });
  }
  if (settings.language) {
    const globalSettings = (await chrome.storage.local.get('settings')).settings || {};
    globalSettings.language = settings.language;
    await chrome.storage.local.set({ settings: globalSettings });
  }
}

// 공간 퇴장 시점
async function exitSpaceUI() {
  // 1. 현재 페이지의 theme/lang을 공간 데이터에 반영 (사용자가 공간 안에서 변경했을 수도 있음)
  const themeData = await chrome.storage.local.get(THEME_KEY);
  const settingsData = await chrome.storage.local.get('settings');
  if (themeData[THEME_KEY]) {
    await sendMessage({ type: 'setSpaceSetting', key: 'theme', value: themeData[THEME_KEY] });
  }
  if (settingsData.settings?.language) {
    await sendMessage({ type: 'setSpaceSetting', key: 'language', value: settingsData.settings.language });
  }

  // 2. 공간 퇴장
  await sendMessage({ type: 'exitSpace' });

  // 3. 백업한 글로벌 값 복원
  const globalTheme = sessionStorage.getItem('_sgGlobalTheme') || 'dark';
  const globalLang = sessionStorage.getItem('_sgGlobalLang') || 'auto';
  await chrome.storage.local.set({ [THEME_KEY]: globalTheme });
  const settings = (await chrome.storage.local.get('settings')).settings || {};
  settings.language = globalLang;
  await chrome.storage.local.set({ settings });
}

핵심은 백업·복원 dance입니다. 공간 안에 머무는 동안 chrome.storage.local의 테마·언어 값은 해당 공간의 값으로 임시 덮어쓰여 있습니다. 공간을 나가면 백업한 글로벌 값으로 되돌립니다. 페이지 코드(theme.js, i18n.js)는 아무것도 모릅니다. 자기가 구독하는 키가 바뀌었으니 그대로 새 값을 반영할 뿐입니다.

이 설계의 묘미는 페이지 스크립트를 단 한 줄도 건드리지 않는다는 점입니다. 크로스 페이지 동기화 로직은 그대로 두고, 값이 "공간 진입 중에만 덮어쓰이는 프록시" 역할을 하도록 chrome.storage.local의 의미를 살짝 바꿨습니다.

3.4 글로벌 Pro vs 공간 Pro: 라이선스 상속 규칙

라이선스는 공간별로 격리할지, 전역적으로 할지 초기엔 고민이 있었습니다.

  • 완전 격리: 각 공간마다 별도 라이선스 필요 → 사용자가 여러 번 구매해야 함 → 불공정
  • 완전 글로벌: 라이선스가 모든 공간에 적용 → 단순하지만 공간별 Pro 차별화 불가
  • 최종 결정: 상속 + 오버라이드: 기본 공간에 Pro가 활성화되면 모든 공간에 상속, PIN 공간은 자체 라이선스로 추가 활성화 가능

최종 로직은 이렇게 생겼습니다.

// extension/background.js — getSpaceState 핸들러
} else if (msg.type === 'getSpaceState') {
  const globalPro = !!(await getProStatus()).active;
  const spacePro = SpaceManager.isInSpace() ? SpaceManager.isActivePro() : false;
  sendResponse({
    inSpace: SpaceManager.isInSpace(),
    isPro: globalPro || spacePro, // ← OR 결합 = 상속
  });
}

그리고 isActivePro()는 현재 공간의 proStatus.active 플래그만 봅니다. 이렇게 하면:

기본 공간 Pro PIN 공간 라이선스 현재 공간 결과
- 기본 Pro ✅
- PIN 공간 Pro ✅ (상속)
PIN 공간 Pro ✅
PIN 공간 Pro ❌

UX 원칙으로 표현하면: "Pro는 한 번 사면 모든 공간에서 쓸 수 있지만, 특정 공간만 Pro를 쓰고 싶으면 그 공간에서만 활성화할 수도 있다." 라이선스 서버 한 대와 기기 ID 한 개로 이걸 다 처리하려면, 클라이언트가 공간 전환 시에 proStatus를 어떻게 해석하는지가 중요합니다.

3.5 리팩토링의 부수 효과: Dead Code가 저절로 드러나다

기능 리팩토링의 숨은 보상은 기존 코드에서 "죽은 경로"가 드러난다는 점입니다. 공간 시스템을 구축하는 과정에서 다음이 저절로 dead code가 됐습니다.

① 글로벌 PIN 락 시스템: 기존에는 확장 전체에 하나의 PIN 락이 있었습니다. 공간 시스템이 도입되면서 락은 "각 공간의 진입 요건"으로 의미가 이동했고, 전역 락은 더 이상 필요가 없어졌습니다.

// ❌ 제거된 dead code
async function isPinUnlocked() { /* ... */ }
async function setPinUnlocked(v) { /* ... */ }

// 호출자도 전부 사라짐 — 기본 공간은 "항상 언락", PIN 공간은 각자의 진입 플로우

② LockScreen 전역 오버레이: 페이지 초기화 시 "락 상태면 전체 덮어쓰는 UI"가 있었습니다. 공간 시스템은 lock screen을 options.js의 공간 진입 UI로 대체했고, 전역 LockScreen은 호출되지 않게 됐습니다.

③ Onboarding의 PIN 설정 페이지: 최초 설치 시 "PIN을 설정하시겠습니까?"를 묻는 단계가 있었습니다. 공간 시스템에서는 "옵션 → 디코이 스페이스"에서 처음 PIN을 입력하는 순간 공간이 생성되므로, onboarding의 해당 단계가 의미를 잃었습니다.

이것들을 한 번에 전부 제거하지 않았다는 점이 중요합니다. 기능이 안정화되고 사용자 피드백이 돌아온 뒤 별도의 정리 PR로 제거했습니다. 리팩토링 중에 dead code를 같이 건드리면 "어느 변경이 어느 버그를 일으켰는지"를 분간하기 어려워집니다.

3.6 테스트할 수 없는 것을 방어하는 법: 체크리스트

공간 간 누출 버그는 유닛 테스트로 잡기가 거의 불가능합니다. 메시지 핸들러는 각각 chrome.* API에 의존하고, SpaceManager의 내부 상태에도 의존하며, 그 두 상태의 상호작용이 버그의 원인이기 때문입니다. 대신 다음 세 가지 방어선을 씁니다.

grep 체크리스트: 새 핸들러를 추가할 때마다 다음을 순회합니다.

grep -n "msg.type === '" extension/background.js | wc -l
grep -n "SpaceManager.isInSpace" extension/background.js | wc -l

두 숫자가 거의 같아야 합니다. 대부분의 핸들러는 공간 분기를 가져야 하므로, 차이가 벌어지면 "공간 분기를 빼먹은 핸들러"가 있을 가능성이 높습니다.

② E2E 테스트 시나리오: 수동으로 돌리지만, 문서화해 두고 매 배포 전에 확인합니다.

시나리오 1: 공간 A에 북마크 추가 → 공간 B 진입 → 북마크 안 보임
시나리오 2: 공간 A 라이선스 활성 → 공간 B 진입 → Pro 표시 안 됨
시나리오 3: 공간 A 테마 light → 공간 B 테마 dark → 번갈아 진입 시 테마 적용 즉시 전환
시나리오 4: 공간 A 만료된 북마크 → 진입 시 자동 삭제 → 공간 B 같은 URL 있어도 남아있음

sender.id 검증: 모든 메시지 핸들러 진입 시점에 sender.id === chrome.runtime.id를 확인해서 외부에서 주입된 메시지가 공간 상태를 오염시키지 못하도록 막습니다. 이건 공간 시스템과 독립적인 기본 방어선이지만, 멀티테넌트 확장에서는 더욱 중요합니다.


4. 핵심 개념 정리

4.1 데이터 분류: 공간 스코프 vs 글로벌

데이터 스코프 이유
북마크 리스트 공간 격리가 곧 기능
최근 탭 공간 공간별로 다르게 보여야 함
만료 일수 (expiryDays) 공간 공간마다 다르게 설정 가능
Auto-lock 시간/활성 여부 공간 공간 진입 후의 비활성 잠금
자동 변환 (autoConvert) 공간 Pro 기능, 공간별 설정
테마, 언어 공간 (백업·복원 dance) 공간 고유 UI 경험
라이선스 키 공간 + 글로벌 상속 규칙 적용
proStatus 공간 + 글로벌 상속 규칙 적용
Device ID 글로벌 기기 자체의 정체성
Rate limit 기록 글로벌 공격자가 공간별로 초기화할 수 없도록
Salt 글로벌 모든 공간이 같은 salt 공유
슬롯 배열 _c 글로벌 물리적 저장 구조

4.2 Space Isolation 패턴의 일반 형태

async function handleMessage(msg) {
  // 1. 입력 검증 (공간 여부와 무관)
  const validation = validate(msg);
  if (!validation.valid) return { ok: false, reason: validation.reason };

  // 2. 공간 분기
  if (SpaceManager.isInSpace()) {
    const space = SpaceManager.getActiveSpace();
    // 메모리 상 space 객체에 변경 적용
    space.X = newValue;
    // 3a. 공간 저장 (메모리 → 암호화 → 디스크)
    await SpaceManager.saveActiveSpace();
  } else {
    // 3b. 글로벌 storage 경유
    const settings = await getSettings();
    const area = getArea(settings.persistSecretBookmarks);
    await area.set({ [KEY]: newValue });
  }

  return { ok: true };
}

4.3 백업·복원 dance (테마/언어)

[Default space]
  globalTheme = "dark"
  chrome.storage.local.theme = "dark"
       │
       │ enterSpace(pin)
       ▼
[Backup]
  sessionStorage._sgGlobalTheme = "dark"
       │
       │ apply space theme
       ▼
[Space A]
  chrome.storage.local.theme = "light"   ← 덮어쓰기
  (모든 페이지의 storage.onChanged 발동 → 라이트 모드 적용)
       │
       │ exitSpace()
       ▼
[Save + Restore]
  space.settings.theme = "light" (저장)
  chrome.storage.local.theme = "dark" (백업 복원)
  (모든 페이지 다시 다크 모드로 복귀)

5. 베스트 프랙티스

5.1 새 메시지 핸들러 추가 시 체크리스트

  • [ ] SpaceManager.isInSpace() 분기 추가
  • [ ] 공간 분기 안에서 space.X 필드 접근
  • [ ] 쓰기면 await SpaceManager.saveActiveSpace() 호출
  • [ ] 기본 분기는 기존 chrome.storage 경로 유지
  • [ ] sender.id === chrome.runtime.id 검증
  • [ ] E2E 시나리오에 "공간 A → 공간 B 누출 없음" 케이스 추가
  • [ ] grep으로 핸들러 수와 isInSpace 호출 수 차이 확인

5.2 주의할 것

  • ❌ 공간 안 분기에서 chrome.storage.local.set() 직접 호출 (메모리 경유 안 하면 암호화 우회)
  • space.bookmarks.push(x) 만 하고 saveActiveSpace() 생략 (메모리 갱신만 됨)
  • ❌ 공간 진입/퇴장 외에서 activeSpace를 쓰기 (상태 경합)
  • ❌ 테마/언어를 공간 안에서 setSpaceSetting으로만 저장 (cross-page sync 깨짐)
  • ❌ 글로벌 Pro와 공간 Pro를 AND로 묶기 (사용자 혼란 유발 — 반드시 OR)
  • ❌ Dead code를 리팩토링과 같은 PR에서 제거 (원인 분리 어려움)

5.3 리팩토링 순서 (이 시리즈의 경우)

실제로 이 기능을 구축한 순서입니다. 순서가 중요합니다.

  1. 암호화 레이어 (crypto.js, space-manager.js) — 순수 모듈 단독
  2. 공간 제어 핸들러 (enterSpace, exitSpace, registerSpace, deleteSpace)
  3. 읽기 핸들러 공간 분기 (getState, listBookmarks, listRecent, getSettings)
  4. 쓰기 핸들러 공간 분기 (addBookmark, removeBookmark, saveAllTabs, ...)
  5. 라이선스 상속 (getSpaceState, activateLicense, getProStatus)
  6. 테마/언어 백업·복원 dance
  7. 기능 안정화 후 legacy PIN 시스템 제거 (별도 PR)

읽기부터 고치는 게 중요합니다. 읽기 경로가 불완전한 상태에서 쓰기 경로를 고치면, "공간 안에서 썼는데 밖에서 보이는" 종류의 버그가 디버깅 중에 섞입니다.


6. FAQ

Q1. 모든 핸들러에 if/else를 넣으면 중복 코드가 심해집니다. 공통 wrapper로 추상화하면 안 되나요?

A. 시도해 봤습니다. 문제는 각 핸들러의 읽기/쓰기 대상이 너무 다르다는 것입니다. addBookmark는 배열에 push하고, togglePersist는 설정 필드를 바꾸고, setSpaceSetting은 키-값을 쓰고, activateLicense는 네트워크 호출 결과를 저장합니다. 이걸 하나의 wrapper로 묶으려면 wrapper 시그니처가 너무 일반적이어서 결국 각 핸들러마다 클로저로 동작 주입하게 됩니다. 코드량은 줄지만 가독성이 더 나빠졌습니다. 지금처럼 명시적 if/else가 차라리 grep이나 코드 리뷰에 친화적입니다.

Q2. SpaceManager.getActiveSpace()가 반환한 객체를 직접 mutate하는 게 이상하지 않나요? 불변 구조가 낫지 않나요?

A. 의식적으로 mutable로 갔습니다. 이유는 성능과 단순성입니다. 불변 구조로 만들면 addBookmark 할 때마다 space 객체 전체를 복사해야 하고, 복사본을 다시 SpaceManager에 돌려주는 setActiveSpace(newSpace) 같은 API가 필요해집니다. 크롬 확장의 메시지 핸들러 특성상 동시 변경이 없고(모든 메시지는 single-threaded 루프), 바로 직후 saveActiveSpace()가 스냅샷을 찍기 때문에 mutate→save 패턴이 안전합니다.

Q3. 테마/언어 백업을 sessionStorage에 저장하는 이유는?

A. sessionStorage페이지 생명주기와 묶여 있어서 브라우저가 닫히면 자동으로 날아갑니다. 백업이 영구적으로 남으면 사용자가 실수로 공간 안에서 브라우저를 강제 종료했을 때 다음 기동 시에 잘못된 상태가 복원될 수 있습니다. sessionStorage는 이걸 공짜로 막아줍니다 — 다음 기동 시에는 "기본 공간" 상태에서 깨끗하게 시작합니다.

Q4. listBookmarks가 공간 안일 때 persist: true를 항상 반환합니다. 공간별로 persist를 끌 수 있어야 하지 않나요?

A. 공간 안에서는 항상 암호화 저장입니다. 이 단계에서 persist의 의미가 "암호화 저장 여부"로 바뀝니다. 사용자가 "세션 스토리지에만 저장"을 고를 수 있는 건 기본 공간일 때뿐입니다. PIN 공간은 정의상 암호화된 슬롯에 쓰는 것이 유일한 저장 경로이고, 이걸 끌 수 있으면 공간 자체가 무의미해집니다.

Q5. 공간 간 누출 버그를 프로덕션에서 어떻게 모니터링하나요?

A. 솔직히 완벽한 방법이 없습니다. 확장 프로그램은 원격 로깅이 제한적이고, 민감 데이터라 로깅할 수도 없습니다. 그래서 E2E 시나리오를 배포 전 반드시 돌리고, 사용자 리포트가 오면 재현 가능한 시나리오를 먼저 확보합니다. 발견 이후 수정은 빠르게, 하지만 발견 자체는 수작업입니다.

Q6. background.js 하나가 1300줄인데 파일 분리를 고민하지 않나요?

A. 고민합니다. 다만 Manifest V3 service worker의 특성상 엔트리포인트 하나에 전부 모여있는 게 유지보수가 단순합니다. 모듈 분리를 하면 import 의존성이 복잡해지고, service worker 재기동 시의 초기화 순서 버그가 생기기 쉽습니다. 지금은 "한 파일, 섹션 주석으로 구분"이 최선이라고 보고 있고, 2000줄을 넘으면 재검토할 예정입니다.

Q7. 동시에 여러 공간을 열 수는 없나요?

A. 의도적으로 막혀 있습니다. 한 번에 하나의 공간만 활성이고, 다른 공간에 가려면 현재 공간을 나와야 합니다. 이유는 Plausible Deniability입니다. 동시에 여러 공간이 열려 있으면 UI에 "현재 2개 공간 활성" 같은 힌트가 들어가게 되고, 이게 메타데이터 누출이 됩니다. "한 번에 하나"가 단순하고 안전합니다.


7. 참고 자료


8. 다음 단계

이 글은 데이터 격리를 다뤘습니다. 시리즈의 다음 질문은 "이 모든 걸 전달하는 랜딩 페이지는 어떻게 설계했는가" — 즉 기능이 아니라 이야기가 어떻게 작동하는가입니다. 다음 글에서 다룹니다.

시리즈 (크롬 확장 Plausible Deniability 계열):

  1. Web Crypto API로 안전한 해싱 구현하기: SHA-256에서 PBKDF2까지
  2. 크롬 확장에 위장 공간 만들기: AES-GCM과 티어드 패딩으로 Plausible Deniability 구현
  3. 단일 크롬 확장에서 멀티테넌트 구현하기: Space Isolation 패턴 ← 현재 글