Skip to main content
Summary

최적화는 마지막 장식이 아니라 완성 조건이다

게임이 기능적으로 완성되었다고 해서 플레이할 준비가 끝난 것은 아니다. 프레임이 끊기거나, 터치 버튼이 눌리지 않거나, 화면 가장자리에 UI가 잘리면 플레이어는 게임 규칙을 이해하기 전에 이탈한다.

성능 최적화의 목표는 "모든 것을 최고 품질로 보이게 하는 것"이 아니다. 목표는 핵심 플레이가 안정적으로 유지되게 하는 것이다. 특히 Phaser 게임은 브라우저 위에서 실행되므로 데스크톱, 태블릿, 모바일, 저사양 기기까지 실행 환경이 다양하다.

이번 장에서는 다음 순서로 점검한다.

  1. 프레임 저하를 숫자로 확인한다.

  2. 반복 생성되는 오브젝트를 재사용한다.

  3. 이미지와 사운드 에셋을 가볍게 만든다.

  4. 모바일 화면과 터치 UI를 안전하게 배치한다.

  5. 기기 성능에 따라 효과 품질을 단계적으로 낮춘다.

최적화는 감으로 시작하지 않는다. 먼저 측정하고, 병목을 찾고, 필요한 부분만 줄인다.

성능 최적화와 모바일 대응AI 생성 이미지

FPS, 메모리, 오브젝트 수 확인하기

가장 먼저 볼 숫자는 FPS다. FPS는 초당 화면이 몇 번 갱신되는지를 뜻한다. 일반적인 액션 게임은 60FPS에 가까울수록 부드럽다. 하지만 항상 60을 고집하기보다, 끊김이 언제 발생하는지를 찾는 것이 더 중요하다.

Phaser에서는 게임 루프의 실제 FPS를 확인할 수 있다. 개발 중에는 간단한 디버그 텍스트를 화면에 띄워 두면 좋다.1

class PlayScene extends Phaser.Scene {
  create() {
    this.debugText = this.add.text(10, 10, '', {
      fontSize: '14px',
      color: '#00ff88',
      backgroundColor: '#000000'
    }).setScrollFactor(0).setDepth(9999);
  }

  update(time, delta) {
    const fps = Math.round(this.game.loop.actualFps);
    const objects = this.children.list.length;
    const bodies = this.physics.world.bodies.entries.length;

    let memoryText = '';
    if (performance.memory) {
      const usedMB = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024);
      memoryText = `nMemory: ${usedMB}MB`;
    }

    this.debugText.setText(
      `FPS: ${fps}nDelta: ${delta.toFixed(1)}msnObjects: ${objects}nBodies: ${bodies}${memoryText}`
    );
  }
}

여기서 delta는 이전 프레임 이후 흐른 시간이다. 60FPS라면 한 프레임은 대략 16.7ms 안에 처리되어야 한다. delta가 자주 25ms 이상으로 튄다면 화면이 끊겨 보일 수 있다.

performance.memory는 일부 브라우저, 특히 Chromium 계열에서 주로 제공되는 값이다. 모든 브라우저에서 보장되는 정보는 아니므로, 위 코드처럼 값이 있을 때만 표시하는 정도로 사용하면 된다. 메모리 수치는 절대값 하나보다 "플레이를 반복할수록 계속 증가하는가"를 보는 데 더 의미가 있다.

숫자를 볼 때는 한 번의 순간보다 흐름을 본다.

  • 적이 많이 등장할 때 FPS가 떨어지는가?

  • 총알을 많이 쏜 뒤 메모리가 계속 증가하는가?

  • Scene을 재시작할 때 오브젝트 수가 줄지 않는가?

  • 파티클이 동시에 터질 때만 끊기는가?

이 질문에 답해야 어떤 최적화를 할지 정할 수 있다.

특히 Scene을 재시작했는데 메모리와 오브젝트 수가 계속 늘어난다면 이벤트 리스너, 타이머, Tween, 사운드 인스턴스가 정리되지 않았을 가능성이 있다. 이전 장에서 저장 데이터에 기본값과 예외 처리를 두었던 것처럼, 실행 중에 만든 리소스도 끝날 때 정리해야 한다.

create() {
  this.onCoinChanged = (score) => {
    this.scoreText.setText(`Score: ${score}`);
  };

  // 예: 여러 Scene이 함께 쓰는 전역 이벤트에 리스너를 등록한 경우
  this.game.events.on('coin-changed', this.onCoinChanged);

  this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
    this.game.events.off('coin-changed', this.onCoinChanged);
    this.tweens.killAll();
    this.time.removeAllEvents();
  });
}

모든 Scene에서 killAll()이나 removeAllEvents()를 무조건 써야 한다는 뜻은 아니다. 중요한 것은 내가 만든 반복 작업이 Scene 종료 뒤에도 남지 않게 하는 것이다. 특히 this.game.events, this.scale, DOM 이벤트, 직접 만든 이벤트 버스처럼 Scene 밖에 있는 객체에 등록한 리스너는 Scene이 끝나도 자동으로 사라지지 않을 수 있다. 등록한 곳과 제거할 곳을 한 쌍으로 생각하는 습관을 들이면 메모리 누수를 줄일 수 있다.

반복 생성과 파괴를 줄이는 Object Pooling

슈팅 게임에서 총알은 짧은 시간 동안 많이 생겼다가 사라진다. 폭발, 데미지 숫자, 코인 파티클도 비슷하다. 이런 오브젝트를 매번 새로 만들고 destroy()하면 브라우저의 가비지 컬렉션이 자주 동작한다. 가비지 컬렉션은 사용하지 않는 메모리를 정리하는 과정인데, 타이밍이 나쁘면 순간적인 프레임 끊김을 만든다.

이 문제를 줄이는 대표적인 방법이 Object Pooling이다. 오브젝트를 삭제하지 않고 숨겨 두었다가 다시 사용하는 방식이다.2

Phaser의 Group은 풀링 구조를 만들기 좋다.

create() {
  this.bullets = this.physics.add.group({
    defaultKey: 'bullet',
    maxSize: 40
  });
}

fireBullet(x, y) {
  const bullet = this.bullets.get(x, y);

  if (!bullet) return; // maxSize를 넘으면 새 총알을 만들지 않는다.

  bullet.enableBody(true, x, y, true, true);
  bullet.setVelocity(0, -600);
}

update() {
  this.bullets.children.each((bullet) => {
    if (!bullet.active) return;

    if (bullet.y < -20) {
      this.releaseBullet(bullet);
    }
  });
}

releaseBullet(bullet) {
  bullet.setVelocity(0, 0);
  bullet.disableBody(true, true);
}

이 구조에서는 총알이 화면 밖으로 나가도 삭제하지 않는다. 비활성화하고 숨긴 뒤 다음 발사 때 다시 꺼낸다. enableBody()는 물리 Body와 GameObject를 다시 켜고, disableBody(true, true)는 Body를 끄면서 화면에서도 숨긴다. 직접 setActive(false), setVisible(false), body.enable = false를 조합해도 되지만, 물리 오브젝트를 풀링할 때는 이렇게 한 번에 처리하는 편이 실수를 줄이기 쉽다.

풀링을 적용하기 좋은 대상은 다음과 같다.

  • 총알

  • 적 총알

  • 폭발 스프라이트

  • 데미지 숫자

  • 코인 획득 이펙트

  • 짧게 나타났다 사라지는 UI 효과

반대로 플레이어, 보스, 한 번만 배치되는 배경처럼 개수가 적고 오래 살아 있는 오브젝트는 풀링의 이점이 크지 않다. 풀링도 관리 비용이 있으므로 반복 생성이 많은 대상에만 적용한다.

이미지, 아틀라스, 오디오를 가볍게 만들기

성능 문제는 코드에서만 생기지 않는다. 에셋이 너무 크면 로딩이 느려지고, 메모리를 많이 쓰며, 모바일에서 렌더링 부담이 커진다.

이미지는 먼저 "게임에서 실제로 보이는 크기"를 기준으로 줄인다. 화면에 64×64로 보이는 아이콘을 1024×1024 이미지로 넣을 필요는 없다. 캐릭터, 아이콘, 효과 이미지는 실제 표시 크기보다 약간 여유 있는 정도면 충분하다.

여러 작은 이미지는 가능하면 아틀라스로 묶는다. 아틀라스는 여러 이미지를 큰 이미지 한 장과 위치 정보 JSON으로 관리하는 방식이다. 브라우저 요청 수를 줄이고, 렌더링 과정에서도 같은 텍스처를 묶어 처리하기 쉬워진다.1

preload() {
  this.load.atlas(
    'game-atlas',
    'assets/atlas/game.png',
    'assets/atlas/game.json'
  );
}

create() {
  this.add.sprite(100, 100, 'game-atlas', 'player_idle_0');
  this.add.image(200, 100, 'game-atlas', 'coin');
}

아틀라스를 만들 때는 역할별로 나누면 관리하기 쉽다.

  • ui-atlas: 버튼, 아이콘, 하트, 숫자

  • player-atlas: 플레이어 애니메이션

  • enemy-atlas: 적과 보스

  • effect-atlas: 폭발, 먼지, 타격 효과

모바일까지 고려한다면 한 아틀라스에 모든 이미지를 무리하게 넣기보다 적당한 크기로 나눈다. 아주 큰 텍스처는 일부 기기에서 부담이 될 수 있다. 실무에서는 모바일용 아틀라스를 너무 크게 만들지 않고, 필요한 Scene에서 필요한 에셋만 로드하는 방식이 안정적이다.

오디오도 마찬가지다. 효과음은 짧고 가볍게 만든다. 배경음악은 길이가 길기 때문에 압축 포맷을 사용하고, 볼륨과 반복 재생을 코드에서 관리한다.

preload() {
  this.load.audio('sfx-coin', [
    'assets/audio/coin.ogg',
    'assets/audio/coin.mp3'
  ]);
}

브라우저마다 지원 포맷이 다를 수 있으므로 ogg와 mp3를 함께 제공하면 대응 범위가 넓어진다. 또한 모바일 브라우저는 사용자 터치 전에는 사운드 재생을 제한하는 경우가 있으므로, 첫 시작 버튼을 누른 뒤 배경음악을 재생하는 흐름이 안전하다.

모바일 화면과 터치 UI 배치하기

모바일 대응에서 가장 흔한 문제는 화면 비율이다. 같은 게임이라도 어떤 기기는 16:9, 어떤 기기는 더 길고, 어떤 기기는 상단 카메라 영역 때문에 안전하게 사용할 수 있는 영역이 좁다.

Phaser 설정에서는 먼저 기준 해상도를 정하고, 브라우저 화면에 맞춰 스케일되게 만든다.

const config = {
  type: Phaser.AUTO,
  width: 960,
  height: 540,
  scale: {
    mode: Phaser.Scale.FIT,
    autoCenter: Phaser.Scale.CENTER_BOTH
  },
  physics: {
    default: 'arcade',
    arcade: {
      gravity: { y: 900 },
      debug: false
    }
  },
  scene: [BootScene, PlayScene]
};

FIT은 기준 화면을 유지한 채 가능한 크게 맞춘다. 덕분에 게임 화면이 찌그러지지 않는다. 대신 기기 비율에 따라 남는 여백이 생길 수 있다. UI를 배치할 때는 고정 숫자만 믿지 말고, 현재 게임 크기와 카메라를 기준으로 계산한다.

create() {
  const { width, height } = this.scale;

  this.pauseButton = this.add.image(width - 48, 48, 'ui-pause')
    .setScrollFactor(0)
    .setInteractive({ useHandCursor: true });

  this.scoreText = this.add.text(24, 24, 'Score: 0', {
    fontSize: '24px',
    color: '#ffffff'
  }).setScrollFactor(0);
}

모바일 터치 버튼은 보기보다 크게 잡아야 한다. 손가락은 마우스 커서처럼 정확하지 않다. 버튼 이미지는 작아도 실제 터치 영역은 44~64픽셀 이상으로 두는 편이 안전하다.

createMobileButton(x, y, label, onDown, onUp) {
  const zone = this.add.zone(x, y, 80, 80)
    .setScrollFactor(0)
    .setInteractive();

  const bg = this.add.circle(x, y, 36, 0x000000, 0.35)
    .setScrollFactor(0);

  const text = this.add.text(x, y, label, {
    fontSize: '22px',
    color: '#ffffff'
  }).setOrigin(0.5).setScrollFactor(0);

  zone.on('pointerdown', onDown);
  zone.on('pointerup', onUp);
  zone.on('pointerout', onUp);
  zone.on('pointerupoutside', onUp);

  return { zone, bg, text };
}

pointerupoutside를 함께 처리하는 이유는 손가락을 누른 채 버튼 밖으로 이동한 뒤 떼는 경우가 있기 때문이다. 이동 버튼이나 점프 버튼에서 이런 처리를 빼면 버튼이 계속 눌린 상태로 남는 문제가 생길 수 있다.

화면 회전도 고려해야 한다. 가로 게임이라면 세로 화면에서 "기기를 가로로 돌려 주세요" 안내 Scene을 보여 줄 수 있다.

create() {
  this.orientationText = this.add.text(480, 270, '', {
    fontSize: '28px',
    color: '#ffffff'
  }).setOrigin(0.5).setScrollFactor(0);

  this.scale.on('resize', this.handleResize, this);
  this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
    this.scale.off('resize', this.handleResize, this);
  });

  this.handleResize();
}

handleResize() {
  const isPortrait = window.innerHeight > window.innerWidth;
  this.orientationText.setText(
    isPortrait ? '기기를 가로로 돌려 주세요' : ''
  );
}

여기서도 resize 리스너를 등록했으므로 Scene이 끝날 때 제거한다. 화면 회전 안내처럼 단순한 기능도 여러 번 Scene을 재시작하다 보면 리스너가 중복 등록될 수 있기 때문이다.

안전 영역은 기기의 노치, 둥근 모서리, 브라우저 UI에 가려질 수 있는 부분을 뜻한다. 완벽한 기기별 대응은 별도 처리가 필요하지만, 기본 원칙은 간단하다. 중요한 버튼을 화면 끝에 딱 붙이지 않는다. 점수, 일시정지, 이동 버튼은 최소 20~40픽셀 정도 안쪽으로 들여 배치한다.

저사양 기기를 위한 품질 단계 만들기

모든 기기에서 같은 효과를 강제로 보여 주면 저사양 기기에서 핵심 플레이가 무너질 수 있다. 따라서 효과는 단계적으로 줄일 수 있어야 한다. 중요한 것은 그래픽 품질이 아니라 조작 반응이다.

품질 단계를 간단히 나누어 보자.

const QUALITY = {
  HIGH: 'high',
  MEDIUM: 'medium',
  LOW: 'low'
};

const EFFECT_SETTINGS = {
  high: {
    particles: 30,
    cameraShake: true,
    additiveBlend: true
  },
  medium: {
    particles: 12,
    cameraShake: true,
    additiveBlend: false
  },
  low: {
    particles: 4,
    cameraShake: false,
    additiveBlend: false
  }
};

게임 시작 후 일정 시간 동안 FPS를 관찰하고, 낮은 FPS가 계속되면 품질을 낮춘다.

create() {
  this.quality = QUALITY.HIGH;
  this.lowFpsTime = 0;
}

update(time, delta) {
  if (delta > 25) {
    this.lowFpsTime += delta;
  } else {
    this.lowFpsTime = Math.max(0, this.lowFpsTime - delta);
  }

  if (this.lowFpsTime > 3000 && this.quality !== QUALITY.LOW) {
    this.setQuality(QUALITY.LOW);
  }
}

setQuality(level) {
  this.quality = level;
  console.log(`Quality changed: ${level}`);
}

이 코드는 단순한 예시다. 실제 게임에서는 품질을 바로 낮추기보다 HIGH에서 MEDIUM, MEDIUM에서 LOW처럼 한 단계씩 낮추는 편이 자연스럽다. 또한 로딩 직후처럼 잠깐 느려지는 구간만 보고 판단하지 않도록, 몇 초 동안의 흐름을 보고 결정하는 것이 좋다.

파티클을 만들 때는 이 설정을 사용한다. 저사양에서는 입자 수를 줄이고, 수명을 짧게 하며, 계속 뿜는 효과보다 필요한 순간에만 터지는 효과를 사용한다.3

spawnHitEffect(x, y) {
  const setting = EFFECT_SETTINGS[this.quality];

  this.hitEmitter.explode(setting.particles, x, y);

  if (setting.cameraShake) {
    this.cameras.main.shake(80, 0.004);
  }
}

줄이기 좋은 효과는 다음과 같다.

  • 파티클 개수

  • 파티클 수명

  • 화면 흔들림

  • 반투명 레이어

  • ADD 블렌드 효과

  • 그림자, 블러, 후처리 효과

  • 동시에 재생되는 사운드 수

반대로 줄이면 안 되는 것은 플레이 판정이다. 적 충돌, 총알 판정, 점프 입력, 제한 시간 같은 핵심 규칙은 품질 단계와 분리한다. 저사양 모드에서도 게임은 같은 규칙으로 동작해야 한다.

최적화 체크리스트

마지막으로 실제 테스트 전에 다음 항목을 확인하자.

  • 개발용 physics.arcade.debug가 꺼져 있는가?

  • 화면 밖 총알, 적, 이펙트가 비활성화되는가?

  • 반복 생성되는 오브젝트에 풀링을 적용했는가?

  • Scene 종료 시 이벤트, 타이머, Tween이 정리되는가?

  • Scene 밖 객체에 등록한 리스너를 해제하고 있는가?

  • 아틀라스와 이미지 크기가 실제 표시 크기에 비해 과하지 않은가?

  • 모바일 터치 버튼의 실제 입력 영역이 충분히 큰가?

  • 중요한 UI가 화면 끝과 노치 영역에 너무 붙어 있지 않은가?

  • 파티클과 카메라 효과를 줄일 수 있는 품질 단계가 있는가?

  • 실제 모바일 기기에서 테스트했는가?

성능 최적화는 한 번에 끝나는 작업이 아니다. 새 적을 추가하거나, 새 이펙트를 넣거나, 레벨 크기를 키울 때마다 다시 측정해야 한다. 좋은 기준은 단순하다. 플레이어가 입력했을 때 즉시 반응하고, 중요한 정보가 잘 보이며, 오래 플레이해도 점점 느려지지 않아야 한다.

다음 장에서는 이렇게 다듬은 게임을 빌드하고 배포할 준비를 한다. 로컬에서 잘 실행되는 게임을 실제 사용자가 접속할 수 있는 웹 게임으로 만드는 단계다.

참고

1Phaser Performance Optimization Guide: 60 FPS on Every Device

2Understand object Pooling for Performance

3Understand performance Optimization for Effects

책 목록

Phaser로 시작하는 웹 2D 게임 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기