본문 바로가기
카테고리 없음

미니앱 개발하기 2 - 모바일 전용 버그

by 테크구루스 2026. 8. 10.

데스크톱에선 멀쩡한데 폰에서만 슬라임이 끝없이 늘어나는 모바일 전용 버그가 생겼습니다. 토스 앱 WebView에서만 재현되는 실기기 전용 버그를 pointerup 유실이라는 원인까지 좁혀 3중 안전망으로 막은 개발기입니다.

쫀득 슬라임(playwithslime)은 앱인토스에 올린 three.js 토이입니다. 손가락으로 젤리를 잡아 늘리고, 톡 찌르고, 푹 누르는 그 촉감 하나로 굴러가는 앱이죠. 그런데 앱을 올리고 얼마 지나지 않아 이런 제보를 받았습니다. "잡고 늘리면 슬라임이 무한정 늘어나요." 문제는 제 데스크톱 브라우저에서는 아무리 잡아당겨도 그런 일이 일어나지 않았다는 겁니다.

실기기에서만 터지는 버그가 제일 골치 아프다

개발자에게 가장 다루기 까다로운 버그는 "내 손에서 재현이 안 되는 버그"입니다. 크래시 로그가 화려하게 남는 것도 아니고, 콘솔에 빨간 에러가 찍히는 것도 아니죠. 그저 특정 환경에 있는 사용자에게서만, 그것도 말로 전해 듣는 증상으로만 존재합니다.

 

미니앱 개발하기 2 - 모바일 전용 버그
미니앱 개발하기 2 - 모바일 전용 버그

 

이번 버그가 딱 그랬습니다. 데스크톱 크롬에서는 마우스로 슬라임을 붙잡고 몇 초를 끌어당겨도 어느 지점에서 얌전히 멈췄습니다. 그런데 토스 앱 WebView, 즉 실기기에서만 슬라임이 손을 뗀 뒤에도 계속 부풀어 오른다는 것이었죠. 같은 코드, 같은 물리 엔진인데 실행되는 무대만 다른 상황이었습니다.

여기서 실기기 전용 버그의 진짜 괴로움이 시작됩니다. 코드를 한 줄 고칠 때마다 데스크톱에서 확인하고 끝나는 게 아니라, 매번 빌드를 실기기에 다시 물려 손가락으로 직접 재현해야 하죠. 한 번의 검증 사이클이 몇 분씩 걸리니, 가설을 함부로 늘어놓을 수가 없었습니다. 그래서 "무엇을 의심할지"를 최대한 좁혀 두는 게 이 싸움의 핵심이었습니다.

물리를 의심했다가 이벤트를 의심하기까지

가장 먼저 의심한 것은 물리 엔진이었습니다. 이 슬라임은 IcosahedronGeometry를 병합한 812개 정점의 XPBD 소프트바디로 돌아갑니다. 매 프레임 그랩 제약과 엣지 거리 제약, 부피 보존을 순서대로 푸는 구조죠. "무한정 늘어난다"는 증상만 놓고 보면, 제약 계산 어딘가에서 값이 발산해 스스로 자라나는 게 아닐까 싶었습니다.

그래서 데스크톱에서 대조 실험을 했습니다. 마우스로 슬라임을 붙잡은 채 버튼을 계속 누르고만 있어 봤습니다. 만약 물리가 스스로 발산한다면 손을 안 떼도 계속 커져야 하죠. 그런데 결과는 반대였습니다. 잡은 목표 지점까지 늘어난 뒤에는 그 자리에 수렴해 멈춰 버렸습니다. 자가 성장이 없다는 것, 즉 물리 자체는 안정적이라는 확증이었죠.

그러자 질문이 뒤집혔습니다. 물리가 멀쩡하다면, 실기기에서 슬라임을 계속 늘리는 "손"은 대체 누구인가. 답은 하나뿐이었습니다. 사용자는 이미 손가락을 뗐는데, 코드가 그 사실을 모르고 있다는 것이죠. 그랩(grab)이 시작될 때 저는 pointerId별로 잡은 지점을 저장하고, pointerup이 오면 그 그랩을 해제합니다. 그런데 이 해제 신호가 영영 오지 않는다면? 코드 입장에서는 사용자가 아직도 슬라임을 붙잡고 있는 셈이라, 카메라를 향한 평면 위에서 목표점을 계속 끌어당기게 됩니다.

여기서 WebView라는 무대의 특성이 걸렸습니다. 브라우저라면 포인터를 놓는 순간 pointerup이 반드시 캔버스로 옵니다. 하지만 토스 앱 WebView 안에서는 이 표준 이벤트가 늘 보장되지 않았습니다. 실제로 그랩 시작 시 setPointerCapture를 거는 코드에도 try/catch가 이미 들어가 있었죠. 일부 WebView와 합성 이벤트에서는 pointer capture 자체가 실패할 수 있기 때문입니다.

try {
  renderer.domElement.setPointerCapture(e.pointerId);
} catch {
  // 일부 WebView·합성 이벤트에서는 pointer capture가 불가능하다
}

capture가 이렇게 미덥지 못하다면, 그 뒤에 오는 pointerup이라고 다를 리가 없었습니다. WebView는 표준 포인터 이벤트를 그대로 흘려보낸다는 보장이 없다 — 이게 이 버그가 가르쳐 준 교훈이었습니다.

3중 안전망과, 레이캐스트가 놓친 슬라임

원인을 이벤트 유실로 좁히고 나니, 해법의 방향은 분명해졌습니다. 캔버스 하나에만 의존하지 말고, 놓친 신호를 여러 겹에서 주워 담자는 것이죠. 그래서 2026년 7월 30일에 3중 안전망을 넣었습니다.

dom.addEventListener("pointercancel", onPointerUp); // ③ 취소=놓음

// ① 캔버스가 놓친 그랩을 window 버블 단계에서 강제 해제
const missedRelease = (e: PointerEvent) => {
  if (grabPlanes.has(e.pointerId)) onPointerUp(e);
};
window.addEventListener("pointerup", missedRelease);
window.addEventListener("pointercancel", missedRelease);

// ② 화면 이탈(백그라운드·전면 광고 등) 시 모든 그랩 일괄 해제
const onHiddenRelease = () => {
  if (document.hidden) releaseAllGrabs();
};
document.addEventListener("visibilitychange", onHiddenRelease);

세 겹의 역할은 이렇습니다.

  1. window 버블 단계에서 다시 받기 — 캔버스가 pointerup을 놓치더라도, 이벤트가 버블링으로 window까지 올라오면 그때 grabPlanes에 아직 살아 있는 그랩을 강제로 놓습니다. 정상 경로에서는 이미 지워진 뒤라 아무 일도 안 하는 no-op이 되죠.
  2. 화면을 떠날 때 전부 놓기visibilitychange로 화면이 hidden이 되면 모든 그랩을 일괄 해제합니다. 백그라운드로 갔다가 돌아오거나 전면형 광고가 떴다 사라진 뒤에 잔류 그랩이 남는 상황까지 막으려는 장치입니다.
  3. 취소를 놓음으로 취급pointercancelpointerup과 같은 핸들러로 처리해, 시스템이 제스처를 가로채 취소시켜도 그랩이 확실히 풀리게 했습니다.

여기서 짝을 이룬 또 하나의 함정이 레이캐스트였습니다. 슬라임을 크게 늘리고 나면, 늘어난 그 부분을 다시 잡거나 톡 찌르려 해도 반응이 없는 버그가 있었죠. 원인은 bounding sphere였습니다. 레이캐스트는 먼저 대략적인 경계 구로 충돌을 걸러내는데, 슬라임이 변형되어 그 경계 밖으로 삐져나간 부분은 낡은 경계 구 기준으로 "빗나감" 처리되어 버립니다. 그래서 물리 스텝 끝마다 경계 구를 다시 계산하도록 했습니다.

// 레이캐스트가 낡은 bounding sphere로 조기 탈락하지 않도록 매 프레임 갱신
this.geometry.computeBoundingSphere();

마지막으로 값의 안정성도 챙겼습니다. 프레임 간격 dt가 0이 되면 substep 나눗셈에서 NaN이 터져 슬라임 전체가 폭발하듯 사라지므로, 상·하한을 함께 걸어 클램프했습니다.

const dt = Math.min(Math.max((now - lastTime) / 1000, 1 / 240), 1 / 30);

성능 쪽으로도 한 가지만 덧붙이면, 크기 상한을 눌러 주는 applyBounds는 슬라임이 한계 반경 96% 근처에 가거나 잡기·누르기가 진행 중일 때만 정점을 훑도록 했습니다. 평상시에는 경계 구 반경만 보고 곧장 빠져나오니 비용이 사실상 0이죠. (실기기 렉의 진짜 주범은 따로 있었는데, 그 이야기는 다음 편에서 오디오와 함께 풀어 보겠습니다.)

돌아보면 이 버그의 8할은 "재현되는 무대를 좁히는 일"이었습니다. 데스크톱과 실기기를 맞대어 물리를 용의선상에서 지우자, 남은 건 WebView의 이벤트뿐이었죠. 웹에서 당연하다고 믿었던 pointerup 한 번이 실기기에서는 당연하지 않았고, 그 틈을 방어적인 안전망으로 메우는 게 결국 답이었습니다. 표준을 믿되, WebView 위에서는 그 표준이 새어 나갈 자리를 늘 하나쯤 마련해 두는 편이 마음 편하더군요.