Link의 prefetch를 까보자

SeongHyeon Bae·2024년 3월 31일
4

오픈소스 까보기

목록 보기
3/6
post-thumbnail

NextJS 환경에서 개발하다 보면 Link 컴포넌트를 사용하지 않을 수 없는데요. 프로젝트를 진행하다가 Link 태그에 hover시 무한렌더링이 발생하는 이슈를 겪었습니다. 그런데 참 이상하게 개발환경에서는 발생하지 않지만 배포환경에서만 발생을 하였습니다. 해당 이슈 해결과정을 적어보려고 합니다.

문제 상황

문제 상황은 아주 간단합니다. App router 개발 환경에서 Link 버튼을 클릭하면 정상적으로 라우팅이 되었지만, 막상 배포환경에서는 Link 컴포넌트 hover시에 페이지 전체가 계속해서 무한 렌더링이 발생하였습니다. 처음에는 어떻게 프론트 단에서 개발환경과 배포환경이 다를수가 있지? 라는 충격과 함께 디버깅을 위해 매번 배포하는 멍청한 짓을 했습니다.

next build -> next start

문제 해결을 하기 전 prodction 환경을 테스팅 하기에 매번 배포를 하는것은 시간비용이 엄청나게 낭비된다고 생각했습니다. 분명 배포 환경을 테스팅 하는 법이 있을것이라고 생각하여 찾아보니 공식문서에서 다음과 같이 친절하게 적혀있었습니다.

매번 무의식적으로 NextJS를 개발을 위해 npm run dev 명령어를 통해 해당 뜻도 모르고 개발했던것을 반성하며 한 도구를 사용하는 것에 대해 정확히 알고 있는가? 에 대해 다시 생각해 보았습니다.

문제 원인

프로젝트에 전역상태(jotai), react-query, tanstack table 등등 너무 많은 라이브러리들이 의존되어 있어 문제의 원인을 찾기가 어려웠습니다. 계속해서 검색을 해보며 Link 와 production 환경의 키워드로 Link 태그의 prefetch 속성이 있다는 것을 알았습니다.

Prefetch

Link 태그에는 prefetch 라는 속성이 있습니다. 이 속성은 말 그대로 Link태그의 href에 해당 사이트의 데이터를 미리 받아오는것입니다. 왜 미리 받아올까요?

NextJs는 Client side render 방식처럼 한번에 모든 데이터를 받아오는 방식과 다르게 Page 별 데이터를 요청할 때 마다 받아오는 방식입니다. 그러다 보니 유저가 페이지 라우팅을 위해 Link를 클릭 한 후 데이터를 받아오게 되면, 데이터가 클 경우 유저는 오래 기다리게 되어 좋지않은 UX를 제공합니다.

이를 개선하기 위해 NextJS는 유저의 Viewport에 해당하는 Link들은 이동할 가능성이 있다고 판단하여 미리 데이터를 받아놓습니다. 기본적으로 App router에서 prefetch는 null로 설정되어 있고 이는 static routes의 경우 모든데이터를, dynamic routes의 경우 loading.js boundary의 가장 가까운 세그먼트를 다운받게 된다고 합니다.

해결

정확한 원인은 모르겠지만 환경에 따라 영향을 받는것은 prefetch 기능이니 기능을 false로 변경하여 정상적으로 무한렌더링이 해결되었습니다. 하지만 매우 찝찝합니다. prefetch기능은 분명 viewport와 관련이 있는것이였고 그럼 hover가 아닌 Link가 viewport에 들어온 시점부터 무한 렌더링이 발생해야 하는것이 아닌가? 또한, 왜 무한렌더링이 발생하는것일까? 에 대해서 궁금증이 남아있었습니다.

그럼 hover와 무슨 상관?

Link hover와 관련된 게시글을 몇몇 찾아보니 다음과 같이 false일경우에도 hover 경우에는 작동한다고 하였습니다.

공식 문서에 있는 설명인데, 기본값(true )일 경우 브라우저의 Viewport 내에 있으면 Link의 경로에 해당하는 페이지를 백그라운드에서 미리 가져오는 역할을 한다고 한다. 근데 이 기능은 false 일 때도 완전히 꺼지는 것은 아니고 링크를 hover하면 로딩한다고 한다. - 출처 : Web: Next.js Link와 Prefetch 과정 파헤쳐보기 , 공식문서

매우 이상합니다. 이 논리대로면 prefetch 기능을 false로 변경하더라도 hover시에는 완전히 꺼지지 않으므로 계속해서 무한렌더링이 발생해야하는데요. 여기서 위 게시글을 작성자 처럼 NextJS 의 Link 태그를 까보기로 결정합니다.

// tag v14.1.2
// https://github.com/vercel/next.js/blob/f564deef86be32a6b25125ddb8172c7c27d3f19a/packages/next/src/client/link.tsx#L638

     onMouseEnter(e) {
        if (!legacyBehavior && typeof onMouseEnterProp === 'function') {
          onMouseEnterProp(e)
        }

        if (
          legacyBehavior &&
          child.props &&
          typeof child.props.onMouseEnter === 'function'
        ) {
          child.props.onMouseEnter(e)
        }

        if (!router) {
          return
        }

       // 이곳을 주목!!!
        if (
          (!prefetchEnabled || process.env.NODE_ENV === 'development') &&
          isAppRouter
        ) {
          return
        }

        prefetch(
          router,
          href,
          as,
          {
            locale,
            priority: true,
            // @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642}
            bypassPrefetchedCheck: true,
          },
          {
            kind: appPrefetchKind,
          },
          isAppRouter
        )
      },

위 코드는 NextJs의 Link 컴포넌트 구현 중 onMouseEnter(hover) 이벤트 발생 시 작동하는 로직입니다. 그중 제가 주석을 달아 놓은 부분의 조건문을 확인해보면 prefetch 조건을 끄고 App router이면 return을 발생시킵니다. 결국, prefetch기능이 false이더라도 hover시 작동하는 것은 Page Rotuer일 경우였고 App router의 경우는 발생하지 않는다고 합니다.

몇몇 스택오버플로우에서는 Prefetch를 껐는데도 hover할때 자꾸 발생한다며 불만이 있어서 그런것 같습니다.

hover가 아닌 Link가 viewport에 들어온 시점부터 무한 렌더링이 발생해야 하는것이 아닌가?

그래도 우리는 이것에 대한 해답을 찾지 못했습니다. 문득 이런생각이 들었습니다.

viewport에 들어왔을때 발생하는 prefetch와 hover시에 발생하는 prefetch의 종류가 다른가?

그래서 다시 오픈소스를 뜯어보기로 하였습니다.

// https://github.com/vercel/next.js/blob/f564deef86be32a6b25125ddb8172c7c27d3f19a/packages/next/src/client/link.tsx#L551

    React.useEffect(() => {
      // in dev, we only prefetch on hover to avoid wasting resources as the prefetch will trigger compiling the page.
      if (process.env.NODE_ENV !== 'production') {
        return
      }

      if (!router) {
        return
      }

      // If we don't need to prefetch the URL, don't do prefetch.
      if (!isVisible || !prefetchEnabled) {
        return
      }

      // Prefetch the URL.
      prefetch(
        router,
        href,
        as,
        { locale },
        {
          kind: appPrefetchKind,
        },
        isAppRouter
      )
    }, [
      as,
      href,
      isVisible,
      locale,
      prefetchEnabled,
      pagesRouter?.locale,
      router,
      isAppRouter,
      appPrefetchKind,
    ])
      

이 코드는 viewport에 들어왔을때 prefetch가 실행되는 코드 입니다. 그럼 위의 onMouseEnter과 어떤 차이가 있는지 비교해보면 한가지 차이가 있습니다.

// viewport의 prefetch
prefetch(
  router,
  href,
  as,
  { locale },
  {
    kind: appPrefetchKind,
  },
  isAppRouter,
);


//onMouseEnter의 prefetch
prefetch(
  router,
  href,
  as,
  {
    locale,
    priority: true,
    // @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642}
    bypassPrefetchedCheck: true,
  },
  {
    kind: appPrefetchKind,
  },
  isAppRouter,
);

여기서 차이점은 옵션으로 priority와 bypassPrefetchedCheck 인데요. 우선 bypassPrefetchedCheck의 경우 위 주석을 들어가보면 hover할떄마다 prefetch를 해서 캐시를 하기위해 준 옵션 같습니다.

그럼 priority가 하는 역할을 따라가 봅시다. (제발 이것이 해답이길....)
prefetch의 정의된 함수를 보면 다음과 같습니다.

// https://github.com/vercel/next.js/blob/f564deef86be32a6b25125ddb8172c7c27d3f19a/packages/next/src/client/link.tsx#L124

import type {
  NextRouter,
  PrefetchOptions as RouterPrefetchOptions,
} from '../shared/lib/router/router'
  
type PrefetchOptions = RouterPrefetchOptions & {
  /**
   * bypassPrefetchedCheck will bypass the check to see if the `href` has
   * already been fetched.
   */
  bypassPrefetchedCheck?: boolean
}

function prefetch(
  router: NextRouter | AppRouterInstance,
  href: string,
  as: string,
  options: PrefetchOptions,
  appOptions: AppRouterPrefetchOptions,
  isAppRouter: boolean
): void {
// ... 불필요한 것 생략
  const prefetchPromise = isAppRouter
    ? (router as AppRouterInstance).prefetch(href, appOptions)
    : (router as NextRouter).prefetch(href, as, options)
    
}

엥? AppRouter의 경우에는 options를 사용하지 않네? 그럼 priority는 상관없는 것 이였구나...

이때부터 방향을 잃어서 아쉽게도 원인을 찾지 못했습니다... 허무하셨다면 죄송합니다.. 😭
하지만 여기서 끝내기에는 아쉬우니 priority가 하는 역할이 무엇인지 찾아봅시다!

prefetch의 priority option (Page router)

먼저 router에서 prefetch의 프로퍼티에 접근하여 호출하는것 같으므로 router를 찾아봅시다.

//https://github.com/vercel/next.js/blob/f564deef86be32a6b25125ddb8172c7c27d3f19a/packages/next/src/client/link.tsx#L260C1-L292C44
import { RouterContext } from '../shared/lib/router-context.shared-runtime'
const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
  function LinkComponent(props, forwardedRef) {
	// ... 생략

    const pagesRouter = React.useContext(RouterContext)
    const appRouter = React.useContext(AppRouterContext)
    const router = pagesRouter ?? appRouter

router는 LinkComponent에서 선언이 되어 있었고 RouterContext를 통해 받아옴을 알 수 있었습니다.

// '../shared/lib/router-context.shared-runtime'
export const RouterContext = React.createContext<NextRouter | null>(null)

// https://github.com/vercel/next.js/blob/1c5aa7fa09cc5503c621c534fc40065cbd2aefcb/packages/next/src/client/index.tsx#L321C14-L332C40

<RouterContext.Provider value={makePublicRouterInstance(router)}>
  <HeadManagerContext.Provider value={headManager}>
    <ImageConfigContext.Provider
      value={process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete}
    >
      {children}
    </ImageConfigContext.Provider>
  </HeadManagerContext.Provider>
</RouterContext.Provider>;

../shared/lib/router-context.shared-runtime 경로에는 단순 RouterContext 를 선언한 곳이였고 해당 Context의 value는 Provider에 makePublicRouterInstance(router)를 통해 주입하고있었습니다.

//https://github.com/vercel/next.js/blob/1c5aa7fa09cc5503c621c534fc40065cbd2aefcb/packages/next/src/client/router.ts#L169
export function makePublicRouterInstance(router: Router): NextRouter {
 // ...생략

  return instance
}

정확하게 makePublicRouterInstance 함수의 내부 로직은 이해하지 못했지만 React의 Router를 props로 받아 NextRouter로 변환해서 반환하는 함수로 추론했습니다.

//https://github.com/vercel/next.js/blob/4efe14238b5ab11935e73aa09631ef5ec8045b13/packages/next/src/shared/lib/router/router.ts#L355

export type NextRouter = BaseRouter &
  Pick<
    Router,
    | 'push'
    | 'replace'
    | 'reload'
    | 'back'
    | 'forward'
    | 'prefetch'
    | 'beforePopState'
    | 'events'
    | 'isFallback'
    | 'isReady'
    | 'isPreview'
  >
  
  //https://github.com/vercel/next.js/blob/4efe14238b5ab11935e73aa09631ef5ec8045b13/packages/next/src/shared/lib/router/router.ts#L2283
  
 async prefetch(
    url: string,
    asPath: string = url,
    options: PrefetchOptions = {}
  ): Promise<void> {
    // Prefetch is not supported in development mode because it would trigger on-demand-entries
    if (process.env.NODE_ENV !== 'production') {
      return
      
     //생략
      
        await Promise.all([
      this.pageLoader._isSsg(route).then((isSsg) => {
        return isSsg
          ? fetchNextData({
              dataHref: data?.json
                ? data?.dataHref
                : this.pageLoader.getDataHref({
                    href: url,
                    asPath: resolvedAs,
                    locale: locale,
                  }),
              isServerRender: false,
              parseJSON: true,
              inflightCache: this.sdc,
              persistCache: !this.isPreview,
              isPrefetch: true,
              unstable_skipClientCache:
                options.unstable_skipClientCache ||
                (options.priority &&
                  !!process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE),
            })
              .then(() => false)
              .catch(() => false)
          : false
      }),
      this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](route),
    ])
}}

NextRouter 타입이 선언된 곳으로 찾아보니 prefetch라는 속성을 가지고 있었고, 해당 함수를 선언한 곳을 보니 마지막 줄에 option.priority의 여부에 따라 loadPage와 prefetch를 나눠서 적용하는 것을 확인할 수 있었습니다.

이후, pageLoader가 어떤역할을 하는지 분석해야 할 양이 방대하여 여기까지 마무리 하였습니다.

정리

  1. App router 환경에서 Link 컴포넌트에 hover시 무한렌더링이 발생하는 이슈 발생
  2. prefetch 기능 작동을 off하여 문제 해결
  3. prefetch는 page router와 app router의 작동 방식이 조금 다름
  4. prefetch 작동 방식을 Next 코드를 까보며 동작 방식을 이해

    해당 이슈를 정확하게 재연하기가 힘들어 원인 분석이 명확하게 못해서 아쉬움이 많이 남습니다.
    해결책은 알지만 왜 해당 이슈의 해결책인지 정확하게 알지 못해 찝찝함이 남지만 NextJS의 prefetch의 작동방식을 공식문서에 담지못한 부분까지 찾아보았다는 점은 흥미로웠습니다!
    혹시 잘못된 부분이나 원인을 알고계시면 댓글로 알려주시면 감사하겠습니다. 읽어주셔서 감사합니다🙇🏻‍♂️

profile
FE 개발자

0개의 댓글