MOM.SOFT

MOMSOFT  /  기술 블로그

Mobile

Flutter로 게임을 만들 때 부딪히는 성능 문제

Flutter로 게임이 안 되는 게 아니라, 앱을 만들던 습관 그대로 게임을 만들면 프레임이 무너집니다. 무엇을 어디까지 내려가서 손볼지 판단하는 기준을 정리합니다.

프로젝트 문의하기 →

결론부터 말합니다. Flutter로 2D 게임을 만드는 것은 충분히 가능하고, 대부분의 성능 문제는 프레임워크의 한계가 아니라 앱을 만들던 방식 그대로 게임을 만들었기 때문에 생깁니다. 핵심은 하나입니다. 매 프레임 바뀌는 것과 레이아웃이 필요한 것을 분리하고, 매 프레임 바뀌는 것은 위젯 트리 밖으로 빼는 것입니다. 이 원칙만 지켜도 대부분의 프레임 드랍은 사라집니다. 그 뒤에도 남는 문제가 텍스처 관리와 저사양 기기 대응인데, 이건 성격이 다른 작업이라 뒤에서 따로 다룹니다.

아래 내용은 모바일 앱과 게임을 직접 만들어 스토어에 올려 운영하면서 반복해서 부딪혔던 지점과, 널리 알려진 렌더링 동작을 함께 정리한 것입니다. 특정 수치를 제시하기보다 왜 그렇게 되는가어떤 순서로 판단하는가에 집중합니다. 기기와 빌드 구성에 따라 결과가 크게 달라지기 때문에, 남의 숫자를 믿는 것보다 자기 기기에서 재는 습관이 훨씬 중요합니다.

앱의 프레임과 게임의 프레임은 성격이 다르다

일반 앱에서 화면이 다시 그려지는 계기는 사용자의 입력입니다. 탭을 하고, 스크롤을 하고, 네트워크 응답이 도착합니다. 그 사이에는 아무 일도 일어나지 않습니다. 그래서 프레임 하나가 조금 늦어도 사용자는 대체로 눈치채지 못합니다.

게임은 반대입니다. 입력이 없어도 매 프레임 세계가 갱신됩니다. 캐릭터가 움직이고, 파티클이 사라지고, 타이머가 줄어듭니다. 즉 프레임 생성이 상시 작업이고, 한 프레임이 밀리면 그게 곧바로 움직임의 끊김으로 보입니다. 60Hz 기기라면 한 프레임에 주어진 시간은 16.7ms 남짓이고, 120Hz 기기라면 그 절반입니다. 이 예산 안에서 다음 일이 전부 끝나야 합니다.

앞의 셋은 UI 스레드에서, 마지막 하나는 래스터 스레드에서 일어납니다. 두 스레드는 파이프라인으로 겹쳐 돌기 때문에 어느 한쪽이라도 예산을 넘기면 프레임이 밀립니다. 그리고 이 둘을 구분하지 못하면 성능 문제를 영원히 엉뚱한 곳에서 찾게 됩니다. UI 스레드가 문제면 리빌드와 레이아웃을 줄여야 하고, 래스터 스레드가 문제면 그리기 명령의 성격 자체를 바꿔야 합니다. 해야 할 일이 완전히 다릅니다.

위젯 트리 리빌드와 게임 루프의 충돌

가장 흔한 실수

게임 루프를 만들 때 처음 떠오르는 방법은 타이머나 Ticker를 돌리면서 매 틱마다 setState()를 호출하는 것입니다. 동작은 합니다. 그런데 setState()는 그 State가 붙은 서브트리를 dirty로 표시합니다. 게임 화면 전체를 감싸는 위젯에서 이걸 호출하면, 매 프레임마다 배경·HUD·버튼·점수판까지 전부 다시 build되고, 그 결과로 레이아웃 계산까지 딸려옵니다.

여기서 진짜 비싼 것은 build() 자체보다 레이아웃인 경우가 많습니다. Column, Row, Stack, Flexible이 겹쳐 있고 그 안에 텍스트가 들어 있으면, 매 프레임 텍스트 레이아웃이 다시 계산됩니다. 글자 배치 계산은 결코 싼 작업이 아닙니다. 캐릭터 하나 움직이려고 화면의 모든 글자를 다시 배치하고 있는 셈입니다.

해결의 방향: 리빌드 범위를 좁힌다

원칙은 간단합니다. 움직이는 것만 다시 그리게 하고, 그마저도 가능하면 build를 건너뛴다. 도구는 이미 프레임워크에 다 있습니다.

세 번째가 특히 중요합니다. 아래처럼 게임 상태를 Listenable로 만들고 painter에 직접 물리면, 매 프레임 위젯 트리는 손도 대지 않고 캔버스만 다시 칠합니다.

class GameState extends ChangeNotifier {
  final entities = <Entity>[];

  void step(double dt) {
    for (final e in entities) {
      e.update(dt);
    }
    notifyListeners(); // paint만 유발
  }
}

// 위젯 쪽
CustomPaint(
  painter: GamePainter(state), // GamePainter(this.state) : super(repaint: state);
  isComplex: false, // 매 프레임 바뀌므로 래스터 캐시 대상이 아님
  willChange: true,
)

여기서 두 가지를 짚어야 합니다.

첫째, isComplexwillChange서로 반대 방향의 힌트입니다. isComplex는 "래스터 캐시에 담아 둘 만큼 무겁다"는 뜻이고, willChange는 "다음 프레임에 바뀌니 캐시하지 말라"는 뜻입니다. 둘 다 true로 박아 둔 코드를 자주 보는데, 매 프레임 다시 칠하는 게임 필드는 캐시해 봐야 즉시 무효화되므로 캐시 시도 자체가 낭비입니다. 반대로 거의 안 바뀌는 배경 레이어라면 isComplex: true, willChange: false가 맞습니다. 같은 위젯에 둘 다 켜는 조합만 피하면 됩니다.

둘째, shouldRepaint()가 언제 호출되는지를 알아야 합니다. 이것은 위젯이 리빌드되어 새 painter 인스턴스가 들어올 때 비교용으로 호출됩니다. repaint로 들어온 알림 경로와는 별개입니다. 그래서 습관적으로 true를 반환해 두면, 게임과 무관한 상위 화면이 리빌드될 때마다 불필요한 리페인트가 한 번씩 더 붙습니다. 상태 객체를 그대로 들고 다니는 painter라면 대개 oldDelegate.state != state 정도로 충분합니다.

한 가지 더. ChangeNotifier를 매 프레임 notifyListeners() 하면서, 같은 객체를 ValueListenableBuildercontext.watch 류에도 물려 두면 결국 매 프레임 위젯이 리빌드됩니다. 애써 좁혀 놓은 것이 다른 경로로 새는 흔한 사례입니다. 매 프레임 알림용 채널과 HUD 갱신용 채널을 분리하십시오. 점수 같은 HUD 값은 실제로 값이 바뀌는 순간에만 별도 ValueNotifier로 흘려보내면 됩니다.

시간은 프레임 수가 아니라 델타로 다룬다

게임 루프는 Ticker(또는 SchedulerBinding)로 만드는 것이 자연스럽습니다. 화면 갱신에 맞춰 콜백이 오기 때문입니다. 다만 주사율이 기기마다 다르다는 점을 잊으면 안 됩니다. 60Hz에서 맞춰 놓은 이동 속도가 120Hz 기기에서 두 배로 빨라지는 버그는 아주 흔합니다. 이동량은 항상 경과 시간(dt)에 비례해야 합니다.

물리·충돌처럼 결과가 시간 간격에 민감한 로직은 여기서 한 걸음 더 나가서, 가변 dt를 그대로 쓰지 말고 고정 스텝으로 쪼개 누적 처리하는 편이 안전합니다.

const step = 1 / 60;  // 물리 한 스텝
const maxDt = 0.25;   // 한 프레임에 몰아 처리할 상한
double acc = 0;
Duration _last = Duration.zero;

void onTick(Duration elapsed) {
  var dt = (elapsed - _last).inMicroseconds / 1e6;
  _last = elapsed;
  if (dt > maxDt) dt = maxDt; // 정지 후 복귀 시 폭주 방지
  acc += dt;
  while (acc >= step) {
    world.fixedUpdate(step);
    acc -= step;
  }
  state.renderAt(acc / step); // 남은 비율로 보간해 그리기
}

상한이 없으면 앱이 백그라운드에 갔다 돌아왔을 때 누적된 시간이 한꺼번에 풀리면서 수백 번의 물리 스텝이 한 프레임에 몰립니다. 이건 성능 문제이자 게임플레이 버그입니다(적이 순간이동하고, 벽을 통과합니다).

같은 맥락에서 생명주기 처리를 루프와 함께 설계해야 합니다. Ticker는 위젯이 화면에서 벗어나면 TickerMode에 따라 멈추기도 하지만, 게임 쪽 상태(누적 시간, 타이머, 사운드)까지 알아서 정리해 주지는 않습니다. 앱 생명주기 변화에서 루프를 정지하고, 복귀할 때 기준 시각을 다시 잡아 누적값을 리셋하는 처리를 넣지 않으면 위의 폭주가 그대로 재현됩니다.

어디까지 내려갈 것인가: 위젯, 페인터, 렌더 오브젝트

Flutter에서 화면을 그리는 방법은 층이 여러 개입니다. 위로 갈수록 편하고 아래로 갈수록 통제권이 큽니다. 게임이라고 무조건 밑바닥까지 내려갈 필요는 없습니다. 판단 기준은 "이 요소가 매 프레임 바뀌는가""레이아웃 계산이 필요한가" 두 가지입니다.

적합한 대상비용
일반 위젯 조합메뉴, 설정, 상점, 결과 화면레이아웃 비용이 있지만 정적이라 무관
위젯 + 애니메이션HUD, 버튼 반응, 팝업 전환범위만 좁히면 충분히 감당 가능
CustomPainter플레이 필드 전체, 이펙트, 배경paint만 반복. 게임 본체에 가장 무난한 선택
직접 만든 RenderObject히트 테스트·레이아웃까지 특수한 경우구현 부담이 큼. 필요할 때만

대부분의 2D 게임은 세 번째 층에서 끝납니다. 게임 본체 하나를 CustomPaint로 두고, 그 안에서 스프라이트를 직접 배치하는 구조입니다. 엔티티 하나하나를 위젯으로 만드는 설계는 개수가 늘어나는 순간 무너집니다. 엔티티 200개는 위젯 200개, 렌더 오브젝트 200개, 레이아웃 계산 200번을 뜻하기 때문입니다. 게임 오브젝트는 위젯이 아니라 데이터여야 하고, 그리기는 한 곳에서 일괄로 해야 합니다. Flame 같은 게임 엔진 패키지가 하는 일도 결국 이 구조를 정리해 주는 것입니다. 엔진을 쓰든 직접 짜든, 이 원칙을 이해하고 있어야 문제가 생겼을 때 손을 댈 수 있습니다.

대신 대가가 하나 있습니다. 입력 처리를 직접 해야 합니다. 엔티티가 위젯이 아니니 개별 요소에 GestureDetector를 달 수 없고, 필드 전체에 제스처 인식기를 하나 두고 좌표를 게임 좌표계로 역변환해 어떤 엔티티가 맞았는지 계산해야 합니다. 비용 자체는 대개 작지만, 카메라 이동·줌·회전이 들어가면 역변환 로직이 은근히 복잡해집니다. 설계 시점에 알고 시작해야 하는 항목입니다.

RepaintBoundary는 만능이 아니다

RepaintBoundary는 서브트리를 별도 레이어로 분리해서, 그 안이 다시 그려져도 바깥이 영향을 받지 않게 합니다. 자주 바뀌는 영역(게임 필드)과 거의 안 바뀌는 영역(정적 HUD 프레임)의 경계에 두면 효과가 좋습니다.

다만 레이어를 나누는 것 자체에 비용이 있습니다. 레이어마다 메모리를 쓰고, 합성 단계가 늘어납니다. 안 바뀌는 곳에 두면 이득이고, 어차피 매 프레임 바뀌는 곳에 두면 순손해입니다. 습관적으로 여기저기 감싸는 것은 최적화가 아닙니다. 반드시 측정하고 넣어야 합니다.

비싼 그리기 연산을 알아둔다

래스터 스레드가 밀릴 때는 그리기 명령의 종류를 의심해야 합니다. 경험적으로 부담이 큰 것들이 있습니다.

이미지와 아틀라스 관리

드로우 콜을 줄이는 이유

스프라이트 100개를 각각 drawImageRect()로 그리면 그리기 명령이 100개 쌓이고, 텍스처가 서로 다르면 GPU 상태 전환도 그만큼 발생합니다. 같은 텍스처(아틀라스)에 모아 두고 Canvas.drawAtlas()로 한 번에 넘기면, 여러 스프라이트를 한 묶음으로 처리할 수 있습니다. 파티클이나 타일맵처럼 같은 이미지를 대량으로 반복해서 그리는 경우 차이가 큽니다.

canvas.drawAtlas(
  atlasImage,      // 스프라이트를 모아 둔 단일 ui.Image
  transforms,      // List<RSTransform> — 위치·회전·균등 스케일
  rects,           // List<Rect> — 아틀라스 내 잘라낼 영역
  colors,          // 틴트(선택). 지정하면 blendMode도 함께 지정
  BlendMode.modulate,
  null,            // cullRect
  paint,
);

주의할 점이 둘입니다. 첫째, RSTransform회전과 균등 스케일까지만 표현합니다(R과 S). 비균등 스케일이나 전단(shear)이 필요하면 다른 경로를 써야 합니다. 둘째, 매 프레임 List<RSTransform>List<Rect>를 새로 할당하면 그 자체가 부담입니다. 이 경우 원시 버퍼를 받는 drawRawAtlas()로 바꾸고 Float32List/Int32List를 미리 잡아 두었다가 값만 덮어쓰는 방식이 낫습니다. 오브젝트 수가 수백 개를 넘어가는 순간부터 이 차이가 눈에 들어옵니다.

디코딩과 업로드는 별개의 비용이다

이미지에는 비용이 두 번 발생합니다. 파일을 픽셀로 푸는 디코딩, 그리고 그 픽셀을 GPU 텍스처로 올리는 업로드입니다. 둘 다 게임 도중에 일어나면 눈에 띄는 끊김이 됩니다. 그래서 로딩 화면이 필요합니다. 로딩 화면은 UX 장치이기 이전에 비용을 안전한 시점으로 옮기는 기술적 장치입니다.

필터링과 픽셀아트

도트 그래픽이라면 FilterQuality.none명시해야 의도한 각진 픽셀이 나옵니다. 기본값은 API와 버전에 따라 다르므로 기대에 맡기지 말고 지정하십시오. 보간이 들어가면 흐릿해질 뿐 아니라 필터링 비용도 붙습니다. 반대로 원본을 크게 축소해서 쓰면 화면이 지글거릴 수 있으므로, 실제 표시 크기에 가까운 축소본을 에셋 단계에서 미리 준비하는 편이 안전합니다. 화질과 성능을 동시에 잃는 조합이 흔하다는 점만 기억하면 됩니다.

셰이더 컴파일로 인한 첫 실행 끊김

특정 이펙트가 처음 등장하는 순간에만 프레임이 크게 튄다면 셰이더 컴파일을 의심할 만합니다. 이 문제의 대응 방식은 Flutter의 렌더링 백엔드가 Skia에서 Impeller로 옮겨 가면서 달라졌습니다. Impeller는 이 문제 해결을 목표 중 하나로 삼은 백엔드이고, 과거 Skia 시절에 쓰던 워밍업 번들 방식은 그대로 적용되지 않습니다. 구체적인 대응은 사용하는 Flutter 버전과 플랫폼에 따라 다르니 해당 릴리스 노트를 확인하는 것이 정확합니다. 여기서 기억할 것은 "첫 등장에만 튄다면 런타임 로직이 아니라 컴파일·업로드 비용을 의심하라"는 판단 기준입니다.

저사양 기기에서 프레임 지키기

고사양 기기에서 잘 돌아가는 것은 확인이 아닙니다. 실제 사용자 분포의 아래쪽이 기준입니다. 여기서 필요한 것은 개별 최적화가 아니라 예산(budget) 개념입니다.

프로파일링 도구를 읽는 법

먼저: 측정 환경을 틀리지 않는다

디버그 모드에서 잰 숫자는 의미가 없습니다. 어서션이 켜져 있고 JIT로 도는 상태이므로 실제보다 훨씬 느립니다. 성능 측정은 반드시 프로파일 모드, 실제 기기에서 합니다. 에뮬레이터의 GPU 특성은 실기기와 다릅니다. 이 두 가지를 지키지 않고 나온 결론은 전부 다시 재야 합니다.

DevTools의 위젯 빌드/페인트 추적 옵션도 마찬가지입니다. 어디가 리빌드되는지 보여 주지만 그 계측 자체가 프레임을 무겁게 만듭니다. 절대 시간을 그 상태에서 읽지 말고, 켠 채로 전후를 비교하는 용도로만 쓰십시오.

UI 그래프와 래스터 그래프를 구분한다

퍼포먼스 오버레이는 두 개의 막대 그래프를 보여줍니다. 위가 UI 스레드, 아래가 래스터 스레드입니다. 여기서 어느 쪽이 튀는지가 진단의 출발점입니다.

증상의심 지점먼저 할 일
UI 스레드가 튄다과도한 리빌드, 레이아웃, 게임 로직, 프레임 내 할당리빌드 범위 축소, 로직 비용 측정, 객체 재사용
래스터 스레드가 튄다비싼 그리기 연산, 레이어 과다, 텍스처 업로드saveLayer·블러·클립 점검, 드로우 콜 축소, 해상도 조정
특정 장면 진입 시 한 번만 튄다이미지 디코딩·업로드, 셰이더 컴파일사전 로딩으로 시점 이동
시간이 갈수록 나빠진다누수 — dispose 누락, 리스너 해제 누락, 리스트 무한 증가메모리 추이 확인, 풀·상한 도입
주기적으로 규칙적인 끊김GC, 주기적 저장·네트워크 작업프레임 내 할당 축소, 무거운 작업 시점 분산

타임라인은 "가장 긴 막대"부터 본다

DevTools의 퍼포먼스 화면에서 프레임 차트를 보면 어느 프레임이 예산을 넘겼는지 알 수 있고, 그 프레임을 선택하면 내부 구간이 트리로 펼쳐집니다. 여기서 흔히 저지르는 실수는 눈에 익은 이름부터 고치는 것입니다. 순서는 반대여야 합니다. 가장 긴 막대를 먼저 찾고, 그게 무엇인지 이해한 다음에 손을 댑니다. 이해 못 한 채로 바꾸면 개선인지 우연인지 구분할 수 없습니다.

또 하나. 고치기 전에 반드시 재현 시나리오를 고정하십시오. 매번 다르게 플레이하면서 측정하면 숫자가 흔들려서 판단이 불가능합니다. 같은 스테이지, 같은 진입 경로, 같은 시간대로 고정하고 전후를 비교해야 개선을 주장할 수 있습니다. 평균값 하나만 보지 말고 느린 쪽 꼬리(가장 느린 몇 퍼센트의 프레임)를 함께 보십시오. 체감을 망치는 것은 평균이 아니라 꼬리입니다.

앱과 게임이 갈라지는 지점

정리하면 이렇게 됩니다. 같은 프레임워크를 쓰지만 판단 기준이 반대인 항목이 꽤 많습니다.

항목일반 앱게임
화면 갱신 계기입력·데이터 변화매 프레임 상시
상태 관리 목표코드 구조와 테스트 용이성프레임당 비용 최소화
객체 생성부담 없음프레임 내 할당은 억제 대상
화면 구성 단위위젯데이터 + 단일 페인터
레이아웃핵심 기능가급적 회피 대상
입력 처리위젯이 알아서 히트 테스트좌표 역변환을 직접 구현
허용 오차가끔의 지연은 무시 가능한 프레임 지연도 체감
최적화 시점문제 생긴 뒤구조를 잡을 때 미리

마지막 줄이 중요합니다. 일반 앱에서는 "일단 만들고 느리면 고친다"가 대체로 옳습니다. 게임에서는 렌더링 구조를 잘못 잡으면 나중에 고치는 것이 사실상 재작성이 됩니다. 구조에 관한 결정만은 앞에서 하고, 나머지 미세 최적화는 뒤로 미루는 것이 균형점입니다.

실제 작업 순서

  1. 프로파일 모드 + 실기기로 기준선을 잰다. 재현 시나리오를 고정하고 기록해 둡니다.
  2. UI인지 래스터인지 가른다. 이걸 먼저 하지 않으면 이후 작업이 전부 추측이 됩니다.
  3. UI 쪽이면 리빌드 범위부터 좁힌다. 게임 루프에서 화면 전체 setState()를 걷어내고 CustomPainterrepaint로 옮깁니다. 대개 여기서 가장 큰 폭의 개선이 나옵니다.
  4. 알림이 새는 경로를 막는다. 매 프레임 알림에 위젯 빌더가 붙어 있지 않은지 확인하고, HUD 갱신 채널을 분리합니다.
  5. 엔티티가 위젯인지 확인한다. 위젯이라면 데이터로 바꾸고 그리기를 한 곳으로 모읍니다.
  6. 래스터 쪽이면 비싼 연산을 찾는다. saveLayer 유발 요소, 블러, 복잡한 클립을 먼저 걷어냅니다.
  7. 드로우 콜을 묶는다. 반복 스프라이트를 아틀라스로 모으고 drawAtlas(할당이 문제면 drawRawAtlas)로 전환합니다.
  8. 로딩 시점을 정리한다. 디코딩·사전 로딩을 레벨 진입 전으로 옮기고, 쓰지 않는 이미지를 dispose합니다.
  9. 할당과 누수를 잡는다. 풀 도입, 리스너 해제, 상한 설정, 생명주기에서 루프 정지.
  10. 가장 낮은 목표 기기에서 다시 잰다. 필요하면 품질 단계와 해상도 스케일을 붙입니다.
  11. 기준선과 비교해 기록한다. 무엇을 바꿔서 무엇이 좋아졌는지 남기지 않으면 다음 사람이 같은 실험을 반복합니다.

언제는 하지 말아야 하는가

Flutter로 게임을 만들지 말아야 할 때

반대로 퍼즐·카드·보드·캐주얼·방치형처럼 2D UI 비중이 크고, 같은 앱 안에 일반 화면(로그인, 상점, 랭킹, 설정)이 많이 섞여 있는 게임이라면 Flutter는 합리적인 선택입니다. 게임 화면과 앱 화면을 한 코드베이스에서 다룰 수 있다는 점이 실질적인 이득이기 때문입니다.

최적화 자체를 하지 말아야 할 때

정리

Flutter 게임의 성능 문제는 대부분 "매 프레임 바뀌는 것을 위젯 트리 안에 두었다"는 하나의 원인으로 수렴합니다. 게임 루프를 위젯 리빌드가 아니라 페인트 갱신으로 연결하고, 엔티티를 위젯이 아니라 데이터로 다루고, 반복되는 스프라이트를 아틀라스로 묶는 것. 이 세 가지가 전체 개선의 큰 몫을 차지합니다.

그 다음은 도구를 정직하게 읽는 일입니다. UI 스레드와 래스터 스레드를 구분하고, 프로파일 모드 실기기에서 고정된 시나리오로 재고, 가장 긴 막대부터 손대는 것. 화려한 기법보다 이 절차가 결과를 만듭니다. 그리고 이 모든 판단에 앞서, 지금 만들려는 게임이 애초에 Flutter에 맞는 종류인지를 먼저 정하는 것이 가장 값싼 최적화입니다.

같은 주제의 다른 글

맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.

기술 판단이 필요하신가요

맘소프트는 소프트웨어 개발과 CTO 대행 · 기술 자문을 제공합니다. 편하게 문의해 주세요.

[email protected]