KKflow

BUG · 버그 도감 #9

스틱을 누르고 있는데 한 발짝만 걸었다: pointercancel을 '손 뗌'으로 본 대가

최후의 파도 모바일 웹에서 왼쪽 가상 스틱을 누르고 있어도 캐릭터가 한 프레임만 움직이고 멈췄습니다. 브라우저가 보내는 pointercancel을 손을 뗀 것으로 처리한 코드, 그리고 취소를 무시하는 대신 '정말 끝나는 길'을 모두 챙긴 해결을 정리했습니다.

· KKflow · 읽는 시간 약 9분

버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.

프로젝트 최후의 파도 · 시기 2026년 8월 23일 · 관련 기술 Pointer Events, touch-action, 모바일 웹 입력

증상

최후의 파도는 콜로세움에서 몰려오는 적을 버티는 3D 웹게임입니다. 컴퓨터에서는 WASD나 방향키로 움직이고, 휴대폰에서는 화면 왼쪽 아래의 둥근 가상 스틱을 엄지로 밀어서 움직입니다. 오른쪽 아래에는 대시 버튼이 있습니다.

8월 23일 저녁(한국 시각 19시 43분)에 들어간 수정 커밋의 제목이 증상을 그대로 말해 줍니다.

"모바일 웹에서 스틱을 누르고 있으면 계속 이동하게"

거꾸로 읽으면, 그 전까지는 스틱을 누르고 있어도 계속 이동하지 않았다는 뜻입니다. 커밋 본문은 더 구체적입니다. "왼쪽 스틱이 한 프레임만 움직였다." 엄지를 스틱에 대고 한쪽으로 밀고 있는데, 캐릭터는 한 발짝도 안 되는 거리만 움찔하고 그 자리에 섭니다. 적이 사방에서 몰려오는 게임에서 휴대폰으로는 사실상 피할 수가 없는 상태였습니다.

컴퓨터에서는 이 문제가 드러나지 않았습니다. 키보드 입력은 스틱과 다른 길로 들어오기 때문입니다. 어떤 기기에서 처음 발견했는지, 언제부터 이랬는지는 커밋 기록에 남아 있지 않습니다. 이 글은 고친 커밋(PR #35)의 설명과 바뀐 코드를 근거로 합니다.

원인: '취소'를 '손 뗌'과 똑같이 처리했다

요즘 웹에서 마우스·손가락·펜 입력은 Pointer Events라는 하나의 체계로 받습니다. 손가락이 닿으면 pointerdown, 움직이면 pointermove, 떼면 pointerup이 옵니다. 그리고 하나가 더 있습니다. pointercancel입니다. 이건 "이 손가락에 대해서는 이제 너에게 알려 주지 않겠다"는 브라우저의 통보입니다.

일반적으로 브라우저는 손가락 움직임을 페이지 스크롤이나 확대 같은 자기 제스처로 쓰기로 결정하면 pointercancel을 보냅니다. 손가락은 여전히 화면에 붙어 있지만, 그 뒤의 움직임은 페이지가 아니라 브라우저가 가져갑니다. 커밋에 남은 코드 주석은 한 가지 사례를 더 적어 두었습니다. iOS Safari는 손가락을 붙잡아 두려고 setPointerCapture를 부른 직후에, 손가락이 아직 내려가 있는데도 pointercancel을 보내는 경우가 많다는 것입니다.

고치기 전 스틱 코드는 이 통보를 이렇게 받았습니다(src/input/controls.ts, 고치기 전).

stick.addEventListener('pointerdown', onDown);
stick.addEventListener('pointermove', onMove);
stick.addEventListener('pointerup', onUp);
stick.addEventListener('pointercancel', onUp);   // 취소도 '손 뗌'으로

마지막 줄이 문제였습니다. onUp은 스틱을 놓는 함수입니다. 방향 값을 0으로 되돌리고, 스틱이 눌려 있지 않다고 표시합니다. 그러니 손가락이 닿자마자 브라우저가 취소를 보내면, 게임은 첫 프레임에 한 번 방향을 읽고 바로 다음 순간 "손을 뗐다"고 판단합니다. 커밋의 표현 그대로 "pointercancel을 즉시 해제로 보던 탓에" 한 프레임만 움직인 것입니다.

고치기 전 취소 = 손을 뗀 것으로 봄 손가락 닿음 pointerdown 브라우저가 취소 pointercancel 손가락 뗌 pointerup 고친 뒤 취소는 무시, 뗄 때까지 유지 손가락 닿음 pointerdown 브라우저가 취소 pointercancel 손가락 뗌 pointerup 손가락은 그대로인데 캐릭터는 멈춤 누르고 있는 동안 계속 이동 시간 →
왼쪽 스틱을 누르고 있는 동안 일어나는 일을 시간 순서로 그렸습니다. 고치기 전 코드는 브라우저의 '취소' 알림을 손을 뗀 것과 똑같이 처리해서, 손가락이 화면에 붙어 있는데도 이동이 끝났습니다.

여기에 두 가지 사정이 겹쳤습니다. 첫째, 스틱 영역에도 페이지 전체에도 touch-action: none이 없었습니다. 이 CSS 속성은 "이 영역의 손가락 움직임은 페이지가 직접 처리할 테니 브라우저는 스크롤·확대로 가져가지 말라"는 뜻입니다. 이게 없으면 브라우저는 스틱을 미는 엄지를 스크롤 제스처로 가져갈 수 있고, 그때 pointercancel이 옵니다. 둘째, 움직임을 듣는 귀가 스틱 원 안에만 달려 있었습니다. 엄지가 원 밖으로 조금만 벗어나도, 그리고 손가락 붙잡기(capture)가 거절되면, 움직임 알림을 받을 길이 없었습니다.

해결: 스틱을 '누르고 있는 상태'로 따로 떼어 내기

커밋은 해결을 한 줄로 요약했습니다. "홀드 축을 StickHold로 분리하고, 캡처·윈도우 추적·touch-action:none을 넣었다." 하나씩 풀어 보겠습니다.

1. StickHold: 누르고 있는 상태만 책임지는 작은 부품

스틱이 지금 어느 손가락에 잡혀 있는지, 어느 방향으로 얼마나 밀렸는지를 src/input/stick.ts의 StickHold라는 작은 클래스로 옮겼습니다. 화면이나 이벤트와 상관없이 숫자만 다루기 때문에 브라우저 없이도 테스트할 수 있습니다. 클래스 머리에 붙은 주석이 이 부품의 원칙입니다.

"pointerdown 뒤에 짝이 되는 pointerup이 오기 전까지는 마지막 방향을 유지해야 한다. 탭은 한 프레임짜리 충격이 아니다."

핵심은 취소를 다루는 함수입니다.

// src/input/stick.ts (요약)
cancel(pointerId, fingerStillDown) {
  if (!this.owns(pointerId)) return false;
  if (fingerStillDown) return false;   // 손가락이 아직 있으면 붙잡은 채로 둔다
  this.release();
  return true;
}

실제 게임 코드는 pointercancel과 lostpointercapture가 올 때 이 함수를 "손가락이 아직 있다"로 부릅니다. 즉 브라우저의 취소 통보로는 스틱을 놓지 않습니다. 마지막으로 밀던 방향을 그대로 유지합니다.

2. 귀를 창 전체에 달기

손가락이 닿는 것(pointerdown)은 스틱 영역에서 듣지만, 움직임과 뗌은 window 전체에서 듣도록 바꿨습니다. 엄지가 스틱 원 밖으로 미끄러져도 같은 손가락이면 계속 따라갑니다. 손가락 붙잡기(setPointerCapture)도 시도하지만, 실패해도 괜찮게 감쌌습니다. 코드 주석에 "Safari가 캡처를 거절할 수 있다. 그래도 창 전체 리스너가 누르고 있는 상태를 추적한다"고 적혀 있습니다.

3. 브라우저에게 제스처를 넘기지 않기

게임 화면 전체(body, 앱 영역, 3D 캔버스, 스틱)에 touch-action: none과 overscroll-behavior: none을 넣었습니다. 페이지 전체에서 touchmove와 iOS의 gesturestart도 막았습니다. 다만 상점 목록처럼 실제로 스크롤해야 하는 곳은 예외로 두고(touch-action: pan-y), 버튼에는 manipulation을 줘서 탭은 빠르게 받되 두 번 탭 확대는 일어나지 않게 했습니다. 엿새 뒤(8월 29일) 화면 레이아웃을 손볼 때 두 손가락 확대와 Ctrl+휠 확대도 같은 함수에서 막도록 더해졌습니다.

4. 스틱 영역 넓히기

스틱이 반응하는 영역도 바꿨습니다. 전에는 눈에 보이는 둥근 스틱 자체만 눌렀을 때 반응했는데, 이제는 화면 왼쪽 아래의 넓은 영역(가로 46%, 세로 58%)을 누르면 스틱이 잡힙니다. 방향은 여전히 보이는 스틱의 중심을 기준으로 계산합니다. 끝까지 밀었다고 보는 거리도 48픽셀에서 56픽셀로 늘었습니다. 이 두 가지를 바꾼 이유는 커밋에 따로 적혀 있지 않습니다.

취소를 무시하면 생기는 반대편 버그

여기서 끝났다면 새 버그가 생겼을 겁니다. 취소 통보로 스틱을 놓지 않기로 했으니, 정말로 손가락이 사라졌는데 pointerup이 오지 않는 경우에는 스틱이 영원히 눌린 채로 남습니다. 캐릭터가 혼자 계속 걷는 버그입니다. 그래서 이 커밋은 "정말 끝나는 길"을 하나하나 해제로 연결했습니다.

취소를 무시하면, 다른 '끝나는 길'을 전부 챙겨야 한다 손가락을 뗌 pointerup 마지막 손가락이 떨어짐 touchend · touchcancel 일시정지·결과 화면으로 전환 releaseTouch() 탭을 숨기거나 앱 전환 visibilitychange · pagehide 스틱 해제 축을 0으로, 캐릭터 정지 브라우저가 제스처를 취소 pointercancel · lostpointercapture → 해제하지 않음(마지막 방향 유지)
취소 알림을 무시하는 대신, 손가락이 정말로 떨어졌거나 게임 화면을 벗어난 경우는 빠짐없이 해제로 연결했습니다. 하나라도 빠지면 이번에는 반대로 '혼자 계속 걷는' 버그가 됩니다.
  • 손가락을 뗌 — 같은 손가락의 pointerup이 오면 해제합니다.
  • 마지막 손가락이 떨어짐 — pointer 이벤트와 별개로 touchend·touchcancel에서 화면에 남은 손가락이 0개면 해제합니다. pointerup이 끝내 오지 않는 경우를 받아 주는 안전망입니다.
  • 화면 전환 — 게임 화면이 아닌 화면(일시정지, 결과 등)으로 바뀌면 해제합니다. 전에는 일시정지 화면에서도 터치 패드가 보였는데, 이제는 게임 중에만 보입니다.
  • 탭 숨김·앱 전환 — 브라우저 탭이 가려지거나(visibilitychange) 페이지를 떠나면(pagehide) 해제합니다. 전화가 와서 앱을 벗어났다가 돌아왔는데 캐릭터가 혼자 걸어가고 있으면 안 되니까요.

무언가를 "무시"하기로 할 때는, 그 무언가가 대신 해 주던 일을 다른 누가 맡는지 확인해야 합니다. 이 커밋에서 pointercancel이 하던 "끝내기"는 네 군데로 나뉘어 옮겨졌습니다.

같은 커밋에 들어간 것들

스틱 말고도 모바일 입력을 함께 손봤습니다.

  • 버튼은 pointerup에서 바로 실행 — 새 bindTap 함수가 손가락을 뗄 때 바로 동작을 실행하고, 그 뒤에 브라우저가 따로 만들어 보내는 click은 삼킵니다. 한 번 탭에 두 번 실행되는 일을 막기 위해서입니다. 손가락이 14픽셀 넘게 움직였으면 탭이 아니라 스크롤로 보고 무시합니다.
  • 대시 버튼은 누르는 순간 — 대시는 pointerdown에서 바로 들어가고, 그 손가락도 붙잡아 둡니다.
  • 터치 패드에 정지 버튼 — 휴대폰에는 Esc 키가 없으니, 대시 버튼 위에 '정지' 버튼을 더했습니다.

테스트: 브라우저 없이 '누르고 있음'을 확인하기

이번 커밋으로 tests/controls.test.ts가 새로 생겼습니다. 스틱의 상태를 StickHold로, 입력을 이동 명령으로 바꾸는 계산을 commandFromHold 함수로 떼어 냈기 때문에 가능한 테스트입니다. 이름만 봐도 이번 버그를 정확히 겨냥하고 있습니다.

  • "pointerup 전까지 여러 번 읽어도 왼쪽 방향이 유지된다"
  • "손가락이 아직 있으면 pointercancel로 놓지 않는다"
  • "두 번째 손가락은 무시하고, 새로 누르면 다시 잡는다"
  • "키를 떼지 않아도 여러 프레임에 걸쳐 이동 명령이 같다" — 이 테스트는 실제 게임 진행 함수를 두 번 돌려 캐릭터 위치가 두 번 모두 움직이는지까지 봅니다.

PR 안의 두 번째 커밋은 "다시 잡기" 테스트의 손가락 위치를 끝까지 민 거리로 맞추는 수정이었습니다. 이 수정 커밋에는 Cursor Agent가 공동 작성자로 올라 있습니다. 8월 이후 최후의 파도 작업은 Cursor의 코딩 에이전트와 함께 했습니다.

교훈

  • "취소"는 "끝"이 아니다. pointercancel은 사용자가 손을 뗐다는 뜻이 아니라, 브라우저가 그 손가락을 가져갔다는 뜻입니다. 둘을 같은 함수로 처리하면 모바일에서만 이상하게 동작합니다.
  • 제스처를 직접 처리할 영역은 브라우저에 미리 알린다. 게임 화면이나 그림판처럼 손가락 움직임을 직접 쓰는 곳에는 touch-action: none을 둡니다. 스크롤이 필요한 곳만 골라서 풀어 줍니다.
  • 무언가를 무시하기로 했다면, 그것이 하던 일을 누가 맡는지 확인한다. 취소를 무시하는 순간 "혼자 걷는" 버그의 문이 열립니다. 손 뗌, 마지막 손가락, 화면 전환, 탭 숨김까지 끝나는 길을 전부 적어 보는 것이 안전합니다.
  • 컴퓨터에서 잘 된다고 휴대폰에서 되는 것은 아니다. 이번 버그는 키보드로는 절대 재현되지 않았습니다. 터치 입력은 실제 휴대폰에서 직접 눌러 봐야 합니다.

최후의 파도의 다른 기록은 최후의 파도 제작기와 버그 도감 #3: 캐릭터가 전부 T자 자세로 굳었다에 있습니다. 모바일 웹게임을 만들 때 함께 보면 좋은 글로 웹게임을 앱처럼: 홈 화면 설치와 오프라인 플레이도 있습니다.

함께 읽으면 좋은 글