프론트엔드 개발을 하다 보면 여러 브라우저 탭 사이에서 상태를 동기화해야 하는 상황이 생긴다.
예를 들어 쇼핑몰에서 다음과 같은 상황을 생각해볼 수 있다.
Tab 1
└── 상품 상세 페이지
└── 상품 좋아요 변경
Tab 2
└── 홈 페이지
└── 해당 상품의 좋아요 상태 표시
Detail 페이지에서 상품의 좋아요 상태를 변경했을 때 다른 탭의 Home에서도 변경된 상태를 반영해야 한다.
처음에는 전역 상태 관리 라이브러리인 Zustand를 생각할 수 있다.
Detail
↓
Zustand 상태 변경
↓
Home
하지만 여기에는 한 가지 문제가 있다.
Zustand는 기본적으로 같은 JavaScript 실행 환경 안에서 상태를 공유하는 도구다.
브라우저 탭이 분리되어 있다면 각 탭은 서로 다른 JavaScript 실행 환경을 가지고 있기 때문에 하나의 Zustand 상태를 자동으로 공유하지 않는다.
┌───────────────┐ ┌───────────────┐
│ Tab 1 │ │ Tab 2 │
│ │ │ │
│ Detail │ │ Home │
│ │ │ │
│ Zustand │ │ Zustand │
└───────────────┘ └───────────────┘
이때 사용할 수 있는 Web API가 BroadcastChannel API다.
BroadcastChannel은 같은 Origin을 사용하는 서로 다른 브라우징 컨텍스트 사이에서 메시지를 전달할 수 있도록 해주는 Web API다.
쉽게 말하면 다음과 같다.
같은 웹사이트의 다른 탭이나 창에 메시지를 보내는 브라우저 기능이다.
구조는 다음과 같다.
┌───────────────┐
│ Tab 1 │
│ │
│ Detail │
└───────┬───────┘
│
│ postMessage()
▼
┌──────────────────────┐
│ BroadcastChannel │
└──────────────────────┘
│
│ message
▼
┌───────────────┐
│ Tab 2 │
│ │
│ Home │
└───────────────┘
별도의 서버를 구축하지 않고도 브라우저 내부에서 탭 간 통신을 구현할 수 있다는 것이 특징이다.
BroadcastChannel을 생성하는 방법은 간단하다.
const channel = new BroadcastChannel('my-channel');
여기서 'my-channel'은 통신에 사용할 채널 이름이다.
같은 이름의 BroadcastChannel을 생성한 브라우징 컨텍스트끼리 메시지를 주고받을 수 있다.
channel.postMessage({
type: 'PRODUCT_UPDATED',
productId: 123,
});
channel.onmessage = (event) => {
console.log(event.data);
};
또는 addEventListener를 사용할 수도 있다.
channel.addEventListener('message', (event) => {
console.log(event.data);
});
사용이 끝났다면 채널을 닫아주는 것이 좋다.
channel.close();
쇼핑몰을 예시로 들어보겠다.
현재 브라우저에서 두 개의 탭을 열어두었다고 가정한다.
Tab 1
└── 상품 상세 페이지
└── 상품 좋아요 변경
Tab 2
└── 홈 페이지
└── 해당 상품의 좋아요 상태 표시
Detail에서 좋아요를 변경하면 Home에서도 변경된 상태를 반영해야 한다.
Detail에서는 다음과 같이 메시지를 전송할 수 있다.
const channel = new BroadcastChannel('product');
function updateLike(productId: number) {
// 서버에 좋아요 변경 요청
updateProductLike(productId);
// 다른 탭에 변경 사실 전달
channel.postMessage({
type: 'LIKE_UPDATED',
productId,
});
}
Home에서는 메시지를 수신한다.
const channel = new BroadcastChannel('product');
channel.addEventListener('message', (event) => {
if (event.data.type === 'LIKE_UPDATED') {
const { productId } = event.data;
// 해당 상품 데이터 다시 조회
refetchProduct(productId);
}
});
전체적인 흐름은 다음과 같다.
Detail Tab
│
│ 서버에 상태 변경
▼
Server
│
│
│
Detail Tab ──────┼──── BroadcastChannel
│
▼
Home Tab
│
│ refetch
▼
Server
│
▼
최신 데이터
여기서 중요한 점이 있다.
BroadcastChannel 자체가 서버의 상태를 동기화해주는 것은 아니다.
BroadcastChannel은 단순히
"다른 탭에서 이런 일이 발생했다."
라는 이벤트를 전달하는 역할을 한다.
실제 데이터의 정합성을 맞추는 것은 서버 API나 데이터 조회 로직이 담당한다.
여기서 Zustand와 헷갈릴 수 있다.
둘은 비슷해 보이지만 목적이 다르다.
Zustand는 애플리케이션의 상태를 관리하기 위한 도구다.
Component A
│
▼
┌──────────────┐
│ Zustand │
│ │
│ user │
│ cart │
│ modal │
└──────────────┘
▲
│
Component B
예를 들어 다음과 같은 상태를 관리할 수 있다.
const useStore = create((set) => ({
count: 0,
increment: () =>
set((state) => ({
count: state.count + 1,
})),
}));
같은 애플리케이션 안의 여러 컴포넌트가 하나의 상태를 공유할 수 있다.
다음과 같은 상황을 생각해보자.
Chrome Tab 1
┌─────────────────────┐
│ React Application │
│ │
│ Zustand Store A │
└─────────────────────┘
Chrome Tab 2
┌─────────────────────┐
│ React Application │
│ │
│ Zustand Store B │
└─────────────────────┘
Tab 1의 Zustand Store와 Tab 2의 Zustand Store는 서로 다른 메모리 공간에 존재한다.
따라서 Tab 1에서 Zustand의 상태를 변경했다고 해서 Tab 2의 Zustand 상태가 자동으로 변경되는 것은 아니다.
이때 BroadcastChannel을 추가할 수 있다.
Tab 1
┌───────────────┐
│ Zustand │
└───────┬───────┘
│
│ postMessage()
▼
┌──────────────────────┐
│ BroadcastChannel │
└──────────┬───────────┘
│
▼
Tab 2
┌───────────────┐
│ Zustand │
└───────────────┘
즉,
Zustand = 상태를 관리한다.
BroadcastChannel = 다른 브라우징 컨텍스트에 이벤트를 전달한다.
라고 구분하면 이해하기 쉽다.
BroadcastChannel을 공부하다 보면 WebSocket과도 비슷해 보인다.
둘 다 메시지를 주고받을 수 있기 때문이다.
하지만 통신 대상이 완전히 다르다.
Browser
│
├── Tab A
│
├── Tab B
│
└── Tab C
Tab A ── BroadcastChannel ──> Tab B
└────> Tab C
브라우저 내부의 다른 브라우징 컨텍스트와 통신한다.
Client A ──────┐
│
Client B ──────┼── WebSocket Server
│
Client C ──────┘
WebSocket은 클라이언트와 서버 사이의 실시간 양방향 통신을 위한 기술이다.
따라서 사용 목적도 다르다.
| 구분 | BroadcastChannel | WebSocket |
|---|---|---|
| 통신 대상 | 같은 Origin의 브라우저 컨텍스트 | 클라이언트 ↔ 서버 |
| 서버 필요 | ❌ | ⭕ |
| 다른 탭 통신 | ⭕ | ⭕ |
| 서버와 실시간 통신 | ❌ | ⭕ |
| 대표적인 용도 | 탭 간 상태/이벤트 전달 | 실시간 데이터 |
| 채팅 | 부적합 | 적합 |
| 실시간 주식 가격 | 부적합 | 적합 |
| 로그인 상태 변경 전달 | 적합 | 가능하지만 과함 |
| 여러 탭 간 UI 상태 동기화 | 적합 | 과함 |
물론 가능하다.
예를 들어 모든 탭이 하나의 WebSocket 서버에 연결되어 있다고 생각해보자.
Tab A ─────┐
│
Tab B ─────┼──── WebSocket Server
│
Tab C ─────┘
Tab A에서 데이터를 변경하면 서버가 Tab B와 Tab C에 변경 이벤트를 전송할 수 있다.
Tab A
│
│ update
▼
Server
│
├────────> Tab B
│
└────────> Tab C
하지만 단순히 같은 브라우저 안에서 탭끼리 이벤트를 전달하려는 목적이라면 WebSocket은 상당히 무거운 해결책이 될 수 있다.
서버 연결을 유지해야 하고,
등을 추가로 고려해야 하기 때문이다.
단순히
"다른 탭에서 데이터가 변경됐다는 사실만 알려주고 싶다."
라면 BroadcastChannel이 훨씬 단순하다.
세 기술을 역할 관점에서 비교하면 더 쉽게 이해할 수 있다.
┌─────────────────┐
│ 상태 관리 │
│ Zustand │
└─────────────────┘
│
▼
"현재 상태가 무엇인가?"
┌─────────────────┐
│ 탭 간 통신 │
│ BroadcastChannel│
└─────────────────┘
│
▼
"다른 탭에 알려준다."
┌─────────────────┐
│ 서버 통신 │
│ WebSocket │
└─────────────────┘
│
▼
"서버와 실시간으로 통신한다."
따라서 이 셋은 서로 경쟁하는 관계라기보다는 서로 다른 문제를 해결하는 도구라고 보는 것이 좋다.
실제 React 애플리케이션에서는 BroadcastChannel과 Zustand를 함께 사용할 수도 있다.
예를 들어 인증 상태를 생각해보자.
Tab A에서 로그아웃했다고 가정한다.
Tab A
│
│ logout
▼
Server
│
▼
BroadcastChannel
│
├──────────────> Tab B
│
└──────────────> Tab C
Tab B와 Tab C에서는 메시지를 받는다.
channel.addEventListener('message', (event) => {
if (event.data.type === 'LOGOUT') {
useAuthStore.getState().logout();
}
});
그러면 역할이 명확해진다.
BroadcastChannel
│
│ 이벤트 전달
▼
Zustand
│
│ 상태 변경
▼
React UI
이런 구조라면 각각의 도구가 자신의 역할에 집중할 수 있다.
BroadcastChannel은 상태를 저장하지 않는다.
channel.postMessage({
user: 'kim',
});
이 메시지를 보내고 나서 나중에 채널에 들어왔다고 해서 과거 메시지를 받을 수 있는 것은 아니다.
즉,
BroadcastChannel
│
├── 메시지 전달
│
└── 상태 저장 ❌
상태가 필요하다면 Zustand, 서버, IndexedDB 등의 별도 저장소가 필요하다.
이 부분이 가장 중요하다.
예를 들어 Detail에서 다음과 같이 메시지를 보냈다고 해보자.
channel.postMessage({
type: 'PRODUCT_UPDATED',
});
이 메시지는
"상품이 변경됐다."
라는 이벤트다.
하지만 Home이 이 메시지만 가지고 실제 최신 상품 데이터를 알 수 있는 것은 아니다.
따라서 보통은 다음과 같이 사용한다.
Detail
│
│ 상태 변경
▼
Server
│
│ 성공
▼
BroadcastChannel
│
│ PRODUCT_UPDATED
▼
Home
│
│ refetch
▼
Server
│
▼
최신 데이터
이렇게 하면 서버를 Source of Truth로 유지하면서 탭 간 변경 사실만 빠르게 전달할 수 있다.
다음과 같은 상황에서 특히 유용하다.
Tab A에서 로그아웃
↓
BroadcastChannel
↓
Tab B도 로그아웃 처리
Detail에서 상품 변경
↓
BroadcastChannel
↓
Home에서 해당 데이터 refetch
Admin Tab
↓
상품 상태 변경
↓
BroadcastChannel
↓
User Tab
예를 들어 한 탭에서 특정 작업을 완료했다는 사실을 다른 탭에 전달할 때 사용할 수 있다.
다음과 같은 상황에서는 다른 기술을 사용하는 것이 좋다.
주식 가격
채팅
게임 상태
실시간 알림
→ WebSocket 등의 서버 기반 실시간 통신이 적합하다.
모달
사용자 설정
장바구니 UI 상태
필터
현재 선택된 메뉴
→ Zustand 같은 상태 관리 도구가 적합하다.
새로고침 후에도 유지해야 하는 데이터
→ 서버 DB, localStorage, IndexedDB 등의 저장소를 고려해야 한다.
BroadcastChannel API를 처음 접하면 Zustand나 WebSocket과 비슷해 보일 수 있다.
하지만 각각의 역할을 구분하면 어렵지 않다.
┌──────────────────────┐
│ Zustand │
│ │
│ 애플리케이션 상태 관리 │
└──────────────────────┘
┌──────────────────────┐
│ BroadcastChannel │
│ │
│ 브라우저 컨텍스트 간 │
│ 메시지 전달 │
└──────────────────────┘
┌──────────────────────┐
│ WebSocket │
│ │
│ 클라이언트 ↔ 서버 │
│ 실시간 통신 │
└──────────────────────┘
한 문장으로 정리하면 다음과 같다.
Zustand는 "상태를 관리하기 위한 도구", BroadcastChannel은 "다른 브라우저 컨텍스트에 이벤트를 전달하기 위한 도구", WebSocket은 "서버와 실시간으로 통신하기 위한 도구"다.
그리고 실제 서비스에서는 이들을 서로 대체하기보다는 조합해서 사용할 수 있다.
예를 들어 다음과 같은 구조를 생각할 수 있다.
┌──────────────┐
│ Server │
│ Source of │
│ Truth │
└──────┬───────┘
│
┌─────────┴─────────┐
│ │
Detail Home
│ ▲
│ │
│ BroadcastChannel │
└───────────────────┘
│
refetch
서버가 실제 상태를 관리하고, BroadcastChannel은 탭 간 변경 사실을 전달하며, 각 애플리케이션은 Zustand나 TanStack Query 등을 이용해 자신의 상태를 갱신하는 방식이다.
결국 중요한 것은 특정 기술을 선택하는 것이 아니라 각 기술이 해결하려는 문제를 구분하는 것이다.
이렇게 역할을 나누면 불필요하게 WebSocket을 도입하지 않고도 브라우저 탭 간 상태 동기화 문제를 간단하게 해결할 수 있다.