트러블슈팅
모바일에서 모달을 닫으면 뒤 요소까지 클릭되던 문제
상황
무엇을 하다가 발생했나
- 모달(dialog)의 불투명한 배경(overlay) 을 클릭/터치하면 모달을 닫도록 구현하고 있었다.
- 닫기 트리거를 배경의
pointerup이벤트에 등록했다 (SvelteKit 환경,page.state.openedModal+history.back()으로 닫음). - "누른 곳도 모달 밖, 뗀 곳도 모달 밖"일 때만 닫도록
pointerdown에서pressedOutsideDialog플래그를 추적하는 정교한 로직을 사용 중이었다. <meta name="viewport" content="width=device-width, initial-scale=1" />설정은 이미 되어 있었다.
증상
- 데스크탑: 배경(다른 click 이벤트가 걸린 위치)을 클릭해 모달을 닫으면 → 모달만 제거됨 (정상)
- 모바일 실기기: 같은 위치를 터치했다 떼면 → 모달이 닫히고 + 그 위치에 있던 요소의 click 이벤트까지 실행됨 (버그)
- 크롬 DevTools 모바일 뷰: 정상 동작 (버그 재현 안 됨) ← 함정
재현 방법:
- 모달을 연다 (
page.state.openedModal === true). - 모달 뒤 배경에서, 그 아래에 click 핸들러가 있는 요소가 위치한 좌표를 누른다.
- 그 자리에서 손가락을 뗀다 (실제 터치 기기에서).
- 모달이 닫히면서, 가려져 있던 아래 요소의 click이 함께 실행된다 (ghost click / click-through).
환경
- OS / 버전: 데스크탑(정상) vs 실제 모바일 폰(버그). 크롬 DevTools 모바일 뷰는 실기기와 다르게 동작.
- 관련 도구: Svelte / SvelteKit (shallow routing,
page.state,history.back()), Pointer Events API,viewportmeta 설정됨.
가설
- 가설 1:
viewportmeta가 없어서 생긴 300ms 지연 문제다 → 기각. meta는 더블탭 줌 지연(300ms)에만 관련 있고, click 통과(ghost click)와는 별개. meta가 있어도 통과는 발생. - 가설 2: 데스크탑은
pointerup에서 닫으면 안전하다 → 부분 기각. 데스크탑이 안전해 보이는 건 "보장된 동작"이 아니라 타이밍·엔진 구현에 따른 우연. 명세상 SHOULD 권고라 엔진별 차이 있음. - 가설 3 (정답): 모바일의 합성(compatibility) click 이
pointerup이후 별도 시점에 발사되며, 그 시점의 좌표를 다시 hit-test 한다.pointerup에서 모달을 이미 제거했기 때문에 click이 아래 요소로 떨어진다.
시도한 것들
| 시도 | 결과 | 비고 |
|---|---|---|
viewport meta 확인 | 실패 | 이미 설정돼 있었고, 통과 문제와 무관 |
| 데스크탑에서 테스트 | "정상"처럼 보임 | 의존하면 안 되는 우연한 동작 |
| 크롬 DevTools 모바일 뷰로 테스트 | "정상"처럼 보임 | 실기기의 합성 click을 재현 못 함 → 신뢰 불가 |
pointerup에서 history.back() + onCloseClick 차단 로직 주석 처리 | 실패 | click 차단이 꺼져 있어 통과 그대로 발생 |
pointerup의 캡처링 단계에서 e.stopPropagation() 으로 막으려 시도 | 실패 | pointerup을 막아도 click은 별개의 이벤트로 뒤따라 발사되므로 소용 없음. pointerup 전파를 멈추는 것과 그 뒤에 올 click을 막는 건 다른 문제였다 |
pointerup에서 닫되, 따라오는 click을 capture에서 1회 삼키기 | 대안 | swallowNextClick 플래그 방식 (가능하지만 최종 선택 아님) |
닫기 트리거를 pointerup → click으로 변경 | 성공(최종 채택) | DOM 변경(닫기)을 click 시점에 하니 뒤따라올 click이 없어 통과 자체가 사라짐. 가장 단순하고 확실 |
🎯 원인
핵심은 "pointerup 시점에 DOM을 바꿨는데, 그 뒤에 click이 따로 발사된다" 는 점이며, 데스크탑과 모바일의 click 타겟 결정 방식이 다르다.
이벤트 순서
- 데스크탑 클릭:
pointerdown → pointerup → click(같은 시퀀스). - 모바일 터치:
touchstart → touchend(원천) →pointerdown/up(터치 타입으로 직접 발생) → 합성mousedown → mouseup → click(touchend 좌표 기준, 시간차를 두고 발사).- 정정: pointer 이벤트는 "에뮬레이션"이 아니라 터치에서 1차로 발생함. 에뮬레이션(합성)되는 건 mouse 계열 + click.
click 타겟 결정 방식의 차이 (명세 근거)
- 데스크탑: click 타겟 = mousedown 타겟과 mouseup 타겟의 가장 가까운 공통 inclusive 조상(nearest common inclusive ancestor).
- "조상이 다르면 어떻게 되냐"의 답: 항상 공통 조상은 존재한다. DOM은 트리라 어떤 두 노드든 공통 조상이 있고(최악은
<html>/document), click은 그 지점으로 간다. - 명세(Pointer Events): 타겟이 시퀀스 도중 DOM에서 제거되면 남은 이벤트는 그 요소로 발사되어선 안 된다(MUST NOT). → 그래서 배경 제거 시 아래 요소로 정확히 안 떨어져 통과가 안 일어났던 것.
- 단, 이건 SHOULD 권고라 WebKit/Firefox/IE 구현이 갈림 → 데스크탑 "안전"은 보장 아님.
- "조상이 다르면 어떻게 되냐"의 답: 항상 공통 조상은 존재한다. DOM은 트리라 어떤 두 노드든 공통 조상이 있고(최악은
- 모바일 합성 click: 타겟 = 공통 조상 규칙이 아니라 합성 시점에 좌표를 hit-test한 결과 요소.
pointerup에서 모달을 제거했으니 좌표 재검사 → 아래 요소로 click → 통과.
왜 데스크탑/DevTools는 되고 실기기는 안 됐나
- 데스크탑·DevTools 모바일 뷰는 실기기의 합성 click 동작을 똑같이 재현하지 못함.
- 데스크탑조차 이론상 통과 가능(예:
history.back()/리렌더가 비동기라 click 시점에 DOM 상태가 갈릴 수 있음). 지금 안 터진 건 우연.
(보너스) 이벤트 루프 관점
- 사용자 입력 이벤트 핸들러는 MacroTask(Task Queue) 에 큐잉되어 동기 실행.
- 핸들러 종료 후 다음 MacroTask 전에 MicroTask Queue(Promise 등) 를 먼저 비움.
- 데스크탑·모바일 모두 이 큐 처리 메커니즘은 동일. 차이는 이벤트 종류·순서·타겟 결정에 있음.
✅ 해결
최종 채택: 닫기 트리거를 pointerup → click으로 변경했다.
근본 원인이 "pointerup 시점에 DOM(모달)을 제거했는데, 그 뒤에 click이 별도로 따라온다"는 것이었으므로, DOM 변경 자체를 click 시점에 하면 뒤따라올 click이 더 이상 없어 통과 문제가 원천 제거된다.
let pressedOutsideDialog = false;
const insideDialog = (target: EventTarget | null) =>
target instanceof Element && target.closest('[role="dialog"]') !== null;
function onPointerDown(e: PointerEvent) {
// "누른 곳도 모달 밖"인지 추적 (정교한 닫기 조건 유지)
pressedOutsideDialog = !!page.state.openedModal && !insideDialog(e.target);
}
function onCloseClick(e: MouseEvent) {
// 닫기를 click에서 수행 → 뒤따라올 click이 없으므로 통과(ghost click) 자체가 사라짐
if (page.state.openedModal && pressedOutsideDialog && !insideDialog(e.target)) {
e.stopPropagation();
e.preventDefault();
history.back();
}
pressedOutsideDialog = false;
}
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('click', onCloseClick, true);
return () => {
window.removeEventListener('keydown', onPressEscape);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('click', onCloseClick, true);
};
왜 pointerup에서 막는 시도는 실패했나 (중요)
- 처음엔
pointerup의 캡처링 단계에서e.stopPropagation()으로 막으려 했다. - 그러나
stopPropagation은 그 이벤트(pointerup)의 전파만 멈출 뿐이다. click은 pointerup과 별개의 독립 이벤트로 그 뒤에 따로 발사되므로, pointerup 전파를 아무리 막아도 뒤따라오는 click에는 영향을 주지 못했다. - 즉 "pointerup 전파를 멈추는 것"과 "그 뒤에 올 click을 막는 것"은 서로 다른 문제다. 전자를 해결해도 후자는 그대로 남는다.
대안 (참고): pointerup을 꼭 유지해야 한다면 swallow 방식
pointerup에서 닫는 로직을 유지해야 하는 사정이 있다면, 닫은 직후 따라오는 click을 capture 단계에서 1회 삼키는swallowNextClick플래그 방식이 가능하다(타임아웃 자동 해제 필수).- 다만 이번 케이스에서는
click으로 옮기는 쪽이 훨씬 단순하고 확실해서 그쪽을 채택했다.
💭 배운 점 / 다음에는
pointerup/mouseup에서 DOM을 변경하면, 뒤따르는 click의 타겟이 의도와 달라진다. 닫기·삭제 같은 DOM 변경은 가급적click에서 하거나, pointerup에서 한다면 따라올 click을 반드시 처리한다. → 이번엔 닫기를click으로 옮겨 근본 해결.e.stopPropagation()은 "그 이벤트"의 전파만 멈춘다.pointerup을 막아도 그 뒤에 오는 click은 별개의 독립 이벤트라 영향받지 않는다. "한 이벤트의 전파를 막는 것"과 "뒤따르는 다른 이벤트를 막는 것"은 다른 문제임을 혼동하지 말 것.- 크롬 DevTools 모바일 뷰를 믿지 말 것. 화면 크기·일부 터치만 흉내 낼 뿐, 실기기의 합성 click을 재현하지 못한다. 터치 관련 버그는 실기기 원격 디버깅으로 확인.
- 안드로이드 크롬: 폰에서 USB 디버깅 켜기 → USB 연결 → PC 크롬
chrome://inspect→ 해당 탭 inspect. 실기기의 진짜 콘솔/요소를 본다. - iOS 사파리: 아이폰
설정 → Safari → 고급 → 웹 검사기켜기 → Mac에 USB 연결 → Mac 사파리개발자용메뉴 → 기기/페이지 선택.
- 안드로이드 크롬: 폰에서 USB 디버깅 켜기 → USB 연결 → PC 크롬
- 데스크탑의 "잘 됨"은 명세 보장이 아니라 우연일 수 있다(공통 조상 규칙은 SHOULD 권고 → WebKit/Firefox/IE 구현 차이). 의존 금지.
- 데스크탑 click 타겟 = down/up의 가장 가까운 공통 inclusive 조상 (둘이 달라도 공통 조상은 항상 존재). 모바일 합성 click 타겟 = 합성 시점 좌표 hit-test 결과.
- 입력 이벤트는 MacroTask, Promise는 MicroTask. 핸들러 종료 후 다음 태스크 전에 마이크로태스크를 전부 비운다 — 데스크탑·모바일 동일.
viewportmeta는 300ms 지연 문제용이지 ghost click(통과) 해법이 아니다.
🔗 참고
- [[Pointer Events (W3C) — 공통 조상 규칙 + 타겟 제거 시 남은 이벤트 미발사(MUST)]] https://www.w3.org/TR/pointerevents/
- [[UI Events (W3C) — click/mousedown/mouseup 순서·타겟 결정 원천 명세]] https://w3c.github.io/uievents/
- [[Touch Events (W3C) — compatibility(합성) mouse events 생성 절차, 좌표 기준 hit-test]] https://www.w3.org/TR/touch-events/
- [[MDN: Element click event — 공통 조상 규칙 + 모바일 합성 click/ghost click 주의]] https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event
- [[Firefox Bug 1004895 — mouseup→click 타겟 동작 엔진별 차이 사례]] https://bugzilla.mozilla.org/show_bug.cgi?id=1004895
- [[Maisie Johnson 블로그 — click event.target이 down/up 공통 조상임을 추적한 실제 디버깅 사례]] https://blog.maisie.ink/click-event-target/