[React] Toast 만들기

hyeondoonge·2023년 7월 2일
16
post-thumbnail

개요

  1. React에서 사용자 상호작용 반응을 나타내기 위한 Toast를 어떻게 구현하는 게 좋을까?
    • portal X vs. portal O
  2. 토스트 상태관리는 지역적, 전역적 둘 중 어느 방식으로 구현하는 게 좋을까?
    • local state vs. global state
  3. 별다른 설정없이 팀원들이 빠르게 적용할 수 있을까?
    • custom hook

구현 방식

createPortal

  • React 내장 API
  • DOM 상에서 부모 트리 밖에 요소를 그린다. 때문에 스타일링이 복잡해지지 않는다.
    사실 css fixed를 이용해서 위치를 뷰포트 기준으로 쉽게 잡을 수 있었다. 단, MDN 문서에 따르면transform, perspective, filter 속성을 가지고있는 부모가 없다고 가정이 되어있어야 한다!
    이런 제한적인 상황이 있기 때문에, portal을 이용하면 viewport를 기준잡아 스타일링하기 편하다.

위와 같은 장점이 있고 사용법도 어렵지 않아 포탈을 이용해서 구현해보기로 했다.

creaetPortal을 사용하기위해서 그릴 것과 위치를 정해야한다. 나는 아래와 같이 해주었는데, 토스트 리스트를 id=toast인 dom element 내부에 렌더링한다는 의미다.

createPortal(
    <ToastList>
        {toasts.map(({ message, type }, index) => (
            <Toast type={type} key={index}>
              {message}
            </Toast>
          ))}
        </ToastList>,
        document.getElementById('toast')
)}

이때, index.html에는 id=root인 블록 하나만 존재하기 때문에, id=toast인 블록은 직접 추가해야한다.

상태관리

토스트 상태를 각 페이지 내에서 지역적으로 관리할지, 아니면 모든 페이지들이 접근할 수 있게 전역에서 관리되도록 할지에 대해서 고민했다.

1️⃣ 지역적

// 예시 코드
function PageComponent () {
	const [toasts, setToasts] = useState([]);
	
	const onClick = () => {
		setToast([...toasts, { message: `${toasts.length}번째 메시지` }]);
	}

  return (
		<div>
			<ToastList>
              {toasts.map(({ message }) => 
              <Toast>{message}</Toast>)}
      		</ToastList>
      		<div onClick={onClick}>
		</div>
	);
}
  • 토스트가 필요한 모든 컴포넌트에서 상태를 사용한다고 선언해야한다.
  • Toast상태는 부모 컴포넌트가 언마운트될 때 제거된다. 기본적으로 페이지 단위로 상태를 선언해줄텐데, 이에 따라 페이지를 이동하면 기존 토스트들이 제거된다. 만약 하위 컴포넌트에서도 중복해서 선언하여 사용하면, 기존 토스트들이 제거되서 위와 같은 현상을 볼 수 있다. 이러한 문제로 페이지 내에 한 번만 선언하도록 제한을 둬야한다.
  • 페이지 이동 시 기존의 토스트들이 사라진다. 따라서 ux 저하를 유발할 수 있다.

2️⃣ 전역적

Context API를 사용해서 토스트 상태를 중앙 집중식으로 관리한다.

export default function ToastsContextProvier({ children }) {
  const [toasts, setToasts] = useState([]);
  const data = useMemo(() => [toasts, setToasts], [toasts]);

  return (
    <ToastContext.Provider value={data}>
      {children}
      {createPortal(
        <ToastList>
          {toasts.map(({ message, type }, index) => (
            <Toast type={type} key={index}>
              {message}
            </Toast>
          ))}
        </ToastList>,
        document.getElementById('toast')
      )}
    </ToastContext.Provider>
  );
}
  • 여러 페이지에서 공유해서 사용하고, 위 방식에서 발생하는 ux저하가 없다. 떠있던 토스트가 갑자기 사라지거나 하지않기 때문이다.
  • 불필요한 리렌더링이 발생할 수 있다. 새로운 Toast를 그리기위해 toast관련 element를 리렌더링할 뿐만 아니라, setToast만 사용하는 컴포넌트도 리렌더링을 수행한다.

비교 결과, Context API를 사용한 관리 방법을 선택했다.
UX 관점에서 전역적으로 관리하는 방식이 더 낫다고 생각했다. 또한 2번 방식을 개선하면 성능면에서도 더 나았다. 지역적으로 구현할 경우, toast가 추가되면 페이지 전체에 리렌더링이 발생한다. 하지만 context api를 이용해서 toast list 컴포넌트만 리렌더링이 되도록 구현할 수 있다.

개선하기

Toast가 거의 모든 사용자 상호작용에 대한 응답을 렌더링하는데 사용이 될 예정이었다.
따라서 다른 팀원들은 이 기능을 사용해야했고, 빠르게 기능 구현하기위해 팀원들이 쉽게 사용할 방법에 대해서 생각해야했다.

팀원들이 사용하기 편하도록 useToast 훅을 만들어서 제공했다.

export default function useToast() {
  const toastContext = useContext(ToastContext);

  if (!toastContext) throw new Error('Toast provider를 추가해주세요');

  const [toasts, setToasts] = toastContext;

  const createToast = (toast) => {
    setToasts([...toasts, toast]);
  };

  return createToast;
}
  1. 추상화
    useContext, ToastContext를 불러오는 코드를 추상화했다.
  2. 에러 핸들링
    Context provider의 유무를 체크하여, 명확한 에러를 렌더링하여 필요한 작업을 안내한다.
  3. createToast 호출을 통해 append는 자동으로 되도록 구현
    원래 append하는 코드를 팀원들이 작성해야했다. 이는 반복될 코드이고 개선함으로서, 새로 등록할 토스트만 전달해도 동작되도록 했다.

결과

팀원들이 사용하기 너무 쉽고 편하다는 피드백을 해주었다. 정말 뿌듯했다.
동작만 하는 코드를 구현하는 것이 아닌, 생산성을 높여주고 팀원들의 시간을 아끼는 또 미래의 내 시간을 아껴주는 코드에 시간을 쓰는 것에 가치를 느꼈다.

개선점

  • 한 번에 띄울 수 있는 토스트 개수를 제한한다.
    연속적으로 화면에 많이 띄울경우, 나열된 토스트에 화면이 가려져서 기능 사용에 제한이 있는 문제를 식별했다. (3개 정도면 괜찮지 않을까?)

  • DOM에서는 일정시간 이후 토스트가 제거되지만, 실제 토스트 상태값에서는 제거되지않는다.
    큰 문제는 발생하지 않으나 메모리를 불필요하게 낭비하고있는 상황이다.
    → shift의 속도는 O(n)이며 이는 연결리스트를 통해 O(1)만에 해결할 수 있다. 최악의 상황을 고려하면 shift에 더 빠른 속도를 제공하는 연결리스트 구조를 이용하는 방법이 좋겠다.

  • 토스트가 사라질 때 애니메이션이 버벅이는 현상이 있다. 왜 그런지 알아봐야겠다..

2개의 댓글

comment-user-thumbnail
2024년 7월 11일

리액트 관련 글 검색하면 항상 이 블로그네요

1개의 답글