여러 개의 탭/윈도우에서 동일한 React 앱을 사용할 때, 로그인/로그아웃, 장바구니, 테마, 피처 플래그처럼 전역적으로 공유되어야 할 데이터는 즉시 동기화되어야 합니다. 이 글에서는 브라우저 네이티브 능력만으로 신뢰할 수 있는 멀티 윈도우 동기화를 구현하는 실무 패턴을 정리합니다.
1. 전략 개요
- BroadcastChannel: 가장 간단하고 빠른 브로드캐스트(동일 출처 탭 간). Safari/iOS 15.4+ 포함 대부분 브라우저에서 지원합니다.
- localStorage storage 이벤트: 모든 메이저 브라우저에서 동작하는 폴백 전략입니다. 현재 탭에는 이벤트가 발생하지 않으므로 자체 갱신은 별도 처리합니다.
- Service Worker 브로드캐스트: PWA 환경에서 SW가 모든 클라이언트 페이지에 메시지를 중계합니다.
- Web Locks API: 동시 기록으로 인한 레이스/충돌을 줄입니다(지원 브라우저에서만).
2. 최소 구현: 채널 추상화(BroadcastChannel → localStorage 폴백)
프로젝트 어디서든 동일한 API로 메시지를 주고받기 위해 채널을 추상화합니다. 우선 BroadcastChannel을 사용하고, 미지원 환경에서는 localStorage의 storage 이벤트로 폴백합니다.
// channel.js
export function createCrossTabChannel(name) {
const supportsBC = typeof window !== "undefined" && "BroadcastChannel" in window;
const listeners = new Set();
const channel = supportsBC ? new BroadcastChannel(name) : null;
let storageHandler = null;
const emit = (data) => {
listeners.forEach((fn) => fn(data));
};
if (supportsBC) {
const onMessage = (e) => emit(e.data);
channel.addEventListener("message", onMessage);
} else {
storageHandler = (e) => {
if (!e.key || !e.newValue) return;
if (!e.key.startsWith(`${name}:msg:`)) return;
try {
const data = JSON.parse(e.newValue);
if (data && data.__channel === name) emit(data);
} catch (_) {}
};
window.addEventListener("storage", storageHandler);
}
const publish = (payload) => {
const data = { ...payload, __channel: name };
if (supportsBC) {
channel.postMessage(data);
} else {
const key = `${name}:msg:${Date.now()}:${Math.random().toString(36).slice(2)}`;
localStorage.setItem(key, JSON.stringify(data));
// 약간의 지연 후 청소(다른 탭이 수신할 시간 확보)
setTimeout(() => localStorage.removeItem(key), 1000);
}
};
const subscribe = (fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
};
const close = () => {
listeners.clear();
if (supportsBC) channel.close();
else if (storageHandler) window.removeEventListener("storage", storageHandler);
};
return { publish, subscribe, close };
}
3. React Hook: 특정 키의 상태를 멀티 윈도우로 동기화
키 단위로 상태를 공유하는 useCrossTabState 훅을 구현합니다. 초기값은 로컬 저장소에서 복원하고, 변경 시 브로드캐스트 + 저장을 수행합니다. 타 탭에서 온 더 최신(ts) 업데이트만 반영해 레이스를 줄입니다.
// useCrossTabState.js
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createCrossTabChannel } from "./channel";
export function useCrossTabState(key, initialValue) {
const channel = useMemo(() => createCrossTabChannel("app-sync"), []);
const tabIdRef = useRef(self.crypto?.randomUUID?.() || `tab-${Math.random()}`);
const [state, setState] = useState(() => {
try {
const raw = localStorage.getItem(`sync:${key}`);
if (!raw) return initialValue;
const parsed = JSON.parse(raw);
return parsed?.value ?? initialValue;
} catch (_) {
return initialValue;
}
});
const lastTsRef = useRef(0);
// mount 시점에 저장된 ts 로드
useEffect(() => {
try {
const raw = localStorage.getItem(`sync:${key}`);
if (raw) {
const { ts } = JSON.parse(raw);
if (typeof ts === "number") lastTsRef.current = ts;
}
} catch (_) {}
}, [key]);
// inbound 메시지 처리
useEffect(() => {
const unsub = channel.subscribe((msg) => {
if (msg.type !== "state:update") return;
if (msg.key !== key) return;
if (msg.origin === tabIdRef.current) return; // 자기 메시지 무시
if (typeof msg.ts !== "number") return;
if (msg.ts <= lastTsRef.current) return; // 오래된 업데이트 무시
lastTsRef.current = msg.ts;
setState(msg.value);
try {
localStorage.setItem(`sync:${key}`, JSON.stringify({ value: msg.value, ts: msg.ts }));
} catch (_) {}
});
return () => unsub();
}, [channel, key]);
const setSharedState = useCallback((next) => {
setState((prev) => {
const value = typeof next === "function" ? next(prev) : next;
const ts = Date.now();
lastTsRef.current = ts;
try {
localStorage.setItem(`sync:${key}`, JSON.stringify({ value, ts }));
} catch (_) {}
channel.publish({ type: "state:update", key, value, ts, origin: tabIdRef.current });
return value;
});
}, [channel, key]);
useEffect(() => () => channel.close(), [channel]);
return [state, setSharedState];
}
4. 사용 예시: 테마, 로그아웃, 장바구니 카운트
// App.jsx
import { useCrossTabState } from "./useCrossTabState";
export default function App() {
const [theme, setTheme] = useCrossTabState("theme", "light");
const [cartCount, setCartCount] = useCrossTabState("cartCount", 0);
const toggleTheme = () => setTheme((t) => (t === "light" ? "dark" : "light"));
const addCart = () => setCartCount((n) => n + 1);
return (
<div data-theme={theme}>
<h1>멀티 윈도우 동기화 데모</h1>
<p>테마: {theme}</p>
<button onClick={toggleTheme}>테마 토글</button>
<p>장바구니: {cartCount}</p>
<button onClick={addCart}>담기</button>
<LogoutButton />
</div>
);
}
// 로그아웃 브로드캐스트
import { createCrossTabChannel } from "./channel";
const authChannel = createCrossTabChannel("auth");
export function LogoutButton() {
useEffect(() => {
const unsub = authChannel.subscribe((msg) => {
if (msg.type === "auth:logout") {
// 토큰/세션 정리 후 로그인 화면으로
localStorage.removeItem("accessToken");
window.location.assign("/login");
}
});
return () => unsub();
}, []);
const onLogout = () => {
localStorage.removeItem("accessToken");
authChannel.publish({ type: "auth:logout" });
window.location.assign("/login");
};
return <button onClick={onLogout}>로그아웃</button>;
}
5. 레이스/충돌 줄이기: Web Locks와 타임스탬프
동시에 여러 탭이 동일 리소스를 갱신하면 덮어쓰기가 발생할 수 있습니다. 위 훅은 ts(타임스탬프)로 최신값만 반영합니다. 보다 엄격한 원자적 업데이트가 필요하면 Web Locks API를 사용해 단일 작성자를 보장합니다.
// locks.js
export async function withLock(name, fn) {
if ("locks" in navigator) {
return navigator.locks.request(name, { mode: "exclusive" }, fn);
}
// 폴백: 잠금 없이 실행(완전 보장은 없음)
return fn();
}
// 사용 예: 카운터를 안전하게 증가
async function incrementCounterSafely(key) {
await withLock(`lock:${key}`, async () => {
const raw = localStorage.getItem(`sync:${key}`);
const { value = 0 } = raw ? JSON.parse(raw) : {};
const next = (value || 0) + 1;
const ts = Date.now();
localStorage.setItem(`sync:${key}`, JSON.stringify({ value: next, ts }));
// 채널에도 브로드캐스트
createCrossTabChannel("app-sync").publish({
type: "state:update",
key,
value: next,
ts,
origin: "locker"
});
});
}
6. PWA/Service Worker에서의 브로드캐스트
Service Worker가 이미 존재한다면, 서버 푸시/동기화 이벤트를 받아 모든 클라이언트 페이지에 중계할 수 있습니다.
// sw.js (Service Worker)
self.addEventListener("push", (event) => {
const data = event.data?.json() || {};
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
clients.forEach((client) => client.postMessage({ type: "flag:update", payload: data }));
})
);
});
// 페이지 측
if ("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener("message", (e) => {
if (e.data?.type === "flag:update") {
// 필요한 상태 갱신 로직
}
});
}
7. 운영 체크리스트
- 페이로드 크기 최소화: 큰 객체를 그대로 브로드캐스트하지 말고, 키/패치 형태로 전송합니다.
- 에코/루프 방지: origin(tabId)로 자기 메시지를 무시합니다.
- 최신성 보장: 타임스탬프로 오래된 업데이트를 버립니다.
- 저장 한도/프라이버시: 민감 정보는 localStorage에 두지 않습니다. 토큰은 httpOnly 쿠키나 메모리 전략을 우선 고려합니다.
- 브라우저 호환: BroadcastChannel 미지원 환경을 고려하고, 사설 모드에서 저장소 행동이 달라질 수 있음을 염두에 둡니다.
- 성능: 빠른 연속 업데이트는 배치/디바운스합니다. 필요 시 16~50ms로 스로틀링합니다.
- 테스트: 두 개 이상 탭을 열고 빠른 연속 갱신, 오프라인/온라인 전환, 강제 새로고침 등 시나리오를 검증합니다.
8. 결론
동일 출처 멀티 윈도우 동기화는 BroadcastChannel로 단순하게 시작하고, localStorage storage 이벤트를 폴백으로 두면 대부분의 케이스를 커버합니다. 타임스탬프와 Web Locks로 레이스를 줄이고, React 훅으로 캡슐화하면 앱 전반에 일관된 DX를 제공합니다. 위 예제를 기반으로 팀의 상태 관리(예: Redux/Zustand)와 결합해 확장해 보시기 바랍니다.
'React' 카테고리의 다른 글
| React에서 서버사이드 데이터 스트리밍 처리하기 (0) | 2026.06.26 |
|---|---|
| React 앱에서 브라우저 Battery Status API 활용하기 (0) | 2026.06.26 |
| React 앱에서 CSS Grid를 활용한 반응형 레이아웃 구현 (0) | 2026.06.25 |
| React에서 컴포넌트 렌더링 우선순위 제어하기 (0) | 2026.06.25 |
| React 앱에 GraphQL Federation 아키텍처 적용하기 (0) | 2026.06.24 |