의문점

Next.js에서는 페이지의 리로딩 없이 브라우저의 히스토리 스택을 바꾸고 싶다면 History API를 사용하라고 한다.

https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating#using-the-native-history-api

History API는 SPA를 위해 등장한 API로, 페이지의 새로고침없이 브라우저의 URL과 히스토리 스택을 조작할 수 있다.

그럼, Next.js의 useRouter는 페이지 이동 시 새로고침을 하는 location 기반으로 만들어졌다는 걸 의미하는 걸까?

그래서 useRouter는 도대체 어떤 방식으로 구현이 되어있을까 너무 궁금해져서 이 글을 작성하기 시작하게 되었다. 그리고, React에서 useNavigation은 실제로 History API를 사용했을까? 사용했다면 어떤 방식으로 래핑했을까?

Next.js router

Next.js 프로젝트는 상당히 복잡하고 복잡하다.. 그래서 처음에 사당히 헤맸다. 하나씩 차근차근 살펴보자.

  1. next.js/packages/next/src/client/components/navigation.ts

    export function useRouter(): AppRouterInstance {
      const router = useContext(AppRouterContext)
      if (router === null) {
        throw new Error('invariant expected app router to be mounted')
      }
    
      return router
    }
    

    우리가 사용하는 useRouter는 AppRouterContext라는 컨텍스트를 useContext의 인자로 전달해 생성한 라우터 객체이다.

    그럼 이 AppRouterContext는 도대체 뭘까?

  2. next.js/packages/next/src/client/index.tsx

    function AppContainer({
      children,
    }: React.PropsWithChildren<{}>): React.ReactElement {
      const adaptedForAppRouter = React.useMemo(() => {
        return adaptForAppRouterInstance(router)
      }, [])
    	return (
        <AppRouterContext.Provider value={adaptedForAppRouter}>
    	    /* .. */
        </AppRouterContext.Provider>
      )
    });
    

    여기서 놀라운 사실을 발견했다. 우리가 사용하는 Next.js 프레임워크가 위와 같이 Context.Provider로 감싸져 있다는 사실이다. 그 Context.Provider 아래에서 우리가 작성하는 모든 페이지 컴포넌트들이 렌더링되고, 우리는 Context로 관리하는 router, pathname 등을 사용할 수 있는 것이다.

    그럼 궁금한게 또 생겼다. Next.js는 이런 Context의 리렌더링 이슈를 어떻게 해결했을까? layout.tsx, page.tsx도 이런 식으로 구현이 되어 있을까? 이는 나중에 살펴보자.

    다시 본론으로 돌아가보자. AppRouterContext.Provider로 뿌려주는 값은 adaptedForAppRouter이다. adaptedForAppRouter은 adaptForAppRouterInstance(router)의 반환값이다. 여기서 adaptForAppRouterInstance부터 살펴보자.

  3. next.js/packages/next/src/shared/lib/router/adapters.tsx

    export function adaptForAppRouterInstance(
      pagesRouter: NextRouter
    ): AppRouterInstance {
      return {
        back() {
          pagesRouter.back()
        },
        forward() {
          pagesRouter.forward()
        },
        refresh() {
          pagesRouter.reload()
        },
        hmrRefresh() {},
        push(href, { scroll } = {}) {
          void pagesRouter.push(href, undefined, { scroll })
        },
        replace(href, { scroll } = {}) {
          void pagesRouter.replace(href, undefined, { scroll })
        },
        prefetch(href) {
          void pagesRouter.prefetch(href)
        },
      }
    }
    

    adaptForAppRouterInstance는 pagesRouter를 인자로 받아서 AppRouterInstance 타입의 객체를 반환하는데, 이 객체는 기본적인 라우팅 메서드들(back, forward, refresh, push, replace, prefetch)을 구현하고 있다. 각 메서드들은 내부적으로 pagesRouter의 메서드들을 호출하는 래퍼 함수들이다. 이제 이 인자 pagesRouter가 실제로 어떻게 구현되어 있는지 살펴보자.

  4. next.js/packages/next/src/client/index.tsx

    export let router: Router
    // ..
    initialData = JSON.parse(
      document.getElementById('__NEXT_DATA__')!.textContent!
    )
    //..
    router = createRouter(initialData.page, initialData.query, asPath, {
      initialProps: initialData.props,
      pageLoader,
      App: CachedApp,
      // ..
    })
    

    router는 createRouter함수로 만들어진 객체이다.

  5. next.js/packages/next/src/client/router.ts

    export function createRouter(
      ...args: ConstructorParameters<typeof Router>
    ): Router {
      singletonRouter.router = new Router(...args)
      singletonRouter.readyCallbacks.forEach((cb) => cb())
      singletonRouter.readyCallbacks = []
    
      return singletonRouter.router
    }
    
    

    createRouter는 주어진 인자들을 바탕으로 Router객체를 만들어, router를 생성하고 이를 싱글톤으로 관리한다.

  6. next.js/packages/next/src/shared/lib/router/router.ts

    export default class Router implements BaseRouter {
      constructor(){
    	  // ..
      }
      reload(): void {
        window.location.reload()
      }
      back() {
        window.history.back()
      }
      forward() {
        window.history.forward()
      }
      push(url: Url, as?: Url, options: TransitionOptions = {}) {
        if (process.env.__NEXT_SCROLL_RESTORATION) {
          // TODO: remove in the future when we update history before route change
          // is complete, as the popstate event should handle this capture.
          if (manualScrollRestoration) {
            try {
              // Snapshot scroll position right before navigating to a new page:
              sessionStorage.setItem(
                '__next_scroll_' + this._key,
                JSON.stringify({ x: self.pageXOffset, y: self.pageYOffset })
              )
            } catch {}
          }
        }
        ({ url, as } = prepareUrlAs(this, url, as))
        return this.change('pushState', url, as, options)
      }
      replace(url: Url, as?: Url, options: TransitionOptions = {}) {
        ({ url, as } = prepareUrlAs(this, url, as))
        return this.change('replaceState', url, as, options)
      }
    }
    
    1. reload는 window.location.reload를 래핑한다.

    2. back, forward는 window.history.back과 window.history.forward를 래핑한다.

    3. push와 replace는 change메서드를 사용하고, 인자로 각 pushState와 replaceState를 전달한다.

    4. push를 할 경우, Session Storage에 현재 스크롤 위치를 저장한다.

      sessionStorage.setItem(
        '__next_scroll_' + this._key,
        JSON.stringify({ x: self.pageXOffset, y: self.pageYOffset })
      )
      
  7. 다른 의문은 풀렸지만 push와 replace의 change에 대한 의문이 풀리지 않았다. 이 메서드는 과연 뭘까?

    private async change(
      method: HistoryMethod,
      url: string,
      as: string,
      options: TransitionOptions,
      forcedScroll?: { x: number; y: number }
    ): Promise<boolean> {
    	// ..
    	this.changeState(method, url, as, {
    	  ...options,
    	  scroll: false,
    	})
    
    changeState(
      method: HistoryMethod,
      url: string,
      as: string,
      options: TransitionOptions = {}
    ): void {
    	// ..
      if (method !== 'pushState' || getURL() !== as) {
        this._shallow = options.shallow
        window.history[method](
          {
            url,
            as,
            options,
            __N: true,
            key: (this._key = method !== 'pushState' ? this._key : createKey()),
          } as HistoryState,
          '',
          as
        )
      }
    

    찾았다 요놈 !

    History API를 사용하고 있었다.

정리

useRouter는 AppRouterContext라는 Context를 통해 전달되는 라우터 객체이다. 그리고 이 객체는 adaptForAppRouterInstance 함수로 생성하며, 내부적으로는 History API를 사용해 브라우저의 히스토리 스택을 조작한다. 각 라우팅 메서드(push, replace, back, forward 등)는 window.history API를 직접 호출하거나 래핑하여 구현되어 있는 것이다.

여기서 궁금한 것이, history.pushState와 history.replaceState는 세션 히스토리만을 수정하고, 실제로 페이지를 이동시키지는 않는다. 이는 SPA를 지원하기 위해 등장한 개념이다. 그렇다면 내부적으로 Next.js에서 이를 다른 곳에서 감지하고 새로운 페이지를 라우팅하는 것으로 추정된다. 내 생각에는 change메서드가 몇천 줄이 되던데 이 중에 있지 않을까 예상을 한다. 다음 번에는 이를 분석해보는 것도 참 재밌을 듯하다.