TIL 108 - Toast와 Observer pattern(Custom Event)

김영현·2024년 6월 29일
0

TIL

목록 보기
118/129

Toast notification

사용자에게 짧은 정보를 전달하기 위한 창이다. 웹에만 있던 개념은 아니다.

이처럼 윈도우에서도 볼 수 있다. FE진영에선 react-toastify라는 라이브러리가 유명하다.

사진 출처 : https://www.npmjs.com/package/react-toastify

이러한 Toast를 한 번 구현해 볼 것이다. 그전에 짤막한 사전지식이 필요하다.

Observer pattern이란?

observer라고 불리우는 특별한 객체들을 다른 객체인 Observablesubscribe(구독)시킬 수 있다.
이때 observable 객체들은 자신에게 구독된 모든 observer객체들에게 notify메서드를 이용하여 특정한 동작을 행할 수 있다.

보통 주요 메서드와 객체는 다음과 같다.

  • observers: observable객체들에게 알림을 받고싶은 객체들이다.
  • subscribe(): observable객체를 구독하고싶습니다. 유튜브를 열심히 뒤져보는 observer객체들이 심심하면 구독을 누른다.
  • unsubscribe(): observable객체를 구독 취소한다. 아마 사고를 쳤나보다.
  • notify(): 구독, 알림 좋아요!중 알림에 해당한다. 구독자들에게 알림을 보내준다.

짤막하게 구현하면 다음과 같을 것이다.

const listeners = [...]; //수많은 옵저버 객체들
const notify = (data) => listeners.forEach((observer) => observer(data));
//이번에 사용할 observer들은 보통 함수다. 사실 함수도 객체니까 괜찮다.

예시로 보기 좋은 영상 링크를 첨부해놓겠음!
출처 : https://www.patterns.dev/vanilla/observer-pattern/


Toast는 어떻게 제작할까?

간단하게는 아래처럼 사용한다.

//사용하는 컴포넌트
const notify = () => toast('토스트에 보낼 메시지');

<button onClick={notify}>토스트용 버튼</button>

//App.tsx
//이렇게 꼭 ToastContainer를 어딘가에 렌더링 해주어야한다. 위치는 상관 없을 것 같기도하고?
<>
<App/>
<ToastContainer/>
</>

toast라는 함수에 문자열을 전달한 뒤, notify()로 호출하면 토스트 메시지가 발생한다.
어떻게 이런 원리가 가능한 것일까? 추측을 기반으로 사고과정을 나열해보겠다.

CustomEvent

처음에는 Node의 EventEmitter이나, 커스텀 이벤트를 생성하는 CustomEvent 생성자를 이용하는 것 같았다.

왜냐하면
1. toast()함수에서 반환된 함수가 특정 이벤트를 발생시킨다.
2. 특정 이벤트에 반응하여, ToastContainer를 화면에 표시한다.
3. 끝! 간단하다.

그래서 처음에 제작하게 된게, 커스텀 이벤트 기반 토스트 방식이었다.

//toast.tsx
const toast = (message) => {
    const event = new CustomEvent('showToast', { detail: message });
    window.dispatchEvent(event);
};

// ToastComponent.tsx
import React, { useEffect, useState } from 'react';

const ToastContainer = () => {
  const [toasts, setToasts] = useState([]);

  useEffect(() => {
    const handler = (e) => {
      const message = e.detail;
      setToasts((prev) => [...prev, message]);

      // 자동으로 3초 후 토스트 삭제
      setTimeout(() => {
        setToasts((prev) => prev.slice(1));
      }, 3000);
    };

    window.addEventListener('showToast', handler);
    return () => {
      window.removeEventListener('showToast', handler);
    };
  }, []);

  return (
    <div>
      {toasts.map((toast, index) => (
        <div key={index} style={...}>
          {toast}
        </div>
      ))}
    </div>
  );
};

export default ToastComponent;

// App.jsx
const notify = () => toast('커스텀이벤트 토스트')
const App = () => {
  return (
    <div>
      <button onClick={notify}>Show Toast</button>
      <ToastComponent />
    </div>
  );
};

export default App;

이미 존재하는 CustomEvent생성자를 이용하니, 생각보다 간단했다. 다만 렌더링 하는 부분에서 애를 먹었다. 안보였다가 어떻게 보여줄까 심히 고민했는데...그냥 ToastContainer의 첫번째 div만 보여주고, 그 아래 자식들은 상태에 map을 돌려 상태기반으로 렌더링해주면 되었다.

Observer pattern

사실 커스텀 이벤트 방식을 사용하면 훨씬 쉽지만, 쉬운 길도 돌아가고 싶을 때가 있다. 시간이 넘쳐 흐르는 건 아닌데 예전에 한 번 Observer pattern을 적용하고나서 확실히 이해하지 못한 듯 하여 이번 기회에 다시 제작해보았다.

이전에 고양이 사진첩 과제당시 Observer pattern을 이용할땐, 클래스를 사용했다.
거의 모든 예시가 클래스기도 하였고, 객체지향에서 자주 사용되던 패턴이라 더욱 그랬을 것 같다.
이번에는 함수만을 이용해 Observer pattern을 구현하였다.

interface NotifyProps{
  eventType:ToastEventType; 
  message:string; 
  duration?:number
}

type Observer = ({eventType,message,duration}:NotifyProps) => void;


const createObserver = () => {
  const listeners = new Set<Observer>();
  const observe = (_notify:Observer) => {
    listeners.add(_notify);
    return () => listeners.delete(_notify);
  }
  const notify = ({eventType, message, duration}:NotifyProps) => {
    listeners.forEach((observer) => observer({eventType,message,duration}));
  }
  return {observe,notify}
}

export const observerInstance = createObserver();

export type ToastEventType = 'default'| 'warning' | 'error' | 'success';

const showToast = (eventType:ToastEventType, message:string, duration = 2500) => {
  observerInstance.notify({eventType,message,duration})
}

const toast = (message:string,duration?:number) => {
  const notify = () => showToast('default', message, duration);
  const warning = () => showToast('warning', message, duration);
  const error = () => showToast('error', message, duration);
  const success = () => showToast('success', message, duration);
  return {notify,warning,error,success}
}

export default toast;

toast함수 부분은 리팩토링이 필요해 보인다. 굳이 함수가 함수를 리턴하지 않아도 되는데...😥
새롭게 배운건 observe의 리턴 부분이다.

react-toastify의 github를 둘러보다가 발견한 코드였는데, 왜 이렇게 했을까 하고 고민해본 결과 조금있다 설명할 ToastContainer에서 unobserve 즉, 구독 해제 효과를 한다.

"use client";

import { ReactNode, useEffect, useRef, useState } from "react";
import { ToastEventType, observerInstance } from "./createObserver";
import Portal from "../Portal/Portal";
import CloseIcon from "./Icon/CloseIcon";
import ToastDefaultIcon from "./Icon/ToastDefaultIcon";
import ToastErrorIcon from "./Icon/ToastErrorIcon";
import ToastSuccessIcon from "./Icon/ToastSuccessIcon";
import ToastWarningIcon from "./Icon/ToastWarningIcon";
interface Toast {
  id: number;
  message: string;
  duration?: number;
  eventType: ToastEventType;
}

const ToastContainer = () => {
  const [toasts, setToasts] = useState<Toast[]>([]);
  useEffect(() => {
    const handleAddToast = ({
      eventType,
      message,
      duration,
    }: {
      eventType: ToastEventType;
      message: string;
      duration?: number;
    }) => {
      const id = new Date().getTime();

      setToasts((prev) => [...prev, { id, message, duration, eventType }]);
      setTimeout(() => {
        setToasts((prev) => prev.filter((toast) => toast.id !== id));
      }, duration);
    };

    const unsubscribe = observerInstance.observe(handleAddToast);

    return () => {
      unsubscribe();
    };
  }, []);

  return (
    <Portal>
      <div className="fixed top-0 right-0 z-[1000] m-4 flex flex-col gap-2">
        {toasts.map((toast) => {
          		//...대충 렌더링 로직
                }}
                <CloseIcon />
              </button>
            </div>
          );
        })}
      </div>
    </Portal>
  );
};
export default ToastContainer;

커스텀 이벤트로 제작했을 때보다 flow가 조금 더 복잡해졌다. 특히 observe로 넘어간 handlernotify할때 eventType,message,duration을 주입받는 부분이 조금 복잡하여 뚝딱 처리하지 못했다.


그래도 잘 작동하니 기분은 좋다!

아이콘과 스타일 출처 : https://flowbite.com/docs/components/toast/


느낀점

생각보다 언어 자체의 기능이 좋은것 같다. 만약 다음에 Observer pattern을 JS로 사용해야 될 것 같으면, 그냥 CustomEvent생성자를 이용하는게 훨씬 좋아보인다. 극명한 차이점은 보이지 않는데, 어딘가 놓친게 있는걸까?
그래도 바닥부터 구현하면, 구현 숙련도가 차츰 오르는게 느껴져서 기분이가 좋다🙄

이상한부분은 리팩토링하고, Props로 다양한 위치에서 나올수 있게 하기 + 애니메이션 추가까지 하면 그럴듯하게 완성 될 것 같다!

profile
모르는 것을 모른다고 하기

0개의 댓글