모달과 뒤로가기 버튼을 동기화하기 — useHistoryBack 공통 훅 설계
모바일에서 뒤로가기 누르면 모달이 닫히는 UX를 재사용 가능하게. pushState 의 함정을 피하고 replaceState 마커와 dismiss 패턴으로 깔끔하게 구현한 이야기.
시작 — 모달인데 뒤로가기가 페이지를 떠나버림
모바일에서 모달을 열어놓고 뒤로가기를 누르면 당연히 모달이 먼저 닫혀야 합니다. 그런데 초기 구현에서는 뒤로가기가 모달을 무시하고 이전 페이지로 그냥 이동했습니다.
UX 관점에선 치명적입니다. 사용자가 정보 모달을 열었다가 뒤로가기로 닫으려는 자연스러운 행동이 사이트 이탈로 이어지는 셈이니까요.
이 프로젝트에는 비슷한 모달이 여러 개 있었습니다 — NavMenu, InfoModal, SearchPalette, Harmony 녹음 가이드, Mars 이미지 뷰어 등. 각자 구현하면 로직이 흩어지니, 공통 훅 하나 로 정리하기로 했습니다.
첫 시도 — pushState 로 엔트리 추가
직관적인 접근. 모달이 열릴 때 history 엔트리를 하나 추가하고, 뒤로가기로 그 엔트리가 pop 되면 모달을 닫는다.
// 첫 시도
useEffect(() => {
if (!isOpen) return;
history.pushState({ modalOpen: true }, '');
const onPop = () => setIsOpen(false);
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [isOpen]);
여기서 부딪힌 함정
Next.js App Router 환경에서 pushState 수동 엔트리는 뒤로가기 시 popstate 를 "삼켜버리는" 현상이 있었습니다.
예를 들어:
- 사용자가
/galaga에 있음 - 모달 열기 →
pushState엔트리 추가 - 사용자가 뒤로가기 → popstate 발동 → 모달 닫힘 ✓
- 사용자가 다시 뒤로가기 → 아무 일도 안 일어남
수동으로 넣은 엔트리와 Next.js 라우터가 관리하는 엔트리가 뒤섞여서 예상 못한 state 가 되는 것이었습니다.
해결 — replaceState 마커 패턴
pushState 대신 현재 엔트리에 플래그만 새기는 방식입니다.
history.replaceState({ ...history.state, modalOpen: true }, '', location.href);
- 새 엔트리를 추가하지 않음 → 라우터 history 에 간섭 없음
- 현재 state 객체에
modalOpen: true마커를 덧붙임 - 뒤로가기 하면 원래 이전 페이지로 돌아감 (정상)
- 대신 "현재 페이지의 state 에 modalOpen 이 있었다" 는 사실은 남음
모달 닫기 UX 는 별도 경로로 처리합니다. 뒤로가기 버튼 자체는 정상 동작 (이전 페이지로 이동) 하되, 모달 닫기는 다른 이벤트 (X 버튼·백드롭 클릭·ESC) 전용 으로 분리.
단, 여전히 "뒤로가기 → 모달 닫기" UX 가 필요한 경우엔 다른 접근이 필요합니다.
최종 설계 — dismiss 반환 + popstate 분기
실제로 적용한 패턴입니다. 모달의 닫기 경로를 두 가지로 분리:
- "뒤로가기로 닫기" —
history.back()호출 → popstate 발동 → 모달 닫힘 - "X 버튼·ESC 등으로 닫기" — 모달만 닫고 history 는 건드리지 않음
export function useHistoryBack(isOpen: boolean, onClose: () => void, marker: string) {
// 이전 state 유지 + 마커만 덧붙이기 (pushState 아님)
useEffect(() => {
if (!isOpen) return;
history.replaceState({ ...history.state, [marker]: true }, '', location.href);
const onPop = () => {
// popstate 가 이 마커가 있던 상태에서 발동하면 모달 닫기
if (!history.state?.[marker]) {
onClose();
}
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [isOpen, onClose, marker]);
// 사용자가 명시적으로 "닫기" 누를 때 쓰는 함수
const dismiss = useCallback(() => {
if (history.state?.[marker]) {
history.back(); // 뒤로가기 시뮬레이션
} else {
onClose(); // 직접 닫기
}
}, [onClose, marker]);
return dismiss;
}
사용법
function Modal({ isOpen, onClose }: Props) {
const dismiss = useHistoryBack(isOpen, onClose, 'myModalOpen');
return (
<div onClick={dismiss}>
{' '}
{/* 백드롭 클릭 */}
<button onClick={dismiss}>✕</button> {/* X 버튼 */}
{/* ... */}
</div>
);
}
- 백드롭·X·ESC →
dismiss()호출 →history.back()실행 → popstate → 모달 닫힘 - OS 뒤로가기 버튼 → popstate 직접 발동 → 모달 닫힘
- 어느 쪽이든 같은 경로를 지남
덕분에 "브라우저 뒤로가기 한 번 = 모달 하나 닫기" 공식이 성립합니다.
다른 함정들
history.back() 을 cleanup 에서 호출 금지
// ❌ 위험
useEffect(() => {
return () => history.back(); // unmount 시 뒤로가기?
}, []);
Next.js soft navigation 중에 이게 실행되면 라우팅을 취소시키는 버그가 생깁니다. history.back() 은 명시적인 사용자 action 에서만.
중첩 모달 주의
모달 안에서 또 다른 모달을 여는 경우, 마커 이름을 다르게 해야 서로 간섭하지 않습니다.
useHistoryBack(isOpen, onClose, 'outerModalOpen');
// ...
useHistoryBack(innerOpen, innerClose, 'innerModalOpen'); // 마커 다름
popstate 시 history.state 에 어느 마커가 있는지 각자 독립 체크.
마커 사전 삽입으로 pushState 건너뛰기
최초 열기 직전에 replaceState 로 마커를 미리 심어두면, 기존 history 엔트리와의 순서가 꼬이지 않습니다. 이 패턴 덕분에 Next.js 라우터와 충돌 없이 동작합니다.
적용 결과
이 프로젝트에서 useHistoryBack 으로 정리된 곳:
- NavMenu (햄버거 메뉴)
- InfoModal (페이지 정보)
- SearchPalette (
⌘K) - Constellation 정보 패널
- Harmony 녹음 가이드
- Mars 이미지 상세 뷰어
- ThreeBody 도움말
- Devlog
/devlog/[slug]내부 모달들
8곳에서 동일 패턴을 재사용. 각 컴포넌트는 1줄 hook 호출로 끝납니다.
회고
모달을 처음 구현할 때는 단순히 isOpen state 하나로 충분해 보입니다. 하지만 모바일 UX 와 브라우저 history 와의 동기화를 생각하면 의외로 섬세한 설계가 필요합니다.
핵심은 "모달 닫기 = 뒤로가기 1회" 라는 대응을 유지하는 것. 이 공식이 깨지면 사용자는 "뒤로가기를 몇 번 눌러야 이전 페이지로 돌아가는 거지?" 라고 혼란스러워집니다.
replaceState + dismiss 패턴은 이 공식을 정확히 지키면서 라우터와도 충돌하지 않는 균형점입니다. 모달이 많은 SPA 를 만드신다면 재활용 가치 높은 훅입니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...