들어가며

confirm-modal

사용자가 폼에 정보를 입력하던 중 페이지를 이탈하려 할 때 어떻게 페이지 이탈을 막고 원하는 내용을 사용자에게 보여줄 수 있을까요?

페이지 이탈 방지는 프레임워크의 라우터 상태, 브라우저의 History 상태, 이벤트 전파까지 엮여있어 구현하기 매우 까다로운 기능인데요.

이번 글에서는 이렇게 까다로운 페이지 이탈 방지 기능을 Next.js 프레임워크 위에서 구현한 https://github.com/LayerXcom/next-navigation-guard 라이브러리를 소개하고 내부 동작 원리를 살펴보고자 합니다.

페이지 이탈 유형

next-navigation-guard 라이브러리를 살펴보기에 앞서서 사용자의 페이지 이탈 유형을 분석하고 개발자가 방지할 수 있는 페이지 이탈 유형을 알아보겠습니다.

  • beforeunload 이벤트를 발생시키는 페이지 이탈
    • 브라우저의 새로고침 버튼이나 외부 링크를 클릭하여 beforeunload 이벤트를 발생시키는 페이지 이탈입니다.
    • 이벤트 핸들러에서 event.preventDefault()를 호출하여 브라우저 기본 Dialog를 보여주는 것 외에 개발자가 개입할 수 있는 부분은 없습니다.
  • 인앱 링크 페이지 이탈
    • Next.js의 Link 컴포넌트 클릭, router.push(), router.replace() 호출 등으로 발생하는 페이지 이탈입니다.
    • 개발자가 브라우저 History 상태, 라우터 상태를 조작하여 페이지 이탈을 방지할 수 있습니다.
  • 뒤로가기/앞으로가기 페이지 이탈
    • 브라우저의 뒤로가기/앞으로가기 버튼 클릭, Next.js의 router.back()/router.forward() 호출로 발생하는 페이지 이탈입니다.
    • 개발자가 브라우저 History 상태, 라우터 상태를 조작하여 페이지 이탈을 방지할 수 있습니다.

결과적으로 개발자가 방지할 수 있는 페이지 이탈은 1. 인앱 링크 페이지 이탈, 2. 뒤로가기/앞으로가기 페이지 이탈인데요.

next-navigation-guard 라이브러리는 두가지 유형의 페이지 이탈을 어떻게 방지하였는지 알아보겠습니다.

인앱 링크 페이지 이탈

Next.js의 <Link> 컴포넌트를 클릭하면 어떤 일이 발생할까요?

flow-chart-1

  1. router.push() 호출
  2. Next.js 라우터 상태 업데이트
  3. 업데이트된 라우터 상태로 페이지 랜더링
  4. 브라우저 History 스택 업데이트

위 순서대로 router.push()를 호출하면 라우터 상태, 브라우저 History 상태 업데이트가 차례대로 발생합니다.

따라서 router.push() 메서드 호출을 오버라이딩 하여 이후 동작을 방지하면 라우터 상태, 브라우저 History 상태 업데이트까지 방지할 수 있을 것 같습니다.

next-navigation-guard의 인앱 링크 페이지 이탈 방지 구현

Next.js는 라우터 인스턴스를 AppRouterContext 로 관리하는데 next-navigation-guard는 React의 Context는 중첩된 Provider가 있을 때, 가장 가까운 Provider의 값을 사용한다는 것을 활용해 앱이 Next.js의 AppRouterContext를 참조하지 않고 next-navigation-guardAppRouterContext를 참조하도록 합니다.

import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime';
 
const interceptedRouter = useInterceptedAppRouter({ guardMapRef });
 
return (
  <AppRouterContext.Provider value={interceptedRouter}>
    {children}
  </AppRouterContext.Provider>
);

next-navigation-guardAppRouterContextrouter.push() 메서드를 오버라이딩 하여 사용자의 페이지 이탈을 방지하고 개발자가 사전 정의한 동작을 실행할 수 있도록 합니다.

return {
	...origRouter,
	push: (href, ...args) => {
	  debug(`push called with href: ${href}`);
	  guarded("push", href, () => origRouter.push(href, ...args));
  },
},

뒤로가기/앞으로가기 페이지 이탈

이번에는 브라우저의 뒤로가기/앞으로가기 버튼을 클릭하면 어떤 일이 발생하는지 알아보겠습니다.

flow-chart-2

  1. 브라우저 History 스택의 index와 URL 변경
  2. popstate 이벤트 발생
  3. Next.js 라우터 상태 업데이트
  4. 업데이트된 라우터 상태로 페이지 랜더링

위 실행 흐름에서 브라우저의 기본 동작인 History 스택 포인터와 URL 변경은 막을 수 없고, popstate 이벤트 전파를 방지한다 하더라도 이미 History 스택 포인터와 URL은 변경된 상태입니다.

next-navigation-guard의 뒤로가기/앞으로가기 페이지 이탈

그렇다면 next-navigation-guard는 이 문제를 어떻게 해결했을까요?

flow-chart-3

next-navigation-guard는 popstate 이벤트를 가로채 이벤트 핸들러 내부에서 개발자가 사전 정의한 동작을 실행하고 사용자에게 페이지 이탈을 확인 받을 수 있습니다.

이때, 사용자가 페이지 이탈을 거부하면 history.go(-delta)를 호출하여 URL과 History 스택 포인터를 되돌리고 사용자가 페이지 이탈을 수락하면 가로챘던 popstate 이벤트를 다시 발행해 Next.js 라우터 상태 변경을 일으킵니다.

popstate 이벤트 전파 방지하기

next-navigation-guardstopImmediatePropagation()를 활용해 브라우저에서 popstate 이벤트가 발생했을 때 이벤트가 Next.js 라우터에게 전파되는 것을 방지합니다.

window.addEventLisnter("popstate", () => ...)
window.addEventLisnter("popstate", e => e.stopImmediatePropagation())
window.addEventLisnter("popstate", () => ...) // Next.js의 이벤트 핸들러는 호출되지 않음

stopImmediatePropagation()은 호출 이후 요소에 남아있는 이벤트 핸들러의 호출을 방지하기 때문에 Next.js의 popstate 이벤트 핸들러가 호출되기 이전에 stopImmediatePropagation()을 호출한다면 Next.js 라우터 상태 변경을 방지할 수 있습니다.

Next.js는 popstate 이벤트 핸들러를 useEffect() 훅에서 등록하기 때문에 next-navigation-guarduseEffect() 훅보다 먼저 실행되는 useLayoutEffect() 훅(브라우저 페인트 이전에 실행)에서 popstate 이벤트 핸들러를 등록하여 Next.js의 popstate 이벤트 핸들러보다 우선적으로 실행됨을 보장합니다.

useEffect(() => {
  const originalPushState = window.history.pushState.bind(window.history);
  const originalReplaceState = window.history.replaceState.bind(window.history);
 
  // ...
  window.addEventListener('popstate', onPopState);
  return () => {
    window.history.pushState = originalPushState;
    window.history.replaceState = originalReplaceState;
    window.removeEventListener('popstate', onPopState);
  };
}, []);

History 스택 포인터 되돌리기

브라우저 History API에는 History 스택 엔트리의 인덱스를 나타내는 프로퍼티가 없는데 어떻게 History 스택 포인터를 되돌릴 수 있을까요?

next-navigation-guardhistory.pushState() 메서드를 오버라이딩 하여 History 스택의 각 엔트리의 인덱스 정보를 저장합니다.

window.history.pushState = function (state, unused, url) {
	state = { …state, index: ++currentIndex }
	origPushState.call(this, state, unused, url)
}

이동하려는 페이지의 popstate 이벤트에 담긴 History 인덱스와 현재 렌더링된 페이지의 History 인덱스의 차이(delta)를 계산 후 history.go(-delta)를 호출해 History 스택 포인터를 되돌립니다.

참고: popstate 이벤트의 state 객체는 이동하려는 페이지의 state 객체입니다. (즉, 네비게이션이 발생하여 활성화 되는 중인 히스토리 엔트리)

// nextIndex - 이동하려는 페이지의 History 인덱스
// renderedStateRef.current.index - 현재 랜더링된 페이지의 History 인덱스
const delta = nextIndex - renderedStateRef.current.index;
 
if (delta !== 0) {
  // discard event
  window.history.go(-delta);
}

마치며

next-navigation-guard 라이브러리가 React의 Provider 참조 방식, stopImmediatePropagation(), history.pushState() 메서드 오버라이딩 등의 방법을 활용해 Next.js의 페이지 이탈 방지 기능을 구현한 것을 알 수 있었습니다.

next-navigation-guard 라이브러리는 여러 Hack을 사용해 브라우저 History 스택과 Next.js 라우터의 결합을 끊어냈지만 SSR의 대두로 프레임워크와 라우터의 결합이 더 강해지는 방향 속에서 언제까지 이러한 방법이 동작할 수 있을지는 의문입니다.

한편으로는 사용자의 페이지 이탈을 방지하는 것은 공급자 관점만을 고려한 사용자 경험이라는 생각이 드는데요. 노션에서 페이지 이탈 방지 대신 자동저장 기능을 제공하는 것이 이런 맥락 아닐까요?