ETC
  • Development

Next.js useRouter, 자세히 알아보기

2025년 02월 19일

Next.js로 개발해봤다면 useRouter 훅을 한 번쯤은 써봤을 거야. 문제는 React + Vite 기반 프로젝트와 Next.js 프로젝트를 동시에 운영하면서 공용 컴포넌트를 만들다 보면, useRouter가 환경에 따라 정상 동작하지 않는 순간이 생긴다는 점이다.

이번 글에서는 이 이슈를 해결하기 위해 useRouter의 동작 방식을 먼저 이해하고, React + Vite 환경에서도 동작하도록 커스터마이징했던 과정을 정리해보려고 한다.

useRouter 이해하기

useRouterNext.js에서 라우팅을 지원하기 위해 제공하는 훅이고, 반환되는 객체를 통해 프로그래밍적으로 라우팅을 처리할 수 있다.

Next.js 13 이후에는 useRouter가 두 종류로 나뉜다.

각 훅은 서로 다른 실행 환경을 전제로 만들어져 있어서, 적절한 환경에서 사용하지 않으면 제대로 동작하지 않는다. 예를 들어 App Router 환경에서 next/routeruseRouter를 쓰면 아래 오류가 발생한다.

Error: NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted

오류 메시지 그대로 NextRouter가 마운트되지 않았다는 뜻인데, 여기서 말하는 NextRouter가 뭔지부터 확인해보자.

NextRouter 알아보기

이를 확인하기 위해 Next.js 공개 레포지토리를 먼저 봤다. useRouter 선언부를 보면 구현은 꽤 단순하다.

router.ts
export function useRouter(): NextRouter {
const router = React.useContext(RouterContext);
if (!router) {
throw new Error(
"NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted",
);
}
return router;
}

핵심은 React.useContext(RouterContext)다. useRouter는 내부적으로 RouterContext를 참조해서 라우터 객체를 꺼내 반환하고, 컨텍스트 값이 없으면 오류를 던진다.

그럼 RouterContext는 어떻게 정의돼 있을까?

router-context.shared-runtime.ts
import React from "react";
import type { NextRouter } from "./router/router";
export const RouterContext = React.createContext<NextRouter | null>(null);
if (process.env.NODE_ENV !== "production") {
RouterContext.displayName = "RouterContext";
}

RouterContextcreateContext로 생성된 컨텍스트이고, 타입은 NextRouter | null이다. 결국 NextRouter was not mounted 오류는 RouterContext에 Provider가 없거나, Provider가 있어도 value가 주입되지 않은 상태에서 useRouter를 호출했을 때 발생한다는 걸 알 수 있다.

App Router의 useRouter도 구조는 비슷하게 Context API 기반으로 동작한다.

app-router-context.shared-runtime.ts
export const AppRouterContext = React.createContext<AppRouterInstance | null>(
null,
);

여기서는 컨텍스트 타입이 AppRouterInstance로 달라진다. 즉, Pages Router(RouterContext)와 App Router(AppRouterContext)는 서로 다른 컨텍스트를 기준으로 동작한다. 그래서 환경을 섞어 쓰면 “컨텍스트가 없다”는 오류가 자연스럽게 발생한다.

Context API를 사용하는 이유

그럼 Next.js는 왜 라우터를 Context로 관리할까? 이유는 여러 가지가 있겠지만, 가장 설득력 있는 건 SSR(Server-Side Rendering) 환경 대응이다.

Next.js는 SSR을 지원하고, SSR이 활성화되면 서버에서 HTML 렌더링을 수행한다. 이때 클라이언트 전용 API(대표적으로 window)는 서버에서 접근할 수 없다.

라우팅 로직이 window를 직접 참조하는 구조였다면 SSR 단계에서 바로 참조 오류가 나기 쉬웠을 것이다. 반면 Router 객체를 Context로 주입하는 형태라면, 서버에서는 “서버용 라우터”를 넣고, 클라이언트에서는 “클라이언트용 라우터”를 넣는 식으로 안전하게 분리할 수 있다.

실제로 서버 렌더링 코드 일부를 보면 서버에서 사용하는 라우터 구현이 따로 존재한다.

server/render.tsx
class ServerRouter implements NextRouter {
route: string
pathname: string
push(): any {
noRouter()
}
}
const router = new ServerRouter(
pathname,
query,
asPath,
...
)
const AppContainer = ({ children }: { children: JSX.Element }) => (
<AppRouterContext.Provider value={appRouter}>
<RouterContext.Provider value={router}>
{children}
</RouterContext.Provider>
</AppRouterContext.Provider>
)

여기서 ServerRouter.push()는 실제 라우팅을 수행하지 않고, 호출 시 에러를 던지는 방식으로 구현돼 있다.

server/render.tsx
function noRouter() {
const message =
'No router instance found. you should only use "next/router" inside the client side of your app. https://nextjs.org/docs/messages/no-router-instance';
throw new Error(message);
}

즉 SSR 단계에서는 “라우터가 있긴 하지만 실제 이동은 불가능”한 형태로 안전하게 구성하고, CSR에서만 실제 라우팅이 가능하도록 분리해두는 구조다. 이런 설계에서는 Context API를 사용하는 게 자연스럽다.

Next.js Router 커스터마이징

앞에서 확인한 것처럼 useRouter가 정상 동작하려면 해당 훅이 바라보는 컨텍스트가 존재해야 한다. 이번 케이스에서는 React + Vite 기반 프로젝트에서도 App Router의 useRouter(next/navigation)를 사용하는 공용 컴포넌트가 있어, 최소한 AppRouterContext를 제공하는 쪽으로 접근했다.

그래서 루트 렌더링 지점에서 앱 전체를 AppRouterContext.Provider로 감싸고, value로 커스텀 라우터 인스턴스를 주입했다.

main.tsx
import { createRoot } from "react-dom/client";
import { AppRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import App from "./App.tsx";
import CustomRouter from "./CustomRouter.ts";
import "./index.css";
const router = new CustomRouter();
createRoot(document.getElementById("root")!).render(
<AppRouterContext.Provider value={router}>
<App />
</AppRouterContext.Provider>,
);

여기서 중요한 건 value로 전달하는 객체가 AppRouterInstance 인터페이스를 만족해야 한다는 점이다. 그래서 CustomRouterAppRouterInstance 기반으로 구현했다.

custom-router.ts
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
declare type Url = string;
export default class CustomRouter implements AppRouterInstance {
back(): void {
window.history.back();
}
forward(): void {
window.history.forward();
}
refresh() {
window.location.reload();
}
push(url: Url): boolean {
window.location.href = url as string;
return true;
}
replace(url: Url): boolean {
window.location.href = url as string;
return true;
}
prefetch(): void {}
}

이렇게 하면 React + Vite 프로젝트에서도 AppRouterContext는 존재하게 되고, 공용 컴포넌트 내부에서 next/navigationuseRouter를 호출하더라도 컨텍스트를 정상적으로 참조할 수 있다.

결과적으로 React + Vite 환경에서도 useRouter를 사용하는 공용 컴포넌트들이 정상 동작하게 됐다.

참고