들어가며

사용자가 폼에 정보를 입력하던 중 페이지를 이탈하려 할 때 어떻게 페이지 이탈을 막고 원하는 내용을 사용자에게 보여줄 수 있을까요?
페이지 이탈 방지는 프레임워크의 라우터 상태, 브라우저의 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의
- 뒤로가기/앞으로가기 페이지 이탈
- 브라우저의 뒤로가기/앞으로가기 버튼 클릭, Next.js의
router.back()/router.forward()호출로 발생하는 페이지 이탈입니다. - 개발자가 브라우저 History 상태, 라우터 상태를 조작하여 페이지 이탈을 방지할 수 있습니다.
- 브라우저의 뒤로가기/앞으로가기 버튼 클릭, Next.js의
결과적으로 개발자가 방지할 수 있는 페이지 이탈은 1. 인앱 링크 페이지 이탈, 2. 뒤로가기/앞으로가기 페이지 이탈인데요.
next-navigation-guard 라이브러리는 두가지 유형의 페이지 이탈을 어떻게 방지하였는지 알아보겠습니다.
인앱 링크 페이지 이탈
Next.js의 <Link> 컴포넌트를 클릭하면 어떤 일이 발생할까요?

router.push()호출- Next.js 라우터 상태 업데이트
- 업데이트된 라우터 상태로 페이지 랜더링
- 브라우저 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-guard 의 AppRouterContext를 참조하도록 합니다.
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-guard의 AppRouterContext는 router.push() 메서드를 오버라이딩 하여 사용자의 페이지 이탈을 방지하고 개발자가 사전 정의한 동작을 실행할 수 있도록 합니다.
return {
...origRouter,
push: (href, ...args) => {
debug(`push called with href: ${href}`);
guarded("push", href, () => origRouter.push(href, ...args));
},
},뒤로가기/앞으로가기 페이지 이탈
이번에는 브라우저의 뒤로가기/앞으로가기 버튼을 클릭하면 어떤 일이 발생하는지 알아보겠습니다.

- 브라우저 History 스택의 index와 URL 변경
- popstate 이벤트 발생
- Next.js 라우터 상태 업데이트
- 업데이트된 라우터 상태로 페이지 랜더링
위 실행 흐름에서 브라우저의 기본 동작인 History 스택 포인터와 URL 변경은 막을 수 없고, popstate 이벤트 전파를 방지한다 하더라도 이미 History 스택 포인터와 URL은 변경된 상태입니다.
next-navigation-guard의 뒤로가기/앞으로가기 페이지 이탈
그렇다면 next-navigation-guard는 이 문제를 어떻게 해결했을까요?

next-navigation-guard는 popstate 이벤트를 가로채 이벤트 핸들러 내부에서 개발자가 사전 정의한 동작을 실행하고 사용자에게 페이지 이탈을 확인 받을 수 있습니다.
이때, 사용자가 페이지 이탈을 거부하면 history.go(-delta)를 호출하여 URL과 History 스택 포인터를 되돌리고 사용자가 페이지 이탈을 수락하면 가로챘던 popstate 이벤트를 다시 발행해 Next.js 라우터 상태 변경을 일으킵니다.
popstate 이벤트 전파 방지하기
next-navigation-guard는 stopImmediatePropagation()를 활용해 브라우저에서 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-guard는 useEffect() 훅보다 먼저 실행되는 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-guard는 history.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의 대두로 프레임워크와 라우터의 결합이 더 강해지는 방향 속에서 언제까지 이러한 방법이 동작할 수 있을지는 의문입니다.
한편으로는 사용자의 페이지 이탈을 방지하는 것은 공급자 관점만을 고려한 사용자 경험이라는 생각이 드는데요. 노션에서 페이지 이탈 방지 대신 자동저장 기능을 제공하는 것이 이런 맥락 아닐까요?