Next.js App Router - 폼 이탈 방지 훅 개발 및 npm 배포한 이야기

박기찬·2026년 4월 3일

Web tech

목록 보기
15/16
post-thumbnail

입력한 내용이 다 날라갔어요.

회원정보 수정 폼을 열심히 채우다가 실수로 뒤로가기를 눌러본 적 있으신가요?

어드민 페이지를 작업하던 중 이 문제를 마주쳤습니다. 사용자가 긴 폼을 채우다가 링크를 잘못 클릭하거나, 뒤로가기를 실수로 누르거나, 탭을 닫아버리는 바람에 작성한 내용이 전부 날아가는 상황이었습니다.

해결책은 단순해 보였습니다. 이탈할 때 확인 팝업만 띄우면 되니깐요.

그런데 막상 구현하려고 보니 생각보다 복잡했습니다.


문제를 제대로 파악해보니

폼 이탈을 막으려면 사실 세 가지 경로를 모두 처리해야 합니다.

  1. 탭 닫기 / 새로고침 → beforeunload 이벤트
  2. SPA 내부 이동 (<Link> 클릭, router.push()) → 라우터 레벨 인터셉트
  3. 브라우저 뒤로가기 / 앞으로가기 → popstate 이벤트

1번은 어렵지 않습니다. beforeunload에 리스너 하나 달면 되니까요. 문제는 2번과 3번이었습니다.


기존 해결책들이 왜 안 됐나

React Hook Form의 isDirty

formState.isDirty는 폼이 수정됐는지 여부를 알려줄 뿐이고 이탈 자체를 막는 기능은 없습니다. 상태 값의 역할만 수행했습니다.

Pages Router의 router.events

Next.js Pages Router에서는 router.events.on('routeChangeStart', ...) 로 SPA 이동을 인터셉트할 수 있었습니다. 커뮤니티에 예제도 많고 동작도 문제가 없습니다.

그런데 App Router에서 이 API가 완전히 제거됐습니다. 공식 대안도 없습니다. GitHub Issues에는 이러한 이슈에 관한 질문들이 쌓여 있고, 답변들은 대부분 30줄짜리 useEffect 코드를 복붙하는 방식이었습니다.

react-router-prompt

React Router 전용 라이브러리입니다. Next.js에서는 동작하지 않습니다.


핵심 기법 세 가지

1. 탭 닫기 / 새로고침 — beforeunload

window.addEventListener('beforeunload', (e) => {
  if (!isDirty) return
  e.preventDefault()
  e.returnValue = message
})

모던 브라우저는 보안상 이유로 커스텀 메시지를 무시하고 브라우저 기본 팝업만 표시합니다. 현재까지 어쩔 수 없는 제약입니다.

2. SPA 이동 차단 — history.pushState 몽키패칭

App Router에서 <Link> 클릭이나 router.push()는 내부적으로 window.history.pushState를 호출합니다. 이걸 패치하면 모든 SPA 이동을 가로챌 수 있습니다.

const original = window.history.pushState
window.history.pushState = function (...args) {
  if (isDirty) {
    if (window.confirm(message)) original.apply(this, args)
    return
  }
  original.apply(this, args)
}

router.events가 없어도 됩니다. Next.js가 결국 pushState를 쓰기 때문에 여기서 잡을 수 있습니다.

3. 뒤로가기 차단 — popstate + history.go(1)

뒤로가기를 누르면 popstate 이벤트가 발생하는데, 이미 히스토리가 이동한 이후입니다. 이걸 막으려면 즉시 history.go(1)로 되돌린 다음, 사용자에게 확인을 받아야 합니다.

window.addEventListener('popstate', () => {
  if (!isDirty) return
  window.history.go(1) // 즉시 되돌리기
  setTimeout(() => {
    if (window.confirm(message)) window.history.go(-1) // 허용하면 이동
  }, 100)
})

setTimeout이 있는 이유는 history.go(1)이 비동기로 처리되기 때문에, 히스토리가 복원된 이후에 confirm을 띄우기 위함입니다.

4. Stale Closure 방지 — useRef 패턴

useEffect 의존성 배열을 []로 유지하면서 최신 isDirty 값을 참조하려면 ref가 필요합니다.

const shouldBlockRef = useRef(shouldBlock)
shouldBlockRef.current = shouldBlock // 매 렌더마다 동기화

이벤트 리스너 안에서 shouldBlockRef.current를 읽으면 항상 최신 값을 가져올 수 있습니다.


API 설계

사용하기 쉽게 두 가지 형태를 모두 지원합니다.

// 가장 단순한 사용법
useFormGuard(isDirty)

// 옵션 포함
useFormGuard({
  isDirty,
  message: '저장하지 않고 나가시겠습니까?',
  onBlock: () => openMyModal(),  // Promise<boolean>을 반환하는 커스텀 모달
  enabled: !isPreviewMode,       // 조건부 비활성화
})

// 반환값
const { isBlocked } = useFormGuard(isDirty)

onBlockwindow.confirm 대신 자체 모달을 쓰고 싶을 때 사용합니다. Promise<boolean>을 반환하면 되고, true면 이동 허용, false면 차단입니다.

어떤 폼 라이브러리든 boolean 값만 넘기면 동작합니다.

// React Hook Form
useFormGuard(formState.isDirty)
// Formik
useFormGuard(formik.dirty)
// TanStack Form
useFormGuard(form.state.isDirty)
// useState
useFormGuard(isDirty)

개발 중 겪은 삽질들

jsdom에서 beforeunload 테스트 실패

Vitest + jsdom 환경에서 beforeunload 이벤트를 발생시켜도 핸들러가 실행되지 않았습니다. 원인은 new Event('beforeunload')가 기본적으로 cancelable: false라서 브라우저가 무시한 것이였습니다.

// 이렇게 하니까 됐음
new Event('beforeunload', { cancelable: true })

TypeScript 6.0에서 DTS 빌드 실패

tsup으로 빌드할 때 타입 선언 파일(.d.ts) 생성에서 에러가 났습니다. tsup이 내부적으로 baseUrl을 설정하는데 TypeScript 6.0에서 이게 deprecated 됐기 때문이였습니다.

// tsconfig.json
{
  "compilerOptions": {
    "ignoreDeprecations": "6.0"
  }
}

완성된 라이브러리 스펙

항목내용
번들 크기~1.75KB minified (~0.8KB gzipped)
의존성없음 (React ≥ 17 peerDependency만)
빌드tsup (ESM/CJS 듀얼 빌드 + d.ts)
테스트Vitest + React Testing Library (27개)

다른 라이브러리와 비교하면 이렇습니다.

기능use-form-guard수동 beforeunloadreact-router-prompt
탭 닫기/새로고침
SPA 이동 차단✅ (RR만)
뒤로가기 차단✅ (RR만)
Next.js App Router
커스텀 다이얼로그
Zero dependency

마치며

작은 문제 하나에서 시작했는데, 제대로 만들려고 하니 꽤 많은 엣지 케이스가 있었습니다. jsdom 이슈부터 TypeScript 빌드 문제, npm 배포 에러까지 하나씩 해결하면서 생각보다 많이 배웠습니다.

결과물은 작지만 (0.8KB), 실제로 매 프로젝트마다 복붙하던 코드를 한 줄로 대체할 수 있게 됐습니다.

npm install use-form-guard
useFormGuard(isDirty) // 끝

사용해보시고 버그나 개선 사항이 있으면 이슈 남겨주시면 감사하겠습니다.

링크

0개의 댓글