BroadcastChannel API는 동일 origin 탭·창·iframe·worker 사이에서 서버 없이 메시지를 주고받을 수 있게 해 주는 브라우저 내장 Web API다. 아래 내용은 예제 코드(AAPage)를 중심으로 BroadcastChannel을 React에서 어떻게 활용할 수 있는지 정리한 실험 보고서 형식의 글이다.
BroadcastChannel API 개요
BroadcastChannel API는 같은 origin을 공유하는 브라우저 컨텍스트(탭, 창, iframe, worker)끼리 메시지를 브로드캐스트하는 단일 채널을 제공한다. 채널 이름만 일치하면 여러 컨텍스트가 자유롭게 참여하고 탈퇴하면서 데이터를 주고받을 수 있다.
이 API는 로컬스토리지 이벤트나 서버 기반 WebSocket 없이도 탭 간 상태 동기화, 알림 브로드캐스트, 로그인 상태 전파 등 간단한 실시간 통신 패턴을 구현하는 데 적합하다.
브라우저 동작 특성과 제약
BroadcastChannel은 몇 가지 분명한 제약 조건을 가진다.
- 동일 origin(스킴·호스트·포트가 동일)에서만 통신이 가능하다.
- 서로 다른 브라우저(Chrome ↔ Firefox) 혹은 서로 다른 프로필 간에는 동작하지 않는다.
- 일반 모드 탭과 시크릿/인코그니토 모드 탭 사이에서는 채널이 분리되어 메시지가 전달되지 않는다.
따라서 탭 간 동기화 로직을 설계할 때 브라우저·프로필·시크릿 모드에 따라 통신 범위가 달라진다는 점을 전제로 해야 한다.
예제 구조 요약
예제 컴포넌트 AAPage는 다음과 같은 실험용 UI를 제공한다.
- 탭 간 카운터 값 동기화
- 탭 간 채팅 형태의 메시지 브로드캐스트
- 새로운 탭이 열릴 때 연결된 탭 수 추정
모든 통신은 my_channel이라는 이름의 BroadcastChannel 인스턴스를 통해 이루어진다.
const channel = useRef<BroadcastChannel | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [tabId] = useState(() => Math.random().toString(36).substring(7));
const [counter, setCounter] = useState(0);
const [connectedTabs, setConnectedTabs] = useState(1);
각 탭은 랜덤하게 생성된 tabId를 가지며, 이 ID를 통해 메시지 발신자를 식별한다.
채널 생성과 생명주기 관리
BroadcastChannel 인스턴스는 useEffect 안에서 생성하고 컴포넌트 언마운트 시점에 닫는다.
useEffect(() => {
channel.current = new BroadcastChannel('my_channel');
channel.current.onmessage = (event) => {
if (event.data.type === 'message') {
setMessages((prev) => [...prev, event.data.payload]);
} else if (event.data.type === 'counter') {
setCounter(event.data.payload);
} else if (event.data.type === 'ping') {
channel.current?.postMessage({ type: 'pong', from: tabId });
} else if (event.data.type === 'pong') {
setConnectedTabs((prev) => prev + 1);
}
};
channel.current.postMessage({ type: 'ping', from: tabId });
return () => {
channel.current?.close();
};
}, [tabId]);
- 채널 이름
my_channel이 동일한 모든 탭은 하나의 메시지 버스에 연결된다. onmessage핸들러에서 타입별로 메시지를 분기 처리하며, 이 예제에서는message,counter,ping,pong네 가지 타입을 사용한다.- 언마운트 시
close()를 호출해 리소스를 해제함으로써 메모리 누수 및 불필요한 수신을 방지한다.
탭 발견: ping/pong으로 연결된 탭 수 추정
새 탭이 열렸을 때 기존 탭의 존재를 감지하기 위해 ping/pong 프로토콜을 단순하게 구현한다.
- 새 탭이 채널을 생성한 직후
ping메시지를 브로드캐스트한다. - 기존 탭들은
ping을 수신하면pong으로 응답한다. - 새 탭은
pong을 받을 때마다connectedTabs상태를 증가시킨다.
이 방식으로 대략적인 연결 탭 수를 추정할 수 있으며, 멀티 탭 환경에서 "몇 개의 탭이 열려 있는지" 감을 잡는 용도로 활용 가능하다.
카운터 동기화 로직
카운터 동기화는 "상태를 계산한 뒤 결과 값을 브로드캐스트하는" 가장 단순한 패턴을 따른다.
const incrementCounter = () => {
const newCounter = counter + 1;
setCounter(newCounter);
channel.current?.postMessage({
type: 'counter',
payload: newCounter,
});
};
- 한 탭에서 버튼을 클릭하면 로컬 상태를 먼저 증가시키고, 증가된 값을
counter타입 메시지로 전파한다. - 다른 탭들은
counter메시지를 수신하는 즉시 자신의counter상태를 해당 값으로 교체한다.
이 패턴은 "마지막으로 브로드캐스트한 탭의 값을 모두가 따른다"는 의미에서 최종 결과 일관성을 제공하며, 멀티 탭 로그아웃, 테마 변경, 필터 동기화 등에서도 유사하게 사용된다.
메시지 브로드캐스트(간단 채팅)
메시지 브로드캐스트 부분은 채팅 UI에 가깝다.
const sendMessage = () => {
if (!inputText.trim()) return;
const message: Message = {
id: Math.random().toString(36),
text: inputText,
timestamp: Date.now(),
from: tabId,
};
setMessages((prev) => [...prev, message]);
channel.current?.postMessage({
type: 'message',
payload: message,
});
setInputText('');
};
- 입력한 텍스트를
Message객체로 구성하고, 현재 탭에 먼저 추가한 뒤 다른 탭으로 브로드캐스트한다. - 수신 측에서는
type === 'message'일 때payload를 메시지 리스트에 추가한다. - 렌더링 시
from === tabId여부에 따라 현재 탭 메시지와 다른 탭 메시지를 스타일로 구분한다.
이 구조는 단순하면서도 탭 간 브로드캐스트의 UX를 직관적으로 확인하기에 적합하다.
실험 방법 및 관찰 포인트
예제 하단에는 실험 절차가 포함되어 있다.
- 동일 origin에서 이 페이지를 새 탭으로 여러 개 연다.
- 한 탭에서 카운터 증가 버튼을 클릭하고 다른 탭들의 카운터가 동시에 갱신되는지 관찰한다.
- 한 탭에서 메시지를 전송했을 때 모든 탭의 메시지 영역이 어떻게 변하는지 확인한다.
- 개발자 도구 콘솔을 열어 브로드캐스트 메시지 로그를 관찰하면 메시지 흐름을 디버깅하기 쉭다.
추가로, 다음과 같은 상황을 비교해 보면 BroadcastChannel의 특성을 좀 더 명확하게 이해할 수 있다.
- 동일 브라우저·동일 프로필·일반 모드 탭 vs 시크릿 모드 탭
- 동일 브라우저이지만 서로 다른 origin
- 서로 다른 브라우저(Chrome ↔ Firefox)
시크릿 모드나 다른 브라우저에서는 메시지가 전혀 공유되지 않는다는 점이 확인된다.
확장 아이디어
이 예제는 기본적인 메시지 브로드캐스트와 상태 동기화를 보여 주지만, 패턴 자체는 다양한 실전 시나리오에 응용할 수 있다.
- 로그인/로그아웃, 토큰 만료 이벤트를 멀티 탭 전체에 전파
- 쇼핑몰 장바구니, 필터 상태, 테마(다크 모드 등) 실시간 동기화
- 팝업 창(OAuth 인증 등)에서 본창으로 결과 전달
- "다른 탭에서 이미 열려 있습니다" 경고 기능 구현
보다 큰 규모의 React/Next.js 애플리케이션에서는 이 로직을 커스텀 훅이나 별도의 모듈로 분리해 재사용성을 높이고, 메시지 타입·버전·중복 처리 전략 등을 명확히 설계하는 것이 바람직하다.
(참고로 브라우저의 일반 모드와 시크릿 모드로 예제를 확인해 봤는데 동기화가 안되어 찾아보니 시크릿 모드 자체가 별도의 격리된 브라우징 컨텍스트로 처리하기 때문이었다. 브라우저는 멀티 프로세스이지만 시크릿 모드 자체를 다른 브라우저라 판단하여 동기화가 안되는 듯 하다.)
아래는 예제 전체 코드
'use client';
import { useEffect, useRef, useState } from 'react';
interface Message {
id: string;
text: string;
timestamp: number;
from: string;
}
function AAPage() {
const channel = useRef<BroadcastChannel | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [tabId] = useState(() => Math.random().toString(36).substring(7));
const [counter, setCounter] = useState(0);
const [connectedTabs, setConnectedTabs] = useState(1);
useEffect(() => {
channel.current = new BroadcastChannel('my_channel');
// 메시지 수신
channel.current.onmessage = (event) => {
console.log('Received:', event.data);
if (event.data.type === 'message') {
setMessages((prev) => [...prev, event.data.payload]);
} else if (event.data.type === 'counter') {
setCounter(event.data.payload);
} else if (event.data.type === 'ping') {
// 다른 탭이 열렸을 때 응답
channel.current?.postMessage({ type: 'pong', from: tabId });
} else if (event.data.type === 'pong') {
setConnectedTabs((prev) => prev + 1);
}
};
// 현재 탭이 열렸음을 알림
channel.current.postMessage({ type: 'ping', from: tabId });
// 채널 닫기
return () => {
channel.current?.close();
};
}, [tabId]);
const sendMessage = () => {
if (!inputText.trim()) return;
const message: Message = {
id: Math.random().toString(36),
text: inputText,
timestamp: Date.now(),
from: tabId,
};
// 현재 탭에도 메시지 추가
setMessages((prev) => [...prev, message]);
console.log(channel.current);
// 다른 탭으로 브로드캐스트
channel.current?.postMessage({
type: 'message',
payload: message,
});
setInputText('');
};
const incrementCounter = () => {
const newCounter = counter + 1;
setCounter(newCounter);
// 다른 탭으로 브로드캐스트
channel.current?.postMessage({
type: 'counter',
payload: newCounter,
});
};
const clearMessages = () => {
setMessages([]);
};
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-6">
Broadcast Channel 실험
</h1>
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="mb-4">
<p className="text-sm text-gray-600">
현재 탭 ID:{' '}
<span className="font-mono font-bold">{tabId}</span>
</p>
<p className="text-sm text-gray-600">
연결된 탭 수:{' '}
<span className="font-bold">{connectedTabs}</span>
</p>
</div>
<div className="border-t pt-4">
<h2 className="text-xl font-semibold mb-4">
카운터 동기화
</h2>
<div className="flex items-center gap-4">
<div className="text-4xl font-bold">{counter}</div>
<button
onClick={incrementCounter}
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold transition"
>
증가 (+1)
</button>
</div>
<p className="text-sm text-gray-500 mt-2">
버튼을 클릭하면 모든 탭의 카운터가 동기화됩니다.
</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">
메시지 브로드캐스트
</h2>
<div className="flex gap-2 mb-4">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' && sendMessage()
}
placeholder="메시지를 입력하세요..."
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={sendMessage}
className="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-lg font-semibold transition"
>
전송
</button>
<button
onClick={clearMessages}
className="bg-red-500 hover:bg-red-600 text-white px-6 py-2 rounded-lg font-semibold transition"
>
초기화
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{messages.length === 0 ? (
<p className="text-gray-400 text-center py-8">
메시지가 없습니다. 메시지를 보내거나 다른 탭에서
메시지를 보내보세요!
</p>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`p-3 rounded-lg ${
msg.from === tabId
? 'bg-blue-100 border-l-4 border-blue-500'
: 'bg-gray-100 border-l-4 border-green-500'
}`}
>
<div className="flex justify-between items-start mb-1">
<span className="text-xs font-mono text-gray-600">
{msg.from === tabId
? '현재 탭'
: `탭: ${msg.from}`}
</span>
<span className="text-xs text-gray-500">
{new Date(
msg.timestamp
).toLocaleTimeString()}
</span>
</div>
<p className="text-gray-800">{msg.text}</p>
</div>
))
)}
</div>
</div>
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 className="font-semibold mb-2">💡 실험 방법</h3>
<ol className="text-sm space-y-1 list-decimal list-inside">
<li>이 페이지를 새 탭에서 여러 개 열어보세요</li>
<li>
한 탭에서 카운터 증가 버튼을 클릭하면 모든 탭이
업데이트됩니다
</li>
<li>메시지를 입력하고 전송하면 모든 탭에 표시됩니다</li>
<li>
콘솔(F12)을 열어 브로드캐스트 로그를 확인해보세요
</li>
</ol>
</div>
</div>
</div>
);
}
export default AAPage;'FrontEnd' 카테고리의 다른 글
| 좋은 코드를 위한 4가지 기준 (0) | 2025.11.22 |
|---|