들어가며.
[이전글] Next.js AppRouter에서 페이지 이탈 컨펌 구현하기
이전에 Next.js의 App Router 환경에서 페이지 이탈 컨펌 구현에 관련한 글을 작성했었는데요, next-navigation-guard를 이용하여 문제를 해결했던 경험을 다루었습니다. 그러나 해당 글에서는 보다 근본적인 의문점들을 미처 해소하지는 못했었는데요, 이번 글에서는 아래와 같은 의문점들을 해소하면서 그 과정에서 Next.js의 아키텍처 변화와 설계 철학 등을 탐구해보려 합니다.
- Next.js는 왜 App Router로 전환하면서 next/router 대신 next/navigation을 사용하도록 권장하게 되었을까요?
- Next.js는 왜 next/navigation의 router에서 (next/router에서 존재하던) router.events를 제거했을까요?
- Next.js 팀이 권장하는 브라우저 이탈(새로고침, 페이지 나가기, 뒤로가기)에 대한 공식적인 해결 방법은 무엇일까요?
이건 첫 번째 레슨: next/navigation를 사용하기.
App Router로 전환하면서 next/router 대신 next/navigation을 사용하도록 권장된 이유는, App Router의 핵심인 '서버 컴포넌트(RSC)' 때문이라고 볼 수 있습니다. 단순히 서버 컴포넌트의 지원이 아니라, 이로 인해 완전히 다른 개념으로 라우팅을 매니징 해야 했습니다. 기존의 next/router를 조금 바꾸는 수준으로는 새로운 패러다임을 적용할 수 없었기 때문입니다.
next/router vs next/navigation의 차이
코드베이스를 통해 next/router와 next/navigation이 어떤 특징을 가지고 있는지, 어떻게 구현되었는지 간단히 살펴보겠습니다.
next/router (Pages Router)
변수명에서 알 수 있듯, 싱글톤(Singleton)으로 구현되었습니다. 전역 singletonRouter 객체로 단일 인스턴스로 관리되며
클라이언트에서 라우팅 상태를 직접 관리하도록 되어 있습니다.
// next 12의 router.ts
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)
}
},
}
next/navigation (App Router)
반면, App Router의 경우 서버-클라이언트 하이브리드 모델로 설계되었습니다. 서버 컴포넌트와의 통신을 전제로 설계되어, 페이지 이동 시 서버에 다음 페이지의 UI(서버 컴포넌트)를 요청하고 스트리밍으로 받아옵니다.
// next 15의 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
}
변경이 필요했던 이유 1: 서버 컴포넌트와 라우팅의 통합
next/navigation이 필요했던 이유는 기존 next/router로는 서버 컴포넌트와 라우팅을 통합 처리하기 어려웠기 때문입니다.
Pages Router와 달리 App Router에서는 Next.js가 서버에서 React API를 사용하여 렌더링을 조율하며, 작업은 개별 라우트 세그먼트와 Suspense Boundary로 분할됩니다.
이 과정에서 Next.js가 React의 renderToReadableStream API를 사용하여 Server Components를 RSC Payload(React Server Component Payload)로 변환하는데요, RSC Payload는 렌더링된 서버 컴포넌트 트리의 압축 및 직렬화된(serialized) 표현으로, 다음을 포함합니다:
- 서버 컴포넌트의 렌더링 결과
- 클라이언트 컴포넌트가 렌더링될 위치에 대한 플레이스홀더
- 클라이언트 컴포넌트의 JavaScript 파일 참조
- 서버에서 클라이언트 컴포넌트로 전달되는 props
// next 15의 app-render.tsx
async function generateDynamicRSCPayload(
ctx: AppRenderContext,
options?: {
actionResult: ActionResult
skipFlight: boolean
}
): Promise<RSCPayload> {
// Flight data that is going to be passed to the browser.
// Currently a single item array but in the future multiple patches might be combined in a single request.
// We initialize `flightData` to an empty string because the client router knows how to tolerate
// it (treating it as an MPA navigation). The only time this function wouldn't generate flight data
// is for server actions, if the server action handler instructs this function to skip it. When the server
// action reducer sees a falsy value, it'll simply resolve the action with no data.
let flightData: FlightData = ''
Next.js는 이 RSC Payload와 클라이언트 컴포넌트 JavaScript 정보를 사용해 HTML을 생성합니다.
클라이언트에서는:
- HTML을 먼저 표시합니다. 비대화형(non-interactive) 프리뷰이기 때문 클릭 등은 아직 동작하지 않습니다.
- JavaScript가 로드되면 RSC Payload로 서버 컴포넌트 트리를 재구성(reconcile)하고
- 클라이언트 컴포넌트를 hydrate해 상호작용을 활성화합니다.
이렇게 하면 사용자는 JavaScript 로딩 전에도 콘텐츠를 볼 수 있고, 로딩 후에 상호작용이 가능해집니다.
변경이 필요했던 이유 2: React Transitions와의 통합
모든 App Router의 네비게이션은 React Transitions 위에 구축되었습니다. 아래 코드에서 볼 수 있듯, next/navigation의 router.push, replace, refresh 등은 내부적으로 startTransition으로 래핑되어 있습니다:
// next 15의 app-router-instance.ts
replace: (href: string, options?: NavigateOptions) => {
startTransition(() => {
dispatchNavigateAction(href, 'replace', options?.scroll ?? true, null)
})
},
push: (href: string, options?: NavigateOptions) => {
startTransition(() => {
dispatchNavigateAction(href, 'push', options?.scroll ?? true, null)
})
},
refresh: () => {
startTransition(() => {
dispatchAppRouterAction({
type: ACTION_REFRESH,
origin: window.location.origin,
})
})
},
이러한 구성을 택한 이유를 간단히 설명하면 다음과 같습니다.
- 네비게이션이 중단 가능(interruptible)해집니다
- startTransition은 업데이트를 긴급하지 않은 것으로 표시해 UI를 블로킹하지 않습니다. 이로 인해 불필요한 로딩 인디케이터를 줄일 수 있습니다.
// next 15의 app-router-instance.ts
} else if (
payload.type === ACTION_NAVIGATE ||
payload.type === ACTION_RESTORE
) {
// Navigations (including back/forward) take priority over any pending actions.
// Mark the pending action as discarded (so the state is never applied) and start the navigation action immediately.
actionQueue.pending.discarded = true
// The rest of the current queue should still execute after this navigation.
// (Note that it can't contain any earlier navigations, because we always put those into `actionQueue.pending` by calling `runAction`)
newAction.next = actionQueue.pending.next
// if the pending action was a server action, mark the queue as needing a refresh once events are processed
if (actionQueue.pending.payload.type === ACTION_SERVER_ACTION) {
actionQueue.needsRefresh = true
}
runAction({
actionQueue,
action: newAction,
setState,
})
위 코드처럼 새로운 네비게이션이 오면 이전 pending action을 discarded = true로 표시하고 즉시 새 네비게이션을 시작합니다.
이러한 근본적인 아키텍처 변화로 인해서 기존 Pages Router의 next/router는 App Router 기반에서 사용이 어려워졌고, 결국 새로운 next/navigation이라는 router API가 필요하게 되었습니다.
이건 두 번째 레슨: router.events 그만 쓰기.
next/navigation의 router에서 (next/router에서 존재하던) router.events를 제거하게 된 이유는 무엇이었을까요? 간단히 이야기하자면, Pages Router의 next/router는 page 단위의 동기적 모델에 어울리는 구조였지만, next/navigation의 세그먼트 단위 비동기 스트리밍 모델과는 맞지 않았기 때문입니다.
서버 스트리밍 환경에서 '이벤트'의 모호함
Pages Router에서 routeChangeStart는 "클라이언트가 다른 페이지로 넘어가기 직전"이라는 명확한 시점이 있었습니다.
하지만 App Router (RSC)에서는 이 시점이 매우 모호해졌습니다:
- routeChangeStart는... 사용자가 <Link>를 클릭한 순간일까요?
- 서버에 다음 페이지(RSC)를 요청한 순간일까요?
- 서버에서 응답이 오기 시작한 순간일까요?
- 클라이언트가 스트리밍 데이터를 받아 렌더링을 시작한 순간일까요?
이렇게 쉽사리 정의 내릴 수 없는, 서버 스트리밍과 RSC의 복잡성 때문에 간단한 '시작/종료' 이벤트를 정의하기 어렵게 되었습니다.
routeChangeStart 와 마찬가지로 완료 이벤트 routeChangeComplete의 시점 역시 명확하지 않습니다.
코드를 한 번 살펴볼까요? routeChangeComplete의 경우를 살펴보겠습니다.
잠시 Pages Router의 코드로 되돌아가 보겠습니다. 아래 코드를 보시면 Pages Router에서 routeChangeComplete는 페이지 전체가 준비되고 emit됩니다. 즉, Pages Router의 경우에는 routeChangeStart와 마찬가지로 명확한 완료 시점이 있습니다.
// next 12의 next/shared/lib/router/router.ts
private async change(
method: HistoryMethod,
url: string,
as: string,
options: TransitionOptions,
forcedScroll?: { x: number; y: number }
): Promise<boolean> {
// ...중략...
if (!isQueryUpdating) {
Router.events.emit('routeChangeComplete', as, routeProps)
}
대신, App Router의 경우는 어떨까요? 다음 코드를 살펴보도록 하겠습니다.
// next 15의 src/client/components/layout-router.tsx
/**
* Renders suspense boundary with the provided "loading" property as the fallback.
* If no loading property is provided it renders the children without a suspense boundary.
*/
function LoadingBoundary({
loading,
children,
}: {
loading: LoadingModuleData | Promise<LoadingModuleData>
children: React.ReactNode
}): JSX.Element {
// If loading is a promise, unwrap it. This happens in cases where we haven't
// yet received the loading data from the server — which includes whether or
// not this layout has a loading component at all.
//
// It's OK to suspend here instead of inside the fallback because this
// promise will resolve simultaneously with the data for the segment itself.
// So it will never suspend for longer than it would have if we didn't use
// a Suspense fallback at all.
let loadingModuleData
if (
typeof loading === 'object' &&
loading !== null &&
typeof (loading as any).then === 'function'
) {
const promiseForLoading = loading as Promise<LoadingModuleData>
loadingModuleData = use(promiseForLoading)
} else {
loadingModuleData = loading as LoadingModuleData
}
if (loadingModuleData) {
const loadingRsc = loadingModuleData[0]
const loadingStyles = loadingModuleData[1]
const loadingScripts = loadingModuleData[2]
return (
<Suspense
fallback={
<>
{loadingStyles}
{loadingScripts}
{loadingRsc}
</>
}
>
{children}
</Suspense>
)
}
return <>{children}</>
}
간단히 이야기하자면, 각 세그먼트가 독립적인 LoadingBoundary로 감싸져 있고, 세그먼트별로 독립적으로 로딩/완료가 비동기로 진행됩니다. 즉, 일부 세그먼트는 완료되었지만 일부는 로딩 중일 수 있습니다. 결국 "페이지 완료" 시점을 단일 이벤트로 정의하기 어렵게 되었습니다.
Concurrent Rendering과의 비호환성
단순 세그먼트별 비동기 스트리밍때문만은 아닙니다. concurrent rendering과도 연관이 있는데요, Next.js 팀 멤버인 icyJoseph의 설명에 따르면: "Concurrent rendering 때문에 router.events는 더 이상 의미가 없습니다. 네비게이션이 감지할 방법 없이 취소될 수 있기 때문입니다".라고 합니다.
React 18의 Concurrent Rendering에서는:
- 렌더링 프로세스가 일시 중지되고 나중에 재개되거나 완전히 포기될 수 있습니다.
- UI가 큰 렌더링 작업이 진행 중이더라도 사용자 입력에 즉시 응답할 수 있습니다.
- 여러 UI 업데이트를 동시에 준비할 수 있으며, React가 렌더링 우선순위를 효과적으로 관리합니다.
App Router의 네비게이션은 startTransition으로 래핑되어 있어, 우선순위가 낮은 업데이트로 처리됩니다. 사용자 입력이 있으면 네비게이션 렌더링이 중단될 수 있습니다.
코드를 살펴보면 다음과 같습니다.
// next 15의 src/client/components/app-router-instance.ts
} else if (
payload.type === ACTION_NAVIGATE ||
payload.type === ACTION_RESTORE
) {
// Navigations (including back/forward) take priority over any pending actions.
// Mark the pending action as discarded (so the state is never applied) and start the navigation action immediately.
actionQueue.pending.discarded = true
// ...중략...
}
새로운 네비게이션이 들어오면 이전 pending action이 discarded = true로 표시됩니다. 이는 네비게이션이 감지 없이 취소될 수 있음을 보여줍니다. 이벤트를 emit해도 해당 네비게이션이 실제로 완료되지 않을 수 있습니다.
그럼 router.events의 대안이 있는 것일까?
그럼에도 불구하고, 어떻게든 routeChangeStart 혹은 routeChangeComplete를 사용하듯이 해당 시점을 이벤트로 정의하고 싶을 수 있습니다. (특히 마이그레이션 시 관련 로직이 작성되어 있다면요)
nextjs의 github에서는 여러 discussion이 있었는데요, 커뮤니티에서는 아래와 같은 방법들을 이용하여 비슷하게라 기존의 routeChangeStart 혹은 routeChangeComplete를 대체할 수 있다고 합니다. 그러나 이 방법이 만능은 아니기 때문에 여전히 router.events가 필요하다고 요청하는 개발자들이 많습니다.
진행 중인 라우트 전환 표시 예시 코드
'use client';
import Link from 'next/link';
export function CustomLink({ href, children, onRouteStart, ...rest }) {
const handleClick = () => {
// ⚠️ 주의: 이는 클릭 시점을 감지하는 것이지,
// 실제 네비게이션이 시작되는 시점과는 다를 수 있습니다.
// Next.js의 Link는 내부적으로 startTransition을 사용하므로
// 네비게이션이 즉시 시작되지 않을 수 있습니다.
onRouteStart?.(href);
};
return (
<Link href={href} {...rest} onClick={handleClick}>
{children}
</Link>
);
}
// 사용 예시
function MyForm() {
const [isNavigating, setIsNavigating] = useState(false);
return (
<CustomLink
href="/dashboard"
onRouteStart={() => setIsNavigating(true)}
>
{isNavigating ? '이동 중...' : '대시보드로'}
</CustomLink>
);
}
이 방식은 router.events.on('routeChangeStart')를 부분적으로 대체합니다. 다만, 실제 네비게이션 시작 시점과는 차이가 있을 수 있습니다.
라우트 변경 감지 예시 코드
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
export function useRouteChangeComplete(callback: (url: string) => void) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const qs = searchParams.toString();
const url = qs ? `${pathname}?${qs}` : pathname;
// ⚠️ 주의: 이는 "URL이 변경되고 Context가 업데이트된 후"를 감지하는 것입니다.
// "모든 세그먼트 렌더링이 완료된 후"를 보장하지는 않습니다.
// 일부 세그먼트는 여전히 Suspense Boundary에서 로딩 중일 수 있습니다.
callback(url);
}, [pathname, searchParams, callback]);
}
// 사용 예시
function MyComponent() {
useRouteChangeComplete((url) => {
console.log("Route changed to:", url);
// 여기서 분석 도구에 페이지뷰를 전송하거나
// 다른 사이드 이펙트를 수행할 수 있습니다
});
return <div>...</div>;
}
이 방식은 router.events.on('routeChangeComplete')와 완전히 동일하지는 않지만, App Router에서 사용 가능한 최선의 대체 방식 중 하나입니다. 다만, 세그먼트별 비동기 렌더링 특성상 "완료"의 의미가 Pages Router와는 다르다는 점을 주의해야 합니다.
router.events 관련해서 논의되었던 내용들 중 일부 참고할만한 내용을 아래 공유해봅니다.
- Next.js GitHub Discussion #47020 - "Conditionally Block Navigation" 토론입니다. 수많은 개발자가 router.events를 돌려달라고 요청하지만, Next.js 팀은 서버 스트리밍과 RSC의 복잡성 때문에 간단한 '시작/종료' 이벤트를 정의하기 어렵다는 입장을 보입니다.
- Next.js GitHub Discussion #41934 - Router Events 대안에 대한 공식 논의
- Next.js GitHub Discussion #42016 - router.events 제거에 대한 설명
이건 마지막 레슨: UX를 바꾸거나 써드파티 라이브러리 쓰기.
사실 공식 문서에 "이탈 방지는 이렇게 하세요"라는 가이드는 없습니다. 이는 Next.js 팀이 이 문제를 (적어도 지금은) 라우터의 핵심 책임으로 보지 않는다는 것을 시사합니다. 오히려 Next.js는 '라우팅(페이지 이동)' 기능과 '앱의 상태 관리(데이터 변경 여부)'를 별개의 책임으로 분리했을 가능성이 높습니다. (GitHub Discussion #47020)
위 토론에서 Next.js 팀이 공식적인 API를 제공하는 대신, next-navigation-guard 같은 커뮤니티 라이브러리의 해결책을 언급하거나 유저들의 다양한 '워크어라운드(workaround)'가 논의되는 것 자체가, 이 문제가 '유저랜드(라이브러리 내부가 아닌 개발자가 직접 구현하는 영역)'에서 해결되어야 할 영역으로 간주되고 있음을 보여줍니다.
그리고 단순한 추측이지만, Next.js가 이상적으로 생각하는 UX는 Google Docs처럼 '자동 저장'이 되어 이탈 방지가 불필요하거나, '서버 액션(Server Actions)'을 통한 명시적인 데이터 제출일 수도 있습니다. (Next.js 공식 문서 - Server Actions & Forms)
Next.js는 useFormStatus 훅을 통해 폼 제출 시 pending 상태를 관리하는 등, '데이터 제출' 플로우에 집중하고 있습니다. 이는 '이탈' 시점을 가로채는 것보다 '제출' 시점의 UX를 더 중요하게 생각한다는 게 아닐까 싶습니다.
게다가 본문에서는 자세히 살펴보지 않았지만, Pages Router에서 사용 가능했던 router.beforePopState 역시 App Router에서 제거되었습니다. 이유는 역시 위에서 이야기한 맥락과 동일합니다. React Server Components와 호환되지 않고, Concurrent Rendering 모델과 충돌되는 등의 이슈 때문입니다. 이를 해결하기 위해 특별히 무언가를 하기 보다는, next 팀은 View Transitions API의 발전을 기대하고 있는 것 같습니다. (Next.js 공식 문서 - View Transitions)
결국, 돌고 돌아서.. App Router에서 브라우저 이탈 방지를 위한 로직을 작성하고 처리하려면 이 글의 1편에서 제시했던 것처럼, 커뮤니티에서 파생된 라이브러리 nextjs13-router-events, next-navigation-guard 등을 이용하는 것이 좋겠습니다.
마치며.
next-navigation-guard를 이용하여 페이지 이탈을 방지하기 위한 컨펌을 구현해야 하는 문제를 해결하면서 발생했던 의문점들을 모두 해소할 수 있었습니다.
정리하자면, App Router의 변화는 React 18의 Concurrent Features와 Server Components를 완전히 활용하기 위한 근본적인 아키텍처 재설계였고, 이로 인해 routing의 많은 부분이 바뀌어야 했습니다. router.events 제거는 이러한 새로운 패러다임에서 불가피한 선택이었으며, 브라우저 이탈 처리에 대해서는 웹 표준(beforeunload, popstate, View Transitions API)을 활용하거나 커뮤니티 솔루션을 사용하는 방향을 권장하고 있습니다.
이번 기회를 통해 대략적으로 next의 routing 관련 코드베이스를 살펴보면서 조금 더 App Router에 대해 잘 이해할 수 있는 기회가 되었습니다. App Router의 routing 관련해서 보다 더 정돈된 글을 써보고 싶다는 생각을 하며 글을 마무리해봅니다.
p.s. 혹시 잘못된 점이 있다면 부디 댓글로 피드백을 주시면 감사하겠습니다.
'About IT > 기술 및 언어' 카테고리의 다른 글
| AI가 내게 Named Export를 쓰라고 한 이유 (feat. Next.js) (1) | 2025.12.31 |
|---|---|
| Next.js AppRouter에서 페이지 이탈 컨펌 구현하기 (5) | 2025.10.31 |
| 단 한 줄의 코드로 툴팁 구현하기 (10) | 2025.09.29 |
| 커맨드패턴으로 만들어보는 undo/redo (8) | 2025.08.26 |
| 콘스트처럼? 콘스트 단언! as const (3) | 2025.03.16 |