Observer pattern을 이용한 전역 토스트 메시지 구현

Yoomin Kang·2024년 5월 18일
post-thumbnail

프론트엔드 개발자에게 있어 Observer pattern은 다소 생소한 디자인 패턴일 수 있다. 최신 프론트엔드 개발 경향과 다소 거리가 먼 객체지향 코드에서 사용하는 디자인 패턴이기 때문인데, 필자 또한 요즘 함수로 이루어진 코드만 작성하다 오랜만에 클래스를 다루어보았다.

먼저 Observer pattern에 대해 간단히 짚고 넘어가자.

Observer pattern에는 두 가지 요소가 존재한다.

  1. Subject: 상태를 관리하고, 변경 시 Observer들에게 알려주는 역할
  2. Observer: Subject의 상태 변화를 감지하는 객체

그림으로 살펴보면 위와 같다. (이미지 출처: https://unity.com/how-to/create-modular-and-maintainable-code-observer-pattern)

코드로 살펴보도록 하자.

// ToastSubject.ts
type Observer = (message: string) => void;
class ToastSubject {
  private observers: Observer[] = [];

  addObserver(observer: Observer) {
    this.observers.push(observer);
  }

  removeObserver(observer: Observer) {
    this.observers = this.observers.filter((o) => o !== observer);
  }

  notifyObservers(message: string) {
    this.observers.forEach((observer) => observer(message));
  }
}
const toastSubject = new ToastSubject();
export default toastSubject;

addObserver라는 메소드에서는 observers에 매개변수로 들어온 observer을 추가하고, removeObserver라는 메소드에서는 observers에서 매개변수로 들어온 observer을 제거한다.

notifyObservers라는 메소드는 모든 Observer들에게 변경 사실을 알리는 역할을 한다.


이제 각각의 메소드들을 사용해야 한다.

useEffect 내에서 호출해야 하므로, 커스텀 훅을 하나 만들자.

// useToast.ts
import { useEffect, useState } from "react";
import toastSubject from "@subjects/ToastSubject";

type Toast = {
  id: number;
  message: string;
};
const useToast = () => {
  const [toasts, setToasts] = useState<Toast[]>([]);

  useEffect(() => {
    const handleNewToast = (message: string) => {
      const id = Date.now();
      setToasts((prev) => [
        ...prev,
        {
          id,
          message,
        },
      ]);
      setTimeout(() => {
        setToasts((prev) => prev.filter((toast) => toast.id !== id));
      }, 5000);
    };

    toastSubject.addObserver(handleNewToast);
    return () => {
      toastSubject.removeObserver(handleNewToast);
    };
  }, []);

  return toasts;
};
export default useToast;

핵심은 간단하다. handleNewToast라는 함수를 콜백으로 addObserver에 넘겨주어 observers에 등록하고, Cleanup 함수에서 removeObserver에 넘겨주어 observers에서 제거한다.


거의 다 왔다. Toasts 컴포넌트를 만들어서 토스트 메시지를 표시해줄 공간을 만들어준다.

// Toasts.tsx
import useToast from "@hooks/useToast";

const Toasts = () => {
  const toasts = useToast();
  return (
    <div>
      {toasts.map((toast) => (
        <div key={toast.id}>{toast.message}</div>
      ))}
    </div>
  );
};
export default Toasts;

이제 사용만 하면 된다!

// App.tsx
import Toasts from "@components/Toasts";
import toastSubject from "@subjects/ToastSubject";

function App() {
  return (
    <>
      <button
        onClick={() => {
          toastSubject.notifyObservers("🍞토스트 메시지");
        }}
      >
        토스트 메시지 표시하기
      </button>
      <Toasts />
    </>
  );
}

export default App;

toastSubject.notifyObservers 같이 쓰는 게 싫다면, 따로 showToast 함수를 만들어 감출 수도 있다.

// toast.ts
import toastSubject from "@subjects/ToastSubject";

export const showToast = (message: string) => {
  toastSubject.notifyObservers(message);
};

다시 적용하면, 다음과 같다.

// App.tsx
import Toasts from "@components/Toasts";
import toastSubject from "@subjects/ToastSubject";
import { showToast } from "@utils/toast";

function App() {
  return (
    <>
      <button
        onClick={() => {
          showToast("🍞토스트 메시지");
        }}
      >
        토스트 메시지 표시하기
      </button>
      <Toasts />
    </>
  );
}

export default App;

지금까지 Observer pattern을 이용한 전역 토스트 메시지 구현에 대해 알아보았다.

이렇게 구현했을 때 장점은, React component 내부가 아니어도 showToast 함수를 호출할 수 있다는 점이다. 예를 들면 axios interceptor 등이 있겠다.

추가적으로, React Portal을 이용하면 z-index를 고려하지 않아도 되는 완벽한 토스트 메시지가 될 것인데, 오늘 주제와는 무관하므로 넘어가도록 하겠다.

profile
FE Developer @Toss | GSHS 36 | Korea Univ 21

2개의 댓글

comment-user-thumbnail
2024년 5월 31일

안녕하세요 좋은 글 잘 읽었습니다!
궁금한 게 있는데요, 옵저버 구독할 때 useEffect + useState 조합 말고 useSyncExternalStore을 사용할 수도 있을 것 같은데 useEffect + useState 조합을 선택하신 이유가 있으신지 궁금합니다.

1개의 답글