커스텀 모달이나 사이드 드로어를 만들 때마다 "닫기"를 어떻게 처리할지가 은근히 귀찮았다. X 버튼이야 쉽다. 문제는 그 다음부터다.
데스크톱에서는 Esc 키를 눌렀을 때 닫혀야 하니까 keydown 리스너를 붙인다. 그런데 모바일, 특히 안드로이드에서는 사용자가 뒤로가기 제스처나 버튼으로 오버레이를 닫으려고 한다. 네이티브 <dialog>가 아닌 커스텀 오버레이는 이걸 기본으로 처리해주지 않는다. 그래서 history에 state를 하나 push해두고 popstate를 듣다가 닫는 식으로 직접 꿰맸는데, 뒤로가기로 닫은 건지 실제로 페이지를 떠나는 건지 구분하는 게 늘 지저분했다.
CloseWatcher가 이걸 하나로 묶는다
CloseWatcher는 "닫기 요청"이라는 추상적인 신호를 하나의 close 이벤트로 받게 해준다. Esc 키든 안드로이드 뒤로가기든, 플랫폼이 "이거 닫으려는 의도야"라고 판단하면 전부 close로 들어온다. 내가 키 코드를 비교하거나 history를 만질 필요가 없다.
function openDrawer() {
setOpen(true);
const watcher = new CloseWatcher();
watcher.onclose = () => {
setOpen(false); // Esc, 안드로이드 뒤로가기 모두 여기로
};
// X 버튼처럼 내가 직접 닫을 때
closeButton.onclick = () => watcher.requestClose();
return watcher;
}
핵심은 requestClose()다. X 버튼을 눌렀을 때 그냥 상태만 바꾸지 말고 watcher.requestClose()를 호출하면, 직접 닫든 Esc로 닫든 전부 onclose 한 군데로 모인다. 닫기 로직을 한 곳에만 두면 되니까 분기가 사라진다.
오버레이가 다른 이유로 사라졌다면 watcher를 정리해줘야 한다.
useEffect(() => {
if (!open) return;
const watcher = new CloseWatcher();
watcher.onclose = () => setOpen(false);
return () => watcher.destroy(); // 언마운트 시 정리
}, [open]);
destroy()를 호출하면 더 이상 close 신호를 받지 않는다. 안 해두면 이미 닫힌 오버레이에 뒤로가기가 먹히는 유령 동작이 생긴다.
알아둘 점
아직 Chromium 계열 위주라 Safari·Firefox에서는 window.CloseWatcher가 없을 수 있다. 그래서 if ('CloseWatcher' in window)로 감싸고, 없을 때는 기존 keydown 리스너로 떨어지게 두면 된다. 참고로 popover나 네이티브 <dialog>는 내부적으로 이미 CloseWatcher를 쓰고 있어서, 그걸 쓸 수 있는 상황이면 애초에 직접 만들 필요가 없다.
정리하면, 커스텀 오버레이의 "닫기"를 Esc·뒤로가기까지 챙기고 싶을 때 history를 직접 만지는 대신 CloseWatcher 하나로 끝낼 수 있다. 닫기 분기가 requestClose()로 단일화된다는 점이 특히 마음에 들었다.