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
합성 이벤트가 취소되는 경우 ⚠️
touchstart나touchend에서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의 작동 원리
:hover는mouseover이벤트를 기준으로 판단하지 않음: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 이벤트 루프 (핸들러 실행)
- 하드웨어 → OS 커널: 터치스크린이 전하 변화 감지 → 컨트롤러 칩이 좌표(x,y)+압력+시간으로 변환 → 인터럽트로 드라이버에 전달. 아직 "클릭"이 아닌 원시 좌표(raw input)
- OS → 브라우저 프로세스: OS가 좌표가 속한 창을 판단해 브라우저 메인 프로세스에 전달
- 브라우저 프로세스 → 렌더러 프로세스: IPC로 해당 탭 렌더러에 전달. 컴포지터 스레드가 먼저 받음
- 핸들러 없으면 → 메인 스레드 안 거치고 즉시 스크롤 처리 (부드러움)
- 핸들러 있을 가능성 있으면 → 메인 스레드로 넘김
- 여기서
touch-action이 작동
- 렌더러 메인 스레드:
- (a) 히트 테스트: "이 (x,y)에 어떤 DOM 요소가 있나?" 레이아웃 트리로 계산 →
event.target확정 - (b) 이벤트 객체 생성:
MouseEvent/PointerEvent객체 생성 (type,target,clientX/Y,timeStamp,bubbles등) - (c) 마우스 이벤트 합성(모바일):
touchend후mousedown→mouseup→click합성
- (a) 히트 테스트: "이 (x,y)에 어떤 DOM 요소가 있나?" 레이아웃 트리로 계산 →
- 이벤트 디스패치 → JS 실행: 캡처링 → 타겟 → 버블링으로 전파하며 핸들러 실행. JS는 싱글 스레드 + 이벤트 루프 → 디스패치 = 콜백을 태스크 큐에 넣기 → 콜 스택 비면 이벤트 루프가 꺼내 실행
8. 히트 테스트 & "가장 위쪽"의 정확한 의미
- 히트 테스트의 "가장 위쪽"은 DOM 트리의 위/아래가 아니라, 화면에서 사용자 쪽으로 가장 가까운(맨 앞에 그려진) 요소 = Z축, z-index/쌓임 순서 기준
- 예: 버튼 위에 반투명 모달이 겹쳐 있으면 → 좌표상 둘 다 있지만 맨 앞 모달이 히트 테스트 결과
- 이 요소가 곧
event.target이고, 캡처링이 끝나고 도달하는 타겟 단계의 바로 그 노드
target 확정 순서 ⚠️
1. 히트 테스트: Z축 맨 앞 요소를 찾음 → target으로 확정
2. target 기준으로 전파 경로 계산 (window → ... → target → ... → window)
3. 캡처링 → 타겟 → 버블링
- target은 전파 시작 전에 이미 확정됨 (캡처링하며 정해지는 게 아님)
- 캡처링 단계 핸들러에서도
event.target은 이미 최종 타겟을 가리킴 target(고정) vscurrentTarget(현재 핸들러 달린 요소, 단계마다 바뀜) 구분 중요
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이벤트가 아니라 포인터의 현재 위치 상태stopPropagationvspreventDefault→ 전자는 전파 차단, 후자는 기본 동작 차단 (서로 다른 개념)- 손가락을 안 떼면 →
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,contextmenupreventDefault - 관련 작업: [[ ]]
더 공부할 것
- Pointer Events (
pointerdown/pointermove/pointerup) — 마우스·터치·펜을 통합 처리하는 표준 - Passive Event Listener (
{ passive: true }) —preventDefault안 한다고 미리 알려 스크롤 성능 향상 (touch-action과 연관) - 이벤트 루프 심화 — 태스크 큐 vs 마이크로태스크 큐,
setTimeoutvsPromise실행 순서 - 컴포지터 스레드 / 렌더링 파이프라인 — Layout → Paint → Composite 단계
targetvscurrentTargetvsrelatedTarget차이 정리- passive listener와
touch-action의 조합 베스트 프랙티스 - 접근성:
user-scalable=no가 왜 비권장인지, 키보드/스크린리더 이벤트 처리
🔗 참고
- MDN —
touch-action: https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action - MDN — Introduction to events: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events
- MDN — Event bubbling and capture: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Event_bubbling
- MDN — Viewport meta tag: https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag
- MDN —
:hover: https://developer.mozilla.org/en-US/docs/Web/CSS/:hover - web.dev — Inside look at modern web browser (렌더러/컴포지터/입력 처리): https://developer.chrome.com/blog/inside-browser-part1
- https://claude.ai/share/1da86b73-755f-470b-82d1-0402b1e3d89d