사용자가 동일한 앱을 여러 탭에서 열 때, 필터 상태나 로그인 토큰, 다크모드, 폼 초안 등 UI 상태가 자연스럽게 동기화되면 경험이 크게 좋아집니다. 본 글은 BroadcastChannel과 storage 이벤트를 활용해 React 상태를 멀티 탭으로 안전·실용적으로 동기화하는 방법을 다룹니다.
1. 언제 멀티 탭 동기화가 필요한가
- 사용자 선호(다크모드, 언어), 필터/검색 상태, 알림 읽음 처리 같은 클라이언트 상태를 여러 탭에서 일관되게 유지하고 싶을 때입니다.
- 서버 리소스 중복 요청 방지(한 탭만 폴링/동기화) 및 세션 만료/로그아웃을 모든 탭에 즉시 반영할 때 유용합니다.
2. 구현 전략 개요
- 1순위: BroadcastChannel API를 사용해 동일 origin의 탭 간 메시지를 실시간으로 전달합니다.
- 2순위: Safari 등 일부 환경 호환을 위해 localStorage의 storage 이벤트로 폴백합니다.
- 상태 루프 방지, 쓰기 스로틀, 스키마 버전 관리로 안정성과 성능을 확보합니다.
3. 실전: useCrossTabState 훅 만들기
아래 훅은 특정 키의 React 상태를 멀티 탭에 동기화합니다. BroadcastChannel 사용이 기본이며, 미지원 환경에선 storage 이벤트로 폴백합니다. 로컬 지속(persist) 옵션으로 초기값을 다른 탭에서 복원할 수도 있습니다.
import { useEffect, useRef, useState } from "react";
function safeParse(json) {
try { return JSON.parse(json); } catch { return null; }
}
export function useCrossTabState(
key,
initialValue,
opts = {}
) {
const {
channelName = "app-sync",
schema = 1,
throttleMs = 80,
persist = true,
} = opts;
const tabIdRef = useRef(
sessionStorage.getItem("__tabId__") || crypto.randomUUID()
);
useEffect(() => {
sessionStorage.setItem("__tabId__", tabIdRef.current);
}, []);
const storageStateKey = `__state:${key}`;
const [state, setState] = useState(() => {
if (persist) {
const saved = safeParse(localStorage.getItem(storageStateKey));
if (saved !== null && saved?.schema === schema) return saved.value;
}
return typeof initialValue === "function" ? initialValue() : initialValue;
});
const isRemoteUpdate = useRef(false);
const lastSentAt = useRef(0);
const channelRef = useRef(null);
useEffect(() => {
// Setup BroadcastChannel if available
if ("BroadcastChannel" in window) {
channelRef.current = new BroadcastChannel(channelName);
channelRef.current.onmessage = (ev) => {
const msg = ev.data;
if (!msg || msg.type !== "SET" || msg.key !== key) return;
if (msg.schema !== schema) return;
if (msg.origin === tabIdRef.current) return; // ignore self
isRemoteUpdate.current = true;
setState(msg.value);
};
return () => {
try { channelRef.current?.close(); } catch {}
};
}
// Fallback: storage event
const onStorage = (ev) => {
if (ev.key !== `__sync:${channelName}` || !ev.newValue) return;
const msg = safeParse(ev.newValue);
if (!msg || msg.type !== "SET" || msg.key !== key) return;
if (msg.schema !== schema) return;
if (msg.origin === tabIdRef.current) return;
isRemoteUpdate.current = true;
setState(msg.value);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key, channelName, schema]);
useEffect(() => {
// persist locally for initial hydration across tabs
if (!persist) return;
try {
localStorage.setItem(
storageStateKey,
JSON.stringify({ schema, value: state })
);
} catch {}
}, [state, schema, storageStateKey, persist]);
const broadcast = (value) => {
const now = Date.now();
if (now - lastSentAt.current < throttleMs) return;
lastSentAt.current = now;
const msg = {
type: "SET",
key,
value,
origin: tabIdRef.current,
schema,
ts: now,
};
if (channelRef.current) {
try { channelRef.current.postMessage(msg); } catch {}
} else {
try {
// trigger storage event on other tabs
localStorage.setItem(`__sync:${channelName}`, JSON.stringify(msg));
} catch {}
}
};
const setCrossTabState = (updater) => {
const next = typeof updater === "function" ? updater(state) : updater;
setState(next);
if (isRemoteUpdate.current) {
// prevent re-broadcast loop
isRemoteUpdate.current = false;
return;
}
broadcast(next);
};
return [state, setCrossTabState];
}
사용 예시입니다. 검색/필터 UI를 모든 탭에서 동기화합니다.
import React from "react";
import { useCrossTabState } from "./useCrossTabState";
export default function FilterBar() {
const [filter, setFilter] = useCrossTabState(
"filter",
{ q: "", onlyOpen: false },
{ channelName: "todo", schema: 1, throttleMs: 60 }
);
return (
<div>
<input
value={filter.q}
placeholder="검색"
onChange={(e) => setFilter((f) => ({ ...f, q: e.target.value }))}
/>
<label>
<input
type="checkbox"
checked={filter.onlyOpen}
onChange={(e) =>
setFilter((f) => ({ ...f, onlyOpen: e.target.checked }))
}
/>
열림만
</label>
</div>
);
}
4. 리더 탭 선출로 중복 작업 방지
여러 탭이 동일한 폴링/동기화 작업을 중복 수행하면 리소스가 낭비됩니다. Navigator Locks API를 우선 활용하고, 필요 시 BroadcastChannel로 폴백하는 전략을 권장합니다.
import { useEffect, useState } from "react";
export function useLeaderTab(lockName = "app-leader") {
const [isLeader, setIsLeader] = useState(false);
useEffect(() => {
let released = false;
async function elect() {
if (navigator.locks?.request) {
// Try to acquire an origin-scoped lock
await navigator.locks.request(lockName, { ifAvailable: true }, (lock) => {
if (!lock) {
setIsLeader(false);
return;
}
setIsLeader(true);
// Hold until tab unload
return new Promise((resolve) => {
const onUnload = () => {
if (!released) { released = true; resolve(); }
};
window.addEventListener("beforeunload", onUnload, { once: true });
window.addEventListener("pagehide", onUnload, { once: true });
});
});
} else {
// Fallback: naive leader by timestamp
const key = `__leader:${lockName}`;
const now = Date.now();
try {
localStorage.setItem(key, String(now));
} catch {}
setIsLeader(true);
}
}
elect();
}, [lockName]);
return isLeader;
}
// Usage: only leader does polling
export function NotificationsPoller() {
const isLeader = useLeaderTab("notifications");
useEffect(() => {
if (!isLeader) return;
const id = setInterval(() => {
// fetch notifications
}, 15000);
return () => clearInterval(id);
}, [isLeader]);
return null;
}
5. 탭 존재(Presence)와 안전한 종료
- beforeunload/pagehide에서 마지막 의도치 않은 브로드캐스트를 막습니다.
- 세션별 tabId를 sessionStorage에 저장해 출처 식별을 유지합니다.
useEffect(() => {
const cleanup = () => {
// 필요 시 presence 업데이트 또는 리소스 릴리즈
};
window.addEventListener("beforeunload", cleanup);
window.addEventListener("pagehide", cleanup);
return () => {
window.removeEventListener("beforeunload", cleanup);
window.removeEventListener("pagehide", cleanup);
};
}, []);
6. 성능 최적화 체크리스트
- 쓰기 스로틀/디바운스: 입력 필드처럼 변경이 잦은 상태는 throttleMs를 짧게 설정합니다.
- 메시지 크기 최소화: 대형 객체는 키 단위로 쪼개거나 변경된 델타만 전송합니다.
- 루프 방지: origin(tabId) 비교와 isRemoteUpdate 플래그로 재브로드캐스트를 차단합니다.
- 스키마 관리: schema 버전을 두어 구조 변경 시 하위 호환을 제어합니다.
- 대량 갱신: 리스트 선택/토글 등은 배치 업데이트로 묶어 전송 횟수를 줄입니다.
7. 보안/개인정보 유의사항
- 민감 정보(액세스 토큰, PII)는 브로드캐스트하거나 localStorage에 저장하지 않습니다.
- 동일 origin에서만 작동하므로 서브도메인/포트가 다른 경우 별도 채널을 사용합니다.
- 저장소 접근이 차단된 사설모드/정책 환경을 고려해 기능 폴백을 준비합니다.
8. Next.js 등 통합 팁
- SSR 환경에선 window가 없으므로 훅 초기화는 클라이언트 사이드에서만 수행합니다.
- 라우팅 사이드이펙트와 상태 동기화가 충돌하지 않도록 useEffect 의존성 최소화 및 키 스코프를 명확히 합니다.
- 스토어(Zustand/Redux)와 함께 사용할 경우, 액션 디스패치 시 브로드캐스트를 트리거하고, 리모트 수신은 미들웨어로 처리하면 구조가 깔끔합니다.
9. 디버깅/테스트 팁
- 두 개 이상의 탭을 열고 storage 이벤트/Network 패널로 트래픽과 메시지 흐름을 확인합니다.
- Safari/Firefox/Chromium 기반 브라우저에서 각각 테스트합니다.
- 빠른 입력 시 상태가 뒤늦게 도착하는 문제는 throttleMs 조정과 배치 업데이트로 해결합니다.
10. 자주 묻는 질문(FAQ)
- Q: storage 이벤트는 동일 탭에 발동하나요? A: 아닙니다. 다른 탭에서만 발생하므로 현재 탭 업데이트는 직접 setState로 처리합니다.
- Q: BroadcastChannel이 Safari에서 동작하나요? A: 최신 버전(대체로 16.4+)에서 지원하지만, 폴백을 반드시 준비하는 것이 안전합니다.
- Q: 대규모 상태를 전부 동기화해야 하나요? A: 실사용되는 경량 UI 상태만 대상으로 하고, 서버 소스 데이터는 리더 탭만 동기화 후 각 탭에서 캐시를 공유하는 방식을 추천합니다.
- Q: 로그인/로그아웃 동기화는 어떻게 하나요? A: storage 이벤트로 "auth:logout" 같은 메시지를 브로드캐스트하고, 각 탭에서 즉시 라우팅/스토어 초기화를 수행합니다.
위 패턴을 도입하면 사용자 경험과 리소스 효율을 동시에 개선할 수 있습니다. 작은 훅부터 시작해 필요한 영역에 점진적으로 확장하는 접근을 권장합니다.
'React' 카테고리의 다른 글
| React 앱에서 REST API 요청 최적화 및 중복 제거 방법 (0) | 2026.06.16 |
|---|---|
| React에서 데이터 시각화를 위한 Recharts 라이브러리 활용 (0) | 2026.06.15 |
| React에서 컴포넌트 로딩 시 애니메이션 트랜지션 적용하기 (0) | 2026.06.12 |
| React 앱에서 클라우드 스토리지(AWS S3, GCP Storage) 연동하기 (0) | 2026.06.12 |
| React에서 HTML5 Canvas를 활용한 인터랙티브 그래픽 그리기 (0) | 2026.06.12 |