Tuesday, June 10, 2025

플러터 성능 최적화: 불필요한 리빌드(Rebuild)를 막는 핵심 전략

Flutter는 뛰어난 UI 개발 경험과 네이티브에 가까운 성능으로 많은 사랑을 받고 있습니다. 하지만 앱의 규모가 커지고 복잡해지면서 성능 저하, 특히 '버벅거림(Jank)' 현상을 마주하게 됩니다. 이 문제의 주된 원인 중 하나는 바로 불필요한 위젯 리빌드(Rebuild)입니다. 이 글에서는 Flutter의 리빌드 메커니즘을 깊이 이해하고, 불필요한 리빌드를 최소화하여 앱 성능을 극대화하는 다양한 전략과 최적화 기법을 상세히 다룹니다.

1. 리빌드(Rebuild)는 왜 발생하는가? Flutter의 3가지 트리 이해하기

최적화에 앞서 Flutter가 어떻게 화면을 그리는지 이해해야 합니다. Flutter는 세 가지 핵심 트리 구조를 가집니다.

  • 위젯 트리 (Widget Tree): 개발자가 작성하는 코드 그 자체입니다. 위젯의 구성과 구조를 정의합니다. StatelessWidget, StatefulWidget 등이 여기에 해당하며, 상대적으로 가볍고 일시적입니다.
  • 엘리먼트 트리 (Element Tree): 위젯 트리를 기반으로 생성되며, 화면에 표시될 위젯의 구체적인 인스턴스를 관리합니다. 위젯과 렌더 객체 사이의 다리 역할을 하며, 위젯의 생명주기를 관리합니다. setState()가 호출되면, Flutter는 이 엘리먼트 트리를 통해 변경이 필요한 부분을 식별합니다.
  • 렌더 객체 트리 (RenderObject Tree): 실제 화면에 UI를 그리고 배치(Layout)하는 역할을 담당하는 무거운 객체들의 트리입니다. 페인팅, 히트 테스팅 등 실제 렌더링 로직을 포함합니다. 이 트리는 가능한 한 변경되지 않도록 유지하는 것이 성능의 핵심입니다.

setState()가 호출되면, 해당 위젯의 엘리먼트는 'dirty' 상태가 됩니다. 다음 프레임에서 Flutter는 dirty 상태의 엘리먼트와 그 자식들을 다시 빌드(rebuild)하여 새로운 위젯 트리를 생성하고, 기존 위젯과 비교하여 변경이 필요한 부분만 렌더 객체 트리에 반영합니다. 문제는 상태 변경과 관련 없는 위젯까지 불필요하게 리빌드되는 경우, CPU 자원이 낭비되고 프레임 드롭으로 이어질 수 있다는 점입니다.

2. 리빌드 최소화를 위한 핵심 전략

이제 불필요한 리빌드를 막기 위한 구체적이고 실용적인 전략들을 살펴보겠습니다.

전략 1: const 키워드를 적극적으로 활용하라

가장 간단하면서도 가장 강력한 최적화 기법입니다. 컴파일 시점에 값을 알 수 있는 위젯에 const 생성자를 사용하면, 해당 위젯은 상수(constant)가 됩니다. Flutter는 const로 선언된 위젯은 절대 리빌드하지 않습니다. 부모 위젯이 리빌드되더라도 const 위젯은 이전 인스턴스를 그대로 재사용하여 빌드 과정을 완전히 건너뜁니다.

나쁜 예:


class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('성능 테스트'), // 매번 새로운 Text 위젯 생성
      ),
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(8.0), // 매번 새로운 Padding 위젯 생성
          child: Text('불필요한 리빌드'),
        ),
      ),
    );
  }
}

좋은 예:


class MyWidget extends StatelessWidget {
  // 위젯 자체도 const로 선언 가능
  const MyWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      appBar: AppBar(
        title: Text('성능 테스트'), // Text는 const가 아니지만, 부모가 const면 효과가 전파될 수 있음
      ),
      body: Center(
        child: Padding(
          // const를 붙일 수 있는 곳은 최대한 붙인다.
          padding: EdgeInsets.all(8.0),
          child: Text('리빌드 방지!'),
        ),
      ),
    );
  }
}

Flutter SDK의 많은 위젯(Padding, SizedBox, Text 등)이 const 생성자를 지원합니다. Lint 규칙(prefer_const_constructors)을 활성화하여 IDE에서 const를 추가하라는 제안을 받도록 설정하는 것이 좋습니다.

전략 2: 위젯을 작게 분리하라 (Push State Down)

상태(State)를 최대한 위젯 트리의 아래쪽(leaf)으로 내리는 전략입니다. 거대한 단일 위젯에서 setState()를 호출하면 그 위젯의 모든 자식 위젯이 리빌드됩니다. 하지만 상태 변경이 필요한 부분만 별도의 StatefulWidget으로 분리하면, 리빌드 범위를 해당 위젯으로 국한시킬 수 있습니다.

나쁜 예: 전체 페이지가 리빌드됨


class BigWidget extends StatefulWidget {
  @override
  _BigWidgetState createState() => _BigWidgetState();
}

class _BigWidgetState extends State {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    print('BigWidget is rebuilding!'); // 버튼 누를 때마다 호출됨
    return Scaffold(
      appBar: AppBar(title: const Text('큰 위젯')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('이 위젯은 카운터와 상관없지만 리빌드됩니다.'),
            Text('카운터: $_counter'), // 이 부분만 변경되면 됨
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _increment,
        child: const Icon(Icons.add),
      ),
    );
  }
}

좋은 예: 카운터 위젯만 리빌드됨


class OptimizedPage extends StatelessWidget {
  const OptimizedPage({super.key});

  @override
  Widget build(BuildContext context) {
    print('OptimizedPage is building!'); // 한번만 호출됨
    return Scaffold(
      appBar: AppBar(title: const Text('분리된 위젯')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('이 위젯은 리빌드되지 않습니다.'),
            const CounterText(), // 상태를 가진 위젯을 분리
          ],
        ),
      ),
    );
  }
}

class CounterText extends StatefulWidget {
  const CounterText({super.key});

  @override
  _CounterTextState createState() => _CounterTextState();
}

class _CounterTextState extends State {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    print('CounterText is rebuilding!'); // 이 부분만 리빌드됨
    return Column(
      children: [
        Text('카운터: $_counter'),
        ElevatedButton(onPressed: _increment, child: const Text('증가'))
      ],
    );
  }
}

전략 3: 상태 관리 솔루션을 현명하게 사용하라

setState만으로는 복잡한 앱의 상태를 효율적으로 관리하기 어렵습니다. Provider, Riverpod, BLoC, GetX와 같은 상태 관리 라이브러리는 리빌드를 제어하는 강력한 기능을 제공합니다.

  • Provider / Riverpod:
    • Consumer: 위젯 트리의 특정 부분만 구독하여 해당 데이터가 변경될 때만 리빌드합니다.
    • Selector: Consumer보다 더 정교한 제어가 가능합니다. 복잡한 객체에서 특정 값 하나만 선택하여 그 값이 변경될 때만 리빌드하도록 할 수 있습니다.
    • context.watch() vs context.read(): watch는 데이터 변경을 감지하여 위젯을 리빌드하지만, read는 데이터를 한 번 읽어오기만 하고 리빌드를 유발하지 않습니다. 버튼 클릭 시 데이터 변경 함수를 호출하는 것처럼, 데이터 구독이 필요 없는 곳에서는 반드시 read를 사용해야 합니다.
  • BLoC (Business Logic Component):
    • BlocBuilder: BLoC의 상태(state) 변경에 따라 UI를 다시 그립니다. buildWhen 속성을 사용하면 이전 상태와 현재 상태를 비교하여 특정 조건이 만족될 때만 리빌드하도록 제어할 수 있어 매우 효과적입니다.
    • BlocListener: UI 리빌드 없이 SnackBar 표시, 다이얼로그 띄우기, 페이지 이동 등 '액션'을 수행할 때 사용합니다. 리빌드를 유발하지 않는다는 점이 중요합니다.

Provider의 Selector 사용 예:


class User {
  final String name;
  final int age;
  User(this.name, this.age);
}

// ... Provider 설정 후

// 이름만 필요한 위젯
class UserNameWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // User 객체 전체가 아닌, name만 구독한다.
    // 나이(age)가 변경되어도 이 위젯은 리빌드되지 않는다.
    final name = context.select((User user) => user.name);
    return Text(name);
  }
}

전략 4: child 파라미터를 활용한 캐싱

AnimatedBuilder, ValueListenableBuilder, Consumer와 같은 빌더(Builder) 패턴을 사용하는 위젯들은 child 파라미터를 제공하는 경우가 많습니다. 이 child 파라미터에 전달된 위젯은 빌더의 로직과 상관없이 리빌드되지 않습니다.

애니메이션 효과를 적용할 때, 애니메이션 자체는 계속 변하지만 그 안의 내용은 변하지 않는 경우가 많습니다. 이럴 때 child를 활용하면 성능을 크게 향상시킬 수 있습니다.

나쁜 예: 매 프레임마다 MyExpensiveWidget이 리빌드됨


AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    return Transform.rotate(
      angle: _controller.value * 2.0 * math.pi,
      // builder 내부에서 생성하면 매번 리빌드된다.
      child: MyExpensiveWidget(), 
    );
  },
)

좋은 예: MyExpensiveWidget은 한 번만 생성됨


AnimatedBuilder(
  animation: _controller,
  // 리빌드되지 않을 위젯을 child 파라미터로 전달
  child: const MyExpensiveWidget(), 
  builder: (context, child) {
    return Transform.rotate(
      angle: _controller.value * 2.0 * math.pi,
      // 전달받은 child를 사용한다.
      child: child,
    );
  },
)

3. 성능 측정 및 분석: Flutter DevTools 활용하기

최적화는 추측이 아닌 측정에 기반해야 합니다. Flutter DevTools는 앱의 성능을 분석하는 강력한 도구 모음입니다.

  1. Performance View: 앱의 프레임 속도(FPS)를 실시간으로 보여줍니다. UI 스레드와 GPU 스레드의 작업량을 시각적으로 확인하여 병목 현상을 찾을 수 있습니다. 프레임 차트에서 빨간색으로 표시되는 프레임은 60FPS(약 16ms)를 초과하여 '버벅거림'이 발생했음을 의미합니다.
  2. Flutter Inspector - "Track Widget Builds": 이 기능을 활성화하면 어떤 위젯이 리빌드되고 있는지 실시간으로 화면에 시각화해줍니다. 불필요하게 자주 리빌드되는 위젯을 한눈에 파악할 수 있어 최적화 대상을 찾는 데 매우 유용합니다.

DevTools를 사용하여 리빌드가 빈번한 위젯을 찾고, 위에서 설명한 전략들을 적용하여 리빌드 횟수를 줄이는 과정을 반복하는 것이 성능 최적화의 핵심 사이클입니다.

결론: 현명한 리빌드 관리가 고성능 앱의 열쇠

Flutter에서 모든 리빌드가 나쁜 것은 아닙니다. UI를 업데이트하기 위해 리빌드는 필수적입니다. 중요한 것은 '불필요한' 리빌드를 최소화하는 것입니다. 오늘 다룬 전략들을 요약하면 다음과 같습니다.

  • const: 변경되지 않는 위젯에 const를 붙여 리빌드를 원천 차단하세요.
  • 위젯 분리: 상태의 영향을 받는 범위를 최소화하도록 위젯을 작게 나누세요.
  • 상태 관리: Provider의 Selector, BLoC의 buildWhen 등 각 솔루션이 제공하는 리빌드 제어 기능을 적극 활용하세요.
  • child 캐싱: 빌더 패턴에서 변하지 않는 부분은 child 파라미터로 빼내세요.
  • 측정: DevTools를 사용해 추측이 아닌 데이터에 기반한 최적화를 진행하세요.

이러한 원칙들을 개발 초기부터 습관처럼 적용한다면, 사용자가 사랑하는 부드럽고 쾌적한 고성능 Flutter 앱을 만들 수 있을 것입니다.


0 개의 댓글:

Post a Comment