dkmqflx's blogGithub

BroadcastChannel API를 활용해서 서로 다른 컨텍스트 간에 커뮤니케이션 하기

2022.11.10

들어가며

프론트엔드 애플리케이션을 개발하다 보면 서로 다른 탭 사이에 데이터를 전달해야하는 상황을 마주칠 수 있습니다. OAuth 인증 후 메인 탭으로 상태 전달, 멀티 탭 애플리케이션의 상태 동기화, 실시간 알림 시스템 구현 등 다양한 상황에서 사용될 수 있습니다.

이러한 요구사항을 해결하기 위한 여러 접근 방식이 있지만, 오늘은 Web API 중 하나인 BroadcastChannel을 활용한 방식에 대해 알아보도록 하겠습니다.


BroadcastChannel

BroadcastChannel은 동일한 Origin 컨텍스트 내에서 브라우저 창, 탭, iframe 간의 안전한 통신을 가능하게 해주는 Web API입니다. 이는 다중 탭이나 창 간에 데이터를 손쉽게 주고받을 수 있도록 합니다.

작동 원리

BroadcastChannel의 인스턴스를 생성하는 방법은 다음과 같습니다:

const broadcastChannel = new BroadcastChannel(CHANNEL_NAME);

BroadcastChannel를 사용할 때 주의해야 할 점은 다음과 같습니다:

  1. 메모리 관리: 채널은 가비지 컬렉션의 대상이 되며, 모든 참조가 해제되면 자동으로 정리됩니다.
  2. 동시성 처리: 메시지는 비동기적으로 처리되며, 이벤트 루프를 통해 전달됩니다.
  3. 직렬화 제약: 전송되는 데이터는 구조적 복제 알고리즘을 통해 직렬화되므로, 함수나 Symbol 등은 전송할 수 없습니다.

메시지 전송

메시지 전송은 다음과 같이 구현할 수 있습니다:

const sendBroadcastChannel = new BroadcastChannel(CHANNEL_NAME);
// 구조적 복제가 가능한 데이터만 전송
sendBroadcastChannel.postMessage({
  type: "USER_ACTION",
  payload: { id: 1, timestamp: Date.now() },
});

전달된 메시지는 아래와 같이 동일한 CHANNEL_NAME을 사용하는 다른 BroadcastChannel 객체에서 수신할 수 있으며, 아래와 같은 이벤트 핸들러를 통해 접근 가능합니다.

메시지 수신 방식

BroadcastChannel에서 메시지를 수신하는 방법에는 두 가지가 있습니다:

  1. addEventListener 방식 (권장)
const receiveBroadcastChannel = new BroadcastChannel(CHANNEL_NAME);
 
const messageHandler = (event: MessageEvent) => {
  const { type, payload } = event.data;
  console.log("Received:", payload);
};
 
// 이벤트 리스너 등록
receiveBroadcastChannel.addEventListener("message", messageHandler);
 
// 이벤트 리스너 제거
receiveBroadcastChannel.removeEventListener("message", messageHandler);
receiveBroadcastChannel.close();
  1. onmessage 속성 방식
const receiveBroadcastChannel = new BroadcastChannel(CHANNEL_NAME);
 
receiveBroadcastChannel.onmessage = (event) => {
  const { type, payload } = event.data;
  console.log("Received:", payload);
};

이벤트 객체는 아래와 같은 프로퍼티를 가지고 있습니다:


React 환경에서의 사용하기

React 환경에서 BroadcastChannel을 효과적으로 활용하기 위해, TypeScript와 함께 타입 안전성이 보장된 Custom Hook을 구현해보겠습니다.

// hooks/useBroadcastChannel.tsx
 
import { useEffect, useRef, useState } from "react";
 
/**
 * 메시지 타입 정의
 * @template T - 메시지 페이로드의 타입
 * @property type - 메시지의 종류를 구분하는 문자열
 * @property payload - 실제 전달할 데이터
 */
type Message<T = unknown> = {
  type: string;
  payload: T;
};
 
/**
 * BroadcastChannel Hook의 반환 타입
 * @template T - 메시지 페이로드의 타입
 * @property postMessage - 메시지를 전송하는 함수
 * @property data - 수신된 최신 메시지
 * @property openNewTab - 새 탭을 여는 유틸리티 함수
 */
type BroadcastChannelHook<T> = {
  postMessage: (message: Message<T>) => void;
  data: Message<T> | null;
  openNewTab: (path?: string) => void;
};
 
// 채널 이름 상수 정의
const CHANNEL_NAME = "app-communication";
 
/**
 * BroadcastChannel을 React에서 쉽게 사용할 수 있게 해주는 Custom Hook
 * @template T - 메시지 페이로드의 타입 파라미터
 * @returns {BroadcastChannelHook<T>} Hook의 반환값
 *
 * @example
 * ```tsx
 * const { postMessage, data, openNewTab } = useBroadcastChannel<UserAction>();
 *
 * // 메시지 전송
 * postMessage({ type: 'USER_LOGIN', payload: { userId: '123' } });
 *
 * // 새 탭 열기
 * openNewTab('/auth');
 * ```
 */
export const useBroadcastChannel = <
  T = unknown,
>(): BroadcastChannelHook<T> => {
  // BroadcastChannel 인스턴스를 참조로 관리
  // useRef를 사용하여 리렌더링 간에 인스턴스 유지
  const channelRef = useRef<BroadcastChannel | null>(null);
 
  // 수신된 최신 메시지를 상태로 관리
  const [data, setData] = useState<Message<T> | null>(null);
 
  /**
   * 메시지를 다른 탭으로 전송하는 함수
   * @param message - 전송할 메시지 객체
   */
  const postMessage = (message: Message<T>) => {
    if (!channelRef.current) return;
 
    try {
      channelRef.current.postMessage(message);
    } catch (error) {
      console.error("Failed to post message:", error);
    }
  };
 
  /**
   * 새 탭을 여는 유틸리티 함수
   * @param path - 새 탭에서 열 경로
   */
  const openNewTab = (path) => {
    // noopener,noreferrer로 보안 취약점 방지
    window.open(path, "_blank", "noopener,noreferrer");
  };
 
  useEffect(() => {
    // 컴포넌트 마운트 시 BroadcastChannel 인스턴스 생성
    if (!channelRef.current) {
      channelRef.current = new BroadcastChannel(CHANNEL_NAME);
    }
 
    /**
     * 메시지 수신 이벤트 핸들러
     * @param event - MessageEvent 객체
     */
    const messageHandler = (event: MessageEvent) => {
      try {
        setData(event.data as Message<T>);
      } catch (error) {
        console.error("Failed to process message:", error);
      }
    };
 
    // 메시지 이벤트 리스너 등록
    channelRef.current.addEventListener("message", messageHandler);
 
    // 클린업 함수: 컴포넌트 언마운트 시 실행
    return () => {
      // 이벤트 리스너 제거 및 채널 정리
      channelRef.current?.removeEventListener("message", messageHandler);
      channelRef.current?.close();
      channelRef.current = null;
    };
  }, []); // 빈 의존성 배열로 마운트/언마운트 시에만 실행
 
  return { postMessage, data, openNewTab };
};

실제 활용 예제:

다음은 위에서 구현한 Hook을 활용한 사용 예제입니다:

// components/Home.tsx
import { useBroadcastChannel } from "@/hooks/useBroadcastChannel";
import { useCallback } from "react";
 
type UserAction = {
  userId: string;
  action: "login" | "logout";
  timestamp: number;
};
 
export default function Home() {
  const { data, openNewTab } = useBroadcastChannel<UserAction>();
 
  const handleNewTab = useCallback(() => {
    openNewTab("/auth");
  }, [openNewTab]);
 
  return (
    <div className="p-4">
      <div className="mb-4">
        {data && (
          <div className="rounded-lg bg-gray-100 p-4">
            <h3 className="font-semibold">수신된 사용자 액션:</h3>
            <pre className="mt-2 text-sm">{JSON.stringify(data, null, 2)}</pre>
          </div>
        )}
      </div>
      <button
        onClick={handleNewTab}
        className="rounded-md bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
      >
        인증 창 열기
      </button>
    </div>
  );
}

마치며

지금까지 BroadcastChannel API를 활용하여 브라우저의 서로 다른 탭이나 창 간의 통신을 구현하는 방법에 대해 알아보았습니다. 정리하면 다음과 같습니다:

주요 특징

활용 사례

BroadcastChannel API는 간단하면서도 강력한 브라우저 API로, 적절히 활용하면 복잡한 탭 간 통신 요구사항도 효율적으로 해결할 수 있습니다.

특히 React와 같은 라이브러리와 함께 사용하면, 타입 안전성과 컴포넌트 생명주기를 고려한 안정적인 구현이 가능합니다.