Next.js 페이지 이탈 방지하기 (feat. next-navigation-guard)

iskkiri2024년 11월 24일
Next.js
페이지 이탈 방지
Prevent Navigation
next-navigation-guard
Context Overiding
stopImmediatePropagation
Next.js 페이지 이탈 방지하기 (feat. next-navigation-guard)

웹 애플리케이션에서 사용자 경험을 향상시키는 중요한 요소 중 하나는 작성 중인 내용 손실을 방지하는 것입니다. 특히, 사용자가 게시글 작성이나 폼 입력 중 실수로 페이지를 이탈하는 상황을 예방하는 기능은 필수적입니다.

 

그러나 Next.js에서 페이지 이탈 방지를 이슈없이 구현하는 것은 쉽지 않습니다. pages router에서는 router.events를 활용해 비교적 간단히 구현할 수 있었으나, app router에서는 router.events가 제거되면서 구현 난이도가 증가했습니다. 다음은 페이지 이탈이 발생할 수 있는 주요 상황과 이를 구현하는 과정에서 마주하게 되는 기술적 어려움에 대해 설명합니다.

 

 

페이지 이탈이 발생하는 주요 상황

 

페이지 이탈은 크게 다음 세 가지 상황으로 나눌 수 있습니다.

 

1. Non-SPA 전환

  • 페이지 새로고침
  • 외부 사이트 링크로의 이동
  • 브라우저 창 닫기

 

2. SPA 내부 이동

  • <Link /> 컴포넌트를 통한 페이지 이동
  • router.push() 메서드를 사용한 이동
  • router.replace() 메서드를 사용한 페이지 교체

 

3. 브라우저 히스토리 조작

  • 브라우저의 뒤로가기/앞으로가기 버튼 사용
  • router.back() 메서드를 통한 이전 페이지 이동
  • router.forward() 메서드를 통한 다음 페이지 이동

 

구현의 기술적 제약사항

 

Next.js는 pages routerapp router라는 두 가지 라우팅 시스템을 제공하지만, 이 두 시스템에서 페이지 이탈 방지 기능을 구현할 때는 다음과 같은 기술적 제약이 존재합니다.

 

1. 라우팅 시스템의 차이

  • pages routerapp router는 서로 다른 동작 방식과 라이프사이클을 가집니다.

  • 각 라우터에서 제공하는 이벤트 핸들링 방식의 차이로 인해 공통적인 구현이 어렵습니다.

 

2. History API의 한계

  • popstate 이벤트는 히스토리 변경 후에 발생하기 때문에 이탈 방지 사전 차단이 어렵습니다.

 

3. SPA 라우팅 처리의 복잡성

  • Next.js의 라우터는 내부적으로 History API를 사용합니다. 이로 인해 라우터 이벤트와 History API 이벤트가 중복되거나 충돌할 가능성이 있습니다.

  • 이벤트 처리의 시점 차이로 인해 예상치 못한 동작이 발생할 수 있습니다.

 

이러한 기술적 제약을 극복하고 안정적인 페이지 이탈 방지 기능을 구현하기 위해서는 정교한 설계가 필수적입니다. 각 상황별 특성을 고려한 맞춤형 접근 방식이 필요하며, Next.js의 라우팅 시스템에 대한 이해가 요구됩니다.

 

해결 방안: next-navigation-guard

 

Next.js 레퍼지토리의 Discussion에서 발견한 라이브러리인 next-navigation-guard는 Next.js의 복잡한 라우팅 시스템을 해결하기 위해 설계된 라이브러리입니다. 주요 특징은 다음과 같습니다.

 

  • pages routerapp router 모두 지원

  • 커스텀 모달(다이얼로그) 구현 가능

  • History API와 Next.js 라우터 이벤트 통합 처리

 

만약, 위에서 언급한 기술적 문제의 세부적인 해결 방식에 관심이 없다면, 아래 내용을 읽지 않아도 됩니다. 단순히 페이지 이탈 방지 기능을 구현하는 것이 목적이라면, 라이브러리를 설치하고 간단히 사용하는 것만으로 충분합니다.

 

이후 내용에서는 next-navigation-guardNext.js Router의 동작을 어떻게 변경하고, History API를 활용해 페이지의 인덱스(위치)를 어떻게 관리하는지에 대한 구체적인 구현 방식을 다룹니다. 

 

 

1. SPA 내부 이동 문제 해결

 

Next.js는 SPA 내부 이동 시 내부 상태를 먼저 업데이트한 후 History를 변경합니다. 이를 해결하기 위해 next-navigation-guardReact Context API를 활용하여 라우터를 가로채고 기존 라우터의 메서드를 확장합니다.

 

Context 오버라이딩을 통한 라우터 가로채기

 

// InterceptAppRouterProvider의 핵심 부분
return (
  <AppRouterContext.Provider value={interceptedRouter}>
    {children}
  </AppRouterContext.Provider>
);

 

이 방식이 동작하는 원리는 다음과 같습니다.

 

  1. React Context는 항상 가장 가까운 상위 Provider의 값을 사용합니다.
  2. Next.js의 기본 라우터를 가로채고, 커스텀 라우터(InterceptAppRouterProvider)로 교체합니다.
  3. 결과적으로 애플리케이션의 모든 하위 컴포넌트들은 가로챈 라우터를 사용하게 됩니다.

 

라우터 동작 확장하기

 

useInterceptedAppRouter 훅에서 원본 라우터의 메서드를 확장하여 가드 로직을 추가합니다.

 

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

 

이처럼 Next.js의 코어 로직을 수정하지 않고, 라우팅 동작을 제어할 수 있습니다.

 

2. beforePopstate 이벤트 충돌 해결

 

Next.js 라우터는 내부적으로 popstate 이벤트를 사용하여 라우팅을 제어합니다. 이로 인해 다음과 같은 문제가 발생할 수 있습니다.

 

  1. Next.js 라우터 이벤트와 커스텀 이벤트 핸들러 간의 충돌

  2. 이벤트 처리 순서로 인한 예기치 않은 동작

 

next-navigation-guard는 이 문제를 두 가지 방식으로 해결합니다.

 

1. Pages Router 환경

 

// Pages Router 사용 시의 처리
if (pagesRouter) {
  pagesRouter.beforePopState(() => handlePopState(history.state));
  
  return () => {
    pagesRouter.beforePopState(() => true);
  };
}

 

Pages Router 환경에서는 Next.js가 제공하는 beforePopState API를 직접 활용하여 가드 로직을 통합합니다.

 

2. App Router 환경

 

// App Router 사용 시의 처리
useIsomorphicLayoutEffect(() => {
  const onPopState = (event: PopStateEvent) => {
    if (!handlePopState(event.state)) {
      event.stopImmediatePropagation();
    }
  };

  window.addEventListener("popstate", onPopState);
  return () => window.removeEventListener("popstate", onPopState);
}, []);

 

 

여기서 중요한 점은 useEffect 대신 useIsomorphicLayoutEffect를 사용했다는 것입니다. 이는 useLayoutEffectuseEffect보다 먼저 실행된다는 특징을 활용한 것입니다.

 

Next.js는 내부적으로 popstate 이벤트를 처리할 때 useEffect를 사용합니다. 따라서, useLayoutEffect를 통해 Next.js의 popstate 이벤트 핸들러보다 먼저 커스텀 이벤트 핸들러를 등록할 수 있으며, 이를 통해 stopImmediatePropagation을 사용해 이벤트 전파를 차단할 수 있도록 설계된 것입니다.

 

결과적으로, Next.js의 내부 라우터에 등록된 popstate 핸들러가 실행되지 않도록 제어할 수 있습니다.

 

3. History Stack 변경 복구 문제 해결

 

브라우저의 뒤로 가기/앞으로 가기 동작 시 다음과 같은 문제가 발생합니다.

 

  1. beforePopstate 이벤트가 실행되기 전에 이미 History Stack이 변경됩니다.

  2. 가드에 의해 네비게이션이 취소되더라도 History Stack은 이미 변경된 상태입니다.
  3. 사용자 위치와 History Stack의 상태가 불일치하는 현상이 발생합니다.

 

next-navigation-guard는 History API를 확장하여 History Stack의 상태를 추적하고 관리합니다.

 

// History API 메서드 오버라이딩
window.history.pushState = function (state, unused, url) {
  // History Stack의 현재 위치 추적
  ++renderedStateRef.current.index;
  
  // 상태에 현재 Stack 위치 정보 추가
  const modifiedState = {
    ...state,
    __next_navigation_guard_stack_index: renderedStateRef.current.index,
  };
  
  originalPushState.call(this, modifiedState, unused, url);
};

 

이처럼 History API의 메서드를 오버라이딩하면 History의 Stack 내 위치를 추적할 수 있습니다.

 

4. Popstate 이벤트 핸들링

 

브라우저의 뒤로가기/앞으로가기 동작 시 발생하는 popstate 이벤트 처리는 다음과 같은 순서로 이루어집니다.

 

return (nextState: any): boolean => {
  // 현재 위치와 목표 위치의 차이 계산
  const delta = nextIndex - renderedStateRef.current.index;

  // 가드 처리 로직
  (async () => {
    for (const def of defs) {
      const confirm = await def.callback({ to, type: "popstate" });

      // 1. 페이지 이동 거부 시: 원래 위치로 복구
      if (!confirm) {
        if (delta !== 0) {
          window.history.go(-delta);
        }
        return;
      }
    }

    // 2. 가드 승인 시: 상태 업데이트 후 이벤트 재발생
    dispatchedState = nextState;
    window.dispatchEvent(new PopStateEvent("popstate", { state: nextState }));
  })();

  // 3. 현재 이벤트는 중단
  return false;
};

 

이 구현의 핵심은 세 가지입니다.

 

  1. 페이지 이동 취소 시 History 복구
  • history.go(-delta)를 통해 원래 위치로 History를 복구합니다.
  • 사용자가 의도한 네비게이션이 취소되고 원래 페이지 유지합니다.

 

  1. Next.js Router 이벤트 차단
  • return false를 통해 stopImmediatePropagation() 을 호출하여 Next.js Router의 popstate 핸들러가 실행하지 않습니다.

     

  1. 페이지 이동 허용 시 네비게이션 재개
  • dispatchedState = nextState로 승인된 상태 기록합니다.
  • dispatchEvent로 popstate 이벤트를 수동 발생시켜 실제 네비게이션 수행합니다.
  • 재발생된 이벤트는 dispatchedState === nextState 조건에 의해 가드 검사 없이 통과합니다. 이로 인해 stopImmediatePropagation()이 호출되지 않으므로 Next.js Router의 popstate 이벤트로 이벤트가 전파되고, 원래 라우터의 popstate 이벤트 핸들러가 실행됩니다.

     

결론

 

next-navigation-guard는 Next.js의 복잡한 라우팅 시스템에서 발생할 수 있는 기술적 문제를 해결하며, 다음과 같은 이점을 제공합니다.

 

  1. Context API를 활용한 라우터 가로채기로 SPA 내부 이동 문제 해결
  2. 환경별 차별화된 전략으로 beforePopstate 이벤트 충돌 해결
  3. History API 확장을 통한 History Stack 상태 관리

 

이를 통해 pages routerapp router 모두에서 일관된 사용자 경험을 제공하며, 안정적인 페이지 이탈 방지 기능을 구현할 수 있습니다.

Next.js 페이지 이탈 방지하기 (feat. next-navigation-guard)