← Blog
트러블슈팅

모바일에서 모달을 닫으면 뒤 요소까지 클릭되던 문제

상황

무엇을 하다가 발생했나

  • 모달(dialog)의 불투명한 배경(overlay) 을 클릭/터치하면 모달을 닫도록 구현하고 있었다.
  • 닫기 트리거를 배경의 pointerup 이벤트에 등록했다 (SvelteKit 환경, page.state.openedModal + history.back() 으로 닫음).
  • "누른 곳도 모달 밖, 뗀 곳도 모달 밖"일 때만 닫도록 pointerdown에서 pressedOutsideDialog 플래그를 추적하는 정교한 로직을 사용 중이었다.
  • <meta name="viewport" content="width=device-width, initial-scale=1" /> 설정은 이미 되어 있었다.

증상

- 데스크탑: 배경(다른 click 이벤트가 걸린 위치)을 클릭해 모달을 닫으면 → 모달만 제거됨 (정상)
- 모바일 실기기: 같은 위치를 터치했다 떼면 → 모달이 닫히고 + 그 위치에 있던 요소의 click 이벤트까지 실행됨 (버그)
- 크롬 DevTools 모바일 뷰: 정상 동작 (버그 재현 안 됨) ← 함정

재현 방법:

  1. 모달을 연다 (page.state.openedModal === true).
  2. 모달 뒤 배경에서, 그 아래에 click 핸들러가 있는 요소가 위치한 좌표를 누른다.
  3. 그 자리에서 손가락을 뗀다 (실제 터치 기기에서).
  4. 모달이 닫히면서, 가려져 있던 아래 요소의 click이 함께 실행된다 (ghost click / click-through).

환경

  • OS / 버전: 데스크탑(정상) vs 실제 모바일 폰(버그). 크롬 DevTools 모바일 뷰는 실기기와 다르게 동작.
  • 관련 도구: Svelte / SvelteKit (shallow routing, page.state, history.back()), Pointer Events API, viewport meta 설정됨.

가설

  • 가설 1: viewport meta가 없어서 생긴 300ms 지연 문제다 → 기각. meta는 더블탭 줌 지연(300ms)에만 관련 있고, click 통과(ghost click)와는 별개. meta가 있어도 통과는 발생.
  • 가설 2: 데스크탑은 pointerup에서 닫으면 안전하다 → 부분 기각. 데스크탑이 안전해 보이는 건 "보장된 동작"이 아니라 타이밍·엔진 구현에 따른 우연. 명세상 SHOULD 권고라 엔진별 차이 있음.
  • 가설 3 (정답): 모바일의 합성(compatibility) clickpointerup 이후 별도 시점에 발사되며, 그 시점의 좌표를 다시 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 플래그 방식 (가능하지만 최종 선택 아님)
닫기 트리거를 pointerupclick으로 변경성공(최종 채택)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 구현이 갈림 → 데스크탑 "안전"은 보장 아님.
  • 모바일 합성 click: 타겟 = 공통 조상 규칙이 아니라 합성 시점에 좌표를 hit-test한 결과 요소. pointerup에서 모달을 제거했으니 좌표 재검사 → 아래 요소로 click → 통과.

왜 데스크탑/DevTools는 되고 실기기는 안 됐나

  • 데스크탑·DevTools 모바일 뷰는 실기기의 합성 click 동작을 똑같이 재현하지 못함.
  • 데스크탑조차 이론상 통과 가능(예: history.back()/리렌더가 비동기라 click 시점에 DOM 상태가 갈릴 수 있음). 지금 안 터진 건 우연.

(보너스) 이벤트 루프 관점

  • 사용자 입력 이벤트 핸들러는 MacroTask(Task Queue) 에 큐잉되어 동기 실행.
  • 핸들러 종료 후 다음 MacroTask 전에 MicroTask Queue(Promise 등) 를 먼저 비움.
  • 데스크탑·모바일 모두 이 큐 처리 메커니즘은 동일. 차이는 이벤트 종류·순서·타겟 결정에 있음.

✅ 해결

최종 채택: 닫기 트리거를 pointerupclick으로 변경했다.

근본 원인이 "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 사파리 개발자용 메뉴 → 기기/페이지 선택.
  • 데스크탑의 "잘 됨"은 명세 보장이 아니라 우연일 수 있다(공통 조상 규칙은 SHOULD 권고 → WebKit/Firefox/IE 구현 차이). 의존 금지.
  • 데스크탑 click 타겟 = down/up의 가장 가까운 공통 inclusive 조상 (둘이 달라도 공통 조상은 항상 존재). 모바일 합성 click 타겟 = 합성 시점 좌표 hit-test 결과.
  • 입력 이벤트는 MacroTask, Promise는 MicroTask. 핸들러 종료 후 다음 태스크 전에 마이크로태스크를 전부 비운다 — 데스크탑·모바일 동일.
  • viewport meta는 300ms 지연 문제용이지 ghost click(통과) 해법이 아니다.

🔗 참고