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

5 min read

By 강병준

Created at

Updated at

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

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

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 영역 */}
  )
}

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

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

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

댓글