제한 시간 내에 빠르게 화면을 터치하는 웹뷰 캐주얼 게임.
한 손가락을 탭하면 1회,
여러 손가락을 동시에 누르면 손가락 수만큼 카운트가 올라가야 했다.
최대 8손가락 동시 입력을 지원해야 한다.

onClick으로는 안 되는 이유
onClick과 onPointerDown은 한 번에 하나의 이벤트만 발생한다.
손가락 3개를 동시에 누르면 3개의 이벤트가 순차적으로 발생하는 게 아니라, 동시에 발생해야 한다.
정확한 동시 입력 처리를 위해 Touch API를 사용했다.
Part1. Touch API로 동시 입력 받기
Touch API란?
W3C에서 정의한 웹 표준 인터페이스이다.
터치스크린 디바이스에서 손가락 입력을 처리하기 위한 이벤트 모델이다.
마우스 이벤트(click, mousedown 등)는 '포인터가 하나'라는 전제로 설계되어있다.
동시에 여러 지점을 누르는 개념 자체가 없다.
Touch API는 이 한계를 해결하기 위해 만들어졌다.
핵심 차이는 하나의 이벤트 객체 안에 여러 터치 포인트 정보를 배열로 담고 있다는 것이다.
Touch API의 구성 요소
Touch API의 구성 요소는 아래와 같다.
1. Touch
- 개별 터치 포인트를 나타내는 객체
- clientX, clientY(뷰포트 기준 좌표), identifier(각 손가락을 추적하는 고유 ID) 등의 속성을 가지고 있다.
2. TouchList
- Touch 객체를 담는 전용 그릇
e.changedTouches // TouchList { 0: Touch, 1: Touch, length: 2 }
e.changedTouches[0] // Touch { clientX: 120, clientY: 340, identifier: 0, ... }
e.changedTouches.length // 2
3. TouchEvent
- Touch 이벤트는 세 종류이다
- touchstart: 손가락이 화면에 닿을 때
- touchmove: 손가락이 화면 위에서 움직일 때
- touchend: 손가락이 화면에서 떨어질 때
- 각 이벤트 객체에서는 세 가지 TouchList가 있다.
- touches: 현재 화면에 닿아 있는 모든 손가락
- targetTouches: 현재 화면에 닿아 있는 손가락 중 이 요소에서 시작된 것만
- changedTouches - 이번 이벤트를 발생시킨 손가락만
예시 상황: 손가락 3개를 동시에 화면에 댔을 때
1. 손가락마다 Touch 객체를 하나씩 만든다 (좌표, 식별자 등을 담음)
2. 이 Touch 객체 3개를 TouchList에 넣는다.
3. 이 TouchList를 TouchEvent의 changedTouches 속성에 붙여서 이벤트를 발생시킨다.
핸들러에서 받는 e의 구조는 이렇다.
TouchEvent {
changedTouches: TouchList [
Touch { clientX: 100, clientY: 200, identifier: 0 },
Touch { clientX: 300, clientY: 400, identifier: 1 },
Touch { clientX: 150, clientY: 350, identifier: 2 },
],
touches: TouchList [...],
targetTouches: TouchList [...],
}
마우스 이벤트는 e.clientX로 좌표에 바로 접근하지만,
터치 이벤트는 e.changedTouches[i].clientX로 몇 번째 손가락의 좌표에 접근한다.
이번 구현에서 changedTouches를 쓴 이유
'이번에 새로 닿은 손가락'만 세야 하기 때문이다.
e.changedTouches - 이번 이벤트에서 새로 닿은 터치 포인트 배열
e.touches - 현재 화면에 닿아있는 전체 터치 포인트 배열
동시에 3개 손가락을 누르면 changedTouches.length===3
만약 touches를 썼다면 이미 화면에 닿아 있던 손가락까지 매번 세게 된다.
이미 손가락 2개가 닿아있는 상황에서 3개를 추가로 누른 상황을 보자.
touches.length는 5지만,
changedTouches.length는 3이다.
카운트에 반영해야 할 것은 5가 아니라 3이다!
그래서 changedTouches가 핵심이라고 말한 것이다.
Touch Event 안전하게 처리하기
preventDefault와 click synthesis
브라우저에는 터치-클릭 호환 메커니즘이 있다.
모바일 웹 초창기에 대부분 웹사이트는 click 이벤트만 처리했다.
따라서, 브라우저가 touchstart->touchend 이후에 약 300ms 딜레이를 두고
click 이벤트를 자동으로 만들어 보내는 구조를 만들었다.
터치 디바이스에서도 기존 click 기반 웹사이트가 작동하도록 하기 위한
하위 호환 장치이다.
이를 click synthesis라고 부른다.
따라서, 이 click synthesis 때문에
터치 디바이스에서 touchstart 발생 후 브라우저가 자동으로 click 이벤트도 발생 시킨다.
preventDefault()를 하지 않으면, 터치 1회에 카운트가 2회 올라간다.
'터치 이벤트를 직접 처리할테니, 브라우저가 click 이벤트를 만들지 마라'라는 선언을
preventDefault()를 사용해 하는 것이다.
const handler = (e: TouchEvent) => {
e.preventDefault()
const touchCount = Math.min(e.changedTouches.length, MAX_TOUCHES)
addCount(touchCount)
}
el.addEventListener('touchstart', handler, { passive: false })
passive: false 설정
단, preventDefault()를 쓰려면 이벤트 리스너를 {passive: false}로 등록해야 한다.
터치 이벤트에서 preventDefault()를 호출하면 스크롤도 차단된다.
브라우저 관점
브라우저 입장에서는 매 터치 이벤트마다
'이 핸들러가 preventDefault를 호출할지 안할지'를 JS 실행 끝날 때까지 기다려야 한다.
사용자 손가락 터치
↓
touchstart 이벤트 발생
↓
JS 핸들러 실행 시작 (여기서 preventDefault() 호출할 수도 있음)
↓
... JS 실행 중 ... (얼마나 걸릴지 모름)
↓
JS 핸들러 실행 완료
↓
브라우저: "preventDefault() 호출됐나? 안 됐나?"
↓
안 됐으면 → 이제서야 스크롤 시작
됐으면 → 스크롤 안 함
JS 핸들러 안에서 무거운 연산을 하고 있으면, 그만큼 브라우저가 스크롤을 시작하지 못하고 대기한다.
핸들러가 끝나야 '아 preventDefault 안했네, 그럼 스크롤 하자'라고 판단할 수 있는 것이다.
이 때문에 스크롤이 버벅이는 현상이 발생한다.
그래서 Chrome 51부터 터치/휠 이벤트의 기본 값이 passive: true가 됐다.
(일반 웹페이지 - 뉴스 기사, SNS 피드 등에서 스크롤 경험을 지키기 위해)
이 상태에서는 preventDefault()를 호출해도 무시되기 때문에 명시적으로 {passive:false}를 넣어주는 것이다.
React 관점
React에서 JSX로 터치 이벤트를 등록할 땐 이렇게 한다.
<div onTouchStart={(e) => {
e.preventDefault() // 무시됨!
handleTouch(e)
}} />
하지만 React는 내부적으로 이 이벤트 리스너를 등록할 때 passive: true로 등록한다.
React가 성능을 위해 이런 판단을 한 것이다. (passive: true가 스크롤 버벅임을 방지하므로)
그래서 JSX의 onTouchStart property에서 e.preventDefault()를 호출해도
아무 일도 일어나지 않는다. (무시됨)
브라우저가 '이 리스너는 passive:true니까 preventDefault 무시할게'라고 판단하는 것이다.
그래서 useEffect 내부에서 직접 addEventListener로 등록하는 것이다.
// React JSX 방식 — passive: true (React가 결정)
<div onTouchStart={handler} />
// addEventListener 방식 — passive: false (우리가 결정)
el.addEventListener('touchstart', handler, { passive: false })
그럼, 이런 의문이 들 수 있다.
'아니 스크롤 버벅임이 안생기게 브라우저랑 React에서
default true로 설정해둔 걸 이렇게 강제로 false 해도 돼?'
이 게임으로 상황을 한정지으면 '그렇다'라고 답변할 수 있다.
게임 화면 자체가 스크롤이 없는 고정 레이아웃이라 스크롤 버벅임은 상관없다.
그리고, 게임 영역에서 터치가 발생하면 스크롤이 아니라 게임 입력으로 처리해야하니 스크롤을 차단하는게 맞다.
동시 입력 상한 설정
const MAX_TOUCHES = 8
const MAX_EFFECTS = 16
사용자 입력에 비례해서 무제한으로 리소스가 생성되는 구조에서는
반드시 상한 값을 둬야 한다.
changedTouches.length를 Math.min으로 8개에서 잘라서,
비정상적 입력에서도 예측 가능한 동작을 보장한다.
이펙트도 동일하게 최신 N개만 유지한다.
setEffects(prev => [...prev, ...newEffects].slice(-MAX_EFFECTS))
좌표 변환 + rect 캐싱
각 터치 포인트에 이펙트를 띄워야 한다.
changedTouches를 순회하면서 각 손가락 위치를 게임 영역 내 상대 좌표로 변환한다.
const rect = rectRef.current!
for (let i = 0; i < touchCount; i++) {
const touch = e.changedTouches[i]
effects.push({
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
})
}
여기서 rectRef는 마운트 시 1회만 getBoundingClientRect()를 호출하고 캐싱한 값이다.
매 터치마다 호출하면 강제 리플로우가 발생한다.
callbackRef 패턴: stale closure 방지
const callbackRef = useRef(onTap)
callbackRef.current = onTap
useEffect([],[])로 마운트 시 한 번만 addEventListener를 등록하면,
그 핸들러 함수는 마운트 시점의 onTap 값을 클로저로 캡처한다.
이후 부모가 리렌더되어 새로운 onTap 함수를 내려줘도,
이미 등록된 핸들러는 여전히 옛날 onTap을 참조한다.
stale closure 문제가 발생하는 것이다.
이는 세 가지 방법으로 해결할 수 있다.
1. deps에 onTap 추가
useEffect의 deps에 onTap을 넣어서 변경될 때마다 리스너를 제거/재등록한다.
하지만, 부모가 리렌더 될 때마다 removeEventListener+addEventListener가 반복된다.
이벤트가 자주 발생하는 멀티 터치 게임에서는 비효율적인 방법이다.
2. useCallback으로 안정화
부모에서 onTap을 useCallback으로 감싸서 참조를 안정화시킨다.
하지만, 부모 쪽에 제약을 걸게 되고 deps 관리가 복잡해질 수 있다.
3. callbackRef 패턴
useRef에 매 렌더마다 최신 콜백을 넣어두고,
핸들러에서 callbackRef.current()를 호출한다.
이벤트 리스너는 마운트 시 한 번만 등록하면 되고,
항상 최신 콜백을 참조할 수 있다.
이번 구현에서는 3번을 선택했는데
{passive: false} 리스너를 매번 재등록하는 것 자체가 불필요한 오버헤드이고,
터치 핸들러가 초당 수십 회 호출되는 상황에서 리스너 등록/해제 사이에 이벤트를 놓칠 수 있기 때문이다.
결론
지금까지의 내용을 하나의 시나리오로 연결하면 이렇다.
사용자가 3손가락으로 동시에 화면을 터치한다.
touchstart 발생하고, changedTouches에 Touch 객체 3개가 담긴다.
e.preventDefault()를 설정해 click 이벤트 이중 발생을 막는다.
현재 동시 클릭 중인 손가락 갯수와 동시 입력 상한 상수 값을 비교한다.
Math.min(3, MAX_TOUCHES) -> touchCount=3
상태 머신에 TAP 이벤트를 전달한다. -> count +3
캐싱된 rect로 각 터치 좌표를 계산한다. -> 이펙트 3개 생성
멀티 터치 이벤트를 구현하기 위해 별도의 라이브러리가 필요할 줄 알았는데
기본 이벤트 기능으로 구현할 수 있다는 사실에 좀 놀랐다.
Touch API 자체는 간단했는데,
의도대로 작동시키려면 preventDefault, passive, stale closure 같은 주변 문제들을 하나씩 해결해야 했다.
기능 하나를 구현하려다 브라우저 내부 동작까지 리서치하게 됐는데,
이런 식으로 좀 코어한 지식을 습득할 때마다 내가 성장하는 것 같아 기쁘다.
뭔가 주제를 각잡고 잡는 것보다
구현을 하며 마주치는 문제를 top-down으로 풀어나가면서 습득할 때 더 기억에 오래남는 것 같다.
이 멀티 터치를 구현하며 발생하는 리렌더+발열 이슈를 해결하는 과정은 아래 글에서 확인할 수 있다.
웹뷰 캐주얼 게임 렌더링 최적화 - GPUWatch 수치로 검증하기
: https://developer-dreamer.tistory.com/230
작성: 2026.03.22. 21:11
'개발자 강화 > 프론트엔드' 카테고리의 다른 글
| 웹뷰 캐주얼 게임 렌더링 최적화: GPUWatch 수치로 검증하기 (1) | 2026.03.22 |
|---|---|
| [토스증권 테크톡톡] 26/02/24 - 인사이트 정리 (애니메이션 최적화, DS커스터마이징 등) (0) | 2026.02.28 |
| matter.js - 물리엔진에서 물리를 빼면 남는 것 (0) | 2026.02.12 |
| ThorVG에 대해 알아봅시다! (feat. 로띠/닷로띠 차이점) (1) | 2025.12.23 |
| [매일메일+개발] 쌓임 맥락 + 실무 경험담 (0) | 2025.11.29 |