본문 바로가기

React

React 앱에 멀티 탭 브라우징 상태 동기화하기

사용자가 동일한 앱을 여러 탭에서 열 때, 필터 상태나 로그인 토큰, 다크모드, 폼 초안 등 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" 같은 메시지를 브로드캐스트하고, 각 탭에서 즉시 라우팅/스토어 초기화를 수행합니다.

위 패턴을 도입하면 사용자 경험과 리소스 효율을 동시에 개선할 수 있습니다. 작은 훅부터 시작해 필요한 영역에 점진적으로 확장하는 접근을 권장합니다.