본문 바로가기
About IT/기술 및 언어

Next.js AppRouter에서 페이지 이탈 컨펌 구현하기

by yjin_fe 2025. 10. 31.

들어가며.

Next.js가 App Router를 도입하면서 많은 개발자가 기존 Pages Router와 달라진 라우팅 방식에 적응하고 있습니다. 특히 '서버 컴포넌트(RSC)'와 'Concurrent Rendering'이라는 새로운 패러다임은 많은 것을 바꿔놓았습니다.

 

그 중 제가 마주했던 것은 변경된 useRouter 훅으로 인한 문제였는데요, 예전 Pages Router에서는 next/router의 router.events를 사용해 비교적 간단히 페이지 이탈 시 컨펌(Confirm) 모달을 표시하는 로직을 구현할 수 있었습니다. 하지만 App Router에서는 next/router가 아닌 next/navigation의 useRouter를 쓰도록 했고, 그 결과 router.events를 사용할 수 없는 상황이 되었습니다.

 

이번 글에서는 Next.js의 App Router 환경에서 '저장 전 이탈 확인'을 하는 컨펌 모달을 구현하면서 어떤 문제를 겪었고, 어떻게 해결했는지에 대해 이야기해보고자 합니다.

 

마주했던 문제는.

제가 해결해야 했던 문제, 즉 요구사항은 다음과 같았습니다.

  • 사용자가 데이터를 편집하던 중, 저장하지 않고 페이지를 이탈할 때 저장 유무를 묻는 컨펌 모달을 띄워야 한다.
  • 컨펌 모달에는 '저장 후 나가기', '저장하지 않고 나가기' 두 가지 선택지가 있어야 한다.
  • 새로고침, 페이지 닫기, 뒤로 가기 등 브라우저의 모든 이탈 행위를 '페이지 이탈'로 간주한다.

기존에 Pages Router 기반으로 비슷한 요구사항을 구현했던 경험이 있어서, 관련 코드를 참고하여 빠르게 기능 개발을 시작했는데요, useRouter 부분에서 문제가 발생했습니다. 바로 Next.js의 App Router 에서는 next/router가 제공하던 event 메서드가 next/navigation의 router에서는 사라졌다는 것입니다.

 

결국 아래와 같이 예전 Pages Router에서 사용하던 코드는 더 이상 사용할 수 없게 되었습니다.

const router = useRouter();
router.events.on('routeChangeStart', handleRouterChangeStart)

 

해결 가능한 방법들.

이 문제를 해결하기 위한 방법을 고민했을 때, 크게 4가지 정도를 생각해 볼 수 있었습니다.

 

1. Window의 이벤트 & history API를 직접 조작하기

가장 먼저 떠올렸던 방법입니다. window.beforeunload를 이용하면, 새로고침 및 페이지 나가기의 경우를 감지하여 처리할 수 있었습니다. 다만, 뒤로가기나 Next.js의 자체 라우팅의 경우에 문제가 생길 여지가 많았습니다.

 

Next.js 자체 라우팅에서 내부적으로 popstate 이벤트를 처리하는데, 개발자가 따로 만든 리스너와 비교해서 어떤 리스너가 먼저 실행될지 보장이 어려운 이슈가 있었습니다. (https://github.com/vercel/next.js/issues/56636) 또한, popstate 발생 시 브라우저 url과 usePathname이 반환하는 값이 일치하지 않을 수 있는 문제 등이 있었기에 이 방법을 사용하기는 어려웠습니다.

 

2. Next.js의 버전을 다운그레이드하여 Pages Router를 사용하기

의외로 나쁘지 않을 수 있는 방법이었지만, 이미 App Router 기반으로 프로젝트가 상당 부분 진행되어 있었고, 상당히 많은 코드 베이스를 수정해야 했으므로 선뜻 선택하기 어려운 방법이었습니다.

 

3. 기획 요구사항 변경하기

이탈 시 데이터가 휘발되도록 아무 처리도 하지 않는 방법 혹은 자동 저장 기능을 개발하여 처리하는 방법도 있을 수 있습니다. 다만, 아무 처리도 하지 않는 경우에는 사용자의 불편함을 크게 야기할 수 있으므로 자동 저장 기능을 고려할 수 있었는데, 개발 기간 등 리소스가 다소 부족했기에 역시 선택할 수 없는 방법이었습니다.

 

4. 외부 라이브러리 사용하기

외부 라이브러리 중 next-navigation-guard 라는 라이브러리를 발견했습니다. 편리한 인터페이스의 custom hook을 제공하고 있어, 간단한 코드 작성만으로 위의 요구사항을 해결할 수 있고, 별도의 큰 부작용이 없어 보였습니다.

 

 선택한 해결 방법은.

결국 제가 선택한 방법은 next-navigation-guard를 사용하는 것이었습니다. 라이브러리에서 제공하는 사용 방법을 차근차근 따라하면 되는데요, 핵심 로직은 useNavigationGuard 커스텀 훅을 사용하여 간단하게 구현할 수 있습니다.

 

아래는 간단한 사용 예시 코드입니다.

// 데이터에 변경이 발생한 경우, 저장 없이 이탈하려 한다면 컨펌 모달을 띄웁니다.
const shouldShowConfirmModal = () => {
  return isDataChanged(서버데이터, 클라이언트데이터);
};

const guard = useNavigationGuard({
  enabled: shouldShowConfirmModal,
});

// guard.active: 페이지 나가기, 새로고침, 뒤로가기 등 감지된 경우 true
// guard.accept: 페이지 이탈 허용
// guard.reject: 페이지 이탈 비허용 (즉, 현재 페이지에 머무르기)

const handleSaveAndExit = () => {
  // 저장 처리 (생략)
  // 저장 성공 시 accept
  guard.accept();
}

return (
  <Dialog.Root open={guard.active}>
    <button onClick={handleSaveAndExit}> 저장 후 나가기 </button>
    <button onClick={guard.reject}> 취소하기 </button>
    <button onClick={guard.accept}> 저장하지 않고 나가기 </button>
  </Dialog.Root>
)

 

 

next-navigation-guard는 어떻게 문제를 해결했을까.

그렇다면, 이 next-navigation-guard는 어떻게 router.events에 접근할 수 없는 문제를 해결할 수 있었을까요? 궁금증이 생겨 라이브러리의 내부 코드를 분석해보았습니다.

 

이 라이브러리는 두 가지 다른 타입의 이탈 시나리오를 상정하고, 각각 다른 방식으로 문제를 해결했습니다.

  • 시나리오 A타입: Next.js에서 제공하는 <Link> 클릭, router.push() 등 Next.js 자체적으로 처리하는 라우팅
  • 시나리오 B타입: '뒤로 가기', '새로고침', '탭 닫기' 등 브라우저가 직접 처리하는 라우팅

각 시나리오를 어떻게 처리하는지 살펴보겠습니다.

 

시나리오 A타입: Next.js 자체 라우팅 

<Link> 컴포넌트나 router.push()는 Next.js가 제어하는 영역입니다. App Router가 router.events 같은 이벤트 훅(hook)을 제공하지 않는다는 것은, 라우팅이 시작되기 전의 상황을 감지하여 처리하는 방법이 없다는 것입니다. next-navigation-guard는 이 문제를 React Context API를 활용하여 해결했습니다.

 

1. Provider의 덮어쓰기

우리가 app/layout.tsx에 감싼 <NavigationGuardProvider>는 Next.js의 공식 라우터(AppRouterContext)를 가짜 라우터로 덮어씌웁니다.

// InterceptAppRouterProvider.tsx
  
  const interceptedRouter = useInterceptedAppRouter({ guardMapRef });
  return (
    <AppRouterContext.Provider value={interceptedRouter}>
      {children}
    </AppRouterContext.Provider>
  );

 

2. 라우터 가로채기

이제부터는 앱에서 router.push()를 호출하면, Next.js의 진짜 push가 아니라 NavigationGuardProvider가 주입한 가짜 push 함수가 먼저 실행됩니다.

// useInterceptedAppRouter.ts

return {
  ...origRouter,
  push: (href, ...args) => {
    guarded("push", href, () => origRouter.push(href, ...args));
  },

 

3. 제어권 확보

이 가짜 push 함수는 '현재 useNavigationGuard를 사용중이며 enabled 값이 true(활성화)가 된 곳이 있는지' 먼저 확인합니다.

// useInterceptedAppRouter.ts

const defs = [...guardMapRef.current.values()];
for (const { enabled, callback } of defs) {
	if (!enabled({ to, type })) continue;
    // ... 생략
}

 

4. 모달 표시 및 결정

만약 enabled: true라면, Next.js의 진짜 라우팅(origRouter.push(...)) 실행을 일단 멈추고, 훅의 상태를 guard.active = true로 변경합니다. 이때 우리가 만든 커스텀 모달이 뜹니다. 사용자가 모달에서 '저장'이나 '저장 안 함' 등을 눌러 guard.accept()를 호출하면, 그때서야 라이브러리가 보류했던 Next.js의 진짜 라우팅을 실행시켜 페이지를 이탈시킵니다.

// useInterceptedAppRouter.ts

// ... 중략
    const confirm = await callback({ to, type });
    if (!confirm) {
      debug(`Navigation blocked`);
      return;
    }
  }
  accepted();

 

시나리오 B: 브라우저 자체 라우팅

브라우저 자체 라우팅은 다루기가 조금 더 까다로운데요, beforeunload를 이용해 감지할 수 있는 새로고침과 페이지 나가기 (탭 닫기)와 뒤로가기 이벤트를 다르게 처리해야 하기 때문입니다.

 

['새로고침 혹은 탭 닫기'의 경우]

페이지 새로고침이나 탭을 닫고 페이지를 나가는 경우는 비교적 처리가 간단합니다. 라이브러리는 enabled: true 상태가 되면 window.addEventListener('beforeunload', ...) 리스너를 등록하여 해결합니다.

// useInterceptPageUnload.ts의 handleBeforeUnload 함수 내부 일부분

const enabled = def.enabled({ to: "", type: "beforeunload" });
if (enabled) {
  event.preventDefault();
  event.returnValue = "";
  return;
}

 

beforeunload 이벤트는 보안상 커스텀 모달을 띄우는 것이 불가능하다는 걸 아실 겁니다. 대략적으로 보안상의 이유로 안 되는 것으로 알고 있는데요, 좀 더 정확한 이유를 파악해보면 재밌을 것 같지만 이번 글의 주제에서 벗어나기 때문에 나중에 따로 알아보도록 하겠습니다.

 

['뒤로 가기'의 경우]

 '뒤로 가기'로 발생하는 popstate 이벤트는 beforeunload와 달리 처리가 까다롭습니다.

  • event.preventDefault()로 막을 수 없습니다.
  • 이벤트가 발생했을 땐 이미 URL이 바뀐 상태입니다.

그래서 next-navigation-guard는 다음과 같은 방식으로 위 문제를 해결합니다.

 

1. 히스토리 스택 추적

라이브러리는 시작 시 window.history.pushState와 replaceState를 래핑합니다. 이렇게 하면 페이지 이동이 발생할 때마다 각 히스토리 엔트리에 스택 인덱스와 세션 토큰을 저장할 수 있습니다. 뒤로 가기로 이동하려는 목적지의 인덱스와 현재 인덱스를 비교하면 "몇 칸 이동하려는지"를 알 수 있게 됩니다.

// historyAugmentation.tsx
  window.history.pushState = function (state, unused, url) {
    // If current state is not managed by this library, reset the state.
    if (!renderedStateRef.current.token) {
      renderedStateRef.current.token = newToken();
      renderedStateRef.current.index = -1;
    }

    ++renderedStateRef.current.index;

    const modifiedState = {
      ...state,
      __next_navigation_guard_token: renderedStateRef.current.token,
      __next_navigation_guard_stack_index: renderedStateRef.current.index,
    };
    originalPushState.call(this, modifiedState, unused, url);
  };

 

2. Popstate 이벤트 선제 가로채기

App Router에서는 window.addEventListener('popstate')로 이벤트를 직접 감지합니다. 중요한 점은 useIsomorphicLayoutEffect를 사용해 Next.js 라우터가 useEffect에서 이벤트를 처리하기 전에 먼저 가로채는 것입니다. 이렇게 하면 event.stopImmediatePropagation()으로 원래 이벤트를 중단시킬 수 있습니다.

// useInterceptPopState.ts
  useIsomorphicLayoutEffect(() => {
    const { writeState } = setupHistoryAugmentationOnce({ renderedStateRef });
    const handlePopState = createHandlePopState(guardMapRef, writeState);

    if (pagesRouter) {
      pagesRouter.beforePopState(() => handlePopState(history.state));
      // ...
    } else {
      const onPopState = (event: PopStateEvent) => {
        if (!handlePopState(event.state)) {
          event.stopImmediatePropagation();
        }
      };

      window.addEventListener("popstate", onPopState);

 

3. 가드 콜백 실행 및 결정

가로채기에 성공하면 등록된 모든 가드 콜백을 순차적으로 실행합니다. 사용자가 모달에서 '저장'을 선택하면 true, '취소'를 선택하면 false가 반환됩니다.

// useInterceptPopState.ts
  for (const def of defs) {
    i++;
    
    if (!def.enabled({ to, type: "popstate" })) continue;

    const confirm = await def.callback({ to, type: "popstate" });
    if (!confirm) {
      if (delta !== 0) {
        // discard event
        window.history.go(-delta);
      }
      return;
    }
  }

 

4. 승인 시: 이벤트 수동 재발생

모든 가드가 승인되면, 라이브러리가 window.dispatchEvent(new PopStateEvent('popstate', { state: nextState }))를 호출하여 popstate 이벤트를 수동으로 다시 발생시킵니다. 이제 Next.js 라우터가 정상적으로 네비게이션을 처리할 수 있게 됩니다.

// useInterceptPopState.ts

  // accept
  dispatchedState = nextState;
  window.dispatchEvent(new PopStateEvent("popstate", { state: nextState }));

 

5. 거부 시: 원래 위치로 강제 복귀

가드가 거부되면 window.history.go(-delta)를 호출합니다. 예를 들어 인덱스 2에서 0으로 이동하려다가(delta = -2) 거부되면, history.go(2)를 실행해 다시 인덱스 2로 되돌립니다.

// useInterceptPopState.ts
  if (delta !== 0) {
    // discard event
    window.history.go(-delta);
  }

 

즉, 핵심은 "일단 이벤트를 막고, 승인되면 다시 발생시키고, 거부되면 강제로 되돌리는" 것입니다. 위에서 말씀드렸듯, preventDefault로는 막을 수 없었기에 다소 복잡한 방식으로 우회를 했다고 생각합니다.

 

마치며.

이 글을 통해 Next.js App Router에서 이탈 방지 컨펌 모달을 구현하는 방법과, next-navigation-guard 라이브러리가 어떤 방식으로 이 문제를 해결하는지 알아보았습니다. 만약 실무에서 저와 비슷한 요구사항을 마주한다면, next-navigation-guard를 사용해보는 것은 어떠실까요?

 

글은 여기서 마무리하지만, 계속해서 근본적인 의문이 머릿속을 멤돌았습니다.

  • Next.js는 왜 App Router로 전환하면서 next/router 대신 next/navigation을 사용하도록 강요하게 되었을까요?
  • Next.js는 왜 next/navigation의 router에서 router.events (이벤트 함수)를 제거했을까요?
  • Next.js 팀이 권장하는 브라우저 이탈(새로고침, 페이지 나가기, 뒤로가기)에 대한 공식적인 해결 방법은 무엇일까요?

다음 글에서는 위 의문들을 해소하기 위해 Next.js의 아키텍처 변화 (Pages Router에서 App Router로의 변화), 설계 철학 등을 깊이 있게 탐구해 볼 예정입니다.