Chrome Side Panel 상태 관리: 닫았다 열어도 작업을 이어가는 설계
Chrome 확장 프로그램의 팝업은 툴바 아이콘을 눌렀을 때 잠깐 열렸다가 포커스를 잃으면 닫힙니다. Side Panel은 웹페이지 옆에 계속 열어 둘 수 있고 탭을 이동해도 유지할 수 있어 메모장, 타이머, 계산기처럼 작업을 이어가는 도구에 더 잘 맞습니다.
하지만 “오래 보이는 UI”와 “메모리에 계속 살아 있는 UI”는 같은 뜻이 아닙니다. 사용자가 패널을 닫거나 확장 프로그램이 업데이트되면 문서와 JavaScript 상태는 다시 만들어질 수 있습니다. Manifest V3 서비스 워커도 유휴 상태에서 종료되므로 전역 변수만 믿을 수 없습니다.
먼저 보는 핵심
DOM은 화면 표현, JavaScript 객체는 현재 편집 상태,chrome.storage는 복구 가능한 원본으로 역할을 나누세요. 패널이 열릴 때 저장값을 한 번 복원하고, 변경은 모아서 저장하며, 이벤트 리스너는 한 번만 연결하는 구조가 기본입니다.
팝업과 Side Panel의 수명 주기는 무엇이 다른가
Chrome의 Side Panel API 공식 문서는 Side Panel을 웹페이지 옆에서 지속적인 경험을 제공하는 확장 페이지로 설명합니다. 설정에 따라 탭을 이동해도 열린 상태를 유지할 수 있고, 확장 페이지이므로 Chrome API에도 접근할 수 있습니다.
| 항목 | 팝업 | Side Panel |
|---|---|---|
| 일반적인 종료 시점 | 포커스를 잃으면 닫힘 | 사용자가 닫거나 다른 패널로 전환할 때까지 표시 가능 |
| 적합한 작업 | 빠른 명령, 짧은 조회 | 메모, 계산, 타이머처럼 이어지는 작업 |
| UI 상태 전략 | 열 때마다 복원하는 전제가 강함 | 살아 있는 동안 유지하되 재생성도 견뎌야 함 |
| 탭 이동 | 팝업은 이미 닫힘 | 설정에 따라 열린 상태 유지 가능 |
Side Panel이 열려 있는 동안에는 해당 확장 페이지의 DOM과 메모리 상태를 사용할 수 있습니다. 그렇다고 let currentNote 같은 전역 변수만 저장소처럼 사용하면 안 됩니다. 패널 문서가 사라지면 값도 함께 사라집니다.
반대로 모든 키 입력을 즉시 저장소에서 다시 읽어 화면을 그리는 것도 좋지 않습니다. 비동기 저장과 렌더링이 뒤섞이고, 늦게 끝난 읽기가 최신 입력을 덮는 경쟁 조건을 만들기 쉽습니다.
상태를 세 층으로 나누기
실용적인 기준은 상태를 다음 세 층으로 나누는 것입니다.
| 층 | 예시 | 보관 위치 |
|---|---|---|
| 화면 표현 | 열린 메뉴, 현재 포커스, 토스트 표시 | DOM 또는 짧은 메모리 상태 |
| 작업 중 상태 | 입력 중인 계산식, 선택된 노트 ID | JavaScript 객체, 필요하면 지연 저장 |
| 복구해야 할 원본 | 노트 본문, 설정, 계산 기록 | chrome.storage.local 또는 sync |
어떤 값이 세 번째 층에 속하는지 판단하는 질문은 간단합니다.
지금 패널이 갑자기 다시 로드되어도 사용자가 잃었다고 느낄 값인가?
답이 “예”라면 지속 저장 후보입니다. 반면 1.4초 뒤 사라질 토스트의 표시 여부까지 저장할 필요는 없습니다.
저장소 영역은 데이터 성격으로 선택하기
Chrome의 Storage API 공식 문서에 따르면 확장 프로그램은 local, sync, session, managed 영역을 사용할 수 있습니다. Side Panel 앱에서 주로 선택하는 세 영역은 다음과 같습니다.
| 영역 | 적합한 데이터 | 주의점 |
|---|---|---|
storage.local |
노트, 계산 기록, 기기별 작업 상태 | 확장 삭제 시 제거되며 기본 용량 제한이 있음 |
storage.sync |
테마, 언어, 작은 사용자 설정 | 총량과 항목당 크기, 쓰기 횟수 제한이 작음 |
storage.session |
브라우저 실행 중만 필요한 민감·임시 상태 | 브라우저 재시작, 확장 재로드·업데이트 시 사라짐 |
window.localStorage보다 chrome.storage가 안전한 기본값입니다. 서비스 워커에서도 접근할 수 있고, 확장 컨텍스트 사이에 같은 API를 사용하며, 변경 이벤트도 받을 수 있기 때문입니다.
메모 본문처럼 큰 로컬 데이터와 테마 설정을 무조건 하나의 객체에 넣지 마세요. 변경 빈도와 동기화 필요성이 다른 데이터는 키 또는 저장 영역을 분리하는 편이 낫습니다.
const localState = await chrome.storage.local.get([
"notes",
"currentNoteId"
]);
const syncedSettings = await chrome.storage.sync.get([
"theme",
"fontSize"
]);
초기화는 읽기, 검증, 연결, 렌더 순서로
패널이 열릴 때는 저장값을 가져온 뒤 형태를 검증하고, UI 이벤트를 한 번만 연결한 다음 렌더링합니다.
const defaultState = {
currentNoteId: null,
draft: "",
theme: "light"
};
let state = { ...defaultState };
let initialized = false;
async function init() {
if (initialized) return;
initialized = true;
const saved = await chrome.storage.local.get([
"currentNoteId",
"draft"
]);
state.currentNoteId =
typeof saved.currentNoteId === "string" ? saved.currentNoteId : null;
state.draft = typeof saved.draft === "string" ? saved.draft : "";
bindUI();
render();
}
init().catch(showFatalError);
initialized 방어는 개발 중 스크립트가 중복 호출되거나 초기화 경로가 늘어났을 때 같은 버튼에 리스너가 두 번 붙는 문제를 줄입니다. 더 중요한 것은 bindUI()를 저장소 읽기 콜백이나 메시지 이벤트마다 호출하지 않는 것입니다.
저장된 값은 신뢰하지 말고 타입과 범위를 확인합니다. 이전 버전의 스키마, 손상된 가져오기 파일, 개발자 도구에서 수정한 값이 들어와도 패널 전체가 멈추지 않아야 합니다.
키 입력은 메모리에서 처리하고 저장은 모아서 하기
계산기나 에디터는 짧은 시간에 많은 입력을 받습니다. 매 키마다 storage.local.set()을 호출하기보다 화면은 즉시 갱신하고 저장만 200~500ms 정도 모으면 반응성과 복구 가능성을 함께 얻을 수 있습니다.
let saveTimer = 0;
function updateDraft(nextText) {
state.draft = nextText;
renderDraft();
scheduleSave();
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
await chrome.storage.local.set({
draft: state.draft,
currentNoteId: state.currentNoteId
});
}, 300);
}
이 패턴에서 중요한 점은 타이머가 유일한 저장 수단이 되어서는 안 된다는 것입니다. 저장 버튼을 누르는 명시적 동작, 노트 전환처럼 의미 있는 경계에서는 지연 저장을 기다리지 말고 즉시 커밋하는 경로를 둘 수 있습니다.
async function commitNow() {
clearTimeout(saveTimer);
await chrome.storage.local.set({
draft: state.draft,
currentNoteId: state.currentNoteId
});
}
페이지 종료 이벤트에만 의존해 마지막 저장을 시도하는 방식은 피합니다. 종료 시점의 비동기 작업 완료를 보장하기 어렵기 때문에 변경 중에 주기적으로 복구 지점을 만들어야 합니다.
여러 컨텍스트가 같은 값을 바꿀 때
Side Panel, 옵션 페이지, 서비스 워커가 같은 설정을 변경할 수 있다면 chrome.storage.onChanged로 외부 변경을 반영할 수 있습니다.
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "local") return;
if (changes.currentNoteId) {
state.currentNoteId = changes.currentNoteId.newValue ?? null;
}
if (changes.draft) {
state.draft = changes.draft.newValue ?? "";
}
render();
});
하지만 이 코드를 그대로 쓰면 현재 패널이 저장한 값을 다시 이벤트로 받아 불필요하게 렌더링할 수 있습니다. 값이 실제로 달라졌는지 비교하고, 편집 중인 로컬 변경과 외부 변경이 충돌할 때의 정책을 정해야 합니다.
노트 앱이라면 “마지막 저장이 무조건 승리”보다 다음 정보가 유용합니다.
- 레코드별
updatedAt - 현재 편집 중인지 나타내는 상태
- 충돌 시 덮어쓰기 대신 다시 불러오기 안내
- 전체 배열보다 노트 ID별로 분리한 저장 구조
작은 앱도 쓰기 주체가 둘 이상이면 데이터 소유권을 명확히 해야 합니다.
서비스 워커 전역 변수는 원본이 아니다
Manifest V3 서비스 워커는 이벤트 기반으로 실행되며 유휴 상태에서 종료될 수 있습니다. Chrome의 서비스 워커 이전 가이드는 전역 변수에 애플리케이션 상태를 두지 말고 지속 저장하라고 안내합니다.
// 나쁜 예: 다음 이벤트에서도 남아 있다고 가정
let selectedNoteId = null;
// 좋은 예: 이벤트 처리에 필요한 값을 다시 읽음
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== "CAPTURE_SELECTION") return;
chrome.storage.local.get(["currentNoteId"]).then(({ currentNoteId }) => {
return appendSelection(currentNoteId, message.text);
}).then(() => sendResponse({ ok: true }));
return true;
});
메시지 이름도 문자열을 여기저기 흩뿌리지 말고 작은 프로토콜처럼 관리하세요. 누가 보내고, 누가 처리하며, 응답 형태가 무엇인지 정하면 패널 재초기화와 메시지 중복을 구분하기 쉬워집니다.
실제로 확인해야 할 실패 시나리오
정상 실행만 확인하면 상태 관리 문제를 놓치기 쉽습니다. 다음 순서로 직접 시험해 보세요.
- 값을 입력한 직후 패널을 닫고 다시 연다.
- 탭을 여러 번 이동한 뒤 입력 상태를 확인한다.
chrome://extensions에서 확장 프로그램을 다시 로드한다.- 저장값 일부를 개발자 도구에서 잘못된 타입으로 바꾼다.
- 옵션 페이지와 패널에서 같은 설정을 연달아 바꾼다.
- 저장 용량 초과나 Promise 거부를 강제로 만들고 오류 UI를 확인한다.
- 초기화 함수를 두 번 호출해도 클릭 한 번에 핸들러가 한 번만 실행되는지 본다.
복원 테스트에서는 화면 값만 보지 말고 대기 중인 연산, 선택된 레코드 ID, 포커스 대상처럼 다음 입력의 의미를 바꾸는 상태도 확인해야 합니다.
마무리
Side Panel은 팝업보다 오래 열려 있지만 영구 프로세스는 아닙니다. 그래서 좋은 상태 관리는 “패널이 계속 살아 있을 것”이라는 가정이 아니라 “언제든 다시 만들어져도 이어갈 수 있다”는 가정에서 시작합니다.
DOM, 메모리 상태, 지속 저장소의 역할을 분리하고, 초기화는 한 번만 수행하며, 입력은 즉시 렌더링하되 저장은 적절히 모으세요. 서비스 워커와 다른 확장 페이지까지 같은 값을 바꾼다면 변경 이벤트와 충돌 정책을 추가해야 합니다. 이 구조를 먼저 세우면 메모장뿐 아니라 타이머, 계산기, 번역 도구 같은 Side Panel 앱도 훨씬 예측 가능하게 확장할 수 있습니다.
댓글
댓글 쓰기