본문 바로가기

개발 공부/React

탭 간 상태 동기화 - React

React에서도 문제는 동일

React 역시 Angular와 마찬가지로 SPA입니다.

  • 탭 하나당 React 앱은 완전히 독립 실행
  • Context, Redux, Zustand, React Query 캐시는 탭 간 공유되지 않음

따라서 다음 문제는 React에서도 그대로 발생합니다.

  • 한 탭 로그아웃 → 다른 탭 로그인 유지
  • 포인트/구독 변경 반영 안 됨
  • 인증 완료/실패 상태가 탭마다 다름

해결책은 프레임워크가 아니라 브라우저 레벨 통신입니다.

 


React에서 “그냥 쓰면” 더 위험한 이유

React 개발자들이 자주 빠지는 함정은 이겁니다.

useEffect(() => {
  const channel = new BroadcastChannel('auth');
  channel.onmessage = (e) => {
    setUser(e.data);
  };
}, []);

 

  • 컴포넌트 언마운트 시 close 누락
  • 메시지 구조가 컴포넌트마다 달라짐
  • 자기 탭 메시지까지 다시 state에 반영
  • 여러 컴포넌트에서 채널 중복 생성

 React에서는 특히 Hook 안에서 직접 쓰는 순간 구조가 무너집니다.

 


React에서의 설계

React에서 BroadcastChannel을 쓸 때의 핵심 원칙은 다음과 같습니다.

  1. 채널은 전역 단 하나만 관리한다
  2. 메시지 타입과 payload를 명확히 정의한다
  3. 컴포넌트는 채널을 “직접” 다루지 않는다
  4. Hook은 구독 인터페이스만 제공한다

 


메시지 스키마 정의 

이벤트 타입

export enum BroadcastType {
  Login = 'LOGIN',
  Verification = 'VERIFICATION',
  SubscriptionUpdate = 'SUBSCRIPTION_UPDATE',
  PointChanged = 'POINT_CHANGED'
}

 

 

payload 매핑

export type BroadcastPayloadMap = {
  [BroadcastType.Login]: boolean;
  [BroadcastType.Verification]: null | {
    isVerified: boolean;
    isAdultVerification: boolean;
    isDuplicated: boolean;
  };
  [BroadcastType.SubscriptionUpdate]: { subscriptionId: number };
  [BroadcastType.PointChanged]: { point: number };
};

export interface BroadcastMessage<T extends BroadcastType = BroadcastType> {
  type: T;
  postTabId: string;
  payload: BroadcastPayloadMap[T];
  sentAt: number;
}
  • 이 단계에서 "아무 payload나 보내는 것" 자체를 차단합니다.

Channel Manager (프레임워크 무관 레이어)

React에 종속되지 않는 순수 채널 관리자를 먼저 만듭니다.

// tabBroadcastManager.ts

const randomId = () => Math.random().toString(36).slice(2);

type Listener = (msg: BroadcastMessage) => void;

class TabBroadcastManager {
  private channel?: BroadcastChannel;
  private listeners = new Set<Listener>();
  readonly tabId = randomId();

  open(name: string) {
    if (this.channel) return;

    this.channel = new BroadcastChannel(name);
    this.channel.onmessage = (event) => {
      const msg = event.data as BroadcastMessage;
      if (!this.isValid(msg)) return;
      if (msg.postTabId === this.tabId) return;

      this.listeners.forEach((l) => l(msg));
    };
  }

  close() {
    this.channel?.close();
    this.channel = undefined;
    this.listeners.clear();
  }

  post<T extends BroadcastType>(type: T, payload: BroadcastPayloadMap[T]) {
    if (!this.channel) return;

    const msg: BroadcastMessage<T> = {
      type,
      postTabId: this.tabId,
      payload,
      sentAt: Date.now()
    };

    if (!this.isValid(msg)) {
      throw new Error('Invalid broadcast message');
    }

    this.channel.postMessage(msg);
  }

  subscribe(listener: Listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private isValid(msg: any): msg is BroadcastMessage {
    if (!msg || typeof msg !== 'object') return false;
    if (typeof msg.type !== 'string') return false;
    if (typeof msg.postTabId !== 'string') return false;
    if (typeof msg.sentAt !== 'number') return false;
    return true;
  }
}

export const tabBroadcastManager = new TabBroadcastManager();

 

  • 채널 단일화
  • 자기 탭 메시지 자동 무시
  • React와 완전히 분리

 


Hook으로 감싸기

이제 React에서는 Hook으로만 접근합니다.

// useTabBroadcast.ts
import { useEffect } from 'react';

export function useTabBroadcast(
  handler: (msg: BroadcastMessage) => void
) {
  useEffect(() => {
    const unsubscribe = tabBroadcastManager.subscribe(handler);
    return unsubscribe;
  }, [handler]);
}
  • 컴포넌트는 채널을 몰라도 됩니다.

 


앱 시작 시 채널 오픈

// app.tsx

useEffect(() => {
  tabBroadcastManager.open('my-app-channel');
}, []);
  • 앱 수명 주기 동안 채널 유지
  • 컴포넌트별 open/close 금지

 


 

예제 1: 전 탭 로그아웃

로그아웃 발생 탭

tabBroadcastManager.post(BroadcastType.Login, false);

다른 탭

useTabBroadcast((msg) => {
  if (msg.type === BroadcastType.Login && msg.payload === false) {
    authStore.forceLogout();
  }
});

예제 2: 본인 인증 완료 전파

tabBroadcastManager.post(BroadcastType.Verification, {
  isVerified: true,
  isAdultVerification: true,
  isDuplicated: false
});
useTabBroadcast((msg) => {
  if (msg.type === BroadcastType.Verification && msg.payload) {
    userStore.setVerified(msg.payload.isVerified);
  }
});

 

 


 

React Query / Zustand와 함께 쓰기

BroadcastChannel은 상태를 직접 바꾸기보다,

  • 캐시 무효화
  • 재조회 트리거

용도로 쓰는 것이 가장 안전합니다.

useTabBroadcast((msg) => {
  if (msg.type === BroadcastType.PointChanged) {
    queryClient.invalidateQueries({ queryKey: ['myPoint'] });
  }
});

 

'개발 공부 > React' 카테고리의 다른 글

Next.js API Route  (0) 2026.01.11
Clerk PricingTable  (0) 2025.12.25
Clerk 로그인/회원가입  (0) 2025.11.29
UploadThing을 통한 이미지 업로드 구현하기  (0) 2025.11.27
Next.js App Router 자동 실행 규칙  (0) 2025.11.24