모바일 웹 게임의 입력 지연과 프레임 예산 — 60fps를 지키는 실전 기준

모바일 개발

입력 지연프레임 예산포인터 이벤트모바일 웹INP

이 글은 누구를 위한 것인가

  • 모바일 브라우저에서 동작하는 게임이나 실시간 인터랙션을 만드는 팀
  • 데스크톱에서는 멀쩡한데 실기기에서만 끊기는 현상을 겪고 있는 경우
  • "느리다"는 피드백을 받았지만 무엇이 느린지 특정하지 못한 개발자

들어가며

모바일 웹에서 게임이 "느리다"는 피드백은 두 가지 완전히 다른 문제를 가리킨다.

  [A] 입력 지연 — 눌렀는데 반응이 늦다
      프레임레이트가 60fps여도 발생한다.
      터치 → 처리 사이의 지연 문제.

  [B] 프레임 드랍 — 움직임이 끊긴다
      입력은 즉시 반응하는데 화면이 버벅인다.
      프레임당 작업량 문제.

둘은 원인도 해법도 다르다. A를 고치려고 렌더링을 최적화하거나, B를 고치려고 이벤트 리스너를 손보면 시간만 쓴다. 먼저 어느 쪽인지 확정하는 것부터 시작한다.


1. 어느 쪽인지 먼저 가른다

// 입력 → 첫 반영까지의 시간 측정
let touchAt = 0;

canvas.addEventListener('pointerdown', (e) => {
  touchAt = e.timeStamp;              // 브라우저가 이벤트를 만든 시각
}, { passive: true });

function onFirstRenderAfterInput() {
  if (touchAt) {
    const latency = performance.now() - touchAt;
    metrics.push(latency);
    touchAt = 0;
  }
}
[판정 기준]

  입력 지연 p95 > 100ms   → [A] 입력 경로 문제
  프레임 시간 p95 > 20ms  → [B] 프레임 예산 문제
  둘 다                   → A부터. 체감 개선폭이 훨씬 크다

A를 먼저 고치는 이유는 체감 때문이다. 60fps에서 55fps로 떨어진 건 대부분 못 느끼지만, 탭 반응이 150ms 늦은 건 즉시 느낀다. 사람은 프레임보다 지연에 민감하다.

웹 표준 지표로는 INP(Interaction to Next Paint)가 이 축을 측정한다. 게임 캔버스는 INP 측정 대상에서 빠질 수 있지만, 개념과 임계값(200ms 이하 권장)은 그대로 기준이 된다.


2. 입력 지연 — 300ms는 아직도 살아있다

모바일 브라우저의 탭 지연 300ms는 옛날 이야기로 알려져 있지만, 특정 조건에서 여전히 발생한다.

<!-- 이게 없으면 브라우저가 더블탭 줌을 기다리느라 지연이 생긴다 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
/* 게임 캔버스에서는 브라우저 제스처를 명시적으로 끈다 */
canvas {
  touch-action: none;          /* 스크롤·핀치줌 대기 제거 */
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

touch-action: none이 핵심이다. 이게 없으면 브라우저는 "이 터치가 스크롤인지 탭인지"를 판단하려고 기다린다. 게임 영역에서는 그 판단이 필요 없다.

이벤트 종류도 중요하다.

// ❌ click은 touchend 이후에 발생 — 최대 수십 ms 늦다
canvas.addEventListener('click', handleTap);

// ✅ pointerdown이 가장 이르다
canvas.addEventListener('pointerdown', handleTap, { passive: true });

Pointer Events는 마우스·터치·펜을 하나의 API로 다루므로, 기기별 분기 코드를 없앨 수 있다는 이점도 있다.

[이벤트 발생 순서와 지연]

  pointerdown   ┃ 0ms      ← 여기서 처리
  touchstart    ┃ 0ms
  pointerup     ┃ 터치 종료
  touchend      ┃ 터치 종료
  click         ┃ +0~300ms ← 여기서 처리하면 늦다

{ passive: true }를 붙이는 이유는 별개다. 리스너가 preventDefault()를 호출할 수 있으면 브라우저는 스크롤을 시작하기 전에 리스너 실행을 기다린다. passive를 선언하면 그 대기가 사라진다. 단, preventDefault()가 필요한 리스너에는 붙이면 안 된다 — touch-action: none으로 대체하는 편이 낫다.


3. 입력을 프레임에 정렬한다

pointerdown에서 즉시 상태를 바꾸고 즉시 그리면 오히려 손해다. 한 프레임에 입력이 여러 번 들어오면 그만큼 그린다.

// ❌ 입력마다 렌더
canvas.addEventListener('pointermove', (e) => {
  updateAim(e.clientX, e.clientY);
  render();                        // 프레임당 여러 번 호출될 수 있다
});

// ✅ 입력은 상태만 갱신, 렌더는 프레임 루프에서
let pendingInput = null;

canvas.addEventListener('pointermove', (e) => {
  pendingInput = { x: e.clientX, y: e.clientY };
}, { passive: true });

function frame() {
  if (pendingInput) {
    updateAim(pendingInput.x, pendingInput.y);
    pendingInput = null;
  }
  render();
  requestAnimationFrame(frame);
}

고빈도 포인터 이벤트를 놓치면 안 되는 경우(그리기 앱 등)에는 병합된 이벤트를 사용한다.

canvas.addEventListener('pointermove', (e) => {
  // 브라우저가 프레임 사이에 병합한 중간 이벤트들
  const points = e.getCoalescedEvents?.() ?? [e];
  for (const p of points) path.push({ x: p.clientX, y: p.clientY });
}, { passive: true });

게임에서는 대개 마지막 위치만 필요하므로 병합 이벤트가 불필요하지만, 궤적이 의미를 갖는 입력에서는 이걸 안 쓰면 빠른 동작이 각지게 그려진다.


4. 프레임 예산 — 실제로 쓸 수 있는 건 10ms

60fps는 프레임당 16.7ms지만, 그 전부를 쓸 수는 없다.

[프레임 16.7ms의 실제 배분]

  브라우저 합성·레이아웃      약 3~5ms
  가비지 컬렉션 (간헐적)      0~5ms
  ─────────────────────────────────
  내 코드가 쓸 수 있는 예산   약 10ms

  · update (로직)   3~4ms
  · render (그리기) 5~6ms

저사양 기기에서는 이 예산이 절반으로 줄어든다. 고사양 기기 기준으로 맞추면 저사양에서 무조건 끊긴다.

// 프레임 시간을 나눠서 측정한다 — 어느 쪽이 예산을 넘는지 알아야 고칠 수 있다
function frame(now) {
  const t0 = performance.now();
  update(now - last);
  const t1 = performance.now();
  render();
  const t2 = performance.now();

  stats.update.push(t1 - t0);
  stats.render.push(t2 - t1);
  last = now;
  requestAnimationFrame(frame);
}

측정 없이 렌더링부터 최적화하는 것이 가장 흔한 낭비다. 실제로는 update 쪽에서 매 프레임 객체를 생성해 GC를 유발하는 경우가 많다.

// ❌ 프레임마다 객체 생성 → GC 압박 → 간헐적 프레임 드랍
function update(dt) {
  const velocity = { x: dx * dt, y: dy * dt };   // 매 프레임 새 객체
  entities.forEach(e => e.move(velocity));
}

// ✅ 재사용
const _vel = { x: 0, y: 0 };
function update(dt) {
  _vel.x = dx * dt; _vel.y = dy * dt;
  for (let i = 0; i < entities.length; i++) entities[i].move(_vel);
}

forEach 대신 for 루프를 쓴 것도 의도적이다. 콜백 호출이 프레임당 수천 번이면 그 자체가 비용이 된다. 일반 코드에서는 하지 않을 최적화지만, 프레임 루프 안에서는 다르다.


5. 긴 작업을 쪼갠다

레벨 로딩, 맵 생성, 세이브 직렬화처럼 한 번에 수십 ms가 걸리는 작업은 프레임을 통째로 잡아먹는다.

// 시간 예산 안에서만 처리하고 다음 프레임으로 넘긴다
function processQueue(deadline = 5) {
  const start = performance.now();
  while (queue.length && performance.now() - start < deadline) {
    processOne(queue.shift());
  }
  if (queue.length) requestAnimationFrame(() => processQueue(deadline));
}

메인 스레드에서 뺄 수 있으면 빼는 것이 최선이다. 구조와 제약은 Web Workers 사용 가이드에 정리돼 있다.

[Worker로 옮기기 좋은 것]

  ✓ 경로 탐색, AI 연산
  ✓ 맵·지형 생성
  ✓ 세이브 데이터 직렬화·압축
  ✓ 네트워크 응답 파싱

[옮기면 안 되는 것]

  ✗ DOM 조작 (Worker에서 불가)
  ✗ 프레임마다 결과가 필요한 물리 연산
    — 메시지 왕복 지연이 프레임 예산보다 클 수 있다

마지막 항목이 함정이다. Worker 통신은 공짜가 아니라, 매 프레임 데이터를 주고받으면 오히려 느려진다. 프레임 단위 동기화가 필요한 연산은 메인 스레드에 두는 편이 낫다.


6. 저사양 기기 — 품질을 낮춘다

모든 기기에서 60fps를 내는 것은 불가능하다. 프레임을 지키기 위해 품질을 포기하는 자동 조정이 현실적이다.

const quality = { level: 'high', particles: 200, shadows: true, dpr: 2 };

let slowFrames = 0;
function monitorFrame(frameTime) {
  if (frameTime > 20) slowFrames++;
  else slowFrames = Math.max(0, slowFrames - 1);

  if (slowFrames > 30 && quality.level === 'high') {
    downgrade('medium');   // 파티클 절반, 그림자 끄기
  } else if (slowFrames > 60 && quality.level === 'medium') {
    downgrade('low');      // dpr 1로, 이펙트 최소화
  }
}

해상도(devicePixelRatio)가 가장 효과가 크다. DPR 3인 기기에서 canvas를 물리 픽셀로 그리면 픽셀 수가 9배다. DPR을 2나 1.5로 낮추면 렌더 비용이 즉시 절반 이하로 떨어지고, 게임 화면에서는 화질 저하가 잘 안 보인다.

function resizeCanvas(cssW, cssH) {
  const dpr = Math.min(window.devicePixelRatio || 1, quality.dpr);
  canvas.width  = Math.floor(cssW * dpr);
  canvas.height = Math.floor(cssH * dpr);
  canvas.style.width  = cssW + 'px';
  canvas.style.height = cssH + 'px';
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

한 번 낮춘 품질을 자동으로 되돌리지 않는 편이 좋다. 올렸다 내렸다 반복하면 화면이 계속 변해 더 거슬린다. 되돌리려면 게임 재시작이나 명시적 설정 변경 시점에 한다.

게임 종류에 따라 프레임 예산의 성격도 다르다. 소셜슬롯플레이의 주사위 게임처럼 결과 표시가 짧고 상태가 단순한 유형은 애초에 프레임 예산이 여유롭지만, 그런 게임을 여러 개 한 페이지에 배치하면 개별 예산이 아니라 합계가 문제가 된다. 화면 밖 게임의 루프를 멈추는 처리가 없으면 그 지점에서 무너진다.


7. 실기기에서만 재현되는 것들

데스크톱 개발자 도구의 모바일 에뮬레이션으로는 잡히지 않는 문제가 있다.

[실기기에서만 나타나는 것]

  · 열 스로틀링 — 3~5분 플레이 후 CPU 주파수 하락
  · 저전력 모드 — iOS는 프레임레이트를 30fps로 제한
  · 메모리 압박 — 백그라운드 앱이 많으면 GC 빈도 증가
  · 브라우저 UI 표시/숨김에 따른 뷰포트 크기 변화

열 스로틀링은 반드시 실기기에서 3분 이상 연속 플레이로 확인해야 한다. 처음 30초만 보면 문제가 없어 보이지만, 발열이 시작되면 프레임이 절반으로 떨어지는 경우가 흔하다. 6절의 자동 품질 조정이 필요한 진짜 이유가 이것이다.

뷰포트 변화도 자주 놓친다. 스크롤에 따라 브라우저 주소창이 사라지면 화면 높이가 바뀌는데, canvas 크기를 다시 잡지 않으면 늘어나거나 잘린다.

// resize보다 visualViewport가 정확하다 — https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport
window.visualViewport?.addEventListener('resize', () => {
  resizeCanvas(visualViewport.width, visualViewport.height);
});

8. 정리

  1. 입력 지연과 프레임 드랍을 먼저 구분한다 (입력이 우선)
  2. touch-action: none + pointerdown + passive
  3. 입력은 상태만 갱신, 렌더는 프레임 루프에서
  4. update/render를 나눠 측정 — 대개 GC가 범인
  5. 긴 작업은 시간 예산으로 쪼개거나 Worker로
  6. 저사양은 DPR부터 낮춘다 (자동 복원은 하지 않기)
  7. 열 스로틀링은 3분 이상 실기기 연속 테스트로만 잡힌다

이 중 즉시 효과가 큰 건 2번과 6번이다. CSS 두 줄과 DPR 상한만으로 대부분의 모바일 웹 게임은 체감이 달라진다.