최근 모바일 앱 개발 프로젝트에서 실시간 데이터가 쏟아지는 주식 차트 대시보드를 구현하던 중 심각한 성능 이슈에 직면했습니다. 고사양인 아이폰 14 프로에서는 부드럽게 작동했지만, 보급형 안드로이드 기기(갤럭시 A 시리즈 등)에서 스크롤을 할 때마다 프레임이 뚝뚝 끊기는 'Jank' 현상이 발생했습니다. Flutter DevTools의 Performance 탭을 열어보니, UI 스레드는 널널한데 Raster 스레드가 프레임당 16ms를 훌쩍 넘기며 붉은색 바를 띄우고 있었습니다.
이 글에서는 단순히 로직을 최적화하는 것을 넘어, 렌더링 파이프라인의 병목을 해결하기 위해 build() 메소드를 어떻게 쪼개야 하는지, 그리고 RepaintBoundary를 사용하여 어떻게 그리기(Paint) 단계를 격리시킬 수 있는지, 실제 프로덕션 환경에서의 디버깅 경험을 바탕으로 정리해 봅니다.
Jank의 원인 분석: 위젯 리빌드 방지 실패
문제의 화면은 수십 개의 위젯이 복잡하게 얽혀 있는 구조였습니다. 처음에는 단순히 상태 관리 라이브러리(Provider나 Riverpod)의 문제라고 생각했습니다. 하지만 Dart 튜닝 관점에서 코드를 뜯어보니 근본적인 원인은 '불필요한 렌더링 범위'에 있었습니다.
Flutter의 렌더링 파이프라인은 Build -> Layout -> Paint -> Composite 순서로 동작합니다. 제 코드의 가장 큰 문제는 최상위 위젯의 setState()나 상태 변경 알림이 발생할 때마다, 변하지 않아도 되는 정적인 배경이나 타이틀 위젯까지 통째로 build()가 다시 실행된다는 점이었습니다.
UI Thread: 4.2ms
Raster Thread: 28.5ms (Frame Drop!)
"Too much painting work happening in a single frame."
로그를 보면 알 수 있듯이, UI 스레드에서 위젯 트리를 구성하는 시간은 짧았지만, 변경된 레이아웃을 실제로 GPU 텍스처로 그려내는 Raster 스레드에서 병목이 터지고 있었습니다. 이는 화면의 아주 작은 부분(예: 로딩 인디케이터나 초 단위 타이머)만 바뀌어도 전체 화면을 다시 그리는(Repaint) 비효율적인 구조 때문입니다.
실패한 접근: 단순한 const 추가의 한계
처음에는 단순히 Linter가 추천하는 대로 모든 생성자에 const 키워드를 붙이는 작업에 몰두했습니다. 물론 const 위젯은 Flutter 프레임워크가 Element 트리에서 기존 인스턴스를 재사용하도록 돕기 때문에 성능에 도움이 됩니다. 하지만 거대한 build() 메소드 안에 수백 줄의 코드가 들어있는 상태에서는 const를 붙일 수 있는 구간이 매우 한정적이었습니다. 동적인 데이터가 섞여 있는 거대한 위젯 통짜를 리팩토링하지 않고서는 위젯 리빌드 방지 효과를 제대로 볼 수 없었습니다.
해결책 1: build() 메소드 쪼개기와 클래스 분리
첫 번째 해결책은 '메소드 추출(Extract Method)'이 아닌 '위젯 클래스 추출(Extract Widget)'입니다. 메소드로 분리하면 부모가 리빌드될 때 해당 메소드도 다시 실행되어 내부 위젯들을 재생성하지만, 별도의 StatelessWidget 클래스로 분리하고 const 생성자를 정의하면, 부모가 리빌드되더라도 인자가 바뀌지 않는 한 Flutter는 해당 위젯의 build() 호출을 건너뜁니다.
// Bad Case: 하나의 build 메소드에 모든 로직이 섞여 있음
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Static Header'), // 매번 새로 생성됨
_buildHeavyList(data), // 메소드 호출
CircularProgressIndicator(), // 매번 다시 그려짐
],
);
}
// Optimized Case: 클래스로 분리하여 const 활용 극대화
@override
Widget build(BuildContext context) {
return Column(
children: [
const HeaderWidget(), // 리빌드 건너뜀 (Short-circuit)
HeavyListWidget(data: data),
const LoadingWidget(),
],
);
}
위 코드처럼 변경하면 HeaderWidget이나 LoadingWidget은 상위 위젯이 setState를 호출해도 영향을 받지 않습니다. 이것이 Flutter 성능 최적화의 기초이자 가장 확실한 방법입니다.
해결책 2: RepaintBoundary로 페인팅 격리
하지만 위젯을 분리해도 해결되지 않는 경우가 있습니다. 바로 '애니메이션'입니다. 예를 들어 화면 중앙에서 빙글빙글 도는 로딩 아이콘이 있다고 가정해 봅시다. 이 아이콘은 매 프레임마다 모습이 바뀝니다. 만약 이 아이콘이 복잡한 배경 이미지 위에 있다면, Flutter는 매 프레임마다 배경과 아이콘을 합쳐서 다시 그려야 할 수도 있습니다.
이때 RepaintBoundary 위젯을 사용합니다. 이 위젯은 자식 위젯을 별도의 레이어(Layer)로 분리합니다. 즉, 자식이 다시 그려져야 할 때 부모나 형제 위젯까지 침범하여 다시 그리는(Repaint) 것을 막아줍니다.
class OptimizedAnimation extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 애니메이션이 일어나는 부분만 경계를 설정
return RepaintBoundary(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
),
);
}
}
이렇게 설정하면 CircularProgressIndicator가 회전할 때마다 발생하는 페인팅 작업이 독립된 레이어에서만 수행됩니다. GPU는 변경되지 않은 배경 레이어와 변경된 인디케이터 레이어를 합성(Composite)하기만 하면 되므로 Raster 스레드의 부하가 획기적으로 줄어듭니다. 이는 Dart 언어 레벨의 최적화가 아닌, Flutter 엔진의 렌더링 메커니즘을 이용한 최적화입니다.
성능 검증 및 벤치마크
최적화 적용 전후의 성능을 Pixel 4a 기기에서 프로파일링 모드로 측정하여 비교해보았습니다.
| 지표 (Metric) | 최적화 전 (Legacy) | 최적화 후 (Optimized) | 개선율 |
|---|---|---|---|
| Avg. Build Time | 8.4 ms | 2.1 ms | 75% 감소 |
| Avg. Raster Time | 18.2 ms | 4.5 ms | 75% 감소 |
| FPS (스크롤 시) | 42 fps | 59 fps | 안정화 |
결과는 놀라웠습니다. 특히 Raster Time이 4.5ms 수준으로 떨어지면서 60fps를 안정적으로 방어할 수 있게 되었습니다. RepaintBoundary 하나만 적절히 배치해도 복잡한 리스트뷰나 애니메이션이 포함된 화면에서 드라마틱한 효과를 볼 수 있습니다.
주의사항: RepaintBoundary의 남용 금지
그렇다면 모든 위젯을 RepaintBoundary로 감싸면 좋을까요? 절대 아닙니다. RepaintBoundary는 별도의 텍스처 메모리를 사용합니다. 너무 많이 사용하면 앱의 메모리 사용량이 급증하여 OOM(Out of Memory) 크래시를 유발할 수 있습니다.
따라서 다음과 같은 경우에만 선별적으로 사용해야 합니다:
- 화면의 특정 부분만 빈번하게 변경될 때 (예: 비디오 플레이어, 실시간 그래프, 로딩 스피너).
- 스크롤 시 복잡한 배경 이미지가 계속 다시 그려지는 것이 확인될 때.
- DevTools에서 'Debug Paint'를 켰을 때 불필요하게 넓은 영역에 테두리가 생기는 경우.
debugRepaintRainbowEnabled = true; 코드를 사용하여 앱을 실행하면, 다시 그려지는 영역마다 무지개색 테두리가 생겨 시각적으로 문제를 확인할 수 있습니다.
결론
Flutter 앱의 성능 문제는 대부분 비효율적인 렌더링 파이프라인 활용에서 비롯됩니다. 상태 관리를 아무리 잘해도 뷰 레이어에서 불필요한 페인팅이 일어나면 앱은 느려질 수밖에 없습니다. 오늘 소개한 위젯 클래스 분리를 통한 const 활용, 그리고 RepaintBoundary를 통한 렌더링 격리는 모바일 앱 개발 과정에서 반드시 숙지해야 할 핵심 테크닉입니다. 특히 저사양 안드로이드 기기에서의 사용자 경험을 개선하고 싶다면, 지금 바로 DevTools를 켜고 여러분의 Raster 스레드를 점검해 보시기 바랍니다.
Post a Comment