웹뷰 메시지 수신 구조를 옵저버 패턴으로 리팩토링하기

Created at 2025년 06월 21일

Updated at 2025년 06월 21일

By 강병준

처음에는 웹뷰와의 통신을 처리하는 컴포넌트가 하나뿐이라 구조적 문제를 인지하지 못했습니다. 그러나 시간이 지나면서 관련 로직이 하나, 둘 늘어나고, 컴포넌트 간에 중복 처리와 예상치 못한 동작이 나타나기 시작했습니다.

이 글에서는 이러한 웹뷰 메시지 수신 구조를 옵저버 패턴을 활용해 중앙화하고, 효율적이고 예측 가능한 방식으로 리팩토링한 과정을 공유하고자 합니다.

Code Smell!

Frame_121.png

기존에 웹뷰에서 메시지를 수신하기 위해 Component A, B, C에서 웹뷰 통신 처리 로직이 중복(빨간색 박스)되고 있었는데, 위 코드에서 발생하는 문제를 자세히 분석해보겠습니다.

  • 중복된 이벤트 리스너 로직
    • Component A, B, C 모두 동일한 이벤트 리스너 설정/해제 로직을 가지고 있다.
    • 이는 DRY(Don’t Repeat Yourself) 원칙에 위배된다.

DRY 원칙을 항상 준수하는 것이 정답은 아닙니다. 하지만 Component A, B, C는 각각 A, B, C 메시지에 대한 관심만 있고, 그 외의 메시지에 대해서는 관심이 없다면 어떻게 될까요?

현재 구조에서는 웹뷰에서 브로드 캐스트 방식처럼 웹뷰에서 수신한 메시지를 모든 컴포넌트로 전달하고 있는 것을 확인할 수 있는데, 이러한 구조는 큰 문제점을 가지고 있습니다.

  1. 일관성 없는 메시지 처리
    • 메시지 형식이나 처리 방식이 변경되는 경우, 메시지를 수신하는 핸들러 모두를 수정해야 한다.
  2. 예측 가능성 저하
    • 여러 곳에서 메시지를 수신하고 처리하고 있다 보니, 어떤 메시지가 어떤 컴포넌트에서 처리되는지 추적하기 어렵다.
    • 또한 메시지 타입이 추가될 때마다, 관심사가 아닌 컴포넌트에서는 조건문을 추가해주어야 한다.
    • 이에 따라 예상치 못한 동작이 발생할 수 있다.

“예상치 못한 동작이 발생할 수 있다”라는 것은 개인적으로 치명적인 문제를 방치해두는 것과 별반 다를 것 없다고 생각하였습니다. 그렇기 때문에 예상 가능한 동작을 수행하도록, 메시지를 수신하는 중앙화된 시스템을 두고 시스템이 메시지의 타입에 따라 핸들러가 동작하도록 개선하는 방식을 적용해보겠습니다.

옵저버 패턴 적용하기

💡 옵저버 패턴 (Observer Pattern)

객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다.

// WebView 수신 메시지 구조
interface WebViewMessage<T = unknown> {
  type: string;
  payload: T;
}
 
type MessageHandler<T = unknown> = (payload: T) => void;
 
class WebViewMessagePublisher {
  private static instance: WebViewMessagePublisher;
  private handlers: Map<string, MessageHandler> = new Map();
 
  private constructor() {
    this.setupEventListeners();
  }
 
  public static getInstance() {
    if (!WebViewMessagePublisher.instance) {
      WebViewMessagePublisher.instance = new WebViewMessagePublisher();
    }
 
    return WebViewMessagePublisher.instance;
  }
 
  private setupEventListeners() {
    const handleMessage = (e: MessageEvent) => {
      try {
        const data = JSON.parse(e.data) as WebViewMessage;
        const type = data.type;
        const payload = data.payload;
 
        const handler = this.handlers.get(type);
        if (handler) {
          handler(payload);
        }
      } catch (error) {
        // eslint-disable-next-line no-console
        console.error("Failed to parse message:", error);
      }
    };
 
    window.addEventListener("message", handleMessage);
    document.addEventListener("message", handleMessage);
  }
 
  subscribe<T>(type: string, handler: MessageHandler<T>) {
    this.handlers.set(type, handler as MessageHandler<unknown>);
  }
 
  unsubscribe(type: string) {
    this.handlers.delete(type);
  }
}
 
export function useWebViewMessage<T>(type: string, handler: MessageHandler<T>) {
  useEffect(() => {
    const manager = WebViewMessagePublisher.getInstance();
    manager.subscribe(type, handler);
 
    return () => {
      manager.unsubscribe(type);
    };
  }, [type, handler]);
}

웹뷰 메시지 수신을 위해 Publisher 클래스를 다음과 같이 구현하였는데, 여기서 짚고 넘어가야 할 포인트가 있습니다.

  • 싱글톤 패턴으로 구현

현재 위 코드에서 private constructor로 직접 생성을 막고 getInstance 메서드로만 인스턴스를 획득할 수 있도록 하였는데, JS 모듈 시스템에서는 자체적으로 모듈을 한 번만 로드하기 때문에 다음과 같이 객체를 생성하더라도 유일성을 보장할 수 있습니다.

const webViewMessagePublisher = new WebViewMessagePublisher();

하지만 저는 여기서 싱글톤 패턴을 적용하였는데, 그 이유는 **“이 클래스의 인스턴스는 반드시 하나만 존재해야 한다”**는 설계 의도를 코드에 명확히 표현하고자 다음과 같이 작성하였습니다.

  • Generic 타입 활용

image.png

useWebViewMessage 훅은 Generic을 활용해 각 컴포넌트에서 자신이 처리할 메시지 타입만 명확하게 지정할 수 있도록 했습니다. 이를 통해 구독자는 내부 Publisher 구현을 몰라도 안전한 타입 보장을 받을 수 있습니다.

Frame_100.png

기존에 구독하는 컴포넌트마다 useEffect를 이용하여 이벤트 핸들러를 달아주었던 방식과 달리, 이제는 컴포넌트에서는 useWebViewMessage 훅스를 이용하여 원하는 메시지를 구독할 수 있게 되었습니다.

++  type MessageA = {
++   data: string;
++  };
 
export function ComponentA() {
  const handler = () => {
    // 메시지 수신 이벤트 처리
  }
  
--	useEffect(() => {
--		window.addEventListener("message", handler);
--		document.addEventListener("message", handler);
--		
--		// Clean Up
--		return () => {
--		  window.removeEventListener("message", handler);
--			document.removeEventListener("message", handler);		  
--		}
--	}, []);
 
++  useWebViewMessage<MessageA>("a", handler);
  
  return (
    {/* UI 영역 */}
  )
}

이러한 구조를 통해, 기존의 컴포넌트간에 발생했던 아래의 문제점들은 해결할 수 있게 되었습니다.

  • 중복 코드 제거로 유지보수성 향상
    • 중복된 이벤트 리스너 등록/해제 코드 제거로 코드량을 줄이고, 수정 범위 최소화
  • 컴포넌트 간 관심사 분리 및 예측 가능성 향상
    • 각 컴포넌트는 자신이 구독한 메시지에만 반응하도록 설계되어, 사이드 이펙트나 의도치 않은 처리 방지

이제 메시지 처리 로직은 중앙에서 예측 가능하게 관리되며, 컴포넌트는 오직 자신이 필요한 메시지만 구독하는 구조로 변경되었습니다!