ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React로 Snackbar를 만든 과정
    Web/React 2021. 5. 16. 06:42
    반응형

    스낵바 하나 만드는 데 이렇게 오래 걸릴 줄 몰랐다.

    사실 적당히 만족했더라면 진작에 끝날 수 있었지만..

    그 놈의 욕심 때문에.. 상당한 시간을 소모하고 말았다.

    내가 원하는 대로 동작하는 스낵바를 만들고 싶었다.

     

    코치님께서 입버릇처럼 말씀하시는 '주화입마'에 빠지지 말라는 말이 바로 이런 경우겠거니.

    참고로, 주화입마는 무협지에서 고수들이 욕심을 부려 무리하게 공력을 끌어올리다가

    폭주하여 몸을 망치는 것을 말한다. 불필요한 욕심때문에 삼천포로 빠져 시간을 버리지 말라는 맥락이다.

     

    이전 로또 미션때도 굉장히 사소한 애니메이션 효과에 시간을 많이 쏟았었는데 또 같은 실수를 저지른 셈.

    물론, 이번 스낵바같은 경우는 레벨1 때부터 꽤 오랫동안 고민을 하기도 했고,

    한 번 만들어두면 자주 쓰겠다는 생각때문에 '이 참에 끝장을 보자' 하는 마인드였긴 하지만.

     

    그리고 이번에 스낵바 만들면서 ref나 key props, custom hook에 관해 많이 배울 수 있었기 때문에

    나름대로 값진 시간이었다고 할 수 있겠다.

     

    기왕 시간 많이 쓴 거, 뽕을 뽑아버리기 위해 포스팅까지 하고 자는 걸로..


    처음에 구현한 스낵바 코드는 다음과 같다.

    const Snackbar = ({ text, time, setMessage, backgroundColor }) => {
      const [isShowing, setIsShowing] = useState(true);
      const timer = setTimeout(() => setIsShowing(false), time);
    
      useEffect(() => {
        return () => {
          setMessage('');
          clearTimeout(timer);
        };
      }, [isShowing]);
    
      return (
        <Styled.SnackbarContainer backgroundColor={backgroundColor} time={`${time / 1000}s`}>
          {text}
        </Styled.SnackbarContainer>
      );
    };
    
    // 사용부
    const [snackbarMessage, setSnackbarMessage] = useSnackbar(TIME.SNACKBAR_DURATION);
    // ...
    {snackbarMessage && (
      <Snackbar text={snackbarMessage} time={3000} setMessage={setSnackbarMessage} backgroundColor="#555" />
    )}

    다음부터는 스낵바를 구현하느라 시간을 낭비하지 않아도 되게끔 하기 위해,

    최대한 나중에 다시 쓰겠다는 마인드로 스낵바 내부에서 모든 걸 해결하려고 했다.

     

    대충, 스낵바 컴포넌트가 만들어지면 isShowing이라는 state가 true인 채로 만들어지는데,

    거기에 '정해진 시간이 지나면 isShowing을 false로 만들어 주는 타이머'를 걸어놨다.

    스낵바 메시지(snackbarMessage)는 상위 컴포넌트에서 state로 가지고 있게끔 했고,

    useEffect를 이용하여 isShowing에 변화가 생기면(true에서 false가 되면) 

    타이머를 해제하고 메시지를 초기화하도록 했다.

     

    이렇게 하면, 한 번 스낵바가 만들어진 이후에는 타이머가 끝날 때까지 나머지 입력들이 무시된다.

    (입력이 무시된다기보단, 타이머가 한 번 세팅되면 시간이 다 지날 때까진 타이머가 초기화되지 않는다)

    그리고 타이머가 끝나면 스낵바가 사라진 후(빈 문자열로 setMessage를 해주면, 상위 컴포넌트에서

    리렌더링이 일어나면서 조건부 렌더링에 의해 스낵바가 나타나지 않는다),

    새로운 입력이 들어오면 스낵바가 다시 렌더링된다.

    Material UI의 스낵바 메시지. 요것과 동일하게 동작했다.

    요렇게 스낵바 메시지를 구현하고보니, 스낵바 컴포넌트에서 내부 로직을 제거하고싶다는 생각이 들었고,

    스낵바 내부의 로직들을 커스텀 훅으로 빼야겠다는 생각으로까지 이어졌다.

     

    더불어 이 과정에서 한 가지 고민거리가 있었는데,

    바로 스낵바의 동작 방식이 내가 원하는 방식이 아니었다는 것.

    나는 이벤트가 발생할 때마다 계속해서 새로 나타나는 스낵바를 원했는데,

    내가 만든 건 한 번 클릭하면 무조건 지정된 시간만큼 자리를 차지하고 있는 스낵바였다.

     

    내가 만든 스낵바는 스낵바 내부에서 타이머를 관리하고 있었는데,

    스낵바 컴포넌트 자체에서 '타이머가 끝나면 자기 자신을 unmount 시킨다'는 건 불가능했기 때문에

    지정된 시간이 흐르고 나면 스낵바를 unmount 시키기 위해서는 상위 컴포넌트에서 타이머를 관리해야 했다.

    이 점 또한, 내부 로직을 밖으로 빼서 커스텀 훅을 만들게 한 이유 중 하나였다.

     

    그리고 사실.. 커스텀 훅이 뭔가 간지나서. 한 번 써보고 싶었다.

    커스텀 훅이라고 해봤자, state를 사용하는 로직을 따로 함수로 뺀 것 뿐이지만.

    그냥 이름이 간지나잖아.

     

    그 결과, 이런 'useSnackbar'라는 커스텀 훅이 탄생했다.

    const useSnackbar = (ms) => {
      const [message, setMessage] = useState('');
      const timer = useRef(null);
    
      useUpdateEffect(() => {
        if (timer.current) clearTimeout(timer.current);
        timer.current = setTimeout(() => {
          setMessage('');
        }, ms + 100); // add 100ms for fadeout animation
      }, [message]);
    
      useEffect(() => {
        return () => {
          clearTimeout(timer.current);
          setMessage('');
        };
      }, []);
    
      return [message, setMessage];
    };

     

    일단, 타이머는 useRef를 이용해서 스낵바 인스턴스의 바깥(상위 컴포넌트)에서 관리하도록 했다.

     

    ref는 원래 가상 DOM 엘리먼트를 다루기 위해 사용되는 기능이지만,

    리렌더링이 되더라도 계속해서 값을 유지하고 있다는 점과, (ref는 컴포넌트와 생애 주기를 함께한다)

    그 자체로는 변경되어도 리렌더링을 유발하지 않는다는 점 때문에

    DOM 엘리먼트를 다루지 않는 로직에서도 종종 사용된다.

     

    그리고 이전 포스팅에서 다루었던 'useUpdateEffect' 커스텀 훅을 사용해서

    message state가 업데이트될 때마다 타이머를 새로 세팅하도록 만들어줬다.

     

    꽤나 그럴 듯해 보이는 코드이지만, 사실 원하는 대로 동작하지는 않았다.

     

    우선, 스낵바의 메시지를 useEffect(useUpdateEffect)로 바라보고 있는 코드이기 때문에

    (useEffect의 dependency array에 message가 들어있다는 말)

    메시지의 변경이 없으면(동일한 메시지로 여러 번 입력이 들어오는 경우) useEffect가 실행되지 않는다.

    그리고 나는 동일한 메시지를 여러 번 띄우고 싶었기 때문에 사실상 useEffect는 실행되지 않고 있었다.

     

    스낵바 인스턴스가 한 번 생성되면 처음 설정된 타이머의 시간이 다 흐르기 전까진,

    즉 해당 인스턴스가 unmount 될 때까진 useEffect가 전혀 실행되지 않았고,

    그래서 타이머도 전혀 갱신이 되지 않았던 것.

     

    이 때 생각났던 한 가지 잔머리는, 바로 스낵바의 메시지에 상품명을 넣어서

    매번 message에 변경이 일어나도록 하면 useEffect가 실행되니까 타이머가 갱신되지 않을까 하는 생각이었다.

    뭐 일단 동작 여부는 둘째 치고.. 메시지에 변경이 일어나야만 하는 스낵바라니, 너무 쌈마이였다.

    그리고 새로 렌더링되는 게 아니고 기존에 스낵바에서 텍스트만 변경되었기 때문에 원하는 동작도 아니었다.

     

    머리를 싸매며 고민하던 중.

    전날 함께 스낵바로 고민했던 '곤이'라는 크루한테서, 야밤중에 갑자기 스낵바 코드를 리뷰해달라는 카톡이 왔다.

    스윽 한 번 살펴보니, 곤이가 구현한 스낵바가 정확히 내가 원하는 방식으로 동작하는 스낵바였고

    해당 코드에서 아주 핵심적인 아이디어를 얻을 수 있었다.

    바로 리액트의 'key prop'을 이용하는 것.

     

    리액트를 사용하다보면 맨날 key좀 넣어달라고 찡찡대는 걸 볼 수 있는데,

    그 이유는 리액트가 컴포넌트들을 다시 렌더링 해야하는지 여부를 결정하는 데에 key prop이 활용되기 때문.

    리액트의 동작을 최적화하는 데에 있어서 중요한 역할을 하는 녀석이라고 볼 수 있다.

     

    좀 더 정확히 말하면, 부모 컴포넌트에 변경이 일어났을 때 

    리액트가 자식들을 다시 렌더링할 것인지 여부를 결정하기 위해 key prop을 이용한다.

    그래서 이 key prop을 스낵바에다가 매번 다르게 주입해주면, 

    리액트로 하여금 '아, 이 스낵바가 이전 것과 다른 새로운 스낵바구나!' 라는 생각을 갖도록 할 수 있다.

    그러면 리액트는 해당 컴포넌트의 이전 인스턴스를 unmount 하고 새로운 인스턴스를 mount 한다.

    곤이 지렸고.

    {snackbarMessage && (
      <Snackbar key={Math.random()} message={snackbarMessage} ms={SNACKBAR_DURATION} backgroundColor="#555" />
    )}

    Math.random()을 사용해서 매번 key를 다르게 주입해줬다.

     

    한 가지 더.

    오늘 루터회관에 가서 곤이와 함께 이야기를 나누던 중에

    옆에 스윽 지나가던 '썬'이라는 크루가 '객체는 매번 새로 만들어서 넣어줄 수 있으니까 객체로 메시지를 넣어봐라'

    라는 아이디어를 던져줬다.

    그러면 메시지 내용을 굳이 변경하지 않아도, 새로운 객체니까 useEffect가 정상적으로 동작할 수 있지 않겠냐는 말.

    이번에도 아이디어 줍줍.

     

    중간에 React.Portal에 관해서도 주워들어서, 결과적으로 다음과 같은 스낵바 코드가 탄생했다.

    // 스낵바 컴포넌트
    const Snackbar = ({ message, ms, backgroundColor }) => {
      const content = (
        <Styled.SnackbarContainer backgroundColor={backgroundColor} time={`${ms / 1000}s`}>
          {message}
        </Styled.SnackbarContainer>
      );
      return ReactDOM.createPortal(content, document.querySelector('#snackbar'));
    };
    // useSnackbar 커스텀 훅
    const useSnackbar = (ms) => {
      const [message, setMessage] = useState({ text: '' });
      const timer = useRef(null);
    
      const setSnackbarMessage = (text) => {
        setMessage({ text });
      };
    
      useUpdateEffect(() => {
        if (timer.current) clearTimeout(timer.current);
        timer.current = setTimeout(() => {
          setMessage('');
        }, ms + 100); // add 100ms for fadeout animation
      }, [message]);
    
      useEffect(() => {
        return () => {
          clearTimeout(timer.current);
          setMessage('');
        };
      }, []);
    
      return [message.text, setSnackbarMessage];
    };
    // 사용부
    {snackbarMessage && (
      <Snackbar key={Math.random()} message={snackbarMessage} ms={SNACKBAR_DURATION} backgroundColor="#555" />
    )}

    이렇게 해서, 굳이 메시지 내용을 바꾸지 않아도 매번 새로 렌더링되는,

    정확하게 내가 원하는 방식으로 동작하는 스낵바를 만들어낼 수 있었다.

    다만, 원하는 방식대로 구현하기 위한 아이디어를 내가 직접 떠올리지 못하고

    거지마냥 크루들의 아이디어를 이리저리 줍고 다니면서 가까스로 구현해냈다는 사실이 아쉬울 뿐.

    다들 참 똑똑하다.

    곤이와 썬의 아이디어가 아니었더라면 주화입마에 아주 깊이 빠져서 걸레짝이 될 뻔했지만

    훌륭한 크루들을 둔 덕에 비교적 수월하게 문제를 해결할 수 있었다.

    멋진 아이디어를 제공해 준 곤이와 썬에게 샤라웃을 보내며 

    이제 그만 접고 자러 가야겠다..

    반응형

    댓글

Designed by Tistory.