본문 바로가기

React

React에서 멀티 윈도우 브라우징 데이터 동기화하기

여러 개의 탭/윈도우에서 동일한 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)와 결합해 확장해 보시기 바랍니다.