Next.js App Router 페이지 이동 막기

2024.02.19

Table of Contents

프로젝트에 에디터를 적용하고 사용자가 페이지를 이동할 때 작성한 내용이 날아가면 허무함을 느낄 것 같아 페이지 이동 전에 confirm창을 띄워주고 싶었다. isDirty(boolean) 값을 활용해서 조건에 따라 바로 이동시키거나 확인을 거치는 기능을 구현했다.
검색 결과 Page Router를 활용할 때에는 useRouter(next/router)를 이용해 routeChangeStart와 같은 이벤트들을 사용할 수 있었으나 13버전 이후의 App Router를 활용할 때에는 같은 useRouter이지만 (Page Router도 아직 활용할 수 있기 때문에) next/navigation을 활용해야 한다. 그렇지 않으면 Uncaught Error: NextRouter was not mounted.와 검색해도 불친절한 설명을 보게 된다.
이를 해결한 과정과 무섭게 들리는 “막기” 보다는 이동 전에 확인 받기 기능을 구현하며 작성한 내용에 대해 다뤄보고자 한다.
notion image
 

어떤 동작을 감지해야 할까?

이미 해당 기능(이동 전 확인)이 존재하기 때문에 직접 구현할 수 있을 것이라고 생각했다. 구현 과정에서 크게 3가지 동작으로 분리할 수 있고 대비가 필요하다는 점을 확인했다.
  1. 닫기, 새로고침, a 태그 이동 (페이지 외로 이동)
  1. router.push(), Link 태그 (페이지 내로 이동)
  1. 뒤로 가기 버튼
필요한 예방 동작에 따라 작성해야 하는 함수를 확인하면 좋을 것 같다. 추후 Next의 업데이트에 따라 Page router의 routeChangeStart 이벤트와 비슷한 내용이 추가될 수 있지만 서버 컴포넌트가 기본인 설정이라 어려움이 있을 것이라고 추론하게 되었다.

페이지 이동 (내부, 외부 통합)

내부, 외부(1번, 2번)를 통합해서 페이지 이동을 감지할 수 있는 함수로 이 글을 확인했다.
onbeforeunload 이벤트는 외부로의(닫기, 새로고침, a 태그) 이동을, router.push 로직을 수정하는 방법은 내부로의 이동을 감지하고 예방할 수 있다.
내부 이동은 글 서론의 사진과 같은 보편적인 confirm 통해서, 외부로 이동하는 경우 Chrome에서 제공하는 못생긴(?) confirm 창을 확인할 수 있다.
a 태그도 페이지를 벗어나는 경우에 포함되므로 대비할 수 있고 여기서 확인한 내용은 Link 태그와 같은 경우는 router.push()처럼 처리되는 점을 알게 되었다. 알고 싶지 않았으나 Link는 confirm을 띄우지 않아서 알게되었다.
notion image

코드

const beforeUnloadHandler = useCallback( (event: BeforeUnloadEvent) => { // 페이지를 벗어나지 않아야 하는 경우 if (isDirty) { event.preventDefault(); event.returnValue = true; } }, [isDirty], ); useEffect(() => { const originalPush = router.push; const newPush = ( href: string, options?: NavigateOptions | undefined, ): void => { // 페이지를 벗어나지 않아야 하는 경우 if (isDirty && href === '/' && !confirm(MESSAGE.CONFIRM_LEAVE)) { return; } originalPush(href, options); return; }; router.push = newPush; window.onbeforeunload = beforeUnloadHandler; return () => { router.push = originalPush; window.onbeforeunload = null; }; }, [isDirty, router, beforeUnloadHandler]);

뒤로 가기 버튼

해당 경우를 처리하는 것이 가장 복잡했다. 크게 두가지 이유가 있었다.
  1. popstate 이벤트는 실행취소가 불가능하다. 즉 뒤로 가기 버튼을 누른 경우 history 스택에서 현재 페이지가 빠지고 이를 막을 수 없다.
  1. 컴포넌트 마운트 시점에 현재 페이지의 정보를 저장하기 위해 useEffect에 history.pushState를 사용하는데 이 동작의 예측이 어렵다.
1번을 해결하기 위해서 이미 history에서 빠진 현재 페이지를 다시 넣어야 하는데 go(+1)를 사용하는 경우 popstate 이벤트를 다시 발생시켜 무한 confirm이 된다. 이벤트를 발생시키지 않는 pushState 또는replaceState사용해야 한다.
2번은 개발 환경에서만 발생하는 문제로 해당 로직을 사용하는 컴포넌트가 리렌더링 되는 것과 관련된 이슈라고 생각된다. 즉 pushState가 여러번 적용된 것처럼 동작한다. 배포 환경에서는 문제가 없다는 점이 특이하다.
해결을 위해 다른 분이 블로그에 활용한 isClickedFirst로 한번만 현재 페이지의 정보를 스택에 넣는 방법을 채택했다.

코드

const isClickedFirst = useRef(false); const handlePopState = useCallback(() => { // 페이지를 벗어나지 않아야 하는 경우 if (isDirty && !confirm(MESSAGE.CONFIRM_LEAVE)) { history.pushState(null, '', ''); return; } history.back(); }, [isDirty]); useEffect(() => { if (!isClickedFirst.current) { history.pushState(null, '', ''); isClickedFirst.current = true; } }, []); useEffect(() => { window.addEventListener('popstate', handlePopState); return () => { window.removeEventListener('popstate', handlePopState); }; }, [handlePopState]);

마치며

글의 내용들을 담은 커스텀 훅을 Form 컴포넌트에서 호출해 활용하는 방법을 채택했다.
App Router를 공부하면서 프로젝트에 적용하는 과정에 여러 문제들을 만나게 되는데 이슈로 등록되어 다른 개발자들의 discussion에서 여러 정보를 얻지만 이슈로 등록되지 않은 내용들도 있어 가끔 어려움을 겪는다.
추가적으로 Page Router에서 페이지 이동을 방지하고 싶다면 아래 링크를 참고하면 좋을 것 같다.

참조


Next
WebSocket 활용기 with Next.js, Socket.io