← Blog
Computer Science / 웹

모바일 터치는 어떻게 click이 되는가 — touch-action · viewport · hover

한 줄 요약

물리적 입력(터치/클릭)은 드라이버 → OS → 브라우저 프로세스 → 렌더러 컴포지터 → 렌더러 메인 스레드를 거치고, 메인 스레드가 히트 테스트로 타겟을 찾아 이벤트 객체를 만든 뒤 캡처링 → 타겟 → 버블링으로 DOM에 디스패치하면, 이벤트 루프가 해당 핸들러를 태스크 큐에서 꺼내 실행한다. 모바일에서 click이 작동하는 건 브라우저가 touchend 직후 가상 마우스 이벤트를 합성해주기 때문이다.


왜 알아야 했나

  • touch-pan-x(touch-action: pan-x) 같은 CSS 속성의 뜻이 궁금했음
  • 모바일에서 터치만 했는데 왜 click 이벤트가 작동하는지 원리를 알고 싶었음
  • 터치할 때 이벤트가 어떤 순서로, 어떤 경로로 전파되는지 이해하고 싶었음
  • "클릭이 일어났다"는 물리적 사실이 JS 핸들러까지 도달하는 전체 과정이 궁금했음

핵심 개념

1. touch-action CSS 속성

요소에서 어떤 터치 동작을 브라우저가 처리할지 지정하는 속성.

의미
auto모든 기본 터치 동작 허용 (기본값)
none모든 브라우저 기본 터치 동작 비활성화 (JS로 직접 제어)
pan-x가로 스크롤만 허용
pan-y세로 스크롤만 허용
manipulation패닝·줌만 허용, 더블탭 줌 비활성화
  • touch-pan-x = touch-action: pan-x → 좌우 패닝만 허용, 상하 스크롤·핀치 줌 비활성화
  • 캐러셀(좌우 슬라이드), 드래그 UI 등에서 자주 사용

touch-action이 성능에 주는 영향 (왕복 제거)

  • 브라우저의 딜레마: 손가락 움직임이 (A) 그냥 스크롤인지 (B) JS가 preventDefault()로 막을 동작인지, 핸들러를 실행해보기 전엔 알 수 없음
  • 선언이 없으면 → 컴포지터 스레드가 "혹시 JS가 막을지도?" 하고 메인 스레드의 답을 기다림 → 메인 스레드가 바쁘면 스크롤이 늦게 시작되어 버벅임(jank)
  • 선언이 있으면 (pan-y 등) → "JS가 이 동작에 개입하지 않는다"는 사전 보장 → 컴포지터가 메인 스레드 왕복(round-trip)을 건너뛰고 즉시 스크롤 → 지연 없음
  • 핵심: touch-action은 컴포지터에게 "기다릴 필요 없다"는 약속을 주는 것

2. 모바일에서 click이 작동하는 원리

모바일 브라우저는 손가락 탭을 감지하면 터치 이벤트를 발생시킨 뒤, 마지막에 가상(synthesized) 마우스 이벤트를 합성해서 발사한다. (데스크톱 호환성 목적)

탭 1회 시 이벤트 발생 순서

touchstart
touchmove   (손가락 미세 이동 시 0회 이상)
touchend
mouseover   ┐
mouseenter  │
mousemove   │ ← 여기부터가 브라우저가 합성한
mousedown   │   "가상 마우스 이벤트"
mouseup     │
click       ┘
  • 합성 마우스 이벤트들은 touchend 직후 한꺼번에 만들어짐
  • 그래서 click 핸들러만 등록해도 모바일에서 동작함

300ms 지연 이슈 (과거)

  • 예전 브라우저는 "더블탭 줌"인지 판단하려고 touchend약 300ms 기다린 뒤 click 발사 → 버튼이 굼떠 보임
  • 해결: 반응형 viewport 선언 또는 touch-action: manipulation

합성 이벤트가 취소되는 경우 ⚠️

  • touchstarttouchend에서 preventDefault()를 호출하면 → 뒤따르는 가상 마우스 이벤트와 click이 발사되지 않음
  • 이걸 모르면 "click이 안 먹는" 버그 발생

3. <meta name="viewport" ...>

<meta name="viewport" content="width=device-width, initial-scale=1">
  • width=device-width → 페이지 너비를 기기 실제 화면 너비(CSS 픽셀)에 맞춤. 없으면 약 980px 데스크톱 화면으로 가정하고 축소 표시 → 글씨가 깨알같이 작아짐
  • initial-scale=1 → 첫 로드 시 확대/축소 비율 1:1 (줌 없이 원본 크기)
  • 정리: "데스크톱처럼 축소하지 말고 이 기기 폭에 맞춰 원본 비율로 그려라"
  • 반응형 웹 필수 태그. 선언되면 브라우저가 더블탭 줌 불필요로 판단 → 300ms 지연도 사라짐
  • 추가 옵션: maximum-scale=1, user-scalable=no (확대 차단, 단 접근성상 비권장)

4. 길게 누르기 (long press)

손가락을 떼지 않고 꾹 누르면 OS/브라우저의 기본 컨텍스트 동작(텍스트 선택, 이미지 저장 메뉴, 콜아웃 팝업 등) 발동.

touchstart        ← 닿는 순간 즉시 발생 (떼지 않아도 발생)
(손가락 유지...)
contextmenu       ← OS가 롱프레스로 판단하면 발생 (기기/브라우저마다 다름)
  • touchstart닿는 즉시 발생 (떼지 않아도)
  • 손가락을 떼지 않으면 → touchend 발생 안 함 → 그 뒤 합성되는 mousedown/mouseup/click도 발생 안 함
  • 롱프레스가 OS 메뉴를 띄우면 떼더라도 click이 안 나올 수 있음

차단용 CSS:

.no-callout {
  -webkit-touch-callout: none;  /* iOS 롱프레스 메뉴 차단 */
  user-select: none;            /* 텍스트 선택 차단 */
}

5. :hover의 작동 원리

  • :hovermouseover 이벤트를 기준으로 판단하지 않음
  • :hover는 "포인팅 디바이스(마우스 커서)가 현재 그 요소 위에 올라가 있는 상태"를 나타내는 상태 선택자
  • 데스크톱: 지속적으로 존재하는 마우스 커서가 있어 머무는 동안 계속 적용
  • 모바일: 손가락은 닿거나 떨어지거나 둘 중 하나, "머무르는 커서" 개념이 없음 → 진정한 hover가 존재하지 않음
  • 모바일에서 :hover가 가끔 보이는 이유: 탭 순간 브라우저가 :hover 상태를 잠깐 적용했다가 다른 곳 탭하면 해제 → "탭했더니 hover 스타일이 남아있는" 어색한 현상
  • 둘 다 "포인터가 요소 위에 있다"는 사실에서 비롯되지만 작동 메커니즘은 별개

6. 이벤트 전파 3단계

<body><div><button> 구조에서 button을 누르면:

① 캡처링 (Capturing, 내려감)
   window → document → html → body → div → button

② 타겟 (Target)
   button 도달

③ 버블링 (Bubbling, 올라감)
   button → div → body → html → document → window
  • 기본적으로 대부분 핸들러는 버블링 단계에서 실행
  • 캡처링에서 잡으려면 세 번째 인자 사용
el.addEventListener('click', handler);                 // 버블링
el.addEventListener('click', handler, true);           // 캡처링
el.addEventListener('click', handler, { capture: true }); // 캡처링

전파 제어 메서드

메서드효과
e.stopPropagation()다음 요소로의 전파(버블링/캡처링) 중단
e.stopImmediatePropagation()전파 중단 + 같은 요소의 나머지 핸들러도 실행 안 함
e.preventDefault()전파는 계속되나 브라우저 기본 동작(링크 이동, 폼 제출, 터치→마우스 합성 등) 차단
  • stopPropagation(다른 요소로 퍼지는 것 차단) ≠ preventDefault(기본 동작 차단)

7. 입력이 JS 핸들러까지 도달하는 전체 과정 (5계층)

물리 입력 (손가락/마우스)
   ↓
OS 커널 (디바이스 드라이버)
   ↓
브라우저 프로세스 (입력 수신)
   ↓
렌더러 프로세스 (히트 테스트 → DOM 이벤트 생성)
   ↓
JavaScript 이벤트 루프 (핸들러 실행)
  1. 하드웨어 → OS 커널: 터치스크린이 전하 변화 감지 → 컨트롤러 칩이 좌표(x,y)+압력+시간으로 변환 → 인터럽트로 드라이버에 전달. 아직 "클릭"이 아닌 원시 좌표(raw input)
  2. OS → 브라우저 프로세스: OS가 좌표가 속한 창을 판단해 브라우저 메인 프로세스에 전달
  3. 브라우저 프로세스 → 렌더러 프로세스: IPC로 해당 탭 렌더러에 전달. 컴포지터 스레드가 먼저 받음
    • 핸들러 없으면 → 메인 스레드 안 거치고 즉시 스크롤 처리 (부드러움)
    • 핸들러 있을 가능성 있으면 → 메인 스레드로 넘김
    • 여기서 touch-action이 작동
  4. 렌더러 메인 스레드:
    • (a) 히트 테스트: "이 (x,y)에 어떤 DOM 요소가 있나?" 레이아웃 트리로 계산 → event.target 확정
    • (b) 이벤트 객체 생성: MouseEvent/PointerEvent 객체 생성 (type, target, clientX/Y, timeStamp, bubbles 등)
    • (c) 마우스 이벤트 합성(모바일): touchendmousedown→mouseup→click 합성
  5. 이벤트 디스패치 → JS 실행: 캡처링 → 타겟 → 버블링으로 전파하며 핸들러 실행. JS는 싱글 스레드 + 이벤트 루프 → 디스패치 = 콜백을 태스크 큐에 넣기 → 콜 스택 비면 이벤트 루프가 꺼내 실행

8. 히트 테스트 & "가장 위쪽"의 정확한 의미

  • 히트 테스트의 "가장 위쪽"은 DOM 트리의 위/아래가 아니라, 화면에서 사용자 쪽으로 가장 가까운(맨 앞에 그려진) 요소 = Z축, z-index/쌓임 순서 기준
  • 예: 버튼 위에 반투명 모달이 겹쳐 있으면 → 좌표상 둘 다 있지만 맨 앞 모달이 히트 테스트 결과
  • 이 요소가 곧 event.target이고, 캡처링이 끝나고 도달하는 타겟 단계의 바로 그 노드

target 확정 순서 ⚠️

1. 히트 테스트: Z축 맨 앞 요소를 찾음 → target으로 확정
2. target 기준으로 전파 경로 계산 (window → ... → target → ... → window)
3. 캡처링 → 타겟 → 버블링
  • target은 전파 시작 전에 이미 확정됨 (캡처링하며 정해지는 게 아님)
  • 캡처링 단계 핸들러에서도 event.target은 이미 최종 타겟을 가리킴
  • target(고정) vs currentTarget(현재 핸들러 달린 요소, 단계마다 바뀜) 구분 중요

9. Event 객체가 JS로 전달·실행되는 원리

⚠️ 오해 정리: 이벤트 객체를 별도 프로세스로 "전송"하는 게 아님. 이벤트 객체를 만드는 주체와 JS 실행 주체가 같은 렌더러 메인 스레드.

  • 렌더러 메인 스레드 안에 렌더링 엔진(Blink) + **JS 엔진(V8)**이 함께 들어 있고, 하나의 메인 스레드를 번갈아 사용

실제 흐름

[렌더러 메인 스레드]
1. 렌더링 엔진(Blink)이 히트 테스트로 target 결정
2. C++로 MouseEvent 객체 생성
3. C++ 객체에 JS 래퍼를 씌움 (Web IDL 바인딩)
4. 전파 경로에서 디스패치할 핸들러 목록 수집
5. 각 핸들러를 순서대로 V8에 "이 함수를 이 이벤트 객체로 호출" 요청 → V8이 JS 실행
6. 핸들러 안 e.target, e.clientX 등 읽으면 → JS 래퍼 통해 C++ 객체 실제 값 읽음

이벤트 루프와의 관계

1. 입력 도착 → "이 click을 디스패치하라" 태스크가 큐에 등록
2. 이벤트 루프가 콜 스택 빌 때까지 대기
3. 콜 스택 비면 → 큐에서 태스크 꺼냄
4. 태스크 실행 = 전파 경로 따라 핸들러들을 차례로 V8로 호출
5. 그 태스크 하나가 캡처링~버블링 전부 동기 처리
  • 한 번의 클릭에서 캡처링·타겟·버블링 모든 핸들러는 같은 태스크 안에서 한 번에 동기 실행 (중간에 다른 태스크 안 끼어듦)
  • 단, 핸들러 안 setTimeout/await은 별도 태스크/마이크로태스크로 분리
  • 메인 스레드가 무거운 JS로 바쁘면 → 클릭 핸들러 실행도 그만큼 지연 (페이지 "먹통")

예시 / 코드

캐러셀에서 touch-action

.carousel {
  touch-action: pan-x; /* 좌우만 허용, 세로 스크롤·줌 차단 */
}

반응형 viewport

<meta name="viewport" content="width=device-width, initial-scale=1">

hover를 실제 hover 가능 기기에만 적용

@media (hover: hover) {
  .btn:hover { background: blue; }  /* 마우스 있는 기기에서만 */
}

버블링 활용 — 이벤트 위임 (가장 흔함)

// 자식마다 핸들러 달지 않고 부모 ul에 하나만
document.querySelector('#todo-list').addEventListener('click', (e) => {
  if (e.target.matches('.delete-btn')) {
    e.target.closest('li').remove();
  }
});
// 자식 click이 부모로 버블링 → 동적 추가 항목도 자동 처리, 메모리 효율 ↑

캡처링 활용 1 — 모달 바깥 클릭을 자식보다 먼저 차단

document.addEventListener('click', (e) => {
  if (isModalOpen && !e.target.closest('.modal')) {
    e.stopPropagation();   // 자식 핸들러로 내려가기 전에 차단
    e.preventDefault();
    closeModal();
  }
}, true);  // ← 캡처링 단계

캡처링 활용 2 — 자식이 stopPropagation해도 무조건 먼저 로깅

document.addEventListener('click', (e) => {
  logToAnalytics(e.target);  // 자식이 전파 막기 전에 캡처 단계에서 먼저 실행
}, true);

헷갈렸던 점

  • touch-action이 지연을 줄인다 → 컴포지터가 "JS가 preventDefault()로 막을지" 메인 스레드에 물어보는 왕복을, 사전 보장으로 건너뛰는 것
  • 히트 테스트의 "가장 위쪽" → DOM 트리 상하가 아니라 화면 Z축(z-index)상 맨 앞 요소
  • target 확정 시점 → 캡처링하며 정해지는 게 아니라 전파 시작 전에 이미 확정
  • Event 객체 "전달" → 별도 프로세스 전송이 아니라, 같은 메인 스레드 안에서 Blink(C++ 객체 생성)와 V8(핸들러 실행)이 협력
  • :hover의 기준mouseover 이벤트가 아니라 포인터의 현재 위치 상태
  • stopPropagation vs preventDefault → 전자는 전파 차단, 후자는 기본 동작 차단 (서로 다른 개념)
  • 손가락을 안 떼면touchend 미발생 → 합성 마우스 이벤트·click도 미발생

실무 적용

  • 캐러셀/슬라이더/드래그 UI: touch-action: pan-x 또는 pan-y로 스크롤 충돌 방지 + 성능 개선
  • 버튼 반응 속도 개선: 반응형 viewport 선언 또는 touch-action: manipulation으로 300ms 지연 제거
  • click이 안 먹는 버그 디버깅: touchstart/touchend에서 preventDefault() 호출 여부 확인
  • 모바일 hover 잔상 처리: @media (hover: hover)로 마우스 기기에만 hover 스타일 적용
  • 동적 리스트 처리: 버블링 기반 이벤트 위임으로 핸들러 1개만 등록
  • 모달 바깥 클릭 닫기 / 전역 분석 로깅: 캡처링 단계 활용
  • 스크롤 버벅임(jank) 최적화: 무거운 입력 핸들러 정리 + touch-action 명시로 컴포지터 왕복 제거
  • 롱프레스 메뉴 제어: -webkit-touch-callout, user-select, contextmenu preventDefault
  • 관련 작업: [[ ]]

더 공부할 것

  • Pointer Events (pointerdown/pointermove/pointerup) — 마우스·터치·펜을 통합 처리하는 표준
  • Passive Event Listener ({ passive: true }) — preventDefault 안 한다고 미리 알려 스크롤 성능 향상 (touch-action과 연관)
  • 이벤트 루프 심화 — 태스크 큐 vs 마이크로태스크 큐, setTimeout vs Promise 실행 순서
  • 컴포지터 스레드 / 렌더링 파이프라인 — Layout → Paint → Composite 단계
  • target vs currentTarget vs relatedTarget 차이 정리
  • passive listener와 touch-action의 조합 베스트 프랙티스
  • 접근성: user-scalable=no가 왜 비권장인지, 키보드/스크린리더 이벤트 처리

🔗 참고