포인터React모바일

손가락 하나를 추적하는 데 세 번 고쳤다

2026.08.02

「똥 말랑」 힘주기 화면. 큰 숫자 5와 「자, 힘줘볼까요」 문구 아래 눈을 질끈 감기 시작한 고양이가 누워 있다

배가 아플 때 리듬을 잡아주는 걸 만들었다. 고양이 배를 누르면 힘주기 5초, 떼면 심호흡 3초. 그 사이클을 반복하다 「다 쌌어요」를 누르면 끝난다.

로직은 전부 순수 모듈로 빼서 단위 테스트로 덮었다. 상태 머신, 문구, 집계, 햅틱 전부. 그래서 만드는 건 빨랐다.

깨진 건 전부 그 바깥이었다. 손가락 하나를 추적하는 일에서만 세 번 고쳤다.

1라운드: 두 번째 손가락이 힘주기를 끝냈다

배를 누른 채로 다른 손가락이 화면을 스치면 힘주기가 끊겼다.

당연했다. pointerup이든 pointercancel이든 pointerleave든, 어느 손가락에서 왔는지 안 보고 그냥 release로 처리하고 있었다.

그래서 힘주기를 시작한 포인터의 id를 ref로 기억하게 했다.

const activePointerId = useRef<number | null>(null);

release 쪽에서 id를 확인한다.

if (e.pointerId !== activePointerId.current) return;
activePointerId.current = null;

이걸로 스치는 손가락은 걸러졌다. 그런데 아직 반쪽이었다.

2라운드: 두 번째 손가락이 소유권을 가로챘다

release만 확인하고 press는 무조건 ref를 덮어쓰고 있었다.

그래서 이런 순서가 가능했다.

  1. 첫 손가락이 배를 누른다 → activePointerId = 1
  2. 두 번째 손가락이 아무 데나 누른다 → activePointerId = 2 (가로채기)
  3. 두 번째 손가락을 뗀다 → id가 맞으니 release 발생
  4. 첫 손가락은 아직 배를 누르고 있는데 힘주기가 끝난다

press 쪽에도 가드를 넣었다.

if (activePointerId.current !== null) return; // 이미 다른 손가락이 누르고 있다
activePointerId.current = e.pointerId;

이미 추적 중이면 다른 손가락의 press도 release도 완전히 무시한다. 소유권은 먼저 잡은 쪽이 끝까지 갖는다.

3라운드: 고친 게 회귀를 만들었다

2라운드의 null 가드는 정확했다. 그리고 새 버그를 만들었다.

힘주는 도중에 「다 쌌어요」로 세션이 끝나면 포인터 컨테이너가 언마운트된다. 그러면 그 손가락의 pointerup을 받을 핸들러가 사라진다. activePointerId는 그 손가락 id를 든 채로 영원히 남는다.

그 뒤로는 2라운드에서 넣은 가드가 정확히 반대로 작동한다. activePointerId.current !== null이니까 모든 press가 무시된다. 새로고침 전까지 앱이 죽은 것처럼 보였다.

고친 건 세 줄이다.

if (!running) activePointerId.current = null;

포인터 컨테이너가 마운트되는 조건(running)이 꺼지는 순간 ref를 직접 비운다. 세션이 어떤 경로로 끝나든 다음 press가 소유권을 다시 가져간다.

세 라운드를 되짚어보면 전부 같은 문제였다. ref로 관리하는 소유권에는 획득·해제·강제 회수 세 경로가 다 필요하다. 나는 해제만 먼저 만들고, 획득을 나중에 막고, 강제 회수는 그게 없어서 앱이 죽고 나서야 넣었다.

flex 아이템이 버튼을 밀어냈다

화면이 짧으면 하단 버튼이 통째로 사라졌다.

원인은 min-h-0이 없어서였다. flex 아이템의 자동 최소 높이가 고양이 SVG의 내재 높이(약 580px)로 잡혔고, 화면이 834px보다 짧으면 아래 버튼이 밀려나 overflow-hidden에 잘렸다.

className="flex w-full max-w-sm min-h-0 flex-1 items-center justify-center px-6"

480px까지 안 잘리는 것을 확인했다. 전체화면에서는 홈 인디케이터가 버튼을 덮어서 viewport-fit=cover와 safe-area-inset-bottom도 같이 넣었다.

touch-action도 옮겼다. 루트에 걸어놨더니 완료 화면에서 기록을 스크롤할 수 없었다. 배를 누르는 동안만 막으면 되니까 고양이 컨테이너로 내렸다.

/* touch-action은 여기에만 건다 — 배를 누르는 동안 스크롤을 막으면 되고, */
style={{ touchAction: "none" }}

rAF가 분 단위 dt를 준다

화면을 잠갔다 켜면 단계가 한꺼번에 건너뛰었다.

requestAnimationFrame이 멈춰 있다가 재개되면 분 단위 dt를 한 번에 던진다. 5초짜리 힘주기와 3초짜리 심호흡을 그 한 번의 tick으로 통과해버린다.

클램핑으로 막았다.

export const MAX_TICK_MS = 100;
// ...
dt = Math.min(dt, MAX_TICK_MS);

한 호출이 100ms 이상 진전하지 않으니 단계 경계를 한 번에 하나씩만 넘는다. 자리를 비운 시간은 총 시간에도 안 들어간다.

진동은 정직하게 실패한다

iOS 사파리는 Vibration API를 지원하지 않고, 되게 만들 방법이 없다.

/**
 * iOS 사파리는 Vibration API를 지원하지 않고, 되게 만들 방법이 없다. 그래서
 * 감지해서 조용히 no-op이 된다 — 경고도 안내도 띄우지 않는다. 화면과 문구만으로
 * 앱이 온전히 동작해야 한다는 뜻이고, 진동은 어디까지나 덤이다.
 */

처음엔 buzz()가 무조건 true를 반환하게 짜놨다. 이건 계약 위반이었다. vibrate()는 포커스가 없거나 패턴이 잘못되면 false를 반환한다.

return vibrate.call(navigator, pattern);

호출 결과를 그대로 돌려준다. 되지도 않은 걸 됐다고 말하지 않는다.

5초를 채워도 안 넘어간다

만들면서 뒤집은 결정이 둘 있다.

하나. 처음엔 5초를 채우면 바로 심호흡으로 넘겼다. 그런데 손은 아직 배 위에 있는데 화면이 「배를 꾹 눌러보세요」를 띄우면 고장으로 읽힌다.

그래서 extra 단계를 만들었다. 5초를 채우면 카운트가 1부터 거꾸로 올라가고 문구가 놀라다가 걱정하는 쪽으로 바뀐다. 손을 떼야 심호흡으로 간다.

둘. 5초를 못 채우고 떼면 아무 말도 안 얹기로 했었다. 뒤집어서 「아쉬워요, 다음엔 끝까지 힘줘 봐요」를 1.5초 띄운다. 처음 결정을 뒤집은 것이라 설계 문서에 이유를 남겨뒀다.

문구는 초마다 다르다.

const PUSH_LABELS = ["", "거의 다 왔다!", "더더", "더", "조금만 힘내주세요", "자, 힘줘볼까요"];

처음엔 5초와 4초가 같은 말이었는데 밋밋했다. 같은 말을 5초 내내 띄우면 시간이 안 간다. 표정도 0에서 1까지 이어서 일그러진다 — 눌렀다/안 눌렀다 둘로만 나누면 5초가 정지 화면이 된다.

남은 것

로직은 순수 모듈이라 테스트가 다 잡아줬다. 상태 머신도, 문구도, 집계도, dt 클램핑도.

세 번 깨진 곳은 전부 브라우저와 맞닿은 자리였다. 포인터 소유권, flex 최소 높이, rAF의 dt, Vibration API의 반환값. 여기는 순수 함수로 뺄 수가 없어서 실제로 손가락 두 개를 올려보기 전까지는 모른다.

한 손가락을 추적하는 일이 이렇게 어려울 줄은 몰랐다.

직접 해보실 수 있다. 똥 말랑

같이 읽으면 좋은 글