

InMyDay
ExperienceEditPage 뒤로가기 이탈 감지 로직 안정화
체험 수정 페이지(ExperienceEditPage)에서 폼 작성 중 뒤로가기를 클릭하면 이탈 확인 모달을 노출하도록 구현했다.
초기 구현에서는 정상 동작하는 것처럼 보였지만, 테스트 과정에서 뒤로가기 이탈 감지가 한 번만 동작하는 문제가 발견됐다.
🧩 문제 배경
ExperienceEditPage에서 다음과 같은 흐름을 의도했다.
- 폼 수정(isDirty 상태) 중 뒤로가기 클릭
→ 이탈 확인 모달 표시 - “아니오” 클릭 시 페이지 유지
- 이후 다시 뒤로가기를 눌러도 동일하게 이탈 모달 표시
하지만 실제 동작은 달랐다.
- 뒤로가기 첫 번째 클릭 → 이탈 모달 정상 표시
- “아니오” 클릭 후 다시 뒤로가기 → ❌ 이탈 모달 미표시
즉, 이탈 감지가 1회만 동작하고 이후에는 감지되지 않는 문제가 발생했다 🚨
🔍 원인 분석
이탈 감지를 위해 popstate 이벤트와 history.pushState를 사용하고 있었다.
문제는 이탈 모달에서 “아니오”를 선택했을 때였다.
- 뒤로가기 시 브라우저 히스토리 스택이 이미 한 단계 감소
- 모달만 닫고 히스토리 상태를 복구하지 않음
- 그 결과 이후에는 더 이상 popstate 이벤트가 발생하지 않음
또한 Next.js App Router 환경에서는 window.history를 직접 조작할 경우
Router 내부에서 관리하는 히스토리 스택과 불일치가 발생할 수 있었다.
결과적으로 문제의 핵심은 다음과 같았다.
이탈은 막았지만, 히스토리 상태는 원래대로 복구하지 않은 구조
🛠️ 해결 방법
1️⃣ popstate 발생 시 히스토리 즉시 복원
뒤로가기를 감지했을 때, 이탈 모달을 띄우는 동시에 history.pushState를 다시 호출하도록 수정했다.
const handlePop = (e: PopStateEvent) => {
e.preventDefault();
setPendingUrl("/mypage/experience");
setIsLeaveModalOpen(true);
history.pushState(null, "", pathname); // 히스토리 스택 복원
};
이를 통해 연속으로 뒤로가기를 눌러도 popstate → 이탈 모달 표시 흐름이 반복되도록 개선했다.
2️⃣ “아니오” 클릭 시 Router 기준으로 스택 동기화
브라우저 히스토리만 복구하는 것으로는 충분하지 않았다.
App Router 환경에서는 Next.js Router 기준의 스택 복원이 필요했다.
이탈 모달에서 “아니오”를 클릭했을 때 router.push(pathname)를 호출해
Router 내부 히스토리와 브라우저 히스토리를 다시 동기화했다.
const handleLeaveCancel = () => {
setPendingUrl(null);
setIsLeaveModalOpen(false);
router.push(pathname);
};
이를 통해 URL 상태와 Router 상태가 어긋나지 않도록 했다.
3️⃣ 전역 이탈 감지 리스너 생명주기 정리
이 과정에서 단순히 뒤로가기 이벤트만 수정하는 것이 아니라,
isDirty 상태에 따라 등록되는 전역 이벤트 리스너의 생명주기도 함께 정리했다.
기존 구현에서는 이벤트 핸들러가 useEffect 내부에만 정의되어 있어
mutation 성공 이후에도 일부 리스너가 남아 전역 클릭이 차단되는 문제가 발생할 수 있었다.
이를 해결하기 위해:
- beforeunload, popstate, 내부 링크 클릭 핸들러를 useRef로 관리
- 공통 정리 함수(removeDirtyListeners)를 통해
컴포넌트 외부(mutation 성공 시점 등)에서도 안전하게 제거 가능하도록 개선했다.
const beforeUnloadRef = useRef<...>(null);
const popstateRef = useRef<...>(null);
const linkClickRef = useRef<...>(null);
const removeDirtyListeners = () => {
window.removeEventListener("beforeunload", beforeUnloadRef.current);
window.removeEventListener("popstate", popstateRef.current);
document.removeEventListener("click", linkClickRef.current, true);
};
저장 완료 시에는 isDirty 플래그를 끄는 것과 함께
리스너도 즉시 제거해 이후 네비게이션 흐름이 정상 동작하도록 했다.
4️⃣ 이탈 흐름 통합
다음 모든 이탈 경로에서 동일한 이탈 모달 흐름을 사용하도록 정리했다.
- 뒤로가기 (popstate)
- 내부 링크 클릭 (a 태그)
- 새로고침 / 탭 닫기 (beforeunload)
beforeunload 처리 시에는 Object.defineProperty(e, 'returnValue', …) 방식을 사용해
TypeScript 경고를 제거하고 브라우저 호환성을 유지했다.
✅ 결과
- 연속 뒤로가기 시에도 이탈 모달이 정상적으로 반복 표시됐다.
- “아니오” 선택 후에도 이탈 감지가 끊기지 않았다.
- App Router 환경에서 브라우저 히스토리와 Router 상태가 일관되게 유지됐다.
- 저장 완료 후에는 이탈 감지가 해제되어 불필요한 모달 노출을 방지했다.
📌 정리
이 문제는 단순한 이벤트 누락이 아니라,
히스토리를 막는 것과, 복구하는 것은 별개의 문제
라는 점에서 발생했다.
App Router 환경에서는 브라우저 히스토리와 Router가 각각 상태를 관리하고 있기 때문에,
두 스택을 함께 고려한 이탈 감지 로직이 필요했다.
이번 개선을 통해 뒤로가기 UX를 보다 안정적으로 제어할 수 있었고,
사용자가 예측 가능한 흐름으로 페이지를 이동할 수 있도록 했다 ✨