← Cases
Case 05사용자 웹 A2026.06severity medium

모달을 닫았는데 뒤에 있던 카드가 눌린다

모바일 실기기에서만 생기는 유령 클릭

01 · 문제
모바일에서 모달 바깥을 탭해 닫으면, 그 자리에 있던 뒤 카드까지 눌려 다른 화면으로 넘어간다
02 · 분석
터치 입력의 이벤트 시퀀스와 합성 clickclick 타겟 결정 규칙과 hit-test 시점이벤트 전파와 별개 이벤트의 구분
03 · 04 · 해결 방안과 결론
DOM을 바꾸는 시점을 click으로 옮겨 뒤따라올 이벤트 자체를 없앴다. 우회 플래그는 채택하지 않았다
결과
실기기 유령 클릭 재현 0건실기기 테스트를 QA 체크리스트에 추가
Svelte 5Pointer Events

사내 프로젝트라 이름은 A(사용자용 웹), B(어드민)로 표기한다. 이 글은 A에서 있었던 일이다.

문제

사용자용 웹 A의 피드에서 포스트를 누르면 상세 모달이 뜬다. 모달 바깥의 어두운 배경을 탭하면 닫힌다. 흔한 패턴이고 데스크탑에서는 아무 문제가 없었다.

모바일에서 이런 일이 생겼다.

  1. 피드에서 포스트 하나를 열어 상세 모달을 본다.
  2. 닫으려고 모달 바깥 배경을 탭한다.
  3. 모달은 닫히는데, 그 자리 뒤에 있던 다른 포스트 카드가 눌려서 그 포스트의 상세로 넘어간다.

사용자 입장에서는 "닫으려고 했는데 딴 게 열렸다"다. 배경 뒤에 있던 것이 좋아요 버튼이면 좋아요가 눌리고, 팔로우 버튼이면 팔로우가 된다. 의도하지 않은 조작이 데이터에 남는 종류의 버그라서 심각도를 중간 이상으로 봤다.

더 곤란했던 것은 재현 조건이다. 데스크탑 브라우저에서는 안 생긴다. 크롬 DevTools의 모바일 뷰(터치 에뮬레이션)에서도 안 생긴다. 실제 폰에서만 생겼다. 개발 중에 확인하는 두 가지 방법이 모두 "정상"이라고 말해 주는 상황이라, 발견 자체가 늦었다.

분석

구현은 이랬다. 배경 요소의 pointerup에서 모달을 닫는다. 다만 모달 안에서 누르기 시작해 밖에서 손을 떼는 경우(텍스트 드래그 등)에는 닫히지 않도록, pointerdown에서 "밖에서 눌렀는가"를 플래그로 기록해 두고 pointerup에서 둘 다 밖일 때만 닫았다.

<div
	class="backdrop"
	onpointerdown={(e) => (pressedOutside = e.target === e.currentTarget)}
	onpointerup={(e) => {
		if (pressedOutside && e.target === e.currentTarget) close(); // 여기서 모달 DOM이 사라진다
	}}
>

pointerup에서 닫는 것 자체는 자연스러운 선택처럼 보였다. 문제는 그 뒤에 무엇이 오는가였다.

입력 장치에 따라 이벤트 시퀀스가 다르다

브라우저가 한 번의 탭에서 만들어 내는 이벤트는 장치에 따라 다르다.

[데스크탑 마우스]
pointerdown → mousedown → pointerup → mouseup → click
(전부 같은 입력에서 연속으로 나온다)

[모바일 터치]
touchstart → pointerdown → touchend → pointerup
                                        │
                                        └─ 그 뒤 브라우저가 별도로 합성:
                                           mousedown → mouseup → click

터치 기기에서 click은 손가락이 만든 이벤트가 아니다. 마우스 없는 기기에서도 onclick으로 짠 웹이 동작하도록, 브라우저가 터치 시퀀스가 끝난 뒤 호환용 마우스 이벤트를 합성해서 따로 발사한다. 이 합성 이벤트는 pointerup과 같은 태스크 안에서 나오지 않는다. pointerup 핸들러가 끝나고, 그 안에서 바꾼 DOM이 반영된 뒤에 온다.

click의 타겟은 어떻게 정해지는가

click이 어느 요소로 가는지는 규칙이 있다.

마우스의 경우, UI Events 명세는 click의 타겟을 mousedown 타겟과 mouseup 타겟의 가장 가까운 공통 조상으로 정한다. 그리고 Pointer Events 명세에는 "시퀀스 도중 타겟이 DOM에서 제거되면 남은 이벤트를 그 요소로 보내지 않는다"는 조항이 있다. 데스크탑에서 pointerup에 모달을 지웠을 때 뒤 카드가 눌리지 않았던 것은, 이 규칙 덕에 click이 사라진 배경 요소나 그 조상으로 갔고 아래 카드로는 떨어지지 않았기 때문이다. 우연히 안전했던 것이다.

터치의 합성 click은 다르다. 합성 시점에 touchend 좌표로 다시 hit-test를 해서 그 좌표에 지금 있는 요소를 타겟으로 잡는다. 우리는 pointerup에서 이미 모달과 배경을 지웠다. 그 좌표에 남아 있는 것은 뒤 카드다. 그래서 click이 카드로 떨어졌다.

시간 →

[pointerup]  배경 요소가 타겟 ── 핸들러에서 close() ── 모달·배경 DOM 제거
                                                          │
[합성 click] 같은 좌표를 다시 hit-test ───────────────────┘
             → 그 자리에 있는 건 뒤 카드 → 카드의 onclick 실행

왜 에뮬레이션은 잡아내지 못했나

크롬 DevTools의 기기 모드는 화면 크기와 touch* 이벤트 발생을 흉내 낸다. 하지만 실기기 브라우저가 터치 시퀀스 뒤에 합성 마우스 이벤트를 어떤 타이밍에 어떤 방식으로 붙이는지까지 똑같이 재현하지는 않는다. 즉 "터치 이벤트가 발생하는가"는 테스트되지만 "합성 click이 어디로 가는가"는 테스트되지 않는다. 이 버그는 정확히 후자에 있었다.

처음 시도가 실패한 이유 — 전파를 막는 것과 다음 이벤트를 막는 것은 다르다

처음에는 pointerup의 캡처 단계에서 e.stopPropagation()을 걸어 아래로 가는 것을 막으려 했다. 효과가 없었다.

이벤트 전파(캡처 → 타겟 → 버블)는 하나의 이벤트가 트리를 오가는 경로다. stopPropagation은 그 경로를 끊는다. 하지만 click은 pointerup과 별개의 이벤트다. 별도로 만들어져 별도의 전파 경로를 처음부터 다시 탄다. pointerup의 경로를 어디서 끊든 click에는 아무 영향이 없다. "한 이벤트의 전파를 막는 것"과 "그 뒤에 올 다른 이벤트를 막는 것"을 같은 문제로 착각했던 것이다.

해결 방안

원인을 한 줄로 쓰면 **"DOM을 바꾸는 시점(pointerup)과 그 DOM으로 hit-test하는 시점(합성 click)이 어긋난다"**다. 해결은 이 어긋남을 없애는 것이고, 방법은 여럿이다.

선택지방법장점단점판단
A. 닫기 트리거를 click으로DOM 제거를 click 핸들러에서 한다뒤따라올 이벤트가 없어 통과 자체가 사라진다. 코드 한 줄 이동"밖에서 눌러 밖에서 뗐는가" 판정 로직을 click에서 읽어야 한다채택
B. 다음 click 한 번 무시pointerup에서 닫은 뒤 document에 캡처 리스너를 once로 걸어 다음 click을 preventDefault기존 구조 유지합성 click이 오지 않는 장치(마우스·펜)에서는 리스너가 남아 다음 정상 클릭을 삼킨다. 타이머로 풀면 브라우저별 합성 지연에 의존기각
C. 닫기를 한 프레임 미룬다requestAnimationFrame이나 setTimeout(0) 뒤에 DOM 제거합성 click이 배경에 떨어진다합성 시점은 명세로 보장되지 않는다. 기기·브라우저에 따라 깨질 수 있는 타이밍 코드기각
D. 모달 열린 동안 뒤 요소를 비활성모달 뒤 콘텐츠에 inert 속성click이 떨어져도 반응하지 않는다. 포커스도 갇혀 접근성에 유리이 버그 하나의 해결책으로는 범위가 넓다. inert 지원 범위 확인 필요접근성 개선으로 병행
E. 네이티브 <dialog>.showModal()브라우저가 나머지를 inert 처리표준 동작에 맡긴다모달 컴포넌트 전체 교체. 이 시점에 감당할 변경 범위가 아니다장기 검토

A가 가장 단순하면서 근본적이다. B와 C는 "합성 click이 온다"는 사실에 기대는 우회이고, 그 사실이 성립하지 않는 장치에서 새 버그를 만든다. 문제를 만든 어긋남을 없애는 것이 아니라 어긋남 위에 조건문을 하나 더 쌓는 셈이다.

결론

닫기 트리거를 pointerup에서 click으로 옮겼다. "밖에서 눌러 밖에서 뗐는가" 판정은 그대로 두었다. pointerdown에서 플래그를 기록하고, click 시점에 플래그와 타겟을 함께 확인한다.

<div
	class="backdrop"
	onpointerdown={(e) => (pressedOutside = e.target === e.currentTarget)}
	onclick={(e) => {
		if (pressedOutside && e.target === e.currentTarget) close(); // DOM 변경을 click 시점으로
		pressedOutside = false;
	}}
>

click은 시퀀스의 마지막 이벤트다. 여기서 DOM을 바꾸면 그 DOM으로 hit-test할 다음 이벤트가 없다. 데스크탑·모바일·에뮬레이션 어디서도 같은 시점에 같은 일이 일어난다.

감수한 부작용

click은 pointerup보다 늦게 온다. 옛 모바일 브라우저의 300ms 지연을 걱정했지만, 뷰포트 메타가 설정된 페이지에서는 이 지연이 없다는 것을 실기기에서 확인했다. 체감 차이는 없었다.

또 하나, 이 수정은 이 모달 하나를 고친 것이다. pointerup이나 mouseup에서 DOM을 지우는 다른 코드가 있으면 같은 버그가 있다. 그래서 코드베이스에서 onpointerup·onmouseup 핸들러를 전부 찾아 DOM 제거나 라우팅을 하는 곳이 없는지 확인했다. 두 곳이 더 있었고 같은 방식으로 옮겼다.

운영에서 보는 것

프론트엔드 상호작용 버그는 서버 로그에 남지 않는다. 그래서 확인 방법을 프로세스에 넣었다.

  1. 실기기 테스트를 QA 체크리스트에 추가했다. 모달·바텀시트·드롭다운처럼 배경 탭으로 닫히는 컴포넌트는 iOS Safari와 Android Chrome 실기기에서 "닫은 자리에 다른 요소가 있을 때"를 반드시 한 번 확인한다. 에뮬레이션은 이 항목을 대체하지 못한다는 것을 체크리스트에 명시했다.
  2. 모달이 닫힌 직후 짧은 시간 안에 일어나는 내비게이션을 개발 모드에서 경고로 찍는다. 모달 close() 시각을 기록해 두고, 그 뒤 100ms 안에 라우트 이동이나 좋아요·팔로우 mutation이 발생하면 콘솔에 "닫기 직후 조작" 경고를 남긴다. 유령 클릭이 다시 생기면 실기기에서 이 경고로 바로 잡을 수 있다.
  3. 컴포넌트 규칙 한 줄. "DOM을 제거하거나 라우트를 바꾸는 동작은 click에서 한다. pointerup/mouseup에서 해야 한다면 뒤따르는 click을 처리한 이유를 주석으로 남긴다." 리뷰 때 이 줄을 기준으로 본다.

남긴 것

이벤트 하나를 막았는데 왜 다른 이벤트가 오는지 이해하는 데 시간이 걸렸다. 브라우저는 입력 장치마다 다른 시퀀스를 만들고, 그 시퀀스 안의 이벤트들은 서로 독립적이다. **"내 핸들러가 끝난 뒤 브라우저가 무엇을 더 하는가"**를 알아야 DOM을 바꾸는 시점을 고를 수 있다. 그리고 개발 환경이 "정상"이라고 말해도, 그 환경이 실제로 무엇을 재현하고 무엇을 재현하지 않는지를 알아야 믿을 수 있다는 것도 배웠다.

사례 전체와 이력 보기