ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React 공부는 커스텀 훅으로: useAnimatedText 훅
    프로그래밍 2025. 5. 24. 00:30

    최근에 LLM을 이용한 서비스를 개발하고 있는데 스트리밍 응답으로 오는 텍스트가 너무 뚝뚝 끊겨보여서 방법을 찾다가 좋은 커스텀 훅과 이 훅을 작성한 Sam Selikoff가 설명해주는 유튜브 영상을 찾을 수 있었습니다. 이해하기 쉬우면서도 정리해보면 좋은 내용이 많아서 공유해보려고 합니다. 코드와 원본 영상 링크를 먼저 보여드리면서 시작하겠습니다.

     

     

     

    직접 만들어보기

    먼저 간단하게 시작해봅시다. 텍스트를 애니메이션으로 보여주려면 전체 텍스트를 처음부터 다 보여주는게 아니라 하나씩보여줘야하기 때문에 일단 텍스트를 잘라서 보여주는 것을 생각해봅시다.

    function useAnimatedText(text: string) {
      return text.slice(0, 1);
    }

    저 1이 점점 늘어난다면 텍스트가 늘어나는 것처럼 보이게 되겠죠. 그렇다면 저 1 위치에 갈 숫자를 상태로 만들고 그걸 서서히 늘려나가는 로직을 만들어봅시다. 여기서는 Motion(과거 Framer-motion) 라이브러리를 사용해서 숫자가 올라가는 걸 만들어보겠습니다.

    import { useEffect, useState } from 'react';
    
    import { animate } from 'motion/react';
    
    function useAnimatedText(text: string) {
      const [cursor, setCursor] = useState(0);
    
      useEffect(() => {
        animate(0, text.length, {
          onUpdate(latest) {
            console.log(latest);
            setCursor(Math.floor(latest));
          },
        });
      }, [text.length]);
    
      return text.slice(0, cursor);
    }

    이렇게 하면 0부터 텍스트의 길이까지 숫자가 올라가는 것을 콘솔에서 확인할 수 있습니다. 근데 같은 값이 두 번씩 로그가 되는 것을 보실 수 있을 겁니다.

    Strict Mode를 이용해 버그 가능성 알아내기

    React는 기본적으로 개발 모드에서 Strict Mode로 작동합니다. 여러 동작이 있지만 그중 대표적인 것이 useEffect가 두 번씩 작동하는 것입니다. Sam Selikoff는 이렇게 같은 값이 두 번씩 로그되는 것은 버그의 가능성이 있다는 것을 암시할 수 있다고 이야기합니다. useEffect에 클린업 함수를 추가해서 문제가 없게 해봅시다.

    useEffect(() => {
      const controls = animate(0, text.length, {
       onUpdate(latest) {
         console.log(latest);
         setCursor(Math.floor(latest));
       },
      });
    
      return () => {
       controls.stop();
      };
    }, [text.length]);

    이렇게 변경하면 로그가 한번만 기록됩니다. 그리고 이렇게 클린업 함수를 추가하면서 혹시나 애니메이션이 정리되지 않고 계속 실행되고 있는 가능성이 차단되겠죠.

     

    자, 이제 훅을 붙여보면 문제가 있습니다. 바로 텍스트가 늘어나기는 하는데 계속 처음부터 시작되면서 마치 텍스트가 깜빡이는 듯한 모습을 보입니다. 왜냐면 텍스트가 길어지면 useEffect가 다시 시작하게 되는데 시작점이 0으로 고정되어있기 때문이죠. 여기서 0이 아니라 텍스트 길이가 변경되기 전의 상태를 저장하고 있는 값이 필요합니다. 이것도 state를 사용할 수도 있겠지만 motion을 사용했기 때문에 역시 motion에서 제공하는 useMotionValue를 이용해봅시다.

    export default function useAnimatedText(
      text: string,
    ) {
      const animatedCursor = useMotionValue(0); // 추가
      const [cursor, setCursor] = useState(0);
    
      useEffect(() => {
        const controls = animate(animatedCursor, text.length, { // 0을 animatedCursor로 변경
          onUpdate(latest) {
            setCursor(Math.floor(latest));
          },
        });
    
        return () => {
          controls.stop();
        };
      }, [animatedCursor, text.length]); // 의존성 배열에 추가
    
      return text.slice(0, cursor);
    }

    MotionValue 역시 렌더링이 새로 되어도 값이 휘발되지 않고 유지가 됩니다. animate 함수는 이렇게 사용하면 animatedCursor의 값을 text.length까지 자동으로 변경시킵니다. 이러면 animatedCursor가 의존성 배열에 필요해지기 때문에 추가됩니다.

    의존성 배열은 직관에 맡기지 말라

    useEffect를 처음 배우면 가장 헷갈리는 게 의존성 배열입니다. 이 값은 없어도 되나? 이 값은 넣어놓으면 너무 많이 실행되려나? 등등 고민되는 점이 많을 텐데요. 하지만 useEffect를 쓰는 올바른 방법은 필요한 의존성을 항상 모두 넣어주는 것입니다. (React 공식 문서 참조) Sam Selikoff는 의존성 배열을 직관으로 판단하지 말고 항상 ESLint를 믿고 자동 완성을 사용하라고 말합니다. 항상 react-hooks/exhaustive-deps 규칙을 적용하고 자동 완성을 해서 실수로 빼먹는 의존성이 없도록 하는 것이 좋습니다.

     

    사실 제가 필요한 수준은 이만큼도 충분했습니다. 하지만 영상의 설명을 조금 더 따라가보죠. 만약 스트리밍되는 텍스트를 변경하면 어떻게 될까요? 서비스에서 예를 들면 갑자기 생성되는 텍스트를 바꾼다거나 재시작을 하는 그런 경우겠죠. 영상에서 나오는데 애니메이션이 이상해집니다. 정확히는 마치 중간에서 애니메이션이 시작하는 것 같은 모습을 보여주는데요. useEffect를 다시 확인해보죠.

    useEffect(() => {
      const controls = animate(animatedCursor, text.length, {
        onUpdate(latest) {
          setCursor(Math.floor(latest));
        },
      });
    
      return () => {
        controls.stop();
      };
    }, [animatedCursor, text.length]);

    텍스트가 바뀐다면 text.length의 값이 바뀌고 effect가 다시 실행되겠죠. 근데 animatedCursor의 값은 어떨까요? 예를 들면 200자까지 애니메이션이 되고 있다가 갑자기 새 텍스트로 교체되어서 0으로 줄어든다면 animatedCursor는 아직 200에 남아있다가 서서히 0으로 내려갈 겁니다. (animate는 방향과 무관하게 첫 번째 값이 두 번째 값으로 이동합니다) 실제로 onUpdate 안에서 값을 확인해보면 latest가 올라가다가 내려가다가 다시 올라가는 모습이 보입니다. 그렇기 때문에 text가 변경되면 커서를 초기화하는 작업이 필요합니다.

    이전 렌더링의 정보를 저장하기

    재미있는 패턴이 나오는데요. useEffect가 아닌 렌더링 시점에 setState를 하는 패턴이 나옵니다. 사실 기본적으로는 하면 안된다고 배우는 패턴이죠. React는 state가 바뀌면 렌더링을 다시 하는데 이걸 렌더링 과정에서 하면 무한 렌더링이 되니까요. 하지만 공식 문서에서도 볼 수 있듯 예외 케이스가 있습니다. 사실 가능하면 React는 이 방법보다는 다른 방법을 추천하지만 아무튼 여러 조건이 맞는다면 이 패턴을 사용할 수 있습니다.

    export default function useAnimatedText(
      text: string
    ) {
      const animatedCursor = useMotionValue(0);
      const [cursor, setCursor] = useState(0);
      // 아래 state 두 개 추가
      const [prevText, setPrevText] = useState(text);
      const [isSameText, setIsSameText] = useState(true);
    
      useEffect(() => {
        // 이전과 다른 텍스트면 초기화
        if (!isSameText) {
          animatedCursor.set(0);
        }
        
        const controls = animate(animatedCursor, text.length, {
          onUpdate(latest) {
            setCursor(Math.floor(latest));
          },
        });
    
        return () => {
          controls.stop();
        };
      }, [animatedCursor, isSameText, text.length]);
    
      // 렌더링 중간에 setState 실행
      if (prevText !== text) {
        setPrevText(text);
        setIsSameText(text.startsWith(prevText));
      }
    
      return text.slice(0, cursor);
    }

    이 패턴을 쓸 때는 여러 가지 조건이 있습니다.

    1. 반드시 조건문 안에서 써야 합니다. 안그러면 진짜로 무한대로 렌더링하겠죠.

    2. 자기 자신의 setState 함수만 써야합니다. 다른 컴포넌트에서 쓰는 setState 함수를 이렇게 쓰면 안됩니다.

    3. set 함수의 기본 규칙인 객체를 변경하는 것이 아니라 교체하는 규칙을 지켜야합니다.

     

    React는 이 패턴을 아주 권장하지는 않지만 여기서 useEffect를 써서 하는 것보다는 권장하고 있습니다. 렌더링 도중에 이렇게 set 함수가 실행되면 React는 자식 컴포넌트를 렌더링하지 않고 바로 스스로를 다시 렌더링하기 때문에 useEffect보다 성능에서 이득을 볼 수 있습니다. (역시 useEffect를 최대한 쓰지 않는 것을 권장하는 React입니다.)

     

    여기서 저도 생각했고 영상에서도 나오는 이야기로 외부에서 key를 사용해서 초기화를 하면 어떠냐는 생각이었는데요. Sam Selikoff 역시 이 방법을 생각하지 않은 것은 아니지만 이건 공통 로직을 분리한 커스텀 훅이기 때문에 외부의 초기화에 의존하지 않고 내부에서 해결이 될 수 있게 만들고 싶었다고 합니다. 확실히 key에 의존한다면 다른 개발자가 초기화를 하지 않을 위험이 있겠죠.

    조금 더 나아가기

    영상은 여기서 구분자를 직접 바꿔가면서 사용한다거나 애니메이션을 변경해본다거나 하면서 끝인데요. 이것을 패러미터로 받을 수 있게 조금 더 훅을 만들어봤습니다. 처음에는 이렇게 만들어봤는데요.

    export default function useAnimatedText(
      text: string,
      delimiter: string = '',
      animationOptions: ValueAnimationTransition<number> = {}
    ) {
      // ... state
    
      const textSegments = text.split(delimiter); // text를 분리
    
      useEffect(() => {
        if (!isSameText) {
          animatedCursor.set(0);
        }
        
        const controls = animate(animatedCursor, textSegments.length, {
          ...animationOptions, // options를 spread로 넘ㄱ며주기
          onUpdate(latest) {
            setCursor(Math.floor(latest));
          },
        });
    
        return () => {
          controls.stop();
        };
      }, [animatedCursor, animationOptions, textSegments.length]);
    
      // ... 초기화 로직
    
      return textSegments.slice(0, cursor).join(delimiter); // 나눴다가 다시 합쳐서 리턴
    }

    구분자와 애니메이션 옵션을 패러미터로 받았습니다. 그리고 구분자로 나눈 텍스트로 작업을 합니다. 이제 원래는 한 글자 단위로 됐던 애니메이션을 공백 단위로 변경한다거나 하는 동작이 가능합니다. useEffect의 의존성 배열은 역시 이번에도 자동 완성을 통해 바꿔줍니다.

    기본값을 객체로 설정할 때는 주의

    animationOptions가 의존성으로 추가되었는데요. 기본값을 빈 객체로 주다보니 혹시 이게 매번 effect를 실행시키지 않을까 걱정이 되었습니다. 찾아보니 역시나 그렇게 작동을 하더라고요. 이것을 유의하고 이럴 때는 간단하게 기본값을 컴포넌트 밖에서 선언해서 사용해서 해결할 수 있습니다.

    const DEFAULT_ANIMATION_OPTIONS: ValueAnimationTransition<number> = {};
    
    export default function useAnimatedText(
      text: string,
      delimiter: string = '',
      animationOptions: ValueAnimationTransition<number> = DEFAULT_ANIMATION_OPTIONS,
    ) {
      // ... 훅 로직
    }

     

    이후로는 간단한 작업을 진행했습니다. 먼저 커서 초기화와 애니메이션 실행이 모두 한 useEffect 안에 있는데, 두 로직에서 사용하는 값이 다르다보니 각자의 로직 때문에 의존성이 늘어나고 있었거든요. 그래서 저는 초기화 로직을 별도의 useEffect로 분리했습니다.

      useEffect(() => {
        if (!isSameText) {
          animatedCursor.set(0);
        }
      }, [animatedCursor, isSameText]);
    
      useEffect(() => {
        const controls = animate(animatedCursor, textSegments.length, {
          ...animationOptions,
          onUpdate(latest) {
            setCursor(Math.floor(latest));
          },
        });
    
        return () => {
          controls.stop();
        };
      }, [animatedCursor, animationOptions, textSegments.length]);

    이렇게 해서 각자의 의존성을 가지게 되었고 서로 분리되어서 실행될 수 있게 되었습니다. 그리고 구분자와 options를 하나의 객체로 모두 받게 바꾸었는데요. 만약에 다른 값을 추가로 넣어줘야 하면 계속 훅의 패러미터가 늘어날 것 같다는 생각이 들었습니다.

    interface UseAnimatedTextOptions {
      animationOptions?: ValueAnimationTransition<number>;
      delimiter?: string;
    }
    
    const DEFAULT_ANIMATION_OPTIONS: ValueAnimationTransition<number> = {};
    
    export default function useAnimatedText(
      text: string,
      options?: UseAnimatedTextOptions,
    ) {
      // ... state 선언
    
      const delimiter = options?.delimiter ?? '';
      const animationOptions =
        options?.animationOptions ?? DEFAULT_ANIMATION_OPTIONS;
    
      const textSegments = text.split(delimiter);
      // ... 나머지 로직
     }

    마무리

    자, 이렇게 훅이 마무리가 되었습니다. 이런 커스텀 훅을 보고 공부하면서 생각하는게 커스텀 훅이야말로 React의 정수가 아닐까 하는 생각이 드네요. 커스텀 훅으로 분리했을 때 생기는 문제를 해결한다거나, 어디서 써도 문제 없게 만드는 과정에서 신기한 패턴도 나오고 기본 훅의 이해도가 높아지는 것 같습니다. 원래 이런 훅은 그냥 가져다 쓰거나 필요한 부분만큼만 가져가는데 이번에 이렇게 설명을 들으면서 재미있는 것을 많이 배워서 또 한 분이라도 공유했으면 해서 이렇게 글을 남깁니다. 마지막으로 다시 한번 gist를 공유하면서 마무리하겠습니다.

     

    '프로그래밍' 카테고리의 다른 글

    회사 블로그에 올린 글 옮기기 1. 코드 리뷰  (0) 2024.11.21
Designed by Tistory.