코딩을 잘하고 싶은 코린이 동토니

Next.js Router Instance에 대한 고찰 본문

Web/Next.js

Next.js Router Instance에 대한 고찰

dongtony 2024. 9. 23. 16:47

Next.js에서 현재 route에 접근하거나 다른 route로 이동하는 등 라우터에 대한 접근이 필요할 때 next/router패키지를 사용하게 됩니다 (page router 기준)

해당 모듈은 다음과 같은 방법들로 사용할 수 있습니다.

  1. useRouter 훅 사용
  2. default로 export되는 Router 싱글톤 객체를 사용하기

Next.js에서는 싱글톤 객체 사용 방법은 현재 사라진 상태이며 useRouter를 이용하는것을 권장하고 있습니다.

Next.js 동작 방식

우선 next/router에 대해서 알아보기전에 Next.js 동작방식에 대해서 이해할 필요가 있습니다.

  • SSG (Static Site Generation) : 빌드 타임에 HTML 생성 & 캐싱된 HTML을 기반으로 매 요청마다 재사용
  • SSR (Server Sid Render) : 매 요청마다 HTML 생성

여기서 가장 중요한점은 Next.js는 모든 페이지가 기본적으로 서버에서 빌드가 됩니다.
따라서 Next.js의 모든 페이지는 SSR을 사용하지 않더라도 SSR과 유사하게 동작을 하게 됩니다.

이 내용을 토대로 위에 내용을 다시 설명하자면

  1. 빌드 타임 혹은 요청이 오는 시점에 서버 측에서 HTML을 render한다.
  2. 브라우저에서 HTML을 다운로드 한다.
    1. HTML이 도착한 시점에는 JS가 로드되지 않았기 때문에 페이지는 Non-Interactive상태이다.
  3. 이후 JS가 로드되고 실행되면서 페이지가 Interactive된다. (Hydration)

해당 동작방식을 Next.js의 코드를 보면서 다시 살펴보면

우선 Next.js에서는 pre-render를 진행할때 renderToHTML 함수를 실행하게 됩니다.

// packages/next/server/render.tsx

// pre-render하는 renderToHTML 함수

// https://github.com/vercel/next.js/blob/v12.3.4/packages/next/server/render.tsx#L351



export async function renderToHTML(

req: IncomingMessage,

res: ServerResponse,

pathname: string,

query: NextParsedUrlQuery,

renderOpts: RenderOpts

): Promise<RenderResult | null> {

// ...



// SSG 여부 구분

const isSSG = !!getStaticProps



// ...



// getServerSideProps fetch 처리

if (getServerSideProps && !isFallback) {

// ...

try {

// getServerSideProps 실행

data = await getServerSideProps({

// ...

});

} catch (serverSidePropsError: any) {

// ...

}

// ...

}



// ...



// document render

// _document.tsx, _app.tsx, render하려는 페이지가 통합되어 render됨

const documentResult = await renderDocument()



// ...



// documentResult로 stream 만들기

const streams = [

streamFromArray(prefix),

await documentResult.bodyResult(renderTargetSuffix),

]



// 최적화하기

const postOptimize = (html: string) =>

postProcessHTML(pathname, html, renderOpts, { inAmpMode, hybridAmp })



// generateStaticHTML에 따라 pre-render 결과물이 달라짐

if (generateStaticHTML) {

const html = await streamToString(chainStreams(streams))

const optimizedHtml = await postOptimize(html)

return new RenderResult(optimizedHtml)

}



return new RenderResult(

chainStreams(streams).pipeThrough(

createBufferedTransformStream(postOptimize)

)

)

}

renderToHTML에서는 pre-render될 때 document.tsx, app.tsx, render하려는 페이지가 통합되어 render되게 됩니다. 이렇게 통합되어 render된 페이지는 브라우저에 전달된 이후에 hydrate되고 interactive한 상태로 변하게 됩니다.

document를 render하는 renderDocument 내부를 좀 더 살펴보면 React에서 SSR을 위해 제공하는
ReactDOMServer.renderToReadableStream을 사용하고 있습니다.


// streaming SSR 지원을 위해 renderToReadableStream을 사용
//renderDocument에서 document render 진행 후 renderToString 호출

async function renderToString(element: React.ReactElement) {
    const renderStream = await ReactDOMServer.renderToReadableStream(element)
    await renderStream.allReady

return streamToString(renderStream)

}

Pre-render 이후의 동작

HTML을 브라우저에서 fetch한 이후에 HTML과 React를 다시 연결하는 Hydration 과정이 일어나게 됩니다.

  • hydrate -> render -> doRender -> renderReactElement

Next.js의 client-side 초기화 코드(next/client/next.js) 과정을 보면 initialize가 완료된 이후에 hydrate가 실행됩니다. 그리고 renderdoRender를 거쳐 renderReactElement에서 hydration이 진행되게 됩니다.

Router 알아보기

Next.js의 동작방식을 살펴보았으니 Router에 대해서 좀 더 살펴보게되면

Next.js에서 useRouter, withRouter(deprecated), Router 싱글톤 방식 모두 같은 Router 객체를 사용합니다. 따라서 각 방식의 사용하는 값의 차이는 없습니다

useRouter구현 코드를 살펴보면

export function useRouter() : NextRouter {
    return React.useContext(RouterContext)
}

useRouter는 RouterContext에서 값을 가져오는걸 알 수 있습니다. RouterContext는 Router 싱글톤으로 초기화 되는데 이 Router 싱글톤은 Next.js 동작 과정 중 hydrate 함수 내에서 초기화가 이루어지게 됩니다.

export async function hydrate(opts?: { beforeRender?: () => Promise<void> }) {
// ...
// 전역 객체 초기화
router = createRouter(initialData.page, initialData.query, asPath, {
// ...
})

// ...
// router 초기화 이후에 Next.js App render
render(renderCtx)

}
const singletonRouter: SingletonRouterBase = {
    router: null, // holds the actual router instance
    readyCallbacks: [],
    ready(cb: () => void) {
        if (this.router) return cb()
        if (typeof window !== 'undefined') {
            this.readyCallbacks.push(cb)
        }
    },
}

// createRouter 코드 - Router 싱글톤 초기화

/**
* router을 생성하고 싱글톤 인스턴스로서 할당한다.
* client-side에서 앱을 초기화할 때 사용된다.
* This should **not** be used inside the server.
* @internal
*/

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

    return singletonRouter.router

}

hydrate에서 createRoute가 호출되고 여기서 Router 싱글톤의 초기화가 이루어집니다. 이유는 Next.js가 client-side에서 Hydration되며 초기화되는 과정에서 router 상태가 변할때마다 앱이 re-render되도록 router에 리스너를 등록해야 하기때문에 Router 싱글톤 초기화가 hydrate 에서 이루어지게 됩니다.

이제 여기까지 알아보고 다시 RouterContext 초기화 시점으로 돌아가서 코드를 살펴보면

function AppContainer({
    children,
}: React.PropsWithChildren<{}>): React.ReactElement {
    return (
        <Container>
            <RouterContext.Provider value={makePublicRouterInstance(router)}>
                {/* ... */}
            </RouterContext.Provider>
        </Container>
    )
}

useRouter가 의존하는 RouterContext의 값은 makePublicRouterInstance로 만들어지게 되는데 여기서 의존하는 router 객체가 위에서 살펴본 hydrate 함수에서 createRouter의 리턴값으로 초기화된 Router 싱글톤 객체입니다.

이 점을 유의하고 SSR 시점에서의 Router 동작 코드를 살펴보게 되면 위의 설명을 왜이렇게 하였는지 이해가 됩니다.

export async function renderToHTML() {
// CSR과 다르게 SSR에선 ServerRouter라는 별개의 class로 Router를 만든다.
// ServerRouter parameter는 생략.
const router = new ServerRouter()

// ...
// RouterContext.Provider에 SSR용 라우터 주입.


const AppContainer = ({ children }: { children: JSX.Element }) => (
    <RouterContext.Provider value={router}>
        {/* AppContainer 자식 생략 */}
    </RouterContext.Provider>
)

// ...
// SSR 결과 반환

    return new RenderResult(
        chainStreams(streams).pipeThrough(
        createBufferedTransformStream(postOptimize),
        ),
    )
}

SSR 에서의 Router 코드를 살펴보면 Router 싱글톤 초기화하는 동작은 없고 오로지 RouterContext.Provider에 ServerRouter를 주입하는 코드만 존재합니다.

이로인해서 SSR 시점에 Router 싱글톤에 접근하게 되면 No Router Instance found 에러가 발생하게 됩니다.

SSR이 아니라 SSG를 사용하고 있는 곳이더라도 pre-render 되는 환경은 서버이기 때문에 상황은 동일하고, 다만 차이가 있다면 SSG는 빌드 타임에서 에러가 발생하고, SSR은 런타임에서 에러가 발생하게 됩니다.

실제 오류 발생 후 조치

NCNC-Payment의 useHelpPGOrder 훅에서 No Router Instance found 에러가 발생하였습니다.

const useHelpPgOrder = () => {
... 중략

const failOrder = (alertMessage?: string) => {
    const url = `/v2/orders/cancel?orderId=${orderId}&message=${alertMessage}`

        if (pgName === 'nice') {
            window.parent.postMessage(
                JSON.stringify({
                    type: 'replaceUrl',
                    url,
                }),
            )
        } else {
            router.replace(url)
        }
}

return useHelpPgOrder

해당 코드를 보면 아무런 문제가 없어보이지만 failOrder라는 함수는 hook안에 존재함에도 불구하고 빌드하는 과정에서 client-side에서 실행할 조건이 별도로 없기때문에 빌드 타임에 해당 함수를 생성하려 합니다.

이때 router객체는 아직 초기화 되지 않은 상태로 router.query의 orderId를 통해 router.replace를 진행하려 하는데 이 과정에서 아직 초기화 되지 않은 값으로 replace를 동작하려고 하면서 No Router Instance found 에러를 발생시키게 됩니다.

따라서 해당 코드를 다음과 같이 수정해주면 정상 동작하게 됩니다.

const useHelpPgOrder = () => {
... 중략

const failOrder = (alertMessage?: string) => {
    const url = `/v2/orders/cancel?orderId=${orderId}&message=${alertMessage}`

    if (typeof window !== 'undefined') { // client-side 동작 명시 
        if (pgName === 'nice') {
            window.parent.postMessage(
                JSON.stringify({
                    type: 'replaceUrl',
                    url,
                }),
            )
        } else {
            router.replace(url)
        }
    }
}

return useHelpPgOrder

'Web > Next.js' 카테고리의 다른 글

Next.js app router에 MSW 적용하기  (1) 2024.09.03
ServerSideRendering vs ClientSideRendering  (0) 2022.09.18
React Query와 Next.js  (0) 2022.09.10