- [Flutter/Mobile] Unity 개발자가 본 Flutter 렌더링 파이프라인: RepaintBoundary와 ValueNotifier로 캘린더 위젯 프레임 드랍(Jank) 방어하기2026년 11월 13일 21시 35분 54초에 업로드 된 글입니다.작성자: kugorang
TL;DR
- Flutter에서 월간 캘린더 셀을 터치할 때 발생하는 프레임 드랍(Jank)은 상위 위젯의 setState() 호출로 인해 42개 날짜 셀의 RenderObject 전체가 markNeedsPaint() 루프를 타기 때문입니다.
- Unity 6 (Unity 6.3) 게임 엔진의 컴포넌트 격리 멘탈 모델을 선언형 UI에 적용하여, 날짜 셀에 RepaintBoundary를 부여해 GPU 래스터 레이어 캐시를 확보했습니다.
- 상태 전달을 캘린더 전체 리빌드가 아닌 단일 셀 단위의 ValueNotifier 구독으로 전환하여, UI 스레드 렌더링 시간을 14.8ms에서 1.8ms로 87.8% 단축하고 60fps를 실측 검증했습니다.
1. 문제 현상: 날짜 하나를 눌렀을 뿐인데 왜 화면이 튀는가?
쿠고랑의 캘린더 퍼스트 모듈형 플래너 'Fillday'를 실기기(Android API 29+)에서 테스트하던 중, 월간 뷰에서 날짜 셀을 빠르게 터치할 때 프레임이 41fps까지 급락하는 병목이 관찰되었습니다.
Flutter DevTools의 Performance 오버레이를 확인한 결과, 16.6ms(60fps 기준) 프레임 버짓 중 UI 스레드가 단일 프레임 처리에 14.8ms를 소모하고 있었습니다.
원인은 선언형 UI의 렌더링 전파 구조에 있었습니다.
- 월간 캘린더는 이전 달과 다음 달의 여백을 포함해 정확히 6주, 즉 42개의 날짜 셀(
CalendarDayCell)로 구성됩니다. - 사용자가 15일 셀을 터치하면 상위 캘린더 위젯의
selectedDate상태가 변경됩니다. - 상위 위젯에서
setState()를 호출하면 캘린더 전체 위젯 트리가 재평가되며, 선택되지 않은 41개의 날짜 셀까지 전부build()및paint()단계를 다시 통과합니다.
이것은 1개의 상태 변경을 처리하기 위해 41개의 불필요한 GPU 드로우 콜 계산을 유발하는 비효율적인 구조입니다.
2. 컴퓨터 과학 원리: Widget Tree, Element Tree, 그리고 RenderObject Tree
초등학생도 이해할 수 있는 Flutter의 화면 그리기 3단계 구조입니다.
컴퓨터가 화면에 사각형을 그리기 위해 Flutter는 3개의 나무(Tree) 구조를 사용합니다.
- 위젯 트리 (Widget Tree): "화면에 무엇을 그릴지" 적어둔 설계도입니다. 아주 가볍고 빠르게 생성되고 버려집니다.
- 엘리먼트 트리 (Element Tree): 설계도와 실제 그릴 물건을 연결해주는 뼈대입니다. 화면의 위치를 기억합니다.
- 렌더오브젝트 트리 (RenderObject Tree): 실제 스마트폰 화면의 픽셀 좌표를 계산(Layout)하고 색칠(Paint)하는 진짜 일꾼입니다. 메모리를 많이 쓰고 계산이 무겁습니다.
상위 위젯에서 화면을 다시 그리라고 명령하면(
setState), Flutter는 하위 위젯들을 비교합니다.
위젯 설계도가 다시 만들어지면 렌더오브젝트에게 "너 다시 색칠해!"라는 신호인markNeedsPaint()가 전달됩니다.만약 아무런 경계선이 없다면, 하나의 렌더오브젝트가 다시 칠해질 때 그 부모와 주변 형제 렌더오브젝트들까지 전부 같은 캔버스에 묶여 다시 칠해집니다. 이것이 프레임 드랍의 근본 원인입니다.
3. 해결 아키텍처: RepaintBoundary와 ValueNotifier
3.1 RepaintBoundary: GPU 레이어 분리
RepaintBoundary는 RenderObject Tree에게 "여기서부터는 별도의 도화지(Display List)에 그리고 캐시(Cache)해 두라"고 지시하는 위젯입니다.내부적으로
RenderRepaintBoundary객체가 생성되며,isRepaintBoundary = true플래그가 켜집니다.
이렇게 하면 날짜 셀 내부의 상태가 바뀌어도, 그 변경의 여파가 부모 캔버스로 번지지 않고 해당 셀의 도화지만 다시 칠해집니다. 반대로 캘린더의 다른 영역이 칠해질 때는 이미 칠해둔 비트맵 이미지를 재사용합니다.3.2 ValueNotifier: 전체 리빌드 제거 및 단일 셀 타겟팅
상위 위젯 전체의
setState()를 완전히 제거했습니다.
대신 선택된 날짜를 보관하는ValueNotifier<DateTime>를 선언하고, 각 날짜 셀 내부에서ValueListenableBuilder로 자기 날짜가 선택되었는지만 감시합니다.// [쿠고랑 Fillday 캘린더 셀 렌더링 최적화 코드] import 'package:flutter/material.dart'; class OptimizedDayCell extends StatelessWidget { final DateTime date; final ValueNotifier<DateTime> selectedDateNotifier; final VoidCallback onTap; const OptimizedDayCell({ super.key, required this.date, required this.selectedDateNotifier, required this.onTap, }); @override Widget build(BuildContext context) { // 1. RepaintBoundary로 각 셀의 페인트 레이어를 완전히 분리 return RepaintBoundary( child: ValueListenableBuilder<DateTime>( valueListenable: selectedDateNotifier, builder: (context, selectedDate, child) { final isSelected = DateUtils.isSameDay(date, selectedDate); return InkWell( onTap: onTap, child: Container( alignment: Alignment.center, decoration: BoxDecoration( color: isSelected ? const Color(0xFF111E24) : Colors.transparent, borderRadius: BorderRadius.circular(8.0), ), child: Text( '${date.day}', style: TextStyle( color: isSelected ? Colors.amber : Colors.black87, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ), ); }, ), ); } }터치가 발생했을 때의 동작입니다:
- 사용자가 20일을 터치합니다.
selectedDateNotifier.value = 20일로 갱신됩니다.- 기존에 선택되어 있던 '15일' 셀과 새로 선택된 '20일' 셀, 단 2개의 셀만
builder를 실행합니다. - 나머지 40개 셀은
builder가 호출되지 않으며,RenderObject또한 전혀 다시 칠해지지 않습니다.
4. 정량적 실측 벤치마크 결과
Android 실기기(API 34, Snapdragon 8 Gen 2)에서 Flutter Profile 모드로 측정한 정량적 결과입니다.
측정 항목 기존 구조 (상위 setState) 최적화 구조 (RepaintBoundary + ValueNotifier) 개선율 UI 스레드 평균 소요 시간 14.8 ms 1.8 ms 87.8% 단축 래스터(GPU) 스레드 시간 8.2 ms 3.1 ms 62.2% 단축 초당 프레임 수 (FPS) 41 fps (Jank 발생) 60 fps (안정 유지) 46.3% 향상 프레임당 리빌드 위젯 수 42개 셀 전수 2개 셀 (선택/해제) 95.2% 감소 현우 님의 엔지니어링 헌법:
"코드 수정 ≠ 모의 시험 ≠ 실기기 검증 ≠ 운영 반영"
에뮬레이터의 합성 수치가 아니라, 실제 기기 프로파일러에서 14.8ms가 1.8ms로 단축되는 것을 확인한 뒤에만 프로덕션 베이스라인으로 병합했습니다.
5. 트러블슈팅 FAQ
Q1. 모든 위젯을 RepaintBoundary로 감싸면 무조건 빨라지나요?
A. 아닙니다.RepaintBoundary는 별도의 렌더 레이어와 비트맵 메모리를 차지합니다. 변화가 거의 없거나 아주 단순한 텍스트 위젯마다 무분별하게 붙이면 레이어 합성(Compositing) 비용과 메모리 오버헤드가 발생해 오히려 역효과가 납니다. 화면에서 자주 변하는 부분과 정적인 배경 영역 사이에 경계선으로 사용하는 것이 정석입니다.Q2. Provider, Bloc, Riverpod 같은 상태 관리 도구를 쓰면 자동으로 해결되나요?
A. 상태 관리 도구는 위젯 트리의build()호출 범위를 줄여주는 데 도움을 주지만, 렌더 트리의paint()전파를 완전히 제어하지는 못합니다. 렌더 트리의 도화지 분리는 순수하게 Flutter 엔진의RenderRepaintBoundary레이어에서 일어나므로 상태 관리 도구와 무관하게 렌더 레이어 경계를 명시해야 합니다.Q3. Unity 6 (Unity 6.3)의 렌더링 방식과는 어떤 점이 다른가요?
A. Unity 6 (Unity 6.3) 게임 엔진은 게임 루프에 따라 매 프레임(16.6ms) 화면 전체를 새로 그리는 즉시 모드(Immediate-like) 기반입니다. 반면 Flutter는 변경된 RenderObject만 선별적으로 다시 칠하는 보류 모드(Retained Mode) 렌더링입니다. 따라서 모바일 앱에서는 매 프레임 전체를 그리는 것이 아니라 "무엇을 그리지 않을 것인가"를 정의하는 레이어 격리가 성능의 핵심입니다.728x90728x90다음글이 없습니다.이전글이 없습니다.댓글