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을 쓸 때의 핵심 원칙은 다음과 같습니다.
- 채널은 전역 단 하나만 관리한다
- 메시지 타입과 payload를 명확히 정의한다
- 컴포넌트는 채널을 “직접” 다루지 않는다
- 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 |