본문으로 바로가기
기술

Flame 엔진으로 만드는 Flutter 2D 게임 — 실전 아키텍처와 성능 튜닝

A
AlwaysCorp 엔지니어링팀· Flutter·모바일 게임 개발
||11분 읽기
#Flutter#Flame#게임개발#2D게임#Dart#모바일게임#성능최적화#SpriteSheet

왜 Flame인가

Flutter만으로 게임이 잘 안 되는 이유

Flutter는 위젯 트리 기반의 선언형 UI 프레임워크입니다. `setState` → `build` → diff → 레이아웃 → 페인트로 이어지는 파이프라인은 폼·리스트·대시보드 같은 애플리케이션 UI에는 탁월하지만, 매 프레임 수백 개의 객체 상태가 바뀌는 게임 루프에는 과잉 비용이 따릅니다. 16.6ms(60fps) 안에 위젯 트리를 재구성하고 RenderObject를 갱신하는 일은 오브젝트 수가 늘어날수록 빠르게 한계에 부딪힙니다.

Flame은 이 문제를 정공법으로 풉니다. 위젯 트리 대신 Component 트리를 별도로 운용하고, Flutter의 `CustomPainter` 위에 게임 월드를 직접 그립니다. 결과적으로 위젯 리빌드 비용이 사라지고, 캔버스 API만 호출하는 얇은 레이어가 됩니다.

저희 팀에서 withu_apps라는 Flutter 모노레포 아래 mind_test 심리테스트 앱과 여러 유틸 앱을 운영하면서, 미니게임 한두 개를 같은 저장소에 얹어볼 필요가 있었습니다. 처음엔 그냥 Flutter 위젯으로 뱀 게임 비슷한 것을 구현해 봤는데, 중간 밀도의 장면에서 jank가 먼저 눈에 들어왔습니다. Flame으로 갈아엎고 나서야 저사양 안드로이드에서도 프레임이 평탄해졌고, 그 경험이 이 글의 출발점이기도 합니다.

Flutter의 강점은 그대로

그럼에도 Flame은 Flutter 생태계를 버리지 않습니다. HUD(게임 내 UI)는 여전히 Flutter 위젯으로 올릴 수 있고, 상태 관리 솔루션(Riverpod, Bloc)과도 자연스럽게 연동됩니다. 하이브리드 구조는 인디 개발자와 소규모 팀에게 큰 생산성 이점이 됩니다.

---

Component-System 아키텍처

FCS(Flame Component System)의 기본

Flame의 모든 게임 요소는 `Component`를 상속합니다. `SpriteComponent`, `TextComponent`, `PositionComponent`는 모두 같은 생명주기(`onLoad`, `onMount`, `update`, `render`)를 공유합니다.

```dart class Player extends SpriteComponent with HasGameRef<MyGame>, CollisionCallbacks { Player() : super(size: Vector2(64, 64), anchor: Anchor.center);

@override Future<void> onLoad() async { sprite = await gameRef.loadSprite('player.png'); add(RectangleHitbox()); }

@override void update(double dt) { position += velocity * dt; super.update(dt); } } ```

핵심은 `dt`(delta time) 기반 업데이트입니다. 프레임 속도가 요동쳐도 게임 속도는 일정해야 하므로, 이동·회전·감쇠 같은 물리량은 반드시 `dt`를 곱해서 계산합니다.

HasComponentRef와 의존성 주입

서브 컴포넌트에서 루트 게임 인스턴스에 접근할 때는 `HasGameRef<T>` 믹스인을 사용합니다. 이 패턴은 컴포넌트를 느슨하게 결합시켜, 테스트 시 `MockGame`을 주입하는 것도 쉽습니다.

---

충돌 판정에서 놓치기 쉬운 부분

순진한 구현의 비용

60fps에서 캐릭터 30개, 총알 100개가 상호 충돌을 검사하면 프레임당 3,000번의 비교가 필요합니다. 이를 매 프레임 선형 탐색으로 처리하면 단일 스레드에서 금방 CPU가 녹습니다.

Flame 1.8부터는 내부적으로 Quadtree 기반 공간 분할을 지원합니다. `CollisionDetectionType.quadTree`로 전환하면 오브젝트 밀도가 높은 월드에서 CPU 사용량이 크게 떨어집니다. 2D 종스크롤 슈팅 기준으로 선형 탐색 대비 약 40~60% CPU 절감을 관측한 사례가 있습니다.

```dart class MyGame extends FlameGame with HasCollisionDetection { MyGame() : super( collisionDetection: QuadTreeCollisionDetection( mapDimensions: const Rect.fromLTWH(0, 0, 2048, 2048), maxObjects: 25, maxLevels: 10, ), ); } ```

Hitbox 모양 선택

| 모양 | 비용 | 사용 시점 | |---|---|---| | CircleHitbox | 가장 낮음 | 총알·아이템·파티클 | | RectangleHitbox | 낮음 | 플레이어·일반 적 | | PolygonHitbox | 높음 | 불규칙한 보스·지형 |

"픽셀 단위 정확한 충돌"에 집착하면 성능은 결국 무너집니다. 눈으로 봐서 어색하지 않을 정도의 가장 단순한 모양을 고르는 게 실전에서는 정답에 가깝습니다.

---

Sprite Sheet와 애니메이션

Draw Call을 줄이는 이유

모바일 GPU에서 가장 비싼 연산은 개별 Draw Call이 아니라 텍스처 바인딩 전환입니다. 같은 텍스처를 여러 번 그리는 것은 저렴하지만, 매번 다른 텍스처를 넘나들면 병목이 생깁니다.

해법은 Sprite Sheet(텍스처 아틀라스)입니다. 캐릭터의 Idle·Walk·Attack 애니메이션 프레임을 한 장의 큰 이미지로 묶고, UV 좌표만 바꿔 가며 동일 텍스처에서 잘라 씁니다.

```dart final sheet = await gameRef.loadSpriteSheet( 'hero_atlas.png', srcSize: Vector2(64, 64), ); final walkAnimation = sheet.createAnimation( row: 1, stepTime: 0.08, to: 8, ); ```

대규모 장면에서 배경·타일·UI 아이콘도 한두 개의 아틀라스로 통합하면, 저사양 안드로이드 기기에서도 60fps를 안정적으로 유지할 수 있습니다.

타일맵과 Tiled

`flame_tiled` 패키지는 오픈소스 에디터 Tiled의 `.tmx`/`.tsx` 포맷을 그대로 읽어들입니다. 레벨 디자이너가 Tiled로 맵을 찍으면 코드는 한 줄로 로드됩니다.

```dart final map = await TiledComponent.load('level1.tmx', Vector2.all(32)); add(map); ```

이 구조 덕분에 디자이너와 개발자의 작업이 완전히 분리되고, 레벨 반복(iteration) 속도가 극적으로 빨라집니다.

---

게임 루프와 프레임 예산

16.6ms를 어떻게 나눌 것인가

60fps 게임 루프는 매 프레임 16.6ms의 예산을 가집니다. 이 시간을 대략 다음처럼 분배하는 것이 보편적입니다.

| 단계 | 권장 예산 | |---|---| | 입력 처리 | 0.5ms | | 게임 로직 업데이트 | 3–5ms | | 충돌 판정 | 2–4ms | | 렌더 준비 | 3–5ms | | GPU 제출·여유 | 2–4ms |

Flutter DevTools의 Performance 탭에서 프레임별 타임라인을 확인할 수 있습니다. "Raster thread"가 16ms를 초과한다면 렌더 쪽이 병목, "UI thread"가 초과한다면 로직 쪽이 병목입니다.

오브젝트 풀링

총알·파티클처럼 짧게 살고 빈번히 생성/파괴되는 객체는 풀링(pooling)이 필수입니다. `Component.removeFromParent()`는 메모리를 바로 해제하지 않고 GC에 의존하기 때문에, 피크 상황에서 프레임 드랍을 유발할 수 있습니다.

```dart class BulletPool { final List<Bullet> _available = [];

Bullet acquire() => _available.isNotEmpty ? _available.removeLast() : Bullet();

void release(Bullet bullet) { bullet.reset(); _available.add(bullet); } } ```

---

오디오와 입력

flame_audio의 프리로드

오디오를 처음 재생할 때 파일 디코딩이 일어나 수십 밀리초의 스파이크가 생깁니다. 게임 시작 시 `FlameAudio.audioCache.loadAll([...])`으로 전부 프리로드하면 이 문제가 사라집니다.

입력 믹스인

  • `TapCallbacks` — 탭 이벤트
  • `DragCallbacks` — 드래그
  • `HasKeyboardHandlerComponents` — 키보드
  • `HasGameLoop` / `HasGyroscope` — 모바일 센서

각 믹스인은 독립적이어서 플랫폼별 입력을 깔끔하게 분기할 수 있습니다.

---

배포와 수익화

번들 사이즈 줄이기

Flame은 코어 의존성이 작지만, 이미지·오디오·폰트를 그대로 번들링하면 APK가 금세 100MB를 넘습니다. 실전 팁:

  • 이미지는 WebP로, 필요하면 Lossy 75%
  • 오디오는 `.ogg` 권장 (`.mp3`보다 압축률 좋음)
  • 사용하지 않는 폰트 subset 제거
  • `--split-per-abi`로 ABI별 APK 분할

Google Mobile Ads

Flame 위에서도 `google_mobile_ads` 패키지로 광고를 붙일 수 있습니다. 다만 렌더 쓰레드와 네이티브 광고 뷰의 병목이 생길 수 있어, 광고 로드는 게임 루프 외부(타이틀 화면, 결과 화면)로 빼는 것이 좋습니다.

---

실전 권장 스택

작은 프로젝트 기준으로 다음 스택이 가장 안정적입니다.

  • Flutter: 3.9+
  • flame: ^1.18
  • flame_audio: ^2.10
  • flame_tiled: ^1.20
  • 상태 관리: Riverpod 2.x
  • CI: GitHub Actions + Fastlane
  • Crash 수집: Firebase Crashlytics

---

한 줄로 정리하면

Flame은 Flutter 개발자가 2D 게임에 들어가는 가장 낮은 허들입니다. 언어(Dart), IDE, 빌드 시스템이 그대로고, 한 코드베이스로 iOS·Android·웹·데스크톱이 같이 빠집니다. 다만 프레임 예산과 메모리 관리는 프레임워크가 아니라 결국 아키텍처로 풀어야 합니다.

첫 프로젝트에서는 욕심을 줄이는 편이 낫습니다. 화면 하나, 한 종류의 적, 한 가지 스킬로 먼저 60fps를 편하게 붙잡아 두고, 그 위에 기능을 얹는 순서가 결과적으로 가장 빠릅니다. 저희도 사내 미니게임을 그 순서로 만들다 보니, 정작 게임 자체의 재미에 투자할 여유가 나왔습니다.

A

AlwaysCorp 엔지니어링팀

Flutter·모바일 게임 개발

얼웨이즈 블로그에서 유용한 정보와 인사이트를 공유합니다.