Next.js App Router에서 멀티스텝 폼의 뒤로가기 차단 구현하기
문제 상황
멀티스텝 폼 위자드(플랫폼 생성 4단계 등)를 구현할 때, 사용자가 작성 중 브라우저 뒤로가기를 누르면 입력한 데이터가 모두 유실된다. 이를 방지하기 위해 "정말 나가시겠습니까?" 같은 확인 모달을 보여줘야 한다.
Pages Router에서는 router.events의 routeChangeStart를 활용할 수 있었지만, App Router에서는 이 API가 제거되었다. next/navigation에는 route change를 가로채는 공식 수단이 없다.
해결 전략
세 가지 시나리오를 각각 다른 메커니즘으로 처리한다.
| 시나리오 | 메커니즘 | UI |
|---|---|---|
| 탭 닫기 / 새로고침 | beforeunload 이벤트 | 브라우저 기본 confirm |
| 브라우저 뒤로가기 | History API + popstate 이벤트 | 커스텀 모달 |
| 소프트 네비게이션 (헤더 뒤로가기 등) | requestLeave 콜백 패턴 | 커스텀 모달 |
브라우저 기본 confirm은 텍스트 커스터마이징이 불가능하므로, 뒤로가기에는 History API를 직접 조작하여 커스텀 모달을 띄우는 방식을 사용한다.
핵심 아이디어: 더미 History Entry
History 스택에 더미 entry를 하나 push하여, 뒤로가기의 첫 타격을 더미가 흡수하게 만든다. 실제 페이지는 이탈하지 않으면서 popstate 이벤트만 발생시킬 수 있다.
흐름도
1단계 - 페이지 진입 (mount)
History Stack:
[이전 페이지] -> [현재 페이지] -> [더미 entry] <-- 현재 위치
pushState()로
몰래 추가사용자는 현재 페이지에 있다고 인식하지만, 실제로는 더미 entry 위에 서 있다.
2단계 - 브라우저 뒤로가기 클릭
더미 entry가 pop됨 -> popstate 이벤트 발생
[이전 페이지] -> [현재 페이지] <-- popstate 발생 지점
핸들러 실행:
(1) pushState()로 더미 entry 재push (원상복구)
(2) 확인 모달 open
[이전 페이지] -> [현재 페이지] -> [더미 entry] <-- 원상복구 완료뒤로가기를 눌렀지만, 사용자 입장에서는 URL이 바뀌지 않고 모달만 나타난다.
3단계 - 모달 선택
"이어서 작성" (취소):
모달만 닫음. 스택은 이미 원상복구 상태이므로 변화 없음.
[이전 페이지] -> [현재 페이지] -> [더미 entry] <-- 그대로 유지"이동" (확인):
router.push(목적지) 실행 -> Next.js 소프트 네비게이션
[이전 페이지] -> [현재 페이지] -> [더미 entry] -> [목적지]history.go(-2)가 안 되는 이유
이론적으로 더미를 재push한 뒤 history.go(-2)로 더미 + 현재 페이지 두 칸을 건너뛰면 이전 페이지로 이동해야 한다. 그러나 Next.js App Router에서는 동작하지 않는다.
Next.js App Router는 자체적으로 popstate 이벤트를 감시하며, 내부 history 상태 머신으로 클라이언트 사이드 네비게이션을 관리한다. pushState()로 삽입한 더미 entry는 Next.js의 추적 범위 밖이므로, history.go(-2)가 popstate를 발생시켜도 Next.js의 내부 상태와 브라우저 history가 동기화되지 않아 실제 페이지 전환이 일어나지 않는다.
history.back()도 마찬가지로 동작하지 않는다. Next.js가 popstate를 가로채서 처리하지만, 더미 entry로 인해 내부 상태가 어긋나 있기 때문이다.
따라서 페이지 이동은 반드시 router.push()를 사용해야 한다. 이를 위해 훅이 onConfirmLeave 콜백을 받아 호출하는 구조로 설계했다.
구현
useBeforeUnload
탭 닫기와 새로고침을 차단하는 훅이다. 단순하다.
import { useEffect } from 'react'
export const useBeforeUnload = (enabled: boolean) => {
useEffect(() => {
if (!enabled) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [enabled])
}Chrome 119+에서는 preventDefault()만으로 동작하지만, MDN은 여전히 e.returnValue = '' 할당을 권장한다. 일부 브라우저(특히 구 버전)에서 returnValue 없이는 확인 대화상자가 뜨지 않을 수 있기 때문이다. 호환성이 중요하다면 둘 다 쓰는 게 안전하다.
useNavigationGuard
뒤로가기 차단의 핵심 훅이다.
import { useCallback, useEffect, useRef, useState } from 'react'
export const useNavigationGuard = (
enabled: boolean,
onConfirmLeave: () => void,
) => {
const [isOpen, setIsOpen] = useState(false)
const isOpenRef = useRef(false)
const pendingLeaveRef = useRef<(() => void) | null>(null)
const isConfirmingRef = useRef(false)
useEffect(() => {
if (!enabled) return
window.history.pushState({ navigationGuard: true }, '')
const handlePopState = () => {
// 모달이 이미 열려있으면 추가 pushState 방지 (연타 대응)
if (isOpenRef.current) return
window.history.pushState({ navigationGuard: true }, '')
isOpenRef.current = true
setIsOpen(true)
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [enabled])
const confirmLeave = useCallback(() => {
isConfirmingRef.current = true
isOpenRef.current = false
setIsOpen(false)
const callback = pendingLeaveRef.current
pendingLeaveRef.current = null
isConfirmingRef.current = false
if (callback) {
callback()
} else {
onConfirmLeave()
}
}, [onConfirmLeave])
const cancelLeave = useCallback(() => {
if (isConfirmingRef.current) return
pendingLeaveRef.current = null
isOpenRef.current = false
setIsOpen(false)
}, [])
const requestLeave = useCallback((onConfirm: () => void) => {
if (isOpenRef.current) return
pendingLeaveRef.current = onConfirm
isOpenRef.current = true
setIsOpen(true)
}, [])
return { isOpen, confirmLeave, cancelLeave, requestLeave }
}페이지 컴포넌트에서의 사용
const MyFormPage = () => {
const router = useRouter()
const isActive = pageState !== 'complete'
useBeforeUnload(isActive)
const {
isOpen: isNavGuardOpen,
confirmLeave,
cancelLeave,
requestLeave,
} = useNavigationGuard(isActive, () => {
router.push('/list') // 브라우저 뒤로가기 확인 시 이동할 경로
})
// 헤더 뒤로가기 버튼 등 소프트 네비게이션 차단
const handleBackClick = () => {
requestLeave(() => {
router.push('/list')
})
}
return (
<>
<PageHeader hasBackArrow onBackClick={handleBackClick} />
{/* 페이지 내용 */}
<AlertDialog
open={isNavGuardOpen}
onOpenChange={(e) => {
if (!e.open) cancelLeave()
}}
title="페이지를 이동하시겠어요?"
description={
'작성 중인 내용이 저장되지 않을 수 있어요.\n'
+ '현재 화면을 떠나면 입력하신 내용은\n'
+ '모두 초기화됩니다.'
}
buttons={{
cancelProps: { text: '이동', onClick: confirmLeave },
actionProps: { text: '이어서 작성', onClick: cancelLeave },
}}
/>
</>
)
}enabled에 pageState !== 'complete'를 넘기면, 폼 제출 완료 후에는 자연스럽게 guard가 해제된다.
삽질 기록: history.back()과 history.go(-2)
구현 과정에서 여러 접근을 시도했다. 실패한 것들을 기록해 둔다.
시도 1: 더미 재push + history.go(-2)
popstate 핸들러에서 더미 재push -> confirmLeave에서 go(-2)이론상 더미 + 현재 페이지 두 칸을 건너뛰어야 하지만, Next.js App Router의 내부 history 상태 추적과 충돌하여 실제 페이지 전환이 발생하지 않았다. go(-2)가 popstate를 발생시키고, 이 popstate를 핸들러가 다시 잡아 모달을 재오픈하는 현상도 있었다.
시도 2: guardRef로 재진입 방지
guardRef.current = false 설정 후 go(-2)guardRef.current = enabled이 매 렌더마다 실행되어 false로 설정한 값이 setIsOpen(false) → 리렌더 시점에 true로 복원되는 문제. go(-2)의 비동기 popstate가 발생할 때는 이미 guardRef가 true로 돌아간 상태였다.
시도 3: 더미 재push 안 함 + history.back()
popstate 핸들러에서 재push 없이 모달만 -> confirmLeave에서 back()모달은 열리지만, history.back() 호출 시 Next.js App Router가 네비게이션을 수행하지 않았다. URL이 변경되어야 하지만 Next.js 내부 상태와 동기화되지 않아 무시되는 것으로 보인다.
시도 4: 리스너 제거 후 back() / go(-2)
confirmLeave에서 removeEventListener 후 back() 또는 go(-2)핸들러가 재진입하는 문제는 해결되었으나, 여전히 Next.js가 실제 페이지 전환을 수행하지 않았다. History API를 통한 네비게이션 자체가 App Router에서 동작하지 않는 것으로 결론.
최종 결론
Next.js App Router에서 History API(back(), go(), pushState())로 삽입한 더미 entry와 관련된 네비게이션은 신뢰할 수 없다. 페이지 전환은 반드시 router.push()를 사용해야 한다. 더미 entry는 오직 "뒤로가기 이벤트를 감지하고 URL을 유지"하는 목적으로만 사용하고, 실제 이동은 Next.js 라우터에 위임한다.
엣지케이스와 방어 코드
실제 서비스에 적용하면서 발견한 엣지케이스들이다.
뒤로가기 연타 시 더미 entry 무한 누적
모달이 열린 상태에서 뒤로가기를 연타하면, popstate 핸들러가 매번 pushState를 실행하여 더미 entry가 계속 쌓인다. isOpenRef로 모달이 이미 열려있으면 핸들러를 얼리 리턴하여 방지한다.
const handlePopState = () => {
if (isOpenRef.current) return // 이미 모달 열림 -> pushState 스킵
window.history.pushState({ navigationGuard: true }, '')
isOpenRef.current = true
setIsOpen(true)
}Dialog.CloseTrigger의 이벤트 순서 문제
Chakra UI(Ark UI)의 Dialog.CloseTrigger asChild로 감싼 버튼은, 클릭 시 onOpenChange 콜백이 버튼의 onClick보다 먼저 실행된다. 즉 "이동" 버튼을 누르면:
onOpenChange({open: false})->cancelLeave()실행- 버튼
onClick->confirmLeave()실행
cancelLeave가 먼저 실행되어 pendingLeaveRef.current를 null로 초기화하면, 뒤이어 실행되는 confirmLeave에서 requestLeave(callback)으로 등록했던 콜백을 잃어버린다.
isConfirmingRef를 사용하여, confirmLeave 진입 시 마킹하고 cancelLeave에서 이 플래그를 체크하여 초기화를 건너뛰도록 방어한다.
const confirmLeave = useCallback(() => {
isConfirmingRef.current = true // 마킹
// ...
isConfirmingRef.current = false
}, [onConfirmLeave])
const cancelLeave = useCallback(() => {
if (isConfirmingRef.current) return // confirm 진행 중이면 무시
// ...
}, [])requestLeave 중복 호출 방지
헤더 뒤로가기 버튼을 빠르게 더블클릭하면 requestLeave가 두 번 호출될 수 있다. isOpenRef로 이미 모달이 열려있으면 얼리 리턴한다.
const requestLeave = useCallback((onConfirm: () => void) => {
if (isOpenRef.current) return // 이미 모달 열림
pendingLeaveRef.current = onConfirm
isOpenRef.current = true
setIsOpen(true)
}, [])isOpen state vs isOpenRef
isOpen은 React 상태로, 모달 렌더링에 사용된다. isOpenRef는 이벤트 핸들러 내부에서 동기적으로 모달 상태를 확인하기 위한 ref다. useState의 setter는 비동기로 반영되므로, popstate 핸들러에서 isOpen 상태를 직접 읽으면 stale 값을 참조할 수 있다. ref를 병행하여 항상 최신 값을 보장한다.
소프트 네비게이션 차단: requestLeave 패턴
브라우저 뒤로가기 외에, router.push(), router.back(), <Link> 등 Next.js 내부의 소프트 네비게이션은 popstate를 발생시키지 않는다. 이런 경우는 requestLeave(callback) 패턴으로 처리한다.
// 헤더 뒤로가기 버튼
const handleBackClick = () => {
requestLeave(() => {
router.push('/list')
})
}
// 사이드바 메뉴 클릭
const handleMenuClick = (path: string) => {
requestLeave(() => {
router.push(path)
})
}requestLeave에 넘긴 콜백은 pendingLeaveRef에 저장되었다가, 사용자가 "이동"을 클릭하면 confirmLeave에서 실행된다. "이어서 작성"을 클릭하면 콜백은 폐기된다.
브라우저 뒤로가기의 경우 pendingLeaveRef가 null이므로, confirmLeave는 onConfirmLeave 폴백 콜백을 실행한다.
주의사항
SSR 환경에서의 안전성
window.history와 window.addEventListener는 브라우저에서만 존재한다. useEffect 내부에서만 호출하므로 SSR 시에는 실행되지 않아 문제없다. 하지만 'use client' 지시어는 반드시 필요하다.
cleanup 시 더미 entry 미정리
useEffect cleanup에서 removeEventListener만 하고, push한 더미 entry를 pop하지는 않는다. cleanup 시점에 history.back()을 호출하면 원치 않는 네비게이션이 발생할 수 있다. enabled가 false로 바뀌거나 컴포넌트가 언마운트될 때, 더미 entry가 history에 남는 것은 감수해야 한다.
브라우저 앞으로가기
뒤로가기 → 취소 사이클 후 브라우저 앞으로가기 버튼이 활성화될 수 있다. 더미 entry가 "앞"에 남아 있기 때문이다. 앞으로가기를 누르면 popstate가 발생하지만, isOpenRef 체크로 중복 모달은 방지된다.
정리
Next.js App Router에서 route change 이벤트가 사라지면서, 폼 이탈 방지를 구현하려면 브라우저 API를 직접 다뤄야 한다. 핵심은 History 스택에 더미 entry를 삽입하여 뒤로가기의 첫 충격을 흡수하는 것이다. 단, History API를 통한 실제 페이지 이동(back(), go())은 App Router에서 동작하지 않으므로, 페이지 전환은 반드시 router.push()로 수행해야 한다.
beforeunload + popstate + requestLeave 세 가지를 조합하면, 탭 닫기, 새로고침, 브라우저 뒤로가기, 소프트 네비게이션 네 가지 시나리오를 모두 커버할 수 있다.