메인 콘텐츠로 바로가기

BroadcastChannel API 완벽 가이드

📋 문서 정보

  • 문서 유형: Concept Explanation + API Reference
  • 대상 독자: 중급 웹 개발자
  • 주요 목표: 같은 출처의 여러 탭/창 간 실시간 통신 구현
  • 마지막 업데이트: 2024-12-14

개요

BroadcastChannel API는 같은 origin의 여러 browsing contexts (탭, 창, iframe, 웹 워커 등) 간 양방향 통신을 가능하게 하는 웹 브라우저 API입니다.

이 가이드를 읽고 나면 다음을 할 수 있습니다:

  • 여러 탭/창 간 실시간 데이터 동기화 구현
  • 탭 간 사용자 상태 공유 (로그인, 테마 설정 등)
  • 브라우저 전역 알림 시스템 구축
  • 멀티탭 환경에서 일관된 사용자 경험 제공

왜 BroadcastChannel API가 필요한가?

해결하려는 문제

전통적으로 웹 애플리케이션은 각 탭이 독립적으로 동작했습니다. 사용자가 여러 탭에서 같은 웹 앱을 열면 다음과 같은 문제가 발생했습니다:

  1. 데이터 불일치: 한 탭에서 데이터를 수정해도 다른 탭에 반영되지 않음
  2. 중복 작업: 여러 탭에서 동일한 API 요청을 중복 실행
  3. 인증 상태 불일치: 한 탭에서 로그아웃해도 다른 탭은 여전히 로그인 상태
  4. 비효율적인 리소스 사용: 각 탭이 독립적으로 웹소켓 연결 유지

이전 해결 방법의 한계

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 │ │ │ └───────────────┘ │ └─────────────────────────────────────────────────────────┘

주요 특징

  1. 자동 브로드캐스팅: 한 채널에서 메시지를 전송하면 같은 이름의 모든 채널이 수신
  2. 구조화된 데이터 지원: 객체, 배열 등 복잡한 데이터 타입 직접 전송 가능
  3. 양방향 통신: 모든 채널이 송신자이자 수신자
  4. 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'); // 다른 탭에서 받지 못함

원인:

  1. 채널 이름이 다름
  2. Origin이 다름 (프로토콜, 도메인, 포트)
  3. 리스너가 설정되기 전에 메시지 전송

해결 방법:

// ✅ 방법 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 이벤트

기능BroadcastChannellocalStorage + 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

기능BroadcastChannelSharedWorker
API 복잡도✅ 간단❌ 복잡
브라우저 지원✅ 넓음⚠️ 제한적
중앙 처리❌ 없음✅ 있음
서버 연결 공유❌ 불가✅ 가능

언제 무엇을 사용:

  • BroadcastChannel: 단순 메시지 브로드캐스트
  • SharedWorker: WebSocket 공유, 중앙 상태 관리

vs postMessage (Window)

기능BroadcastChannelwindow.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.com

2. 민감한 데이터 주의

// ❌ 피해야 할 것 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> ); }

다음 단계

추가 학습 자료

실습 프로젝트

  1. 멀티탭 채팅 앱: 실시간 메시지 동기화
  2. 협업 에디터: 여러 탭에서 문서 공동 편집
  3. 게임 상태 동기화: 멀티탭 게임 구현
  4. 알림 센터: 전역 알림 시스템

관련 API

  • SharedWorker: 탭 간 공유 워커
  • Service Worker: 오프라인 기능, 푸시 알림
  • IndexedDB: 대용량 데이터 저장
  • WebSocket: 서버와 실시간 양방향 통신

참고 자료

공식 문서

브라우저 지원

라이브러리


마무리

BroadcastChannel API는 멀티탭 환경에서 원활한 사용자 경험을 제공하는 강력하고 간단한 도구입니다. 복잡한 설정 없이 몇 줄의 코드만으로 탭 간 실시간 동기화를 구현할 수 있습니다.

핵심은 적재적소에 사용하는 것입니다:

  • 실시간 동기화가 필요한 경우 → BroadcastChannel
  • 영구 저장이 필요한 경우 → localStorage와 병용
  • 크로스 도메인 통신 → postMessage 사용

이제 여러분의 웹 애플리케이션에 BroadcastChannel을 적용해보세요!


라이선스

이 문서는 technical-writing.dev  가이드라인을 기반으로 작성되었습니다.

댓글

developjik
All content is licensed under CC BY-NC-SA 4.0 unless otherwise noted.