← 블로그 목록
HTML5 게임입력 처리이벤트 리스너게임 개발Canvas

웹 게임의 입력 처리: 동기 vs 비동기, 키보드·마우스·터치를 AI 코딩으로 통합하기

HTML5 게임에서 키보드·마우스·터치 입력을 처리할 때 이벤트 리스너, 폴링, 게임 루프 상태 체크 중 최적의 방식을 비교 분석하고 입력 지연 문제를 해결하는 방법을 배웁니다.

Vibeollio 팀-

웹 게임의 입력 처리, 왜 지연이 생길까?

HTML5 Canvas로 만든 게임을 플레이해본 경험이 있다면, 버튼을 누르고도 캐릭터가 반응하는 데 약간의 시간이 걸리는 느낌을 받았을 수 있다. 이는 단순한 착각이 아니다. 웹 게임의 입력 처리 방식에 따라 실제로 체감될 만한 지연이 생길 수 있다.

게임에서 입력은 게임 루프의 핵심이다. 플레이어가 누르는 키, 마우스 움직임, 터치 좌표를 얼마나 빨리 감지하고 반영하느냐가 게임의 반응성을 결정한다. 특히 액션 게임이나 슈팅 게임처럼 밀리초 단위의 타이밍이 중요한 장르에서는 더욱 그렇다.

이 글에서는 웹 게임의 입력을 처리하는 세 가지 주요 방식을 비교하고, 각각의 장단점을 파악한 후 최적의 구현 방법을 제시한다.

이벤트 리스너: 반응 빠르지만 불규칙해지기 쉬운 방식

가장 직관적인 방법은 keydown, keyup, mousemove, touchstart 같은 DOM 이벤트에 리스너를 붙이는 것이다.

document.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowLeft') playerX -= 5;
});

이 방식의 장점은 명확하다. 입력이 발생하는 즉시 콜백이 실행되므로 지연이 거의 없다. 하지만 함정이 있다.

문제점: 이벤트 핸들러 안에서 바로 움직이면, 키를 누르고 있는 동안의 이동이 keydown 반복 이벤트(운영체제의 키 반복 설정에 따라 처음 한 번 누른 뒤 잠깐 멈췄다가 반복됨)에 의존하게 되어 게임 루프의 프레임 속도와 무관하게 불규칙하게 움직인다. 또한 여러 키를 동시에 누르고 있을 때 반복 이벤트는 마지막에 누른 키에 대해서만 발생하는 경우가 많아, 동시 입력(예: 위쪽 화살표 + 점프 버튼)을 정확히 처리하기 어렵다.

폴링: 안정적이지만 CPU 낭비 위험

폴링은 게임 루프 내에서 주기적으로 입력 상태를 확인하는 방식이다.

const keys = {};

document.addEventListener('keydown', (e) => {
  keys[e.key] = true;
});

document.addEventListener('keyup', (e) => {
  keys[e.key] = false;
});

function gameLoop() {
  if (keys['ArrowLeft']) playerX -= 5;
  if (keys['ArrowRight']) playerX += 5;
  render();
  requestAnimationFrame(gameLoop);
}

이 방식은 입력 상태를 객체나 배열에 저장했다가 게임 루프에서 읽는다. 장점은 동시 입력 처리가 자연스럽고, 게임 루프의 프레임 속도와 동기화되어 예측 가능한 동작을 한다는 것이다.

단점은, 한 프레임 안에 눌렀다가 떼는 아주 짧은 입력은 루프가 상태를 읽는 시점에 이미 false가 되어 놓칠 수 있다는 것이다. 이런 입력이 중요하다면 keydown 시점에 "눌림" 플래그를 따로 기록해 두었다가 루프에서 읽고 지우는 방식을 함께 쓴다. 이벤트 리스너를 유지하는 오버헤드는 현대 브라우저에서 무시할 수 있는 수준이다.

게임 루프 내 상태 체크: 가장 반응성 좋은 방식

더 나은 방법은 키보드 상태를 직접 폴링하는 것이다. 다만 표준 Web API에는 현재 키 상태를 직접 조회하는 메서드가 없으므로, 이벤트와 상태 저장소를 조합해야 한다.

const inputState = {
  keys: {},
  mouse: { x: 0, y: 0, pressed: false },
  touches: []
};

// 키보드
document.addEventListener('keydown', (e) => {
  inputState.keys[e.code] = true;
});

document.addEventListener('keyup', (e) => {
  inputState.keys[e.code] = false;
});

// 마우스
document.addEventListener('mousemove', (e) => {
  inputState.mouse.x = e.clientX;
  inputState.mouse.y = e.clientY;
});

document.addEventListener('mousedown', () => {
  inputState.mouse.pressed = true;
});

document.addEventListener('mouseup', () => {
  inputState.mouse.pressed = false;
});

// 터치
document.addEventListener('touchstart', (e) => {
  inputState.touches = Array.from(e.touches).map(t => ({
    x: t.clientX,
    y: t.clientY,
    id: t.identifier
  }));
});

document.addEventListener('touchmove', (e) => {
  inputState.touches = Array.from(e.touches).map(t => ({
    x: t.clientX,
    y: t.clientY,
    id: t.identifier
  }));
});

document.addEventListener('touchend', (e) => {
  inputState.touches = Array.from(e.touches).map(t => ({
    x: t.clientX,
    y: t.clientY,
    id: t.identifier
  }));
});

// 게임 루프
function gameLoop() {
  // 입력 상태 확인
  if (inputState.keys['ArrowLeft']) playerX -= 5;
  if (inputState.keys['ArrowRight']) playerX += 5;
  
  // 마우스 좌표로 플레이어 방향 결정
  const dx = inputState.mouse.x - playerX;
  const dy = inputState.mouse.y - playerY;
  playerAngle = Math.atan2(dy, dx);
  
  // 터치 입력 처리
  inputState.touches.forEach(touch => {
    checkCollisionWithButton(touch.x, touch.y);
  });
  
  update();
  render();
  requestAnimationFrame(gameLoop);
}

gameLoop();

이 방식의 핵심은 입력 이벤트는 상태만 업데이트하고, 실제 게임 로직은 게임 루프에서 상태를 읽어 처리한다는 것이다. 결과적으로 다음과 같은 이점이 생긴다:

  • 동시 입력 처리: 여러 키를 동시에 눌러도 상태 객체에 모두 기록된다. (일부 키보드는 하드웨어 한계로 특정 키 조합을 동시에 인식하지 못할 수 있다.)
  • 프레임 동기화: 게임 루프와 입력이 같은 타이밍에 처리되어 예측 가능하다.
  • 플랫폼 통합: 키보드, 마우스, 터치를 하나의 상태 객체로 관리하므로 코드가 깔끔하다.

입력 지연 문제를 실제로 줄이는 법

위 방식만으로도 충분하지만, 추가로 고려할 사항이 있다.

1. requestAnimationFrame 사용: setInterval이나 setTimeout으로 게임 루프를 만들지 말고 반드시 requestAnimationFrame을 사용하라. 브라우저의 리페인트 사이클과 동기화되어 프레임 드롭과 화면 찢김을 줄인다.

2. 이벤트 캡처 단계 활용: 다른 요소가 이벤트 전파를 막는 경우에도 입력을 놓치지 않으려면 캡처 단계에서 받는 방법이 있다. 다만 반응 속도 자체가 눈에 띄게 빨라지지는 않는다.

document.addEventListener('keydown', handleKeyDown, true); // 세 번째 인자가 true면 캡처 단계

3. 터치 입력 정리: 과거 모바일 브라우저에서는 click 이벤트가 탭 후 약 300ms 늦게 발생했다. 지금은 뷰포트 메타 태그가 있으면 대부분 사라졌고, touchstart나 pointerdown으로 입력을 받으면 이 지연과 무관하다. 캔버스에 touch-action: none을 설정하면 더블탭 확대나 스크롤 같은 브라우저 기본 동작이 입력을 방해하는 것을 막을 수 있다.

<meta name="viewport" content="width=device-width, initial-scale=1.0">
canvas {
  touch-action: none;
}

여러 입력 방식을 통합하는 추상화 레이어

실무 게임에서는 키보드, 마우스, 터치, 게임패드까지 모두 지원해야 할 수 있다. 이때 각 입력 방식을 추상화하면 유지보수가 쉬워진다.

class InputManager {
  constructor() {
    this.state = {
      moveX: 0,
      moveY: 0,
      action: false,
      aim: { x: 0, y: 0 }
    };
  }

  update() {
    // 키보드 입력 → moveX, moveY로 정규화
    this.state.moveX = 0;
    this.state.moveY = 0;
    if (inputState.keys['ArrowLeft'] || inputState.keys['KeyA']) this.state.moveX -= 1;
    if (inputState.keys['ArrowRight'] || inputState.keys['KeyD']) this.state.moveX += 1;
    if (inputState.keys['ArrowUp'] || inputState.keys['KeyW']) this.state.moveY -= 1;
    if (inputState.keys['ArrowDown'] || inputState.keys['KeyS']) this.state.moveY += 1;

    // 마우스/터치 입력 → aim으로 정규화
    this.state.aim = { x: inputState.mouse.x, y: inputState.mouse.y };
    if (inputState.touches.length > 0) {
      this.state.aim = { x: inputState.touches[0].x, y: inputState.touches[0].y };
    }

    // 액션 버튼
    this.state.action = inputState.keys['Space'] || inputState.mouse.pressed;
  }

  getState() {
    return this.state;
  }
}

const inputManager = new InputManager();

function gameLoop() {
  inputManager.update();
  const input = inputManager.getState();
  
  // 게임 로직에서 사용
  if (input.moveX !== 0 || input.moveY !== 0) {
    movePlayer(input.moveX, input.moveY);
  }
  if (input.action) {
    playerAttack();
  }
  
  render();
  requestAnimationFrame(gameLoop);
}

이런 추상화 레이어를 두면 나중에 게임패드나 다른 입력 방식을 추가할 때 게임 로직 코드를 수정할 필요가 없다.

웹 게임 입력 처리에서 자주 묻는 질문

모바일 브라우저에서 입력 지연이 더 심한 이유는?

모바일에서는 브라우저가 터치 이벤트를 처리한 후 스크롤 가능성을 판단하는 데 시간을 쓴다. touch-action: none CSS와 e.preventDefault()를 조합하면 이 지연을 줄일 수 있다. 또한 기기 자체의 입력 처리 지연도 있으므로, 즉각적인 반응을 기대하기는 어렵다.

게임패드(조이스틱) 입력은 어떻게 처리하나요?

Gamepad API를 사용하면 된다. 다만 이벤트가 아니라 폴링 방식이므로 게임 루프 내에서 navigator.getGamepads()로 상태를 읽어야 한다. 위에서 설명한 InputManager에 게임패드 폴링 로직을 추가하면 된다.

이벤트 리스너를 여러 개 달면 성능이 떨어지나요?

현대 브라우저에서는 이벤트 리스너를 몇 개 다는 정도는 무시할 수 있는 오버헤드다. 다만 게임 루프 내에서 복잡한 이벤트 처리를 하면 프레임 드롭이 생길 수 있으므로, 이벤트는 상태만 업데이트하고 로직은 게임 루프에서 처리하는 것이 원칙이다.

결론: 상태 기반 입력 처리가 정답

HTML5 게임의 입력 처리는 이벤트로 상태를 업데이트하고, 게임 루프에서 상태를 읽는 방식이 가장 안정적이고 반응성 좋다. 이 패턴은 모바일과 데스크톱, 다양한 입력 장치를 모두 지원하기에 충분하며, 코드도 명확하고 유지보수하기 쉽다.

게임 프로토타입을 만들었다면, Vibeollio에 프로젝트를 등록해 다른 사람들이 직접 플레이해 보고 댓글로 피드백을 남기게 해 보는 것도 좋은 방법이다. 실제 플레이어의 의견을 통해 조작감 문제를 발견하는 데 도움이 된다.