Skip to content

Canvas 2D 게임 루프 기초

게임 루프는 게임의 심장 박동입니다. 브라우저가 허용하는 속도로 계속 돌면서 게임 상태를 업데이트하고 프레임을 그립니다. 이걸 제대로 만들면 나머지가 다 쉬워집니다. 잘못 만들면 특정 기기에서만 나타나는 버그를 쫓아다니게 됩니다.

직접 해보세요 (방향키나 WASD 사용):

가장 단순한 버전

게임 루프를 핵심만 남긴 모습입니다.

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

function gameLoop() {
  update()
  render()
  requestAnimationFrame(gameLoop)
}

function update() {
  // 게임 로직
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // 그리기
}

gameLoop()

이건 돌아가긴 하지만 문제가 있습니다. update 함수는 마지막 프레임 이후로 시간이 얼마나 지났는지 모릅니다. 144Hz 모니터에서는 60Hz 모니터보다 두 배 빠르게 돕니다. 게임 물리가 기기마다 다르게 동작하게 됩니다.

delta time으로 프레임 속도 문제 해결하기

게임이 어디서나 똑같은 속도로 돌게 하려면 프레임 사이에 시간이 얼마나 지났는지 추적해야 합니다. 이걸 delta time이라고 부르고, 보통 dt로 줄여 씁니다.

js
let lastTime = 0

function gameLoop(currentTime) {
  const dt = (currentTime - lastTime) / 1000 // 초 단위로 변환
  lastTime = currentTime
  
  update(dt)
  render()
  
  requestAnimationFrame(gameLoop)
}

function update(dt) {
  player.x += player.speed * dt // 이제 어떤 프레임 속도에서도 동작
}

requestAnimationFrame(gameLoop)

requestAnimationFrame 콜백은 현재 타임스탬프를 밀리초 단위로 받습니다. 저는 1000으로 나눠 초 단위로 바꾸는데, 계산이 더 직관적이기 때문입니다. 속도 200은 초당 200픽셀을 뜻하니 직관적입니다.

한 가지 조심할 점이 있습니다. 첫 프레임에서는 lastTime이 0이라 dt가 엄청 커집니다. 어떤 사람들은 루프를 시작하기 전에 performance.now()lastTime을 초기화하거나, 첫 프레임의 update를 건너뜁니다.

왜 setInterval이 아니라 requestAnimationFrame인가

setInterval 대신 requestAnimationFrame을 쓰는지 궁금할 수 있습니다. 몇 가지 이유가 있습니다.

첫째, requestAnimationFrame은 디스플레이의 주사율과 동기화되어 화면 찢김 없이 부드러운 애니메이션을 얻습니다. 둘째, 탭이 숨겨지면 자동으로 멈춰서 배터리와 CPU를 아낍니다. 셋째, 브라우저가 더 잘 최적화할 수 있습니다.

이 자동 멈춤 동작은 보통 원하는 동작이지만, 사용자가 다른 탭으로 전환하면 게임이 얼어붙는다는 뜻이기도 합니다. 멀티플레이어 게임이나 계속 돌아야 하는 무언가를 만든다면 다른 접근이 필요합니다.

물리에는 고정 타임스텝

가변 delta time은 단순한 이동에는 잘 맞지만 물리 시뮬레이션에서는 문제를 일으킵니다. 한 프레임이 너무 오래 걸리면 충돌 검사가 충돌 순간을 놓쳐서 캐릭터가 벽을 뚫고 지나갈 수 있습니다.

해법은 고정 타임스텝입니다. 시간을 누적해 두고, 프레임이 얼마나 빨리 렌더링되든 상관없이 물리 업데이트를 고정된 속도로 돌립니다.

js
const FIXED_DT = 1 / 60 // 초당 60회 물리 업데이트
let accumulator = 0
let lastTime = 0

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator >= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

Math.min(..., 0.25)가 중요합니다. 탭이 숨겨졌다가 다시 돌아오면 frameTime이 엄청 커질 수 있습니다. 이 상한이 없으면 while 루프가 따라잡으려고 수백 번 업데이트를 돌려 게임이 얼어붙습니다. 0.25초로 제한하면 한 프레임에 물리 업데이트가 15회를 넘지 않습니다.

게임 상태 정리하기

게임이 커지면 모든 데이터를 둘 곳이 필요합니다. 저는 모든 걸 담는 객체 하나를 쓰는 걸 좋아합니다.

js
const game = {
  state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
  score: 0,
  player: {
    x: 100,
    y: 100,
    vx: 0,
    vy: 0,
    speed: 200,
    width: 32,
    height: 32,
  },
  entities: [],
}

이렇게 하면 게임 상태를 저장하고 불러오기, 플레이어가 죽었을 때 리셋하기, game을 콘솔에 찍어 디버깅하기가 쉽습니다. 클래스를 선호하는 사람도 있지만 작거나 중간 규모 게임에는 평범한 객체로 충분합니다.

전부 합치기

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0

const input = { left: false, right: false, up: false, down: false }

const game = {
  player: { x: 100, y: 100, speed: 200 },
}

function update(dt) {
  const p = game.player
  
  if (input.left) p.x -= p.speed * dt
  if (input.right) p.x += p.speed * dt
  if (input.up) p.y -= p.speed * dt
  if (input.down) p.y += p.speed * dt
  
  // 범위 안에 유지
  p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
  p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}

function render() {
  ctx.fillStyle = '#1a1a2e'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#4ade80'
  ctx.fillRect(game.player.x, game.player.y, 32, 32)
}

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator >= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

// 입력 처리
window.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})

window.addEventListener('keyup', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})

requestAnimationFrame(gameLoop)

이건 완성된, 바로 돌릴 수 있는 게임 루프입니다. 플레이어는 방향키나 WASD로 움직이고, 범위 안에 머물며, 프레임 속도와 상관없이 모든 게 일정한 속도로 돕니다. 어떤 Canvas 2D 게임이든 이걸 시작점으로 복사해서 쓸 수 있습니다.

입력 처리가 게임 루프와 분리되어 있다는 점에 주목하세요. 이벤트 리스너는 플래그만 설정하고, update 함수가 그 플래그를 읽습니다. 이 분리가 중요한 이유는 키보드 이벤트는 예측할 수 없는 시점에 발생하는데, 게임 로직은 예측 가능한 속도로 돌리고 싶기 때문입니다.

스프라이트 로드하고 그리기

js
const sprites = {}

async function loadSprite(name, url) {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => {
      sprites[name] = img
      resolve(img)
    }
    img.src = url
  })
}

function drawSprite(name, x, y, width, height) {
  const sprite = sprites[name]
  if (sprite) {
    ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
  }
}

이미지는 비동기로 로드되기 때문에 게임을 시작하기 전에 로드가 끝나길 기다려야 합니다. 흔한 패턴은 loadAssets 함수 안에서 모든 스프라이트를 로드하고 Promise를 반환한 다음, 그게 resolve된 후에만 게임 루프를 시작하는 것입니다.

drawSprite 함수는 그리기 전에 스프라이트가 존재하는지 확인합니다. 이렇게 하면 아직 로드되지 않은 걸 그리려 할 때 에러가 나는 걸 막습니다.

애니메이션 스프라이트

js
class AnimatedSprite {
  constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
    this.image = image
    this.frameWidth = frameWidth
    this.frameHeight = frameHeight
    this.frameCount = frameCount
    this.frameDuration = 1 / fps
    this.currentFrame = 0
    this.elapsed = 0
  }
  
  update(dt) {
    this.elapsed += dt
    if (this.elapsed >= this.frameDuration) {
      this.currentFrame = (this.currentFrame + 1) % this.frameCount
      this.elapsed = 0
    }
  }
  
  draw(ctx, x, y) {
    ctx.drawImage(
      this.image,
      this.currentFrame * this.frameWidth, 0,
      this.frameWidth, this.frameHeight,
      x, y,
      this.frameWidth, this.frameHeight
    )
  }
}

이 클래스는 스프라이트 시트의 프레임이 한 줄로 가로로 배치되어 있다고 가정합니다. 각 프레임은 크기가 같습니다. update 메서드는 경과 시간에 따라 프레임을 넘기고, draw는 9개 인자 버전의 drawImage를 써서 현재 프레임만 잘라냅니다.

이렇게 사용합니다.

js
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// update 안에서:
playerWalk.update(dt)

// render 안에서:
playerWalk.draw(ctx, player.x, player.y)

충돌 검출

js
function rectCollision(a, b) {
  return (
    a.x < b.x + b.width &&
    a.x + a.width > b.x &&
    a.y < b.y + b.height &&
    a.y + a.height > b.y
  )
}

function circleCollision(a, b) {
  const dx = a.x - b.x
  const dy = a.y - b.y
  const distance = Math.sqrt(dx * dx + dy * dy)
  return distance < a.radius + b.radius
}

// update 안에서:
for (const enemy of game.enemies) {
  if (rectCollision(game.player, enemy)) {
    handleCollision(game.player, enemy)
  }
}

사각형 충돌(축 정렬 경계 상자라는 뜻으로 AABB라고도 부릅니다)은 가장 단순하고 빠른 선택지입니다. 대부분의 경우 이걸 쓰세요. 원형 충돌은 둥근 물체나 충돌을 좀 더 너그럽게 느끼게 하고 싶을 때 더 낫습니다.

엔티티가 적을 때는 모든 쌍을 검사해도 괜찮습니다. 엔티티가 수백 개라면 공간 분할(쿼드트리나 그리드)이 필요하지만, 그건 다른 튜토리얼에서 다룰 주제입니다.

간단한 카메라

js
const camera = {
  x: 0,
  y: 0,
  
  follow(target) {
    this.x = target.x - canvas.width / 2
    this.y = target.y - canvas.height / 2
  }
}

function render() {
  ctx.save()
  ctx.translate(-camera.x, -camera.y)
  
  // 월드 그리기
  drawWorld()
  drawEntities()
  
  ctx.restore()
  
  // UI 그리기 (카메라 영향 없음)
  drawUI()
}

핵심은 ctx.save()ctx.restore()입니다. 현재 변환 상태를 저장하고, translate로 카메라 오프셋을 적용한 뒤, 월드 공간에서 모든 걸 그리고, 다시 복원해서 UI 요소가 카메라의 영향을 받지 않게 합니다.

카메라 이동을 더 부드럽게 하려면 곧바로 목표 위치로 붙는 대신 목표를 향해 lerp 할 수 있습니다. this.x += (targetX - this.x) * 0.1 같은 식이면 따라오는 듯한 멋진 효과가 납니다.

게임 일시정지

js
let paused = false

window.addEventListener('keydown', (e) => {
  if (e.code === 'Escape') {
    paused = !paused
  }
})

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  if (!paused) {
    accumulator += frameTime
    while (accumulator >= FIXED_DT) {
      update(FIXED_DT)
      accumulator -= FIXED_DT
    }
  }
  
  render()
  
  if (paused) {
    renderPauseOverlay()
  }
  
  requestAnimationFrame(gameLoop)
}

function renderPauseOverlay() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#fff'
  ctx.font = '32px sans-serif'
  ctx.textAlign = 'center'
  ctx.fillText('PAUSED', canvas.width / 2, canvas.height / 2)
}

핵심은 일시정지하는 동안 시간 누적을 멈추되 렌더링은 계속한다는 점입니다. 이렇게 하면 일시정지 오버레이가 즉시 나타나고, 게임은 일시정지 해제 입력에 계속 반응합니다.

이 패턴을 확장하면 메뉴, 컷신, 게임 오버 화면 같은 여러 게임 상태를 처리할 수 있습니다. 현재 상태를 확인해서 다른 update/render 함수로 분기시키면 됩니다.

흔한 실수

사람들이 자주 빠지는 걸 몇 가지 봤습니다.

canvas 지우기를 잊어버리기. render 시작 부분에서 clearRect를 호출하지 않으면 이전 프레임 위에 그려서 잔상이 생깁니다.

delta time 상한을 두지 않기. 누군가 탭을 전환했다 돌아오면 거대한 delta time이 물리를 망가뜨리거나 게임이 몇 초씩 "따라잡기"를 하게 만들 수 있습니다.

이미지 로드 전에 그리기. Canvas는 아직 로드되지 않은 이미지의 drawImage 호출을 조용히 무시합니다. 스프라이트가 그냥 나타나지 않을 뿐, 에러 메시지는 없습니다.

루프에 setInterval 쓰기. 디스플레이와 동기화되지 않고 탭이 숨겨져도 멈추지 않습니다. requestAnimationFrame을 쓰세요.

월드 좌표와 화면 좌표 섞기. 카메라를 추가하면 어떤 위치가 월드 공간인지 화면 공간인지 잘 추적하세요. 마우스 클릭은 화면 공간으로 들어오므로 변환이 필요합니다.

다음 단계

이 튜토리얼은 기초를 다룹니다. 이 패턴들에 익숙해지면 다음을 살펴보면 좋습니다.

반응형 게임 캔버스는 다양한 화면 크기와 고DPI 디스플레이를 다룹니다.

게임 입력 처리는 터치 컨트롤, 게임패드, 더 정교한 입력 시스템을 다룹니다.

픽셀 아트 렌더링은 흐림 없이 선명한 픽셀 그래픽을 다룹니다.

게임 물리 라이브러리는 게임 루프에 강체 물리, 충돌, 조인트를 추가하는 법을 다룹니다.

프로젝트에 Canvas 2D, WebGL, WebGPU 중 무엇을 쓸지 고민 중이라면 2026년 웹 게임 기술 스택 가이드가 장단점을 정리해 줍니다. 웹 게임 엔진 비교는 게임 루프를 대신 관리해 주는 프레임워크들을 다룹니다.

외부 자료