🌱 ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [WizSched] Toast Component 개선기
    Front-End/Project 2025. 2. 22. 09:56
    🍀목차
    글의 목적
    useToast 커스텀 Hook 만들기
    개선 1 : Provider 사용
    개선 2 : useRef 사용
    최종 완성

     

     

    글의 목적

     Toast 컴포넌트는 사용자에게 일시적으로 표시되는 안내 메시지를 제공하는 컴포넌트이다. 프로젝트에 사용할 Toast 컴포넌트를 만들던 중, 겪었던 문제와 개선 방법을 정리하면 좋을 것 같았다. 같은 문제를 겪고 있는 사람에게 쉽고 유용한 정보가 되었으면 한다. 

     

     

    useToast 커스텀 Hook 만들기

     

     아래는 Toast 컴포넌트가 해야 할 동작을 생각하며 간단히 작성해 본 코드이다.

    open toast 버튼을 클릭하면 show라는 state를 true로 바꿔 <div>🍞</div>가 화면에 표시되었다가 3초 뒤에 사라진다.

      

    import { useState, useEffect } from 'react';
    
    const Test = () => {
      const [show, setShow] = useState(false);
    
      useEffect(() => {
        if (show) {
          const timer = setTimeout(() => {
            setShow(false);
          }, 3000);
          return () => clearTimeout(timer);
        }
      }, [show]);
    
      return (
        <div>
          <button onClick={() => setShow(true)}>open toast</button>
          {show && <div>🍞</div>}
        </div>
      );
    };
    
    export default Test;

     

    클릭하면 3초간 토스트가 나타나는 사진
    클릭하면 3초간 토스트가 나타난다.

     

     

      간단하긴 하지만, 해당 코드를 그대로 쓰기에는 불편한 점이 많다.

     Toast 컴포넌트인 <div>🍞</div>를 표현하기 위해 현재 컴포넌트는 불필요한 일을 많이 하고 있다.

     

    1. Toast 컴포넌트를 사용하기 위해 state를 매번 선언해야 한다.

    2. 로직(useEffect 부분)도 Toast 컴포넌트를 사용하는 컴포넌트에 작성되어 있다.

    ➡️ state, 로직은 사용하는 컴포넌트가 알 필요 없는 정보이다. 

     

     어떻게 개선할 수 있을까? 커스텀 Hook으로 분리하면 좋을 것 같다는 생각이 든다.

    관련 코드를 useToast로 분리하여 개선해 보자. 

     

    import { useEffect, useState } from 'react';
    
    const useToast = ({ duration }: Toast) => {
      const [isShow, setIsShow] = useState(false);
    
      const showToast = () => setIsShow(true);
    
      useEffect(() => {
        if (isShow) {
          const timer = setTimeout(() => setIsShow(false), duration);
          return () => clearTimeout(timer);
        }
      }, [isShow, duration]);
    
      return { isShow, showToast };
    };
    
    interface Toast {
      duration: number;
    }
    
    export default useToast;



    Toast를 사용하는 컴포넌트에서 선언한다.
    Toast를 사용하는 컴포넌트에서 선언한다.

     

    showToast를 호출한다.
    showToast()를 호출한다.

     

    Toast를 사용하는 코드
    위와 같이 사용한다.

     

     

    showToast() 호출 후, 2초간 토스트가 나타난다.

     

     

    재사용성은 조금 개선이 되었으나, 여전히 동일한 문제가 발생하고 있다.

    또, 이 단계에서 다른 문제점을 마주쳤다. 

     

    ➡️ 사용할 컴포넌트마다 {isShow && <Toast />}를 작성해야 한다. 보통 Toast 메시지는 해당 레이아웃을 벗어나 최상위 레이아웃에서 유저에게 알려주는 형태로 나타난다. 깊숙이 있는 레이아웃 안에 표현되어 있는 게 이상하다고 느껴졌다.

     

    결과적으로 나는 Toast 컴포넌트를 전역으로 관리하는 방법을 택했다.

     

     

    개선 1 : Provider 사용


    React Context를 활용하여 Toast Provider로 관리한다면, Toast의 상태를 전역으로 관리할 수 있다. 

     

    import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
    
    const ToastContext = createContext<ToastContextState | undefined>(undefined);
    
    export const useToastContext = () => {
      return useContext(ToastContext);
    };
    
    const ToastProvider = ({ children }: PropsWithChildren) => {
      const [isDisplayed, setIsDisplayed] = useState(false);
      const [message, setMessage] = useState('');
      const [duration, setDuration] = useState(0);
    
      const handleToastDisplay = ({ message, duration = 3000 }: ToastProps) => {
        setIsDisplayed(true);
        setMessage(message);
        setDuration(duration);
      };
    
      const handleToastDismiss = () => {
        setIsDisplayed(false);
        setMessage('');
      };
    
      useEffect(() => {
        let timer: NodeJS.Timeout;
        if (isDisplayed) {
          timer = setTimeout(handleToastDismiss, duration);
        }
        return () => {
          if (timer) {
            clearTimeout(timer);
          }
        };
      }, [isDisplayed, duration]);
    
      const value = {
        isDisplayed,
        message,
        handleToastDisplay,
        handleToastDismiss,
      };
    
      return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
    };
    
    export const useToast = () => {
      const context = useContext(ToastContext);
      if (context === undefined) {
        throw new Error('useToast must be used within a ToastProvider');
      }
    
      return context;
    };
    
    interface ToastProps {
      message: string;
      duration?: number;
    }
    
    interface ToastContextState {
      handleToastDisplay: (props: ToastProps) => void;
      handleToastDismiss: () => void;
      isDisplayed: boolean;
      message: string;
    }
    
    export default ToastProvider;

     

     

    이제 해당 Provider를 상위에서 선언, 사용하는 부분에서 아래와 같이 활용할 수 있게 되었다.

     // 선언
     const { handleToastDisplay } = useToast();
     
     // 사용
     handleToastDisplay({ duration: 3000, message: '🍞' });
     
     // Toast.tsx
     
    const Toast = () => {
      const { isDisplayed, message, handleToastDismiss } = useToast();
    
      if (!isDisplayed) return null;
    
    // 생략...

     

    이렇게 토스트가 나타난다.
    이렇게 토스트가 나타난다.

     

     해당 방식도 단점은 존재한다. 대표적으로, Toast의 상태를 관리할 때(handleToastDisplay, -Dismiss), 해당 Context 값이 변경되면 해당 값을 구독하는 모든 컴포넌트가 리렌더링 된다.

     이 문제를 해결하기 위해 React.memo로 컴포넌트를 메모이제이션하거나, useCallback으로 콜백 함수를 메모이제이션하는 등의 방법이 사용된다. 내부적으로 리렌더링 될 때마다 선언된 함수나 컴포넌트가 새로 생성되기 때문에 이를 방지하기 위한 최적화 방식이다.

     

     다만, 모든 최적화는 비용을 치러야 한다. 아직 큰 성능 문제가 있다고 판단하기에는 너무 이르다고 생각하여 관련 최적화는 진행하지 않았다.

     

    대신 다른 개선을 진행했다.

     

     

    개선 2 : useRef 사용

     

    Radix Toast(https://www.radix-ui.com/primitives/docs/components/toast#api-reference)

     

    Radix의 Toast 코드를 많이 참고하였는데, 내 코드와 달리 timer를 useRef로 선언한 것을 보았다. 공식문서( https://ko.react.dev/reference/react/useRef)에서도 예시를 제공하고 있다.

    // https://ko.react.dev/reference/react/useRef
    function handleStartClick() {
      const intervalId = setInterval(() => {
        // ...
      }, 1000);
      intervalRef.current = intervalId;
    }
    
    // 나중에 ref에서 해당 interval ID를 읽어 해당 interval을 취소할 수 있다.
    function handleStopClick() {
      const intervalId = intervalRef.current;
      clearInterval(intervalId);
    }

     


     state와 달리 ref는 변경되어도 리렌더링을 촉발하지 않기에 컴포넌트의 시각적 출력에 영향을 미치지 않는 정보를 저장하는 데 적합하다. 이를 반영하여 기존 코드에 useEffect, 변수명 개선을 진행해 주었다.

    import { PropsWithChildren, createContext, useContext, useRef, useState } from 'react';
    
    const ToastContext = createContext<ToastContextState | undefined>(undefined);
    
    export const useToast = (): ToastContextState => {
      const context = useContext(ToastContext);
      if (context === undefined) {
        throw new Error();
      }
      return context;
    };
    
    const ToastProvider = ({ children }: PropsWithChildren) => {
      const [isDisplayed, setIsDisplayed] = useState(false);
      const [message, setMessage] = useState('');
      const timerRef = useRef<NodeJS.Timeout>();
    
      const displayToast = ({ message, duration = 3000 }: ToastProps) => {
        if (timerRef.current) {
          clearTimeout(timerRef.current);
        }
        setIsDisplayed(true);
        setMessage(message);
    
        timerRef.current = setTimeout(() => {
          setIsDisplayed(false);
          setMessage('');
        }, duration);
      };
    
      const dismissToast = () => {
        setIsDisplayed(false);
        setMessage('');
        if (timerRef.current) {
          clearTimeout(timerRef.current);
        }
      };
    
      const value = {
        isDisplayed,
        displayToast,
        dismissToast,
        message,
      };
    
      return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
    };
    
    interface ToastProps {
      message: string;
      duration?: number;
    }
    
    interface ToastContextState {
      displayToast: (toast: ToastProps) => void;
      dismissToast: () => void;
      isDisplayed: boolean;
      message: string;
    }
    
    export default ToastProvider;

     

     

     

     useEffect를 사용한 코드, useRef로 개선한 코드 모두 개발 환경에서 렌더링은 4번 일어났다. 

    Strict Mode가 활성화된 개발 환경에서는 의도적으로 초기 렌더링이 발생하기에 2번을 제외하고, 토스트 메시지가 떠오를 때, 사라질 때(state가 변경될 때)가 더해져 총 4번 Count 되었다. 

     

     

    두 방식 다 렌더링 카운트가 동일했다.
    두 방식 다 렌더링 카운트가 동일했다.

     

     

     

    그렇다면 어떤 부분에서 차이점을 발견할 수 있을까?

    for문을 사용해서 0.1s씩 늘리며 각 방식으로 Toast 메시지를 일정 횟수 나타나게 해 보았다. 

     

    useEffect는 일관된 타이머 관리가 어려움.
    useEffect

     

    useRef는 일관된 타이머 관리 가능.
    useRef

     

     useEffect와 달리 useRef방식은 컴포넌트의 리렌더링과 무관하게 유지되는 모습을 볼 수 있다. useEffect로 작성했던 로직은 deps 배열이 변경될 때마다 timer가 재설정, 설정 시점도 제한적이었다. 그러나 useRef 방식은 일관적이며 명확한 타이머 관리가 가능하다.

     

     

    마지막으로 Toast 컴포넌트의 스타일링을 해주었다.

    import { useToast } from '@/contexts/ToastProvider';
    import Button from './button/Button';
    
    const Toast = () => {
      const { isDisplayed, message, dismissToast } = useToast();
    
      if (!isDisplayed) return null;
    
      return (
        <div role="alert" className="fixed bottom-4 right-4 z-40 max-w-sm animate-fade-in-up">
          <div className="rounded bg-white shadow-md">
            <div className={`flex items-center justify-center p-4`}>
              <div>
                {message.split('\n').map((line, index) => (
                  <p key={index} className="mr-4 text-sm">
                    {line}
                  </p>
                ))}
              </div>
              <Button variant="outlined" color="error" onClick={dismissToast} aria-label="알림 닫기">
                <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
                  <path
                    fillRule="evenodd"
                    d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
                    clipRule="evenodd"
                  />
                </svg>
              </Button>
            </div>
          </div>
        </div>
      );
    };
    
    export default Toast;

     

    접근성 관련 개선도 해주었다.

    • svg에 aria-hidden="true"을 설정하여 스크린 리더가 읽지 않도록 한다. 
    • 대신, button에 aria-label로 추가 정보를 전달한다.
    • role="alert"( aria-live="assertive" 속성과 aria-atomic="true" 속성도 암시적으로 할당됨)을 설정하여 스크린 리더는 진행중인 안내를 멈추고 해당 메시지 내용을 사용자에게 전달하게 한다.

     

    최종 완성

    • 촬영 날짜 : 2025년 2월 22일 

     

     

     

     

    참고자료

    https://www.radix-ui.com/primitives/docs/components/toast#api-reference

    댓글

🍀 Y0ungZ dev blog.
스크롤 버튼