BroadcastChannel API 완벽 가이드
📋 문서 정보
- 문서 유형: Concept Explanation + API Reference
- 대상 독자: 중급 웹 개발자
- 주요 목표: 같은 출처의 여러 탭/창 간 실시간 통신 구현
- 마지막 업데이트: 2024-12-14
개요
BroadcastChannel API는 같은 origin의 여러 browsing contexts (탭, 창, iframe, 웹 워커 등) 간 양방향 통신을 가능하게 하는 웹 브라우저 API입니다.
이 가이드를 읽고 나면 다음을 할 수 있습니다:
- 여러 탭/창 간 실시간 데이터 동기화 구현
- 탭 간 사용자 상태 공유 (로그인, 테마 설정 등)
- 브라우저 전역 알림 시스템 구축
- 멀티탭 환경에서 일관된 사용자 경험 제공
왜 BroadcastChannel API가 필요한가?
해결하려는 문제
전통적으로 웹 애플리케이션은 각 탭이 독립적으로 동작했습니다. 사용자가 여러 탭에서 같은 웹 앱을 열면 다음과 같은 문제가 발생했습니다:
- 데이터 불일치: 한 탭에서 데이터를 수정해도 다른 탭에 반영되지 않음
- 중복 작업: 여러 탭에서 동일한 API 요청을 중복 실행
- 인증 상태 불일치: 한 탭에서 로그아웃해도 다른 탭은 여전히 로그인 상태
- 비효율적인 리소스 사용: 각 탭이 독립적으로 웹소켓 연결 유지
이전 해결 방법의 한계
BroadcastChannel 이전에는 다음 방법들을 사용했습니다:
// ❌ 구식 방법 1: localStorage + storage 이벤트
localStorage.setItem('message', JSON.stringify(data));
window.addEventListener('storage', (e) => {
if (e.key === 'message') {
const data = JSON.parse(e.newValue);
// 다른 탭의 변경 사항 처리
}
});
// ❌ 구식 방법 2: SharedWorker
const worker = new SharedWorker('worker.js');
worker.port.onmessage = (e) => {
console.log(e.data);
};이전 방법의 문제점:
- localStorage: 문자열만 저장 가능, 같은 탭에서는 이벤트 발생하지 않음
- SharedWorker: 복잡한 설정, 제한적인 브라우저 지원
- Polling: 비효율적인 리소스 사용
핵심 개념
BroadcastChannel의 작동 원리
┌─────────────────────────────────────────────────────────┐
│ Same Origin │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐│
│ │ Tab 1 │ │ Tab 2 │ │ Tab 3 ││
│ │ │ │ │ │ ││
│ │ Channel │◄────────┤ Channel ├────────►│ Channel ││
│ │ "sync" │ │ "sync" │ │ "sync" ││
│ │ │ │ │ │ ││
│ └────┬─────┘ └────┬─────┘ └────┬─────┘│
│ │ │ │ │
│ └────────────────────┼────────────────────┘ │
│ │ │
│ postMessage(data) │
│ │ │
│ ┌───────▼───────┐ │
│ │ Broadcast to │ │
│ │ All Channels │ │
│ └───────────────┘ │
└─────────────────────────────────────────────────────────┘주요 특징
- 자동 브로드캐스팅: 한 채널에서 메시지를 전송하면 같은 이름의 모든 채널이 수신
- 구조화된 데이터 지원: 객체, 배열 등 복잡한 데이터 타입 직접 전송 가능
- 양방향 통신: 모든 채널이 송신자이자 수신자
- Same-Origin 정책: 같은 origin(프로토콜 + 도메인 + 포트)에서만 동작
API 레퍼런스
BroadcastChannel() 생성자
채널 인스턴스를 생성합니다.
시그니처
const channel = new BroadcastChannel(channelName: string);매개변수
channelName(필수)- 타입:
string - 설명: 채널을 식별하는 고유한 이름. 같은 이름의 채널끼리만 통신 가능
- 예시:
'user-sync','notifications','theme-updates'
- 타입:
예제
// 사용자 동기화 채널 생성
const userChannel = new BroadcastChannel('user-sync');
// 알림 채널 생성
const notificationChannel = new BroadcastChannel('notifications');postMessage() 메서드
채널로 메시지를 전송합니다.
시그니처
channel.postMessage(message: any): void;매개변수
message(필수)- 타입:
any(구조화된 복제 알고리즘 지원 타입) - 설명: 전송할 데이터. 객체, 배열, 기본 타입 모두 가능
- 제한: 함수, Symbol, DOM 노드는 전송 불가
- 타입:
예제
const channel = new BroadcastChannel('app-sync');
// 문자열 전송
channel.postMessage('Hello from Tab 1');
// 객체 전송
channel.postMessage({
type: 'USER_LOGIN',
userId: '12345',
username: 'john_doe',
timestamp: Date.now()
});
// 배열 전송
channel.postMessage(['item1', 'item2', 'item3']);message 이벤트
다른 채널에서 메시지를 수신했을 때 발생합니다.
이벤트 객체
interface MessageEvent {
data: any; // 수신한 메시지 데이터
origin: string; // 메시지 발신 origin
lastEventId: string; // 이벤트 ID (일반적으로 빈 문자열)
source: null; // BroadcastChannel은 항상 null
ports: MessagePort[]; // 전송된 포트 (일반적으로 빈 배열)
}예제
const channel = new BroadcastChannel('app-sync');
channel.addEventListener('message', (event) => {
console.log('받은 메시지:', event.data);
// 타입별 처리
if (event.data.type === 'USER_LOGIN') {
handleUserLogin(event.data);
} else if (event.data.type === 'USER_LOGOUT') {
handleUserLogout(event.data);
}
});
// 또는 onmessage 속성 사용
channel.onmessage = (event) => {
console.log('받은 메시지:', event.data);
};messageerror 이벤트
메시지 역직렬화에 실패했을 때 발생합니다.
예제
const channel = new BroadcastChannel('app-sync');
channel.addEventListener('messageerror', (event) => {
console.error('메시지 처리 오류:', event);
});close() 메서드
채널을 닫고 리소스를 해제합니다.
시그니처
channel.close(): void;예제
const channel = new BroadcastChannel('app-sync');
// 작업 완료 후 채널 닫기
channel.close();
// 닫힌 채널에서 메시지 전송 시도하면 오류 발생
// channel.postMessage('test'); // ❌ DOMException실전 사용 예제
예제 1: 멀티탭 로그인/로그아웃 동기화
모든 탭에서 로그인 상태를 동기화합니다.
// auth-sync.js
class AuthSync {
constructor() {
this.channel = new BroadcastChannel('auth-sync');
this.setupListeners();
}
setupListeners() {
// 다른 탭의 인증 이벤트 수신
this.channel.addEventListener('message', (event) => {
const { type, user } = event.data;
switch (type) {
case 'LOGIN':
this.handleRemoteLogin(user);
break;
case 'LOGOUT':
this.handleRemoteLogout();
break;
case 'TOKEN_REFRESH':
this.handleTokenRefresh(event.data.token);
break;
}
});
}
// 현재 탭에서 로그인
login(user) {
// 1. 로컬 스토리지에 저장
localStorage.setItem('user', JSON.stringify(user));
// 2. 다른 탭에 알림
this.channel.postMessage({
type: 'LOGIN',
user: user,
timestamp: Date.now()
});
// 3. UI 업데이트
this.updateUI(user);
}
// 다른 탭의 로그인 처리
handleRemoteLogin(user) {
console.log('다른 탭에서 로그인됨:', user);
// localStorage는 이미 다른 탭에서 업데이트됨
// UI만 업데이트
this.updateUI(user);
}
// 현재 탭에서 로그아웃
logout() {
// 1. 로컬 스토리지 삭제
localStorage.removeItem('user');
// 2. 다른 탭에 알림
this.channel.postMessage({
type: 'LOGOUT',
timestamp: Date.now()
});
// 3. UI 업데이트
this.updateUI(null);
}
// 다른 탭의 로그아웃 처리
handleRemoteLogout() {
console.log('다른 탭에서 로그아웃됨');
this.updateUI(null);
// 필요시 현재 페이지 리디렉션
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
updateUI(user) {
if (user) {
document.getElementById('user-name').textContent = user.name;
document.getElementById('login-btn').style.display = 'none';
document.getElementById('logout-btn').style.display = 'block';
} else {
document.getElementById('login-btn').style.display = 'block';
document.getElementById('logout-btn').style.display = 'none';
}
}
// 토큰 갱신 동기화
handleTokenRefresh(newToken) {
localStorage.setItem('authToken', newToken);
console.log('토큰이 갱신되었습니다');
}
// 정리
destroy() {
this.channel.close();
}
}
// 사용
const authSync = new AuthSync();
// 로그인 버튼
document.getElementById('login-btn').addEventListener('click', async () => {
const user = await loginAPI(username, password);
authSync.login(user);
});
// 로그아웃 버튼
document.getElementById('logout-btn').addEventListener('click', () => {
authSync.logout();
});
// 페이지 언로드 시 정리
window.addEventListener('beforeunload', () => {
authSync.destroy();
});예제 2: 실시간 장바구니 동기화
여러 탭에서 장바구니를 실시간 동기화합니다.
// cart-sync.js
class CartSync {
constructor() {
this.channel = new BroadcastChannel('cart-sync');
this.cart = this.loadCart();
this.setupListeners();
}
loadCart() {
const saved = localStorage.getItem('cart');
return saved ? JSON.parse(saved) : [];
}
saveCart() {
localStorage.setItem('cart', JSON.stringify(this.cart));
}
setupListeners() {
this.channel.addEventListener('message', (event) => {
const { action, item, itemId } = event.data;
switch (action) {
case 'ADD_ITEM':
this.handleRemoteAddItem(item);
break;
case 'REMOVE_ITEM':
this.handleRemoteRemoveItem(itemId);
break;
case 'UPDATE_QUANTITY':
this.handleRemoteUpdateQuantity(itemId, event.data.quantity);
break;
case 'CLEAR_CART':
this.handleRemoteClearCart();
break;
}
});
}
// 현재 탭에서 아이템 추가
addItem(item) {
// 1. 장바구니에 추가
const existingItem = this.cart.find(i => i.id === item.id);
if (existingItem) {
existingItem.quantity += item.quantity || 1;
} else {
this.cart.push({
...item,
quantity: item.quantity || 1,
addedAt: Date.now()
});
}
// 2. 저장
this.saveCart();
// 3. 다른 탭에 알림
this.channel.postMessage({
action: 'ADD_ITEM',
item: item,
timestamp: Date.now()
});
// 4. UI 업데이트
this.renderCart();
this.showNotification(`${item.name}이(가) 장바구니에 추가되었습니다`);
}
// 다른 탭에서 아이템 추가됨
handleRemoteAddItem(item) {
// cart는 이미 localStorage를 통해 동기화됨
// 로컬 상태만 업데이트
this.cart = this.loadCart();
this.renderCart();
this.showNotification(`다른 탭에서 ${item.name}이(가) 추가되었습니다`);
}
// 아이템 제거
removeItem(itemId) {
this.cart = this.cart.filter(item => item.id !== itemId);
this.saveCart();
this.channel.postMessage({
action: 'REMOVE_ITEM',
itemId: itemId,
timestamp: Date.now()
});
this.renderCart();
}
handleRemoteRemoveItem(itemId) {
this.cart = this.loadCart();
this.renderCart();
this.showNotification('아이템이 제거되었습니다');
}
// 수량 변경
updateQuantity(itemId, quantity) {
const item = this.cart.find(i => i.id === itemId);
if (item) {
item.quantity = quantity;
this.saveCart();
this.channel.postMessage({
action: 'UPDATE_QUANTITY',
itemId: itemId,
quantity: quantity,
timestamp: Date.now()
});
this.renderCart();
}
}
handleRemoteUpdateQuantity(itemId, quantity) {
this.cart = this.loadCart();
this.renderCart();
}
// 장바구니 비우기
clearCart() {
this.cart = [];
this.saveCart();
this.channel.postMessage({
action: 'CLEAR_CART',
timestamp: Date.now()
});
this.renderCart();
}
handleRemoteClearCart() {
this.cart = [];
this.renderCart();
this.showNotification('장바구니가 비워졌습니다');
}
// UI 렌더링
renderCart() {
const cartElement = document.getElementById('cart-items');
const totalElement = document.getElementById('cart-total');
if (this.cart.length === 0) {
cartElement.innerHTML = '<p>장바구니가 비어있습니다</p>';
totalElement.textContent = '₩0';
return;
}
const html = this.cart.map(item => `
<div class="cart-item" data-id="${item.id}">
<img src="${item.image}" alt="${item.name}">
<div class="item-info">
<h3>${item.name}</h3>
<p>₩${item.price.toLocaleString()}</p>
<input
type="number"
value="${item.quantity}"
min="1"
onchange="cartSync.updateQuantity('${item.id}', parseInt(this.value))"
>
</div>
<button onclick="cartSync.removeItem('${item.id}')">삭제</button>
</div>
`).join('');
cartElement.innerHTML = html;
const total = this.cart.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
totalElement.textContent = `₩${total.toLocaleString()}`;
}
showNotification(message) {
// 간단한 토스트 알림
const toast = document.createElement('div');
toast.className = 'toast-notification';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
destroy() {
this.channel.close();
}
}
// 전역 인스턴스
const cartSync = new CartSync();예제 3: 테마 설정 동기화
다크모드/라이트모드를 모든 탭에서 동기화합니다.
// theme-sync.js
class ThemeSync {
constructor() {
this.channel = new BroadcastChannel('theme-sync');
this.currentTheme = this.loadTheme();
this.applyTheme(this.currentTheme);
this.setupListeners();
}
loadTheme() {
return localStorage.getItem('theme') || 'light';
}
setupListeners() {
this.channel.addEventListener('message', (event) => {
if (event.data.type === 'THEME_CHANGE') {
this.applyTheme(event.data.theme);
}
});
}
setTheme(theme) {
// 1. 저장
localStorage.setItem('theme', theme);
this.currentTheme = theme;
// 2. 다른 탭에 알림
this.channel.postMessage({
type: 'THEME_CHANGE',
theme: theme,
timestamp: Date.now()
});
// 3. 현재 탭에 적용
this.applyTheme(theme);
}
applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
// 테마 아이콘 업데이트
const themeIcon = document.getElementById('theme-icon');
if (themeIcon) {
themeIcon.textContent = theme === 'dark' ? '🌙' : '☀️';
}
console.log(`테마가 ${theme}(으)로 변경되었습니다`);
}
toggleTheme() {
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
this.setTheme(newTheme);
}
destroy() {
this.channel.close();
}
}
// 사용
const themeSync = new ThemeSync();
document.getElementById('theme-toggle').addEventListener('click', () => {
themeSync.toggleTheme();
});예제 4: 실시간 알림 시스템
모든 탭에 알림을 브로드캐스트합니다.
// notification-manager.js
class NotificationManager {
constructor() {
this.channel = new BroadcastChannel('notifications');
this.notifications = [];
this.setupListeners();
}
setupListeners() {
this.channel.addEventListener('message', (event) => {
if (event.data.type === 'NEW_NOTIFICATION') {
this.showNotification(event.data.notification);
} else if (event.data.type === 'MARK_AS_READ') {
this.markAsRead(event.data.notificationId);
}
});
}
// 새 알림 생성 및 브로드캐스트
createNotification(notification) {
const newNotification = {
id: Date.now() + Math.random(),
...notification,
read: false,
createdAt: Date.now()
};
// 다른 탭에 브로드캐스트
this.channel.postMessage({
type: 'NEW_NOTIFICATION',
notification: newNotification
});
// 현재 탭에도 표시
this.showNotification(newNotification);
return newNotification;
}
showNotification(notification) {
this.notifications.unshift(notification);
// UI에 표시
const container = document.getElementById('notification-container');
const notifEl = document.createElement('div');
notifEl.className = `notification ${notification.type}`;
notifEl.innerHTML = `
<div class="notification-content">
<h4>${notification.title}</h4>
<p>${notification.message}</p>
<small>${new Date(notification.createdAt).toLocaleTimeString()}</small>
</div>
<button onclick="notificationManager.markAsRead('${notification.id}')">
✓
</button>
`;
container.prepend(notifEl);
// 브라우저 알림 (권한이 있는 경우)
if (Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/icon.png'
});
}
}
markAsRead(notificationId) {
// 다른 탭에 알림
this.channel.postMessage({
type: 'MARK_AS_READ',
notificationId: notificationId
});
// UI 업데이트
const notif = this.notifications.find(n => n.id === notificationId);
if (notif) {
notif.read = true;
this.updateNotificationUI(notificationId);
}
}
updateNotificationUI(notificationId) {
const notifEl = document.querySelector(
`[data-notification-id="${notificationId}"]`
);
if (notifEl) {
notifEl.classList.add('read');
}
}
destroy() {
this.channel.close();
}
}
// 사용
const notificationManager = new NotificationManager();
// 예시: 새 메시지 알림
document.getElementById('send-message').addEventListener('click', () => {
notificationManager.createNotification({
type: 'success',
title: '새 메시지',
message: '새로운 메시지가 도착했습니다'
});
});장점과 한계
장점
- ✅ 간단한 API: 몇 줄의 코드만으로 멀티탭 통신 구현
- ✅ 구조화된 데이터 지원: 객체와 배열을 직렬화 없이 전송 가능
- ✅ 양방향 통신: 모든 채널이 송수신 가능
- ✅ 낮은 오버헤드: localStorage polling보다 효율적
- ✅ 타입 안전성: TypeScript와 완벽 호환
한계
-
⚠️ Same-Origin 제한: 다른 도메인 간 통신 불가
// ❌ 불가능 // https://example.com 탭과 https://other-domain.com 탭 간 통신 -
⚠️ 영구성 없음: 채널은 메모리에만 존재, 재시작하면 소실
// 영구 저장이 필요하면 localStorage와 병용 channel.postMessage(data); localStorage.setItem('backup', JSON.stringify(data)); -
⚠️ 순서 보장 제한: 메시지 순서는 일반적으로 유지되지만 보장되지 않음
-
⚠️ 전송 크기 제한: 매우 큰 데이터는 성능 문제 발생 가능
// ❌ 피해야 할 것 channel.postMessage(veryLargeArray); // 10MB+ 데이터 // ✅ 권장 // 필요한 데이터만 전송하거나 IndexedDB 사용
언제 사용하고, 언제 피해야 하는가?
사용하기 좋은 경우:
- 사용자 인증 상태 동기화
- 테마/설정 동기화
- 실시간 알림 시스템
- 장바구니/위시리스트 동기화
- 멀티탭 게임 상태 공유
피해야 하는 경우:
- 크로스 도메인 통신 필요 시 →
postMessage()사용 - 영구 저장 필요 시 → IndexedDB 사용
- 대용량 데이터 전송 → 서버 경유 또는 SharedWorker
- 엄격한 순서 보장 필요 시 → 별도 순서 번호 체계 구현
브라우저 지원
지원 현황
// 브라우저 지원 확인
if ('BroadcastChannel' in window) {
const channel = new BroadcastChannel('my-channel');
// BroadcastChannel 사용
} else {
console.warn('BroadcastChannel이 지원되지 않습니다');
// 폴백 구현 (localStorage + storage 이벤트)
}지원 브라우저:
- ✅ Chrome 54+
- ✅ Firefox 38+
- ✅ Safari 15.4+
- ✅ Edge 79+
- ❌ Internet Explorer (미지원)
폴백 구현
// polyfill-broadcast-channel.js
class BroadcastChannelPolyfill {
constructor(channelName) {
this.name = channelName;
this.onmessage = null;
this._setupStorageListener();
}
_setupStorageListener() {
window.addEventListener('storage', (e) => {
if (e.key === `broadcast_${this.name}`) {
try {
const data = JSON.parse(e.newValue);
if (this.onmessage) {
this.onmessage({ data });
}
} catch (err) {
console.error('메시지 파싱 오류:', err);
}
}
});
}
postMessage(message) {
const key = `broadcast_${this.name}`;
// localStorage를 사용한 폴백
localStorage.setItem(key, JSON.stringify(message));
// 즉시 제거 (storage 이벤트 트리거용)
localStorage.removeItem(key);
}
close() {
// 정리 작업
}
}
// 자동 폴백
window.BroadcastChannel = window.BroadcastChannel || BroadcastChannelPolyfill;트러블슈팅
문제 1: 메시지가 전달되지 않음
증상:
const channel = new BroadcastChannel('test');
channel.postMessage('Hello'); // 다른 탭에서 받지 못함원인:
- 채널 이름이 다름
- Origin이 다름 (프로토콜, 도메인, 포트)
- 리스너가 설정되기 전에 메시지 전송
해결 방법:
// ✅ 방법 1: 채널 이름 확인
// Tab 1
const channel1 = new BroadcastChannel('my-channel'); // ✓
// Tab 2
const channel2 = new BroadcastChannel('my-channel'); // ✓ 동일해야 함
// ✅ 방법 2: Origin 확인
// http://localhost:3000 ✓
// http://localhost:3001 ❌ 다른 포트
// https://localhost:3000 ❌ 다른 프로토콜
// ✅ 방법 3: 리스너 먼저 설정
const channel = new BroadcastChannel('test');
// 리스너 먼저 설정
channel.addEventListener('message', (e) => {
console.log('받음:', e.data);
});
// 그 다음 메시지 전송
setTimeout(() => {
channel.postMessage('Hello');
}, 100);문제 2: 같은 탭에서 메시지를 받음
증상:
const channel = new BroadcastChannel('test');
channel.onmessage = (e) => {
console.log('받음:', e.data); // 자신이 보낸 메시지도 받음?
};
channel.postMessage('Hello');설명: BroadcastChannel은 송신자를 제외한 같은 채널의 모든 인스턴스에게만 메시지를 전송합니다. 자신이 보낸 메시지는 받지 않습니다.
검증:
const channel = new BroadcastChannel('test');
let receivedCount = 0;
channel.onmessage = (e) => {
receivedCount++;
console.log(`받은 횟수: ${receivedCount}`, e.data);
};
// 10개 전송
for (let i = 0; i < 10; i++) {
channel.postMessage(`Message ${i}`);
}
// receivedCount는 0이어야 함 (다른 탭이 없으면)
setTimeout(() => {
console.log('최종 받은 횟수:', receivedCount); // 0
}, 1000);문제 3: 메모리 누수
증상:
// 컴포넌트가 언마운트되어도 채널이 남아있음
function MyComponent() {
const channel = new BroadcastChannel('test');
channel.onmessage = (e) => {
console.log(e.data);
};
// ❌ 정리하지 않음
}해결 방법:
// ✅ React 예시
import { useEffect, useRef } from 'react';
function MyComponent() {
const channelRef = useRef(null);
useEffect(() => {
// 채널 생성
channelRef.current = new BroadcastChannel('test');
const handleMessage = (event) => {
console.log('받음:', event.data);
};
channelRef.current.addEventListener('message', handleMessage);
// 정리 함수
return () => {
channelRef.current.removeEventListener('message', handleMessage);
channelRef.current.close();
channelRef.current = null;
};
}, []);
const sendMessage = (data) => {
channelRef.current?.postMessage(data);
};
return (
<button onClick={() => sendMessage('Hello')}>
메시지 전송
</button>
);
}
// ✅ Vanilla JS 예시
class ChatComponent {
constructor() {
this.channel = new BroadcastChannel('chat');
this.channel.onmessage = this.handleMessage.bind(this);
}
handleMessage(event) {
console.log(event.data);
}
destroy() {
this.channel.close();
this.channel = null;
}
}
const chat = new ChatComponent();
// 페이지 언로드 시
window.addEventListener('beforeunload', () => {
chat.destroy();
});문제 4: 메시지 순서 보장
문제:
const channel = new BroadcastChannel('test');
// 순서대로 전송
channel.postMessage({ order: 1 });
channel.postMessage({ order: 2 });
channel.postMessage({ order: 3 });
// 다른 탭에서 순서가 뒤바뀔 수 있음?해결 방법:
// ✅ 타임스탬프 또는 시퀀스 번호 추가
class OrderedBroadcastChannel {
constructor(channelName) {
this.channel = new BroadcastChannel(channelName);
this.sequence = 0;
this.receivedMessages = new Map();
this.expectedSequence = 0;
this.channel.addEventListener('message', (event) => {
this.handleOrderedMessage(event.data);
});
}
postMessage(data) {
this.channel.postMessage({
sequence: this.sequence++,
timestamp: Date.now(),
data: data
});
}
handleOrderedMessage(message) {
const { sequence, data } = message;
// 버퍼에 저장
this.receivedMessages.set(sequence, data);
// 순서대로 처리
while (this.receivedMessages.has(this.expectedSequence)) {
const orderedData = this.receivedMessages.get(this.expectedSequence);
this.onmessage?.(orderedData);
this.receivedMessages.delete(this.expectedSequence);
this.expectedSequence++;
}
}
onmessage = null;
close() {
this.channel.close();
}
}
// 사용
const orderedChannel = new OrderedBroadcastChannel('ordered-test');
orderedChannel.onmessage = (data) => {
console.log('순서대로 받음:', data);
};성능 최적화
최적화 1: 메시지 배치 처리
빈번한 업데이트를 배치로 처리합니다.
class BatchedBroadcastChannel {
constructor(channelName, batchInterval = 100) {
this.channel = new BroadcastChannel(channelName);
this.pendingMessages = [];
this.batchInterval = batchInterval;
this.batchTimer = null;
}
postMessage(message) {
this.pendingMessages.push(message);
if (!this.batchTimer) {
this.batchTimer = setTimeout(() => {
this.flush();
}, this.batchInterval);
}
}
flush() {
if (this.pendingMessages.length > 0) {
this.channel.postMessage({
type: 'BATCH',
messages: this.pendingMessages,
count: this.pendingMessages.length
});
this.pendingMessages = [];
}
this.batchTimer = null;
}
close() {
this.flush(); // 남은 메시지 전송
this.channel.close();
}
}
// 사용
const batchChannel = new BatchedBroadcastChannel('batch-test', 100);
// 100ms 내의 모든 메시지가 하나의 배치로 전송됨
for (let i = 0; i < 50; i++) {
batchChannel.postMessage({ id: i, data: 'test' });
}최적화 2: 메시지 압축
큰 데이터를 압축하여 전송합니다.
class CompressedBroadcastChannel {
constructor(channelName) {
this.channel = new BroadcastChannel(channelName);
this.channel.addEventListener('message', async (event) => {
const decompressed = await this.decompress(event.data);
this.onmessage?.(decompressed);
});
}
async postMessage(data) {
const compressed = await this.compress(data);
this.channel.postMessage(compressed);
}
async compress(data) {
const json = JSON.stringify(data);
const blob = new Blob([json]);
const stream = blob.stream();
const compressedStream = stream.pipeThrough(
new CompressionStream('gzip')
);
const compressedBlob = await new Response(compressedStream).blob();
const buffer = await compressedBlob.arrayBuffer();
return {
compressed: true,
data: Array.from(new Uint8Array(buffer))
};
}
async decompress(message) {
if (!message.compressed) return message;
const uint8Array = new Uint8Array(message.data);
const blob = new Blob([uint8Array]);
const stream = blob.stream();
const decompressedStream = stream.pipeThrough(
new DecompressionStream('gzip')
);
const decompressedBlob = await new Response(decompressedStream).blob();
const text = await decompressedBlob.text();
return JSON.parse(text);
}
onmessage = null;
close() {
this.channel.close();
}
}관련 기술 비교
vs localStorage + storage 이벤트
| 기능 | BroadcastChannel | localStorage + storage |
|---|---|---|
| 구조화된 데이터 | ✅ 직접 지원 | ❌ 문자열만, 직렬화 필요 |
| 성능 | ✅ 빠름 | ⚠️ 느림 (디스크 I/O) |
| 메모리 효율 | ✅ 높음 | ⚠️ 낮음 |
| 영구 저장 | ❌ 없음 | ✅ 있음 |
| 같은 탭 이벤트 | ❌ 없음 | ❌ 없음 |
권장 사용:
- BroadcastChannel: 임시 동기화, 실시간 통신
- localStorage: 영구 저장 + 동기화
// 병용 예시
const channel = new BroadcastChannel('sync');
function updateData(data) {
// 1. localStorage에 저장 (영구 저장)
localStorage.setItem('data', JSON.stringify(data));
// 2. BroadcastChannel로 다른 탭에 알림 (실시간)
channel.postMessage({
type: 'DATA_UPDATE',
data: data
});
}vs SharedWorker
| 기능 | BroadcastChannel | SharedWorker |
|---|---|---|
| API 복잡도 | ✅ 간단 | ❌ 복잡 |
| 브라우저 지원 | ✅ 넓음 | ⚠️ 제한적 |
| 중앙 처리 | ❌ 없음 | ✅ 있음 |
| 서버 연결 공유 | ❌ 불가 | ✅ 가능 |
언제 무엇을 사용:
- BroadcastChannel: 단순 메시지 브로드캐스트
- SharedWorker: WebSocket 공유, 중앙 상태 관리
vs postMessage (Window)
| 기능 | BroadcastChannel | window.postMessage |
|---|---|---|
| 크로스 도메인 | ❌ 불가 | ✅ 가능 |
| iframe 통신 | ❌ 불가 | ✅ 가능 |
| 자동 브로드캐스트 | ✅ 있음 | ❌ 없음 (1:1) |
| 설정 복잡도 | ✅ 간단 | ⚠️ 복잡 |
사용 시나리오:
- BroadcastChannel: 같은 origin의 모든 탭/워커 통신
- postMessage: iframe, 팝업, 크로스 도메인 통신
보안 고려사항
1. Same-Origin 정책
// ✅ 안전: 같은 origin만 통신 가능
// https://example.com/page1 ↔ https://example.com/page2
// ❌ 불가능
// https://example.com ↔ https://evil.com2. 민감한 데이터 주의
// ❌ 피해야 할 것
channel.postMessage({
creditCard: '1234-5678-9012-3456',
password: 'secret123'
});
// ✅ 권장
channel.postMessage({
action: 'PAYMENT_COMPLETED',
transactionId: 'abc123' // ID만 전송
});3. 메시지 검증
const channel = new BroadcastChannel('secure-channel');
channel.addEventListener('message', (event) => {
// 메시지 구조 검증
if (!isValidMessage(event.data)) {
console.warn('유효하지 않은 메시지:', event.data);
return;
}
// 처리
handleMessage(event.data);
});
function isValidMessage(data) {
return (
data &&
typeof data === 'object' &&
typeof data.type === 'string' &&
data.timestamp &&
Date.now() - data.timestamp < 60000 // 1분 이내
);
}React에서 사용하기
Custom Hook 구현
// useBroadcastChannel.ts
import { useEffect, useRef, useCallback } from 'react';
interface UseBroadcastChannelOptions<T> {
channelName: string;
onMessage?: (data: T) => void;
}
export function useBroadcastChannel<T = any>({
channelName,
onMessage
}: UseBroadcastChannelOptions<T>) {
const channelRef = useRef<BroadcastChannel | null>(null);
useEffect(() => {
// 채널 생성
channelRef.current = new BroadcastChannel(channelName);
// 메시지 리스너
const handleMessage = (event: MessageEvent<T>) => {
onMessage?.(event.data);
};
channelRef.current.addEventListener('message', handleMessage);
// 정리
return () => {
channelRef.current?.removeEventListener('message', handleMessage);
channelRef.current?.close();
channelRef.current = null;
};
}, [channelName, onMessage]);
// 메시지 전송 함수
const postMessage = useCallback((data: T) => {
channelRef.current?.postMessage(data);
}, []);
return { postMessage };
}사용 예시
// App.tsx
import { useState } from 'react';
import { useBroadcastChannel } from './useBroadcastChannel';
interface AuthMessage {
type: 'LOGIN' | 'LOGOUT';
user?: { id: string; name: string };
}
function App() {
const [user, setUser] = useState<User | null>(null);
const { postMessage } = useBroadcastChannel<AuthMessage>({
channelName: 'auth',
onMessage: (data) => {
if (data.type === 'LOGIN' && data.user) {
setUser(data.user);
} else if (data.type === 'LOGOUT') {
setUser(null);
}
}
});
const handleLogin = async () => {
const user = await loginAPI();
setUser(user);
// 다른 탭에 알림
postMessage({
type: 'LOGIN',
user: user
});
};
const handleLogout = () => {
setUser(null);
// 다른 탭에 알림
postMessage({
type: 'LOGOUT'
});
};
return (
<div>
{user ? (
<>
<p>환영합니다, {user.name}님!</p>
<button onClick={handleLogout}>로그아웃</button>
</>
) : (
<button onClick={handleLogin}>로그인</button>
)}
</div>
);
}다음 단계
추가 학습 자료
실습 프로젝트
- 멀티탭 채팅 앱: 실시간 메시지 동기화
- 협업 에디터: 여러 탭에서 문서 공동 편집
- 게임 상태 동기화: 멀티탭 게임 구현
- 알림 센터: 전역 알림 시스템
관련 API
- SharedWorker: 탭 간 공유 워커
- Service Worker: 오프라인 기능, 푸시 알림
- IndexedDB: 대용량 데이터 저장
- WebSocket: 서버와 실시간 양방향 통신
참고 자료
공식 문서
브라우저 지원
라이브러리
- broadcast-channel - 크로스 브라우저 폴리필
마무리
BroadcastChannel API는 멀티탭 환경에서 원활한 사용자 경험을 제공하는 강력하고 간단한 도구입니다. 복잡한 설정 없이 몇 줄의 코드만으로 탭 간 실시간 동기화를 구현할 수 있습니다.
핵심은 적재적소에 사용하는 것입니다:
- 실시간 동기화가 필요한 경우 → BroadcastChannel
- 영구 저장이 필요한 경우 → localStorage와 병용
- 크로스 도메인 통신 → postMessage 사용
이제 여러분의 웹 애플리케이션에 BroadcastChannel을 적용해보세요!
라이선스
이 문서는 technical-writing.dev 가이드라인을 기반으로 작성되었습니다.