(꼼꼼 프로젝트) startTransition, progress bar 구현에 대한 고민

Updated:

배경

위의 사진은 Next.js로 만든 코드잇 사이트이다.

Link 컴포넌트인 탭 요소를 누르면 주소가 바뀌면서 progress bar가 동작하는 것을 확인할 수 있다.

꼼꼼 프로젝트는 Next.js로 만들어졌기 때문에 url이 변경되면서 렌더링이 일어나는 경우가 많다. (searchParams와 관련된 렌더링도 많다.)

이때 progress bar를 추가하면 UX가 크게 향상될 것이라고 생각하고 고민을 시작했다.

구현

Next는 router event를 외부에 노출시키지 않아서 페이지 바꿀 때 로딩 생기는 걸 구현하는게 매우 어렵다.

이를 해결한 간단한 라이브러리도 찾았으나 실제 동작 과정을 알고 싶어서 실제로 구현된 코드를 위주로 연구를 시작했다.

코드 분석

https://buildui.com/posts/global-progress-in-nextjs 위의 포스팅을 분석해보았다.

layout.tsx

// layout.tsx
import { ReactNode } from "react";
import { ProgressBarLink, ProgressBar } from "./progress-bar";

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <div>
      <ProgressBar className="fixed top-0 h-1 bg-sky-500" />
        <header className="border-b border-gray-700">
          <nav className="m-4 flex gap-4">
            <ProgressBarLink href="/demo-1">Home</ProgressBarLink>
            <ProgressBarLink href="/demo-1/posts/1">Post 1</ProgressBarLink>
            <ProgressBarLink href="/demo-1/posts/2">Post 2</ProgressBarLink>
            <ProgressBarLink href="/demo-1/posts/3">Post 3</ProgressBarLink>
          </nav>
        </header>

        <div className="m-4">{children}</div>
      </ProgressBar>
    </div>
  );
}

progress-bar

// progress-bar
"use client";

import {
  AnimatePresence,
  motion,
  useMotionTemplate,
  useSpring,
} from "framer-motion";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
  ComponentProps,
  ReactNode,
  createContext,
  startTransition,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";

// createContext로 전역 상태 지정
const ProgressBarContext = createContext<ReturnType<typeof useProgress> | null>(
  null
);

// useProgressBar 라는 훅으로 useProgress와 연결해서 사용하고자 함.
export function useProgressBar() {
  let progress = useContext(ProgressBarContext);
  if (progress === null) {
    throw new Error("Need to be inside provider");
  }

  return progress;
}

// Progress Bar 컴포넌트 그 자체
export function ProgressBar({ className, children }: { className: string, children: ReactNode }) {
  // useProgress 사용
  let progress = useProgress();
  // useMotionTemplate: 여러 motion value를 하나의 motion value로 만드는 훅
  // 여기서는 width: ~% 이런식으로 길어지게 만들어 놓음. 내부적으로 progress.value가 100이 되면 complete임
  let width = useMotionTemplate`${progress.value}%`; 

  return (
    <ProgressBarContext.Provider value={progress}>
    // 존재하는 모든 노드의 애니메이션이 완료되면 progress.reset
    // AnimatePresence가 progress.state가 complete된걸 감지해서 exit 애니메이션을 실행시킬 수 있게 해주고
    // 이후에 reset 함수가 실행된다.
      <AnimatePresence onExitComplete={progress.reset}>
      // 마운트시키는 상태를 progress에서 가져옴
        {progress.state !== "complete" && (
          <motion.div
            style=
            exit=
            className={className}
          />
        )}
      </AnimatePresence>
      {children}
    </ProgressBarContext.Provider>
  );
}

// ProgressBar와 연동될 링크 컴포넌트
export function ProgressBarLink({
  href,
  children,
  ...rest
}: ComponentProps<typeof Link>) {
  // useProgress를 context에서 꺼낸다.
  let progress = useProgressBar(); 
  let router = useRouter();

  return (
    <Link
      href={href}
      onClick={(e) => {
        // 기본 동작을 삭제한다.
        e.preventDefault();
        // progress를 in-progress 상태로 만듬.
        progress.start();
        startTransition(() => {
          // router.push를 가장 나중 렌더링으로 바꾼다. 중간에 다른거 누르면 즉시 취소될 수 있다.
          router.push(href.toString());
          // router.push는 void가 리턴값이며 함수가 진행중인지 알 수 있는 방법이 없다. 이를 위해 transition을 사용한다.
          // startTransition은 내부의 함수들이 UI 렌더링 없이 한번에 업데이트 되게 해준다.
          // 이를 통해 router.push와 progress.done은 한번에 업데이트 되게 된다.

		  // 클릭시 progress.start라는 상태 변화는 바로 일어나지만 progress.done이라는 상태 변화는 router.push가 끝나야 진행된다.
		  // 상태 계의 async라고 보면 될 것 같다.
		  
          // push가 끝나면 done으로 progress를 끝낸다. (initial이거나 in-progress면 complete으로 만듬.)
          progress.done(); 
        });
      }}
      {...rest}
    >
      {children}
    </Link>
  );
}

// useProgress (본체)
function useProgress() {
// 현재 상태
  const [state, setState] = useState<
    "initial" | "in-progress" | "completing" | "complete"
  >("initial");

// Spring으로 애니메이션 value 넣음.
  let value = useSpring(0, {
  // 반대쪽에 가해지는 힘
    damping: 25,
  // 무기력해지는 정도
    mass: 0.5,
  // sudden movement 정도
    stiffness: 300,
  // 거리가 이것보다 낮아지면 애니메이션을 끝낸다.
    restDelta: 0.1,
  });

// interval로 콜백함수를 실행시켜주는 훅
  useInterval(
    () => {
      // progress를 시작하는데 bar가 complete 상태면 reset부터 한다.
      if (value.get() === 100) {
        // motionValue를 0으로 만들어서 멈춘다.
        value.jump(0);
      }
      
      // motionValue를 가져와서
      let current = value.get();

      // 바뀌는 정도인듯?
      let diff;
      
      // 0이면 한번에 15움직이고
      if (current === 0) {
        diff = 15;
        // 50 이하일때는 빠르게 움직이고
      } else if (current < 50) {
        diff = rand(1, 10);
        // 초과하면 1 ~ 5까지 움직인다.
      } else {
        diff = rand(1, 5);
      }

      // motionValue에 추가 (최대 99)
      value.set(Math.min(current + diff, 99));
    },
    // in-progress 상태이면 delay (시간텀) 을 750 (0.75s) 아니면 없앰
    state === "in-progress" ? 750 : null
  );

  useEffect(() => {
    // initia이면 0으로
    if (state === "initial") {
      value.jump(0);
    // completing이면 100으로 간다.
    } else if (state === "completing") {
      value.set(100);
    }

    // value가 변화할 때 latest가 100이면 complete 상태로 만든다.
    return value.on("change", (latest) => {
    // on 이벤트는 motionValue를 가져올 수 있다. lastest가 motionValue임
      if (latest === 100) {
        setState("complete");
      }
    });
    // value, state, 컴포넌트 언마운트 시마다 value.on 이라는 이벤트 핸들러는 사라진다.
  }, [value, state]);

  function reset() {
  // initial로 만듬
    setState("initial");
  }

  function start() {
  // start로 만듬
    setState("in-progress");
  }

  function done() {
  // initial이거나 in-progress면 completing아니면 원상태로 돌리는 함수
    setState((state) =>
      state === "initial" || state === "in-progress" ? "completing" : state
    );
  }
  return { state, value, start, done, reset };
}

// 랜덤하게 max와 min 사이에서 양수 하나를 꺼내는 함수
function rand(min: number, max: number) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

// useInterval: callback과 delay를 받으면 interval을 주는 함수
function useInterval(callback: () => void, delay: number | null) {
  // callback을 저장하는 클로저
  const savedCallback = useRef(callback);
  
  useEffect(() => {
    // callback을 변경하는 함수
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    // ref에 담긴 callback 실행하는 함수
    function tick() {
      savedCallback.current();
    }

    if (delay !== null) {
      // 일단 실행
      tick();
      
      // 타이머 켜라
      let id = setInterval(tick, delay);
      // 언마운트 될 때 타이머 지워라
      return () => clearInterval(id);
    }
  }, [delay]);
}

분석 결과

굉장히 복잡한 코드가 나왔지만 결론적으로는 progress bar가 실제 로딩 퍼센트와 관련이 없어도 된다는 아이디어로 시작되는 것 같다.

  1. progress bar는 동작시 바로 15로 간다.
  2. 이후 progress bar는 15 부터 99까지 랜덤으로 채워진다.
  3. 99가 되었을 때 startTransition을 이용해서 router.push와 progress bar의 언마운트가 동시에 된다.
  4. progress bar의 언마운트 시에 Framer-Motion의 AnimatePresense를 이용해서 언마운트되더라도 컴포넌트가 애니메이션 종료까지 남아있게 된다. > 부드러운 언마운트가 이루어 진다.

여기서 startTransition을 이용한 동시성 렌더링으로 progress bar가 언마운트 되는 로직을 router.push와 함께 넣어둔 부분이 굉장히 인상 깊었다.

분석은 끝났으니 이제 활용할 방법을 찾아보자.

우리 프로젝트를 리팩토링할만한 것들은 다음과 같다.

  1. 우리 만의 LinkButton이 있기 때문에 Link를 따로 만들지 않고 훅에서 로직을 받아와서 내려주는 방식으로 리팩토링이 쉽게 해줘야 한다. => 훅으로 따로 생성, 내부 로직은 감춤
  2. useReducer를 이용해서 state 관리를 가독성 있게 하자.

리팩토링 결과

import { rand } from "@/utils/get-rand";
import { useSpring } from "framer-motion";
import { startTransition, useEffect, useReducer, useRef } from "react";

import useInterval from "./use-interval";

type StateType = "initial" | "in-progress" | "completing" | "complete";

type ActionType =
  | { type: "start" }
  | { type: "done" }
  | { type: "reset" }
  | { type: "complete" };

function reducer(state: StateType, action: ActionType): StateType {
  switch (action.type) {
    case "start":
      return "in-progress";
    case "done":
      return state === "initial" || state === "in-progress"
        ? "completing"
        : state;
    case "reset":
      return "initial";
    case "complete":
      return "complete";
    default:
      throw new Error("Unhandled action type");
  }
}

/**
 * @author 이승현
 * @description
 * progress 내부 로직이 담긴 훅입니다.
 *
 * progress bar와 같이 표시해주는 컴포넌트(view)를 만드시려면 state, value, reset 를 사용하시면 됩니다.
 * 자세한 구현 사항은 progress bar를 참고해주세요
 *
 * progress bar와 같은 컴포넌트와 연결되어 있는 컴포넌트를 만드시고 싶으시면 이 훅이 아닌 "useProgressBar"의
 * progress 함수를 사용하시면 됩니다.
 *
 * @example
 * export function ProgressBar({
  className,
  children,
}: {
  className: string;
  children: ReactNode;
}) {
  // useProgress 사용
  let progress = useProgress();
  // useMotionTemplate: 여러 motion value를 하나의 motion value로 만드는 훅
  // 여기서는 width: ~% 이런식으로 길어지게 만들어 놓음. 내부적으로 progress.value가 100이 되면 complete임
  let width = useMotionTemplate`${progress.value}%`;

  return (
    <ProgressBarContext.Provider value={progress}>
      <AnimatePresence onExitComplete={progress.reset}>
        {progress.state !== "complete" && (
          <motion.div
            style=
            exit=
            className={className}
          />
        )}
      </AnimatePresence>
      {children}
    </ProgressBarContext.Provider>
  );
}
 *
 */
export function useProgress() {
  // 현재 상태
  const [state, dispatch] = useReducer(reducer, "initial");

  // Spring으로 애니메이션 value 넣음.
  let value = useSpring(0, {
    // 반대쪽에 가해지는 힘
    damping: 25,
    // 무기력해지는 정도
    mass: 0.5,
    // sudden movement 정도
    stiffness: 300,
    // 거리가 이것보다 낮아지면 애니메이션을 끝낸다.
    restDelta: 0.1,
  });

  // interval로 콜백함수를 실행시켜주는 훅
  useInterval(
    () => {
      // progress를 시작하는데 bar가 complete 상태면 reset부터 한다.
      if (value.get() === 100) {
        // motionValue를 0으로 만들어서 멈춘다.
        value.jump(0);
      }
      // motionValue를 가져와서
      let current = value.get();
      // 바뀌는 정도인듯?
      let diff;
      // 0이면 한번에 15움직이고
      if (current === 0) {
        diff = 15;
        // 50 이하일때는 빠르게 움직이고
      } else if (current < 50) {
        diff = rand(1, 10);
        // 초과하면 1 ~ 5까지 움직인다.
      } else {
        diff = rand(1, 5);
      }
      // motionValue에 추가 (최대 99)
      value.set(Math.min(current + diff, 99));
    },
    // in-progress 상태이면 delay (시간텀) 을 750 (0.75s) 아니면 없앰
    state === "in-progress" ? 750 : null,
  );

  useEffect(() => {
    const unsubscribe = value.on("change", (latest) => {
      if (latest === 100) {
        dispatch({ type: "complete" });
      }
    });
    // initial이면 0으로
    if (state === "initial") {
      value.jump(0);
      // completing이면 100으로 간다.
    } else if (state === "completing") {
      value.set(100);
    }
    return () => {
      unsubscribe();
    };
    // value가 변화할 때 latest가 100이면 complete 상태로 만든다.
    // value, state 변화, 컴포넌트 언마운트 시마다 value.on 이라는 이벤트 핸들러는 사라진다.
  }, [value, state]);

  const handleProgress = (func: () => void) => {
    dispatch({ type: "start" });
    startTransition(() => {
      func();
      dispatch({ type: "done" });
    });
  };

  return {
    state,
    value,
    reset: () => dispatch({ type: "reset" }),
    progress: handleProgress,
  };
}

정리하자면 다음과 같다.

  1. Progress Bar처럼 로딩을 표시하는 view 컴포넌트에 내려줄 useProgress 훅은 useReducer를 이용한 flux 패턴으로 세부 상태 관리는 감추어서 가독성을 높였다.
  2. start, done으로 나누어져 사용하는 쪽에서 startTransition을 직접 구현해야했던 패턴에서 useProgress 훅으로 해당 훅을 숨기게 되었다.

사용하는 쪽에서는

"use client";

import Link, { LinkProps } from "next/link";
import { useRouter } from "next/navigation";
import { MouseEventHandler, useCallback } from "react";

import { useProgressBar } from "../progress-bar/progress-bar";

interface LinkWithProgressProps extends LinkProps {
  className?: string;
}

export default function LinkWithProgress({
  href,
  className,
  children,
  ...props
}: React.PropsWithChildren<LinkWithProgressProps>) {
  const { progress } = useProgressBar();
  const router = useRouter();

  const handleClick: MouseEventHandler<HTMLAnchorElement> = useCallback(
    (e) => {
      e.preventDefault();
      progress(() => router.push(href.toString()));
    },
    [href, progress, router],
  );

  return (
    <Link href={href} onClick={handleClick} {...props}>
      {children}
    </Link>
  );
}

progress에 콜백을 넘기는 것 만으로도 progress Bar와 연결될 수 있게 되었다.

기존 LinkButton 에 progress를 추가해보면 다음과 같다.

"use client"
//...
export function LinkButton({
  href,
  children,
  btnStyle,
  btnSize,
  className,
  ...rest
}: LinkButtonProps) {
  const { progress } = useProgressBar();
  const router = useRouter();

  const handleClick: MouseEventHandler<HTMLAnchorElement> = useCallback(
    (e) => {
      e.preventDefault();
      progress(() => router.push(href.toString()));
    },
    [href, progress, router],
  );

  return (
    <Link
      href={href}
      className={cn(buttonVariants({ className, btnStyle, btnSize }))}
      onClick={handleClick}
      {...rest}
    >
      {children}
    </Link>
  );
}

상태 변화를 다른 함수와 함께 진행시킬 수 있는 startTransition으로 async await 처럼 사용하는 기법을 처음 보게 되었는데 자주 사용하게 될 것 같다!

useReducer를 활용한 flux 패턴으로 상태 관리를 처리하는 리팩토링을 진행하면서 코드가 간결해지는 것을 느꼈고 직접 느껴보니

복잡한 로직이 나올 때 useReducer나 zustand 같은 flux 패턴을 사용하여 구현된 기법을 사용하는 이유를 알게 되었다.

늘 느끼지만 모든 기술이 자신의 문제를 해결하기 위해 찾아볼 때 기억에 잘 새겨지는 것 같다. 이번에도 많이 배웠다.

끝이 아니었다...

리렌더링 에러 ㅎㅎ… 너무 느려서 확인해봤더니..

progress라는 함수를 받는 컴포넌트도 progress bar로 인해서 리렌더링 되는 상황이다

useContext는 상태를 주입하는 훅이기에 상태와 연결된 것 이외에도 하위 컴포넌트들을 리렌더링 시킨다.

progress bar라는 상태가 빈번하게 변화하는 컴포넌트가 최상위에 있어서 리렌더링이 발생하는 것.

zustand를 이용해서 상태를 “관리”하여 이를 해결해보려고 했지만

https://github.com/pmndrs/zustand/discussions/885

useSyncExternalStore를 활용하는 zustand는 별도의 추가 없이는 startTransition과 함께 호환되지 않는다고 한다.

찾아보니 zustand 개발자가 커스텀 훅을 만들어놨다. https://www.npmjs.com/package/use-zustand

이걸 사용했지만 next.js와 context api를 활용해서 연결된 방식과 결합하는 것이다 보니 개발자가 구상했었던 예외처리가 아닌 것 같았다.

구조적으로 context api를 활용해서 리렌더링을 만드는 것, zustand로 불완전한 로딩바를 만드는 것보다는 다른 방법을 생각하게 되었다.

그러다 기존 next.js를 활용한 사이트들에서 next-nprogress-bar 라는 라이브러리를 확인했다.

이는startTransition을 활용하는 방식이 아니었다. 시도해보기로 한다.

next-nprogress-bar

pnpm install next-nprogress-bar
'use client';

import { AppProgressBar as ProgressBar } from 'next-nprogress-bar';

const Providers = ({ children }: { children: React.ReactNode }) => {
  return (
    <>
      {children}
      <ProgressBar
        height="4px"
        color="#fffd00"
        options=
        shallowRouting
      />
    </>
  );
};

export default Providers;
진짜 억울할 정도로 너무 쉽다 ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ

html 자체에 스타일링을 줘서 작동하는 방식인 것 같았다. 여기에 시간을 너무 많이 쏟은 것 같아 라이브러리 분석은 다음에 꼭 해봐야겠다.

이제 Link 자체에 특별한 옵션을 안 넣으면 자동으로 progress bar가 동작한다고 한다.

트러블 슈팅

Next.js에서는 SearchParams에 따른 컴포넌트 렌더링이 많다.

(searchParams에 따라서 정렬을 다르게 하는 자유게시판 페이지의 예시)

인기순에서 최신순으로 정렬기준을 바꿨을 때도 progress bar가 나오는데 좀더 자연스러울 것이다.

하지만 next-nprogress-bar에서는 이를 구현하지 않고 있었다.

따라서 useEffect를 활용하여 이 문제를 해결하였다.

"use client";

import { AppProgressBar, startProgress } from "next-nprogress-bar";
import { useSearchParams } from "next/navigation";
import { ReactNode, useEffect } from "react";

export default function ProgressBarProvider({
  children,
}: {
  children: ReactNode;
}) {
  const searchParams = useSearchParams();

  useEffect(() => {
    startProgress();
  }, [searchParams]);

  return (
    <>
      {children}
      <AppProgressBar
        height="4px"
        color="#10b981"
        options=
        shallowRouting
      />
    </>
  );
}

이제 정렬을 해도 progress bar가 움직이는 것을 확인할 수 있다.

후기

progress bar를 구현하기 위한 아이디어는 무엇이고 next.js에서는 그것을 어떻게 구현하는지를 알아보았다.

비록 프로젝트에는 라이브러리를 활용하는 쪽으로 적용되었지만 그 과정에서 동시성 렌더링을 통해서 구현한 코드를 분석하면서 많은 것을 배웠다.

더 성장해보자.

꼼꼼 프로젝트 진행 중에 겪은 경험들입니다. 오늘도 많이 성장한 것 같아요!

읽어주셔서 감사합니다! 지켜봐주세요!

감사합니다.

Categories:

Updated:

Leave a comment