MOMSOFT / 기술 블로그
Flutter로 게임을 만들 때 부딪히는 성능 문제
Flutter로 게임이 안 되는 게 아니라, 앱을 만들던 습관 그대로 게임을 만들면 프레임이 무너집니다. 무엇을 어디까지 내려가서 손볼지 판단하는 기준을 정리합니다.
프로젝트 문의하기 →결론부터 말합니다. Flutter로 2D 게임을 만드는 것은 충분히 가능하고, 대부분의 성능 문제는 프레임워크의 한계가 아니라 앱을 만들던 방식 그대로 게임을 만들었기 때문에 생깁니다. 핵심은 하나입니다. 매 프레임 바뀌는 것과 레이아웃이 필요한 것을 분리하고, 매 프레임 바뀌는 것은 위젯 트리 밖으로 빼는 것입니다. 이 원칙만 지켜도 대부분의 프레임 드랍은 사라집니다. 그 뒤에도 남는 문제가 텍스처 관리와 저사양 기기 대응인데, 이건 성격이 다른 작업이라 뒤에서 따로 다룹니다.
아래 내용은 모바일 앱과 게임을 직접 만들어 스토어에 올려 운영하면서 반복해서 부딪혔던 지점과, 널리 알려진 렌더링 동작을 함께 정리한 것입니다. 특정 수치를 제시하기보다 왜 그렇게 되는가와 어떤 순서로 판단하는가에 집중합니다. 기기와 빌드 구성에 따라 결과가 크게 달라지기 때문에, 남의 숫자를 믿는 것보다 자기 기기에서 재는 습관이 훨씬 중요합니다.
앱의 프레임과 게임의 프레임은 성격이 다르다
일반 앱에서 화면이 다시 그려지는 계기는 사용자의 입력입니다. 탭을 하고, 스크롤을 하고, 네트워크 응답이 도착합니다. 그 사이에는 아무 일도 일어나지 않습니다. 그래서 프레임 하나가 조금 늦어도 사용자는 대체로 눈치채지 못합니다.
게임은 반대입니다. 입력이 없어도 매 프레임 세계가 갱신됩니다. 캐릭터가 움직이고, 파티클이 사라지고, 타이머가 줄어듭니다. 즉 프레임 생성이 상시 작업이고, 한 프레임이 밀리면 그게 곧바로 움직임의 끊김으로 보입니다. 60Hz 기기라면 한 프레임에 주어진 시간은 16.7ms 남짓이고, 120Hz 기기라면 그 절반입니다. 이 예산 안에서 다음 일이 전부 끝나야 합니다.
- build — 바뀐 위젯의
build()가 다시 호출되어 위젯(설정값) 트리를 새로 만듭니다. - layout — 렌더 트리가 제약을 내려보내고 크기를 올려받으며 배치를 계산합니다.
- paint — 각 렌더 오브젝트가 그리기 명령을 기록하고, 그 결과가 레이어 트리로 묶입니다.
- raster — 별도 스레드가 그 명령을 실제 GPU 작업으로 바꿔 화면에 올립니다.
앞의 셋은 UI 스레드에서, 마지막 하나는 래스터 스레드에서 일어납니다. 두 스레드는 파이프라인으로 겹쳐 돌기 때문에 어느 한쪽이라도 예산을 넘기면 프레임이 밀립니다. 그리고 이 둘을 구분하지 못하면 성능 문제를 영원히 엉뚱한 곳에서 찾게 됩니다. UI 스레드가 문제면 리빌드와 레이아웃을 줄여야 하고, 래스터 스레드가 문제면 그리기 명령의 성격 자체를 바꿔야 합니다. 해야 할 일이 완전히 다릅니다.
위젯 트리 리빌드와 게임 루프의 충돌
가장 흔한 실수
게임 루프를 만들 때 처음 떠오르는 방법은 타이머나 Ticker를 돌리면서 매 틱마다 setState()를 호출하는 것입니다. 동작은 합니다. 그런데 setState()는 그 State가 붙은 서브트리를 dirty로 표시합니다. 게임 화면 전체를 감싸는 위젯에서 이걸 호출하면, 매 프레임마다 배경·HUD·버튼·점수판까지 전부 다시 build되고, 그 결과로 레이아웃 계산까지 딸려옵니다.
여기서 진짜 비싼 것은 build() 자체보다 레이아웃인 경우가 많습니다. Column, Row, Stack, Flexible이 겹쳐 있고 그 안에 텍스트가 들어 있으면, 매 프레임 텍스트 레이아웃이 다시 계산됩니다. 글자 배치 계산은 결코 싼 작업이 아닙니다. 캐릭터 하나 움직이려고 화면의 모든 글자를 다시 배치하고 있는 셈입니다.
해결의 방향: 리빌드 범위를 좁힌다
원칙은 간단합니다. 움직이는 것만 다시 그리게 하고, 그마저도 가능하면 build를 건너뛴다. 도구는 이미 프레임워크에 다 있습니다.
AnimatedBuilder/ListenableBuilder—builder콜백 안쪽만 다시 build됩니다. 바깥의 무거운 서브트리는child로 넘겨 재사용합니다.ValueListenableBuilder— 값 하나가 바뀔 때만 좁은 범위를 갱신합니다.CustomPainter의repaint인자 — 이게 핵심입니다.Listenable을 넘기면 그 값이 바뀔 때build와layout을 건너뛰고 paint만 다시 합니다. 게임 루프에 가장 가까운 형태입니다.const생성자 — 상수 위젯은 같은 인스턴스로 정규화되므로, 부모가 리빌드돼도 엘리먼트가 동일 인스턴스임을 보고 해당 서브트리 갱신을 건너뜁니다. 다만 부모의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,
)
여기서 두 가지를 짚어야 합니다.
첫째, isComplex와 willChange는 서로 반대 방향의 힌트입니다. isComplex는 "래스터 캐시에 담아 둘 만큼 무겁다"는 뜻이고, willChange는 "다음 프레임에 바뀌니 캐시하지 말라"는 뜻입니다. 둘 다 true로 박아 둔 코드를 자주 보는데, 매 프레임 다시 칠하는 게임 필드는 캐시해 봐야 즉시 무효화되므로 캐시 시도 자체가 낭비입니다. 반대로 거의 안 바뀌는 배경 레이어라면 isComplex: true, willChange: false가 맞습니다. 같은 위젯에 둘 다 켜는 조합만 피하면 됩니다.
둘째, shouldRepaint()가 언제 호출되는지를 알아야 합니다. 이것은 위젯이 리빌드되어 새 painter 인스턴스가 들어올 때 비교용으로 호출됩니다. repaint로 들어온 알림 경로와는 별개입니다. 그래서 습관적으로 true를 반환해 두면, 게임과 무관한 상위 화면이 리빌드될 때마다 불필요한 리페인트가 한 번씩 더 붙습니다. 상태 객체를 그대로 들고 다니는 painter라면 대개 oldDelegate.state != state 정도로 충분합니다.
한 가지 더. ChangeNotifier를 매 프레임 notifyListeners() 하면서, 같은 객체를 ValueListenableBuilder나 context.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 프레임)의 경계에 두면 효과가 좋습니다.
다만 레이어를 나누는 것 자체에 비용이 있습니다. 레이어마다 메모리를 쓰고, 합성 단계가 늘어납니다. 안 바뀌는 곳에 두면 이득이고, 어차피 매 프레임 바뀌는 곳에 두면 순손해입니다. 습관적으로 여기저기 감싸는 것은 최적화가 아닙니다. 반드시 측정하고 넣어야 합니다.
비싼 그리기 연산을 알아둔다
래스터 스레드가 밀릴 때는 그리기 명령의 종류를 의심해야 합니다. 경험적으로 부담이 큰 것들이 있습니다.
saveLayer()를 유발하는 연산 — 반투명 그룹, 일부 블렌드 모드, 마스크 등. 오프스크린 버퍼를 새로 잡습니다. 위젯 레벨의Opacity도 자식 구성에 따라 오프스크린 합성을 유발할 수 있으므로, 단순히 색을 흐리게 하려는 것이면Paint.color의 알파를 조정하는 편이 훨씬 쌉니다.- 안티에일리어싱이 켜진 복잡한
clipPath— 사각 클립으로 대체할 수 있으면 대체합니다. - 실시간 그림자·블러 — 반경이 커질수록 급격히 비싸집니다. 미리 렌더한 이미지로 대체하는 것이 정석입니다.
- 매 프레임
TextPainter.layout()— 점수처럼 자주 바뀌는 텍스트는 값이 실제로 바뀔 때만 다시 layout하고 결과를 캐시합니다. 자릿수가 고정된 숫자라면 비트맵 폰트(숫자 스프라이트)로 빼는 것이 가장 확실합니다.
이미지와 아틀라스 관리
드로우 콜을 줄이는 이유
스프라이트 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 장치이기 이전에 비용을 안전한 시점으로 옮기는 기술적 장치입니다.
- 레벨 진입 전에 필요한 이미지를 미리 디코딩해 둡니다. 위젯 계층이라면
precacheImage, 직접 다룬다면ui.instantiateImageCodec계열입니다. 다만 사전 디코딩이 GPU 업로드까지 보장한다고 단정하지는 마십시오. 실제로 첫 프레임에서 튄다면, 로딩 중에 화면 밖이나 투명도 근처에서 한 번 그려 두는 식으로 업로드 시점까지 당겨야 할 수 있습니다. - 디코딩 시 목표 크기를 지정할 수 있으면 지정합니다. 화면에서 100px로 쓸 이미지를 원본 2048px로 들고 있을 이유가 없습니다. 메모리와 대역폭을 함께 낭비합니다. 목표 크기를 받는 API의 이름과 권장형은 버전에 따라 달라졌으니, 쓰는 버전의 문서를 확인하십시오.
- 아틀라스는 무작정 크게 만들지 않습니다. 기기별 최대 텍스처 크기 제한이 있고, 한 장이 너무 커지면 일부만 쓰는 장면에서도 전체가 메모리에 상주합니다. 장면 단위로 쪼개는 편이 안전합니다.
- 더 이상 쓰지 않는
ui.Image는dispose()합니다. 이걸 빠뜨리면 레벨을 넘길수록 메모리가 계단식으로 올라가고, 저사양 기기에서 먼저 죽습니다.
필터링과 픽셀아트
도트 그래픽이라면 FilterQuality.none을 명시해야 의도한 각진 픽셀이 나옵니다. 기본값은 API와 버전에 따라 다르므로 기대에 맡기지 말고 지정하십시오. 보간이 들어가면 흐릿해질 뿐 아니라 필터링 비용도 붙습니다. 반대로 원본을 크게 축소해서 쓰면 화면이 지글거릴 수 있으므로, 실제 표시 크기에 가까운 축소본을 에셋 단계에서 미리 준비하는 편이 안전합니다. 화질과 성능을 동시에 잃는 조합이 흔하다는 점만 기억하면 됩니다.
셰이더 컴파일로 인한 첫 실행 끊김
특정 이펙트가 처음 등장하는 순간에만 프레임이 크게 튄다면 셰이더 컴파일을 의심할 만합니다. 이 문제의 대응 방식은 Flutter의 렌더링 백엔드가 Skia에서 Impeller로 옮겨 가면서 달라졌습니다. Impeller는 이 문제 해결을 목표 중 하나로 삼은 백엔드이고, 과거 Skia 시절에 쓰던 워밍업 번들 방식은 그대로 적용되지 않습니다. 구체적인 대응은 사용하는 Flutter 버전과 플랫폼에 따라 다르니 해당 릴리스 노트를 확인하는 것이 정확합니다. 여기서 기억할 것은 "첫 등장에만 튄다면 런타임 로직이 아니라 컴파일·업로드 비용을 의심하라"는 판단 기준입니다.
저사양 기기에서 프레임 지키기
고사양 기기에서 잘 돌아가는 것은 확인이 아닙니다. 실제 사용자 분포의 아래쪽이 기준입니다. 여기서 필요한 것은 개별 최적화가 아니라 예산(budget) 개념입니다.
- 동시 표시 개수에 상한을 둔다. 파티클, 적, 투사체는 무한히 늘어날 수 있는 대상입니다. 풀(pool)을 미리 잡고 상한을 넘으면 오래된 것부터 재사용합니다. 이건 할당 비용과 GC 압력을 동시에 줄입니다.
- 매 프레임 할당을 줄인다.
Paint,Path,Offset, 리스트를 프레임마다 새로 만들면 짧은 수명의 객체가 대량으로 쌓입니다. Dart의 세대별 GC는 이런 객체를 값싸게 처리하는 편이지만, 게임처럼 매 프레임 수백 개씩 만들면 결국 수집이 프레임 사이에 끼어듭니다. 재사용 가능한 것은 필드로 올려 둡니다. - 품질 단계를 만든다. 그림자 끄기, 파티클 밀도 낮추기, 배경 레이어 수 줄이기 정도만 있어도 대응 폭이 크게 넓어집니다. 기기 모델명으로 분기하는 것보다, 최근 프레임 시간의 이동 평균을 보고 자동으로 한 단계 낮추는 방식이 유지보수가 쉽습니다. 단, 올리는 조건은 내리는 조건보다 훨씬 보수적으로 잡아야 품질이 오르내리며 깜빡이지 않습니다.
- 해상도를 낮추는 카드를 남겨 둔다. 다만 오해가 잦은 지점입니다.
canvas.scale()로 좌표만 키운다고 래스터가 처리하는 픽셀 수가 줄지는 않습니다. 실제로 픽셀 처리량을 줄이려면 더 작은 오프스크린 타깃에 그린 뒤 그 결과를 확대해 합성해야 합니다. Flutter에서는PictureRecorder로 기록한 그림을 이미지로 만들어 확대 표시하는 형태가 되는데, 이미지 변환 자체에 비용이 있고 동기/비동기 API 제공 여부가 버전에 따라 다릅니다. 래스터 스레드가 병목으로 확인됐을 때만, 전후를 재고 도입하십시오. - 목표 프레임을 현실적으로 잡는다. 저사양에서 60fps를 억지로 유지하다 들쭉날쭉해지는 것보다, 30fps로 안정적으로 유지하는 편이 체감이 낫습니다. 일정한 30이 흔들리는 55보다 낫습니다. 다만 Flutter에 프레임 상한을 걸어 주는 표준 스위치가 있는 것은 아니므로, 게임 루프에서 갱신·리페인트를 한 틱 걸러 수행하는 식으로 직접 구현해야 합니다. 이때도 이동량은 여전히 dt 기반이어야 합니다.
- 무거운 순수 계산은 격리한다. 경로 탐색, 맵 생성처럼 오래 걸리는 계산은
Isolate로 보냅니다. 다만 메시지 전달 비용이 있으므로 매 프레임 오가는 작업에는 맞지 않습니다. 로딩·레벨 생성 같은 덩어리 작업에 적합합니다.
프로파일링 도구를 읽는 법
먼저: 측정 환경을 틀리지 않는다
디버그 모드에서 잰 숫자는 의미가 없습니다. 어서션이 켜져 있고 JIT로 도는 상태이므로 실제보다 훨씬 느립니다. 성능 측정은 반드시 프로파일 모드, 실제 기기에서 합니다. 에뮬레이터의 GPU 특성은 실기기와 다릅니다. 이 두 가지를 지키지 않고 나온 결론은 전부 다시 재야 합니다.
DevTools의 위젯 빌드/페인트 추적 옵션도 마찬가지입니다. 어디가 리빌드되는지 보여 주지만 그 계측 자체가 프레임을 무겁게 만듭니다. 절대 시간을 그 상태에서 읽지 말고, 켠 채로 전후를 비교하는 용도로만 쓰십시오.
UI 그래프와 래스터 그래프를 구분한다
퍼포먼스 오버레이는 두 개의 막대 그래프를 보여줍니다. 위가 UI 스레드, 아래가 래스터 스레드입니다. 여기서 어느 쪽이 튀는지가 진단의 출발점입니다.
| 증상 | 의심 지점 | 먼저 할 일 |
|---|---|---|
| UI 스레드가 튄다 | 과도한 리빌드, 레이아웃, 게임 로직, 프레임 내 할당 | 리빌드 범위 축소, 로직 비용 측정, 객체 재사용 |
| 래스터 스레드가 튄다 | 비싼 그리기 연산, 레이어 과다, 텍스처 업로드 | saveLayer·블러·클립 점검, 드로우 콜 축소, 해상도 조정 |
| 특정 장면 진입 시 한 번만 튄다 | 이미지 디코딩·업로드, 셰이더 컴파일 | 사전 로딩으로 시점 이동 |
| 시간이 갈수록 나빠진다 | 누수 — dispose 누락, 리스너 해제 누락, 리스트 무한 증가 | 메모리 추이 확인, 풀·상한 도입 |
| 주기적으로 규칙적인 끊김 | GC, 주기적 저장·네트워크 작업 | 프레임 내 할당 축소, 무거운 작업 시점 분산 |
타임라인은 "가장 긴 막대"부터 본다
DevTools의 퍼포먼스 화면에서 프레임 차트를 보면 어느 프레임이 예산을 넘겼는지 알 수 있고, 그 프레임을 선택하면 내부 구간이 트리로 펼쳐집니다. 여기서 흔히 저지르는 실수는 눈에 익은 이름부터 고치는 것입니다. 순서는 반대여야 합니다. 가장 긴 막대를 먼저 찾고, 그게 무엇인지 이해한 다음에 손을 댑니다. 이해 못 한 채로 바꾸면 개선인지 우연인지 구분할 수 없습니다.
또 하나. 고치기 전에 반드시 재현 시나리오를 고정하십시오. 매번 다르게 플레이하면서 측정하면 숫자가 흔들려서 판단이 불가능합니다. 같은 스테이지, 같은 진입 경로, 같은 시간대로 고정하고 전후를 비교해야 개선을 주장할 수 있습니다. 평균값 하나만 보지 말고 느린 쪽 꼬리(가장 느린 몇 퍼센트의 프레임)를 함께 보십시오. 체감을 망치는 것은 평균이 아니라 꼬리입니다.
앱과 게임이 갈라지는 지점
정리하면 이렇게 됩니다. 같은 프레임워크를 쓰지만 판단 기준이 반대인 항목이 꽤 많습니다.
| 항목 | 일반 앱 | 게임 |
|---|---|---|
| 화면 갱신 계기 | 입력·데이터 변화 | 매 프레임 상시 |
| 상태 관리 목표 | 코드 구조와 테스트 용이성 | 프레임당 비용 최소화 |
| 객체 생성 | 부담 없음 | 프레임 내 할당은 억제 대상 |
| 화면 구성 단위 | 위젯 | 데이터 + 단일 페인터 |
| 레이아웃 | 핵심 기능 | 가급적 회피 대상 |
| 입력 처리 | 위젯이 알아서 히트 테스트 | 좌표 역변환을 직접 구현 |
| 허용 오차 | 가끔의 지연은 무시 가능 | 한 프레임 지연도 체감 |
| 최적화 시점 | 문제 생긴 뒤 | 구조를 잡을 때 미리 |
마지막 줄이 중요합니다. 일반 앱에서는 "일단 만들고 느리면 고친다"가 대체로 옳습니다. 게임에서는 렌더링 구조를 잘못 잡으면 나중에 고치는 것이 사실상 재작성이 됩니다. 구조에 관한 결정만은 앞에서 하고, 나머지 미세 최적화는 뒤로 미루는 것이 균형점입니다.
실제 작업 순서
- 프로파일 모드 + 실기기로 기준선을 잰다. 재현 시나리오를 고정하고 기록해 둡니다.
- UI인지 래스터인지 가른다. 이걸 먼저 하지 않으면 이후 작업이 전부 추측이 됩니다.
- UI 쪽이면 리빌드 범위부터 좁힌다. 게임 루프에서 화면 전체
setState()를 걷어내고CustomPainter의repaint로 옮깁니다. 대개 여기서 가장 큰 폭의 개선이 나옵니다. - 알림이 새는 경로를 막는다. 매 프레임 알림에 위젯 빌더가 붙어 있지 않은지 확인하고, HUD 갱신 채널을 분리합니다.
- 엔티티가 위젯인지 확인한다. 위젯이라면 데이터로 바꾸고 그리기를 한 곳으로 모읍니다.
- 래스터 쪽이면 비싼 연산을 찾는다.
saveLayer유발 요소, 블러, 복잡한 클립을 먼저 걷어냅니다. - 드로우 콜을 묶는다. 반복 스프라이트를 아틀라스로 모으고
drawAtlas(할당이 문제면drawRawAtlas)로 전환합니다. - 로딩 시점을 정리한다. 디코딩·사전 로딩을 레벨 진입 전으로 옮기고, 쓰지 않는 이미지를
dispose합니다. - 할당과 누수를 잡는다. 풀 도입, 리스너 해제, 상한 설정, 생명주기에서 루프 정지.
- 가장 낮은 목표 기기에서 다시 잰다. 필요하면 품질 단계와 해상도 스케일을 붙입니다.
- 기준선과 비교해 기록한다. 무엇을 바꿔서 무엇이 좋아졌는지 남기지 않으면 다음 사람이 같은 실험을 반복합니다.
언제는 하지 말아야 하는가
Flutter로 게임을 만들지 말아야 할 때
- 본격적인 3D가 필요할 때. Flutter의 렌더링 파이프라인은 2D 합성을 위한 것입니다. 3D 씬 그래프, 조명, 물리, 애니메이션 블렌딩이 필요하다면 전용 게임 엔진을 쓰는 편이 압도적으로 빠릅니다. 되게 만들 수는 있지만, 그 노력은 게임이 아니라 엔진에 들어갑니다.
- 고급 셰이더가 게임의 핵심일 때. 커스텀 프래그먼트 셰이더를 쓸 수 있긴 하지만, 셰이더 중심으로 설계된 엔진의 도구 체계와는 비교 대상이 아닙니다.
- 콘솔·PC 포팅이 로드맵에 있을 때. 배포 대상이 넓어질수록 전용 엔진의 이점이 커집니다.
- 이미 다른 엔진의 자산과 인력이 있을 때. 기존 파이프라인을 버리고 얻는 이득보다 잃는 것이 큽니다.
- 수백 개 이상의 객체가 매 프레임 물리 상호작용을 해야 할 때. 렌더링을 아무리 정리해도 로직이 UI 스레드에서 도는 구조 자체가 상한입니다. 이때는 프레임워크를 바꾸는 편이 정직합니다.
반대로 퍼즐·카드·보드·캐주얼·방치형처럼 2D UI 비중이 크고, 같은 앱 안에 일반 화면(로그인, 상점, 랭킹, 설정)이 많이 섞여 있는 게임이라면 Flutter는 합리적인 선택입니다. 게임 화면과 앱 화면을 한 코드베이스에서 다룰 수 있다는 점이 실질적인 이득이기 때문입니다.
최적화 자체를 하지 말아야 할 때
- 측정하지 않았을 때. 가장 흔한 낭비입니다. 느낌으로
RepaintBoundary를 뿌리고const를 붙이는 작업은 코드만 복잡해지고 개선은 없는 경우가 많습니다. - 게임의 재미가 아직 검증되지 않았을 때. 프로토타입 단계에서는 프레임보다 게임플레이가 먼저입니다. 다만 앞서 말한 구조에 관한 결정(엔티티를 위젯으로 만들지 않기)만은 처음부터 지키는 것이 좋습니다. 이건 최적화가 아니라 설계입니다.
- 병목이 아닌 곳일 때. 전체 프레임 시간의 아주 작은 부분을 차지하는 코드를 아무리 다듬어도 체감은 없습니다. 가장 긴 막대가 아니면 건드리지 마십시오.
- 가독성을 크게 해칠 때. 성능을 위해 코드를 비트는 것은 그 대가를 지불할 만한 병목에서만 정당합니다. 왜 이렇게 썼는지 주석으로 남기지 않으면 몇 달 뒤에 원복됩니다.
- 정적인 UI 화면에서. 상점이나 설정 화면은 게임 루프가 돌지 않습니다. 여기에 게임용 기법을 적용하면 복잡도만 늘어납니다.
정리
Flutter 게임의 성능 문제는 대부분 "매 프레임 바뀌는 것을 위젯 트리 안에 두었다"는 하나의 원인으로 수렴합니다. 게임 루프를 위젯 리빌드가 아니라 페인트 갱신으로 연결하고, 엔티티를 위젯이 아니라 데이터로 다루고, 반복되는 스프라이트를 아틀라스로 묶는 것. 이 세 가지가 전체 개선의 큰 몫을 차지합니다.
그 다음은 도구를 정직하게 읽는 일입니다. UI 스레드와 래스터 스레드를 구분하고, 프로파일 모드 실기기에서 고정된 시나리오로 재고, 가장 긴 막대부터 손대는 것. 화려한 기법보다 이 절차가 결과를 만듭니다. 그리고 이 모든 판단에 앞서, 지금 만들려는 게임이 애초에 Flutter에 맞는 종류인지를 먼저 정하는 것이 가장 값싼 최적화입니다.
같은 주제의 다른 글
맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.