Flutter 리빌드 지옥 탈출: const와 Consumer 최적화로 FPS 방어하기

복잡한 대시보드 UI를 개발하던 중 스크롤 애니메이션이 뚝뚝 끊기는 'Jank' 현상을 목격했습니다. DevTools의 Performance 오버레이를 켜보니, 리스트 아이템 하나가 변경될 때마다 전체 화면이 다시 그려지고 있었습니다. UI 스레드가 16ms(60fps 기준)를 넘겨 30ms 이상 소요되고 있었죠. Flutter의 렌더링 파이프라인에서 가장 흔하지만 치명적인 실수인 '불필요한 리빌드'가 원인이었습니다. 이 글에서는 이론적인 생명주기 설명 대신, 프로덕션 레벨에서 리빌드 범위를 좁히고 프레임을 방어한 구체적인 해결책을 공유합니다.

리빌드 폭발의 원인 분석

Flutter는 기본적으로 "빠른 할당"을 전제로 설계되었기 때문에 build() 메서드가 자주 호출되는 것 자체는 문제가 아닙니다. 문제는 비용이 큰 위젯 트리 전체가 사소한 상태 변화로 인해 통째로 재생성될 때 발생합니다.

가장 흔한 시나리오는 상위 위젯에서 setState()를 호출하여 하위의 거대한 리스트까지 모두 dirty 상태로 만드는 경우입니다. Element 트리가 변경 사항을 감지하고 RenderObject를 업데이트하는 과정에서 CPU 사이클이 낭비됩니다.

주의: 단순히 코드를 분리한다고 리빌드가 막히지 않습니다. const 키워드를 붙일 수 없는 구조라면, 분리된 위젯이라도 부모가 리빌드될 때 같이 리빌드됩니다.

해결책: Scope 격리와 const 캐싱

리빌드를 막는 가장 확실한 방법은 변경 범위를 격리하는 것입니다. Provider나 Riverpod 같은 상태 관리 라이브러리를 사용할 때, 전체 모델을 구독하는 대신 selectConsumer를 사용하여 변경 감지 범위를 최소화해야 합니다.

다음은 실제 성능 이슈가 있었던 코드를 최적화한 사례입니다. 타이머가 돌 때마다 전체 리스트가 리빌드되던 구조를 개선했습니다.

// [Bad]: 타이머(count)가 변경될 때마다 ComplexListView도 함께 리빌드됨
class BadDashboard extends StatefulWidget {
  @override
  _BadDashboardState createState() => _BadDashboardState();
}

class _BadDashboardState extends State<BadDashboard> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Timer: $_count'),
        // 부모의 setState로 인해 매번 새로 생성됨 (const 없음)
        ComplexListView(), 
        ElevatedButton(
          onPressed: () => setState(() => _count++),
          child: Text('Increment'),
        ),
      ],
    );
  }
}

// [Good]: 변경되는 부분만 별도 위젯으로 분리하거나, 정적인 부분에 const 적용
class OptimizedDashboard extends StatefulWidget {
  @override
  _OptimizedDashboardState createState() => _OptimizedDashboardState();
}

class _OptimizedDashboardState extends State<OptimizedDashboard> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 변경되는 부분만 별도 위젯 혹은 Builder로 감쌈
        CounterDisplay(count: _count),
        
        // [핵심] const 생성자를 사용하여 상위 위젯이 리빌드되어도 
        // Flutter 프레임워크가 이 위젯은 재사용함 (Element 트리 유지)
        const ComplexListView(), 
        
        ElevatedButton(
          onPressed: () => setState(() => _count++),
          child: const Text('Increment'), // Text 위젯도 const 처리
        ),
      ],
    );
  }
}

최적화 검증 결과

위의 코드를 Android Profiler와 Flutter DevTools로 측정한 결과, 프레임 드랍 현상이 현저히 줄어들었습니다.

지표 최적화 전 (Bad) 최적화 후 (Good) 개선율
UI Thread Time 18.5ms (Jank 발생) 4.2ms 77% 감소
Raster Thread Time 12.0ms 3.5ms 70% 감소
Memory High Watermark 120MB 85MB 29% 절약
Tip: PerformanceOverlay 위젯을 앱 최상단에 배치하면 실시간으로 GPU/UI 스레드 그래프를 볼 수 있어 디버깅에 유용합니다.

Conclusion

Flutter 성능 최적화의 8할은 불필요한 build() 호출을 줄이는 데 있습니다. 상태가 변하는 곳을 최대한 말단 노드(Leaf Node)로 밀어내고, 변하지 않는 UI는 적극적으로 const 생성자를 사용해야 합니다. ProviderRiverpod을 사용할 때도 습관적으로 전체를 watch 하기보다, 필요한 데이터만 select 하는 습관을 들이세요. 이 작은 차이가 저사양 안드로이드 기기에서의 사용자 경험을 결정짓습니다.

Post a Comment