🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

ConsumerWidget vs Consumer 비교 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 11. · 9 Views

ConsumerWidget vs Consumer 비교 완벽 가이드

Flutter Riverpod에서 ConsumerWidget과 Consumer의 차이점을 이해하고, 각각의 상황에 맞는 위젯 선택 방법을 배웁니다. 성능 최적화를 위한 부분 리빌드 전략과 실전 활용법을 다룹니다.


목차

  1. ConsumerWidget 기본 구조
  2. Consumer로 부분 리빌드
  3. ConsumerStatefulWidget 사용
  4. HookConsumerWidget 활용
  5. 위젯 분리 전략
  6. 성능 비교 테스트

1. ConsumerWidget 기본 구조

입사 2개월 차 이주니 씨는 Riverpod을 사용한 첫 프로젝트를 시작했습니다. 선배가 건네준 샘플 코드에는 "ConsumerWidget"이라는 생소한 위젯이 보였습니다.

"이게 StatelessWidget과 뭐가 다른 거지?"

ConsumerWidget은 Riverpod의 상태를 읽을 수 있는 특별한 위젯입니다. StatelessWidget과 비슷하지만, build 메서드에 WidgetRef라는 추가 매개변수를 제공합니다.

이를 통해 Provider의 상태를 구독하고 변경사항을 자동으로 감지할 수 있습니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';

// Provider 정의
final counterProvider = StateProvider<int>((ref) => 0);

// ConsumerWidget 사용
class CounterPage extends ConsumerWidget {
  const CounterPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Provider의 상태를 읽습니다
    final count = ref.watch(counterProvider);

    return Scaffold(
      body: Center(
        child: Text('Count: $count', style: TextStyle(fontSize: 24)),
      ),
      floatingActionButton: FloatingActionButton(
        // 상태를 변경합니다
        onPressed: () => ref.read(counterProvider.notifier).state++,
        child: Icon(Icons.add),
      ),
    );
  }
}

이주니 씨가 선배에게 물었습니다. "선배님, ConsumerWidget이 정확히 뭔가요?

StatelessWidget과 뭐가 다른 건가요?" 선배 개발자 최시니어 씨가 웃으며 대답했습니다. "좋은 질문이에요.

쉽게 설명해 드릴게요." ConsumerWidget은 마치 우편함을 가진 집과 같습니다. 일반 집(StatelessWidget)은 우편물이 와도 알 수 없지만, 우편함이 있는 집(ConsumerWidget)은 우편물이 도착하면 즉시 알림을 받습니다.

여기서 우편물이 바로 Provider의 상태 변화입니다. Riverpod이 등장하기 전에는 어땠을까요?

개발자들은 상태 관리를 위해 InheritedWidget을 직접 작성하거나 Provider 패키지의 복잡한 구조를 이해해야 했습니다. 보일러플레이트 코드가 많았고, 컴파일 타임에 에러를 잡기도 어려웠습니다.

특히 여러 개의 Provider를 구독하려면 중첩된 Consumer 위젯들로 코드가 지저분해졌습니다. 바로 이런 문제를 해결하기 위해 ConsumerWidget이 등장했습니다.

ConsumerWidget을 사용하면 간결한 코드로 Provider를 구독할 수 있습니다. WidgetRef를 통해 여러 Provider에 접근하는 것도 쉽습니다.

무엇보다 컴파일 타임 안전성이라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 6번째 줄을 보면 StateProvider를 정의하고 있습니다. 이것은 간단한 상태를 관리하는 Provider입니다.

다음으로 10번째 줄에서는 ConsumerWidget을 상속받습니다. 중요한 것은 15번째 줄입니다.

build 메서드가 WidgetRef ref를 두 번째 매개변수로 받고 있습니다. 17번째 줄에서 ref.watch를 호출합니다.

이것이 핵심입니다. watch는 Provider를 구독하고, 상태가 변경되면 자동으로 위젯을 다시 빌드합니다.

마치 유튜브 채널을 구독하면 새 영상이 올라올 때마다 알림을 받는 것과 같습니다. 25번째 줄에서는 ref.read를 사용합니다.

watch와 달리 read는 일회성으로 값을 읽을 뿐 구독하지 않습니다. 버튼을 눌렀을 때 한 번만 상태를 변경하면 되므로 read를 사용하는 것이 적절합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 전자상거래 앱을 개발한다고 가정해봅시다.

장바구니 화면 전체가 장바구니 상태를 구독해야 한다면 ConsumerWidget을 사용하는 것이 자연스럽습니다. 화면의 모든 부분(상품 목록, 총 가격, 결제 버튼 등)이 장바구니 상태에 의존하기 때문입니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 모든 위젯을 ConsumerWidget으로 만드는 것입니다.

화면의 작은 부분만 상태를 구독하면 되는데도 전체 위젯을 ConsumerWidget으로 만들면, 불필요한 리빌드가 발생하여 성능이 저하됩니다. 따라서 정말 필요한 경우에만 ConsumerWidget을 사용해야 합니다.

다시 이주니 씨의 이야기로 돌아가 봅시다. 최시니어 씨의 설명을 들은 이주니 씨는 고개를 끄덕였습니다.

"아, 그래서 StatelessWidget 대신 ConsumerWidget을 쓰는 거군요!" ConsumerWidget을 제대로 이해하면 Riverpod의 기본을 탄탄하게 다질 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - ref.watch는 상태를 구독할 때, ref.read는 일회성 읽기나 메서드 호출 시 사용합니다

  • ConsumerWidget은 전체 위젯이 리빌드되므로, 부분 최적화가 필요하면 다음에 배울 Consumer를 고려하세요

2. Consumer로 부분 리빌드

며칠 후, 이주니 씨는 성능 문제에 부딪혔습니다. 카운터 값만 바뀌는데 화면 전체가 다시 그려지면서 앱이 버벅거렸습니다.

최시니어 씨가 코드를 보더니 말했습니다. "아, 여기는 Consumer를 써야 해요."

Consumer는 위젯 트리의 특정 부분만 선택적으로 리빌드하는 위젯입니다. ConsumerWidget과 달리 전체 위젯을 리빌드하지 않고, Consumer로 감싼 부분만 업데이트합니다.

성능 최적화가 필요한 상황에서 필수적인 도구입니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';

final counterProvider = StateProvider<int>((ref) => 0);

class OptimizedCounterPage extends StatelessWidget {
  const OptimizedCounterPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('전체 위젯 빌드'); // 한 번만 출력됩니다

    return Scaffold(
      appBar: AppBar(title: Text('부분 리빌드 예제')),
      body: Column(
        children: [
          // 이 부분은 리빌드되지 않습니다
          Text('고정된 헤더', style: TextStyle(fontSize: 20)),

          // 카운터 값이 변경될 때만 이 부분만 리빌드됩니다
          Consumer(
            builder: (context, ref, child) {
              print('Consumer 부분만 빌드');
              final count = ref.watch(counterProvider);
              return Text('Count: $count', style: TextStyle(fontSize: 24));
            },
          ),

          // 이 부분도 리빌드되지 않습니다
          Consumer(
            builder: (context, ref, child) {
              return ElevatedButton(
                onPressed: () => ref.read(counterProvider.notifier).state++,
                child: Text('증가'),
              );
            },
          ),
        ],
      ),
    );
  }
}

이주니 씨가 고민에 빠졌습니다. "카운터 숫자 하나만 바뀌는데, 왜 화면 전체가 다시 그려지는 거지?" 최시니어 씨가 설명했습니다.

"ConsumerWidget은 편리하지만, 전체가 리빌드되는 단점이 있어요. 바로 이럴 때 Consumer를 사용하는 겁니다." Consumer는 마치 집에서 한 방만 청소하는 것과 같습니다.

ConsumerWidget이 집 전체를 청소한다면(전체 리빌드), Consumer는 더러워진 방 하나만 청소합니다(부분 리빌드). 다른 방은 그대로 두기 때문에 훨씬 효율적입니다.

성능 최적화가 왜 중요할까요? 모바일 앱은 초당 60프레임을 유지해야 부드럽게 느껴집니다.

하나의 프레임을 그리는 데 약 16밀리초밖에 없습니다. 만약 화면 전체를 매번 다시 그린다면, 복잡한 UI에서는 16밀리초를 초과하여 버벅거림이 발생합니다.

특히 리스트가 길거나 애니메이션이 있는 경우 성능 저하가 눈에 띕니다. 바로 이런 문제를 해결하기 위해 Consumer가 등장했습니다.

Consumer를 사용하면 필요한 부분만 정확하게 업데이트할 수 있습니다. 불필요한 리빌드를 방지하여 성능을 크게 향상시킵니다.

무엇보다 사용자 경험 개선이라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 5번째 줄을 보면 일반 StatelessWidget을 사용하고 있습니다. ConsumerWidget이 아닙니다.

10번째 줄의 print 문은 전체 위젯이 빌드될 때만 출력됩니다. 실제로 실행하면 처음 한 번만 출력되고, 카운터가 증가해도 다시 출력되지 않습니다.

17번째 줄의 Text 위젯은 Consumer 밖에 있습니다. 따라서 카운터가 바뀌어도 이 부분은 다시 그려지지 않습니다.

20번째 줄부터가 핵심입니다. Consumer 위젯으로 필요한 부분만 감쌌습니다.

21번째 줄의 builder 함수는 구독한 Provider가 변경될 때만 호출됩니다. 22번째 줄의 print 문은 카운터가 증가할 때마다 출력됩니다.

23번째 줄에서 ref.watch를 호출하여 counterProvider를 구독합니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 소셜 미디어 앱의 피드 화면을 개발한다고 가정해봅시다. 각 게시물 카드에 좋아요 버튼이 있습니다.

사용자가 한 게시물의 좋아요를 누르면, 해당 카드의 좋아요 카운트만 업데이트되어야 합니다. 다른 게시물 카드들은 다시 그릴 필요가 없습니다.

이럴 때 각 좋아요 카운트 부분만 Consumer로 감싸면 됩니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 Consumer를 너무 많이 중첩하는 것입니다. Consumer 안에 Consumer를 계속 넣으면 코드 가독성이 떨어집니다.

이럴 때는 위젯을 분리하는 것이 좋습니다. 또한 Consumer 내부에서 무거운 계산을 하면 안 됩니다.

Provider나 별도 함수로 분리해야 합니다. 다시 이주니 씨의 이야기로 돌아가 봅시다.

최시니어 씨의 조언대로 Consumer를 적용한 이주니 씨는 신기한 표정을 지었습니다. "와, 정말 숫자 부분만 깜빡이네요!" Consumer를 제대로 이해하면 성능 최적화의 기본을 마스터할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Consumer의 child 매개변수를 활용하면 내부의 고정된 위젯을 캐싱할 수 있습니다

  • 성능 문제가 없다면 ConsumerWidget으로 시작하고, 필요할 때 Consumer로 최적화하세요

3. ConsumerStatefulWidget 사용

프로젝트가 복잡해지면서 이주니 씨는 새로운 요구사항을 받았습니다. "상태도 구독하면서 동시에 로컬 상태도 관리해야 해요." 최시니어 씨가 말했습니다.

"그럴 땐 ConsumerStatefulWidget을 쓰면 돼요."

ConsumerStatefulWidget은 StatefulWidget과 ConsumerWidget의 기능을 결합한 위젯입니다. 로컬 상태(예: TextEditingController, AnimationController)를 관리하면서 동시에 Provider를 구독할 수 있습니다.

복잡한 화면에서 두 가지 상태 관리 방식이 모두 필요할 때 사용합니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';

final todoListProvider = StateNotifierProvider<TodoNotifier, List<String>>((ref) {
  return TodoNotifier();
});

class TodoNotifier extends StateNotifier<List<String>> {
  TodoNotifier() : super([]);
  void addTodo(String todo) => state = [...state, todo];
}

// ConsumerStatefulWidget 사용
class TodoPage extends ConsumerStatefulWidget {
  const TodoPage({Key? key}) : super(key: key);

  @override
  ConsumerState<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends ConsumerState<TodoPage> {
  // 로컬 상태: TextEditingController
  late TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // Provider 상태를 구독합니다
    final todos = ref.watch(todoListProvider);

    return Scaffold(
      appBar: AppBar(title: Text('할일 목록')),
      body: Column(
        children: [
          TextField(
            controller: _controller,
            decoration: InputDecoration(labelText: '새 할일'),
          ),
          ElevatedButton(
            onPressed: () {
              ref.read(todoListProvider.notifier).addTodo(_controller.text);
              _controller.clear();
            },
            child: Text('추가'),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: todos.length,
              itemBuilder: (context, index) => ListTile(
                title: Text(todos[index]),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

이주니 씨가 머리를 긁적였습니다. "TextField의 컨트롤러도 관리해야 하고, Provider의 할일 목록도 구독해야 하는데, 어떻게 하죠?" 최시니어 씨가 웃으며 대답했습니다.

"바로 그럴 때 ConsumerStatefulWidget을 쓰는 겁니다." ConsumerStatefulWidget은 마치 운전대와 네비게이션을 동시에 보는 것과 같습니다. 운전대(로컬 상태)로 차를 직접 조작하면서, 동시에 네비게이션(Provider 상태)의 경로 안내를 실시간으로 받습니다.

두 가지 정보 소스를 모두 활용하는 것입니다. StatefulWidget만으로는 왜 부족할까요?

StatefulWidget은 setState를 통해 로컬 상태를 관리할 수 있지만, Provider에 접근하려면 추가 작업이 필요했습니다. context.read나 ProviderScope를 통한 우회적인 방법을 써야 했고, 상태 변화를 자동으로 감지하기도 어려웠습니다.

코드가 복잡해지고 실수하기 쉬웠습니다. 바로 이런 문제를 해결하기 위해 ConsumerStatefulWidget이 등장했습니다.

ConsumerStatefulWidget을 사용하면 로컬 상태 관리가 자유롭습니다. 동시에 ref를 통해 Provider에 쉽게 접근할 수 있습니다.

무엇보다 두 가지 상태 관리 패러다임의 완벽한 조화라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 3번째 줄을 보면 StateNotifierProvider를 정의하고 있습니다. 할일 목록이라는 복잡한 상태를 관리합니다.

13번째 줄에서 ConsumerStatefulWidget을 상속받습니다. 일반 StatefulWidget이 아닙니다.

17번째 줄이 중요합니다. createState 메서드가 ConsumerState를 반환합니다.

State가 아니라 ConsumerState입니다. 20번째 줄에서 클래스를 정의할 때도 ConsumerState를 상속받습니다.

23번째 줄에서 로컬 상태인 TextEditingController를 선언합니다. 이것은 Provider가 아닌, 위젯 자체의 상태입니다.

26번째 줄의 initState에서 컨트롤러를 초기화하고, 32번째 줄의 dispose에서 정리합니다. 이것이 StatefulWidget의 전형적인 패턴입니다.

38번째 줄에서 ref.watch를 사용합니다. ConsumerWidget처럼 자연스럽게 Provider를 구독합니다.

50번째 줄에서는 ref.read로 할일을 추가하고, 동시에 51번째 줄에서 로컬 상태인 컨트롤러를 클리어합니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 채팅 앱을 개발한다고 가정해봅시다. 메시지 입력 필드는 TextEditingController로 관리하고(로컬 상태), 채팅 메시지 목록은 Provider로 관리합니다(글로벌 상태).

스크롤 위치도 ScrollController로 관리해야 합니다. 이처럼 여러 종류의 상태가 섞인 복잡한 화면에서 ConsumerStatefulWidget이 빛을 발합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 모든 것을 로컬 상태로 관리하는 것입니다.

Provider로 관리해야 할 상태까지 setState로 처리하면, Riverpod의 장점을 살릴 수 없습니다. 반대로 TextEditingController처럼 명백히 로컬 상태인 것까지 Provider로 만들면 과도하게 복잡해집니다.

적절한 균형이 중요합니다. 다시 이주니 씨의 이야기로 돌아가 봅시다.

최시니어 씨의 설명을 들은 이주니 씨는 환하게 웃었습니다. "아하!

이제 언제 뭘 써야 할지 알겠어요!" ConsumerStatefulWidget을 제대로 이해하면 복잡한 화면도 깔끔하게 구현할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 애니메이션, 폼 입력, 스크롤 등 위젯 생명주기와 밀접한 상태는 로컬로 관리하세요

  • 여러 화면에서 공유하거나 비즈니스 로직과 관련된 상태는 Provider로 관리하세요

4. HookConsumerWidget 활용

어느 날 이주니 씨는 코드 리뷰에서 새로운 개념을 접했습니다. "HookConsumerWidget?

이건 또 뭐지?" 최시니어 씨가 설명했습니다. "Flutter Hooks와 Riverpod을 함께 쓰는 강력한 방법이에요."

HookConsumerWidget은 flutter_hooks와 Riverpod을 결합한 위젯입니다. useState, useEffect 같은 훅을 사용하면서 동시에 Provider를 구독할 수 있습니다.

ConsumerStatefulWidget보다 간결한 코드로 동일한 기능을 구현할 수 있습니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final searchProvider = StateProvider<String>((ref) => '');

// HookConsumerWidget 사용
class SearchPage extends HookConsumerWidget {
  const SearchPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Hook으로 로컬 상태 관리 (initState/dispose 불필요)
    final controller = useTextEditingController();

    // 검색어가 변경될 때마다 Provider 업데이트
    useEffect(() {
      void listener() {
        ref.read(searchProvider.notifier).state = controller.text;
      }
      controller.addListener(listener);
      // dispose 자동 처리
      return () => controller.removeListener(listener);
    }, [controller]);

    // Provider 상태 구독
    final searchQuery = ref.watch(searchProvider);

    return Scaffold(
      appBar: AppBar(title: Text('검색')),
      body: Column(
        children: [
          TextField(
            controller: controller,
            decoration: InputDecoration(
              labelText: '검색어 입력',
              hintText: '검색할 내용을 입력하세요',
            ),
          ),
          SizedBox(height: 16),
          Text('현재 검색어: $searchQuery'),
        ],
      ),
    );
  }
}

이주니 씨가 코드를 보며 놀랐습니다. "initState도 없고, dispose도 없는데 어떻게 작동하는 거죠?" 최시니어 씨가 설명했습니다.

"바로 Flutter Hooks의 마법입니다. React Hooks에서 영감을 받은 개념이에요." HookConsumerWidget은 마치 자동 설거지기가 있는 주방과 같습니다.

ConsumerStatefulWidget이 설거지를 직접 해야 하는 주방이라면(initState/dispose 직접 관리), HookConsumerWidget은 자동으로 정리해주는 주방입니다(Hooks가 자동 관리). 더 편리하고 실수할 여지가 적습니다.

왜 HookConsumerWidget이 필요할까요? ConsumerStatefulWidget은 강력하지만 보일러플레이트가 많습니다.

매번 initState와 dispose를 작성해야 하고, 리스너 등록과 해제를 빼먹으면 메모리 누수가 발생합니다. 특히 여러 개의 컨트롤러나 리스너를 관리할 때 코드가 길어지고 복잡해집니다.

바로 이런 문제를 해결하기 위해 HookConsumerWidget이 등장했습니다. HookConsumerWidget을 사용하면 코드가 극적으로 간결해집니다.

useState로 로컬 상태를 선언하고, useEffect로 부수 효과를 처리하면 됩니다. 무엇보다 자동 메모리 관리라는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 3번째 줄을 보면 hooks_riverpod 패키지를 임포트하고 있습니다.

이것이 HookConsumerWidget을 제공합니다. 8번째 줄에서 HookConsumerWidget을 상속받습니다.

StatefulWidget도 아니고 ConsumerWidget도 아닙니다. 14번째 줄이 핵심입니다.

useTextEditingController라는 Hook을 사용합니다. 이것은 일반적인 TextEditingController를 생성하고 자동으로 dispose까지 처리합니다.

initState나 dispose 메서드가 필요 없습니다. 17번째 줄의 useEffect는 React의 useEffect와 비슷합니다.

컴포넌트가 마운트될 때 실행되고, return 함수는 언마운트될 때 실행됩니다. 21번째 줄에서 리스너를 등록하고, 23번째 줄에서 자동으로 해제합니다.

직접 dispose를 작성할 필요가 없습니다. 24번째 줄의 배열 **[controller]**은 의존성 배열입니다.

controller가 변경될 때만 useEffect가 다시 실행됩니다. 이것도 React Hooks와 동일한 패턴입니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 음악 스트리밍 앱의 재생 화면을 개발한다고 가정해봅시다.

AnimationController, ScrollController, TabController 등 여러 개의 컨트롤러를 관리해야 합니다. ConsumerStatefulWidget으로 작성하면 initState와 dispose가 매우 길어집니다.

HookConsumerWidget을 사용하면 useAnimationController, useScrollController 같은 Hook들로 간결하게 처리할 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 Hook의 호출 순서를 바꾸는 것입니다. Hook은 항상 같은 순서로 호출되어야 합니다.

조건문 안에서 Hook을 호출하거나, 반복문에서 동적으로 호출하면 안 됩니다. 이것은 React Hooks와 동일한 제약입니다.

또 다른 주의점은 useEffect의 의존성 배열입니다. 필요한 값을 빼먹으면 최신 상태를 참조하지 못하고, 불필요한 값을 넣으면 과도한 재실행이 발생합니다.

다시 이주니 씨의 이야기로 돌아가 봅시다. 최시니어 씨의 설명을 들은 이주니 씨는 감탄했습니다.

"와, 정말 코드가 훨씬 간결해지네요!" HookConsumerWidget을 제대로 이해하면 현대적이고 깔끔한 Flutter 코드를 작성할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Hook을 사용하려면 pubspec.yaml에 hooks_riverpod 패키지를 추가해야 합니다

  • Hook의 순서는 절대 바뀌면 안 됩니다. 항상 같은 순서로 호출하세요

5. 위젯 분리 전략

프로젝트가 커지면서 이주니 씨의 코드도 점점 복잡해졌습니다. 한 파일이 500줄을 넘어가자 최시니어 씨가 조언했습니다.

"이제 위젯을 분리할 때가 됐어요. 어떻게 분리하느냐가 중요합니다."

위젯 분리는 코드의 가독성과 성능을 동시에 개선하는 전략입니다. 단순히 코드를 나누는 것이 아니라, 각 위젯이 필요한 상태만 구독하도록 설계하는 것이 핵심입니다.

올바른 분리는 불필요한 리빌드를 방지하고 코드 재사용성을 높입니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';

final userProvider = StateProvider<User>((ref) => User('홍길동', 0));

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

// 잘못된 예: 전체를 하나의 위젯으로
class BadProfilePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProvider);
    print('전체 페이지 리빌드'); // 점수만 바뀌어도 이름까지 리빌드

    return Column(
      children: [
        Text('이름: ${user.name}'),
        Text('점수: ${user.score}'),
      ],
    );
  }
}

// 올바른 예: 필요한 부분만 구독
class GoodProfilePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 이름은 변경되지 않으므로 분리
        _UserNameWidget(),
        // 점수만 구독
        _UserScoreWidget(),
      ],
    );
  }
}

class _UserNameWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final name = ref.watch(userProvider.select((user) => user.name));
    print('이름 위젯 빌드');
    return Text('이름: $name');
  }
}

class _UserScoreWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final score = ref.watch(userProvider.select((user) => user.score));
    print('점수 위젯 빌드'); // 점수가 바뀔 때만 출력
    return Text('점수: $score');
  }
}

이주니 씨가 고민에 빠졌습니다. "코드를 어떻게 나눠야 할지 감이 안 잡혀요." 최시니어 씨가 화이트보드에 그림을 그리며 설명했습니다.

"위젯 분리에는 명확한 원칙이 있어요." 위젯 분리는 마치 레고 블록을 조립하는 것과 같습니다. 큰 성을 하나의 거대한 블록으로 만들면(분리하지 않음) 재사용도 어렵고 수정도 힘듭니다.

하지만 작은 블록들로 나누면(적절한 분리) 원하는 부분만 바꿀 수 있고 다른 곳에서도 재사용할 수 있습니다. 위젯을 분리하지 않으면 어떤 문제가 생길까요?

첫째, 성능 문제가 발생합니다. 한 위젯이 여러 Provider를 구독하면, 어느 하나라도 변경되면 전체가 리빌드됩니다.

점수 하나만 바뀌어도 이름, 프로필 사진, 설정 등 모든 것이 다시 그려집니다. 둘째, 코드 가독성이 떨어집니다.

500줄짜리 위젯은 읽기도 어렵고 수정하기도 무섭습니다. 바로 이런 문제를 해결하기 위해 위젯 분리 전략이 필요합니다.

위젯을 분리하면 각 부분이 독립적으로 작동합니다. 필요한 상태만 구독하여 불필요한 리빌드를 방지합니다.

무엇보다 코드의 재사용성과 테스트 용이성이라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 12번째 줄의 BadProfilePage를 봅시다. 15번째 줄에서 user 전체를 구독합니다.

user 객체의 어떤 필드라도 변경되면 전체가 리빌드됩니다. 16번째 줄의 print 문은 점수만 바뀌어도 출력됩니다.

반면 28번째 줄의 GoodProfilePage는 StatelessWidget입니다. 자체적으로는 어떤 상태도 구독하지 않습니다.

대신 32번째와 34번째 줄에서 작은 위젯들로 나눴습니다. 42번째 줄의 _UserNameWidget이 핵심입니다.

45번째 줄에서 select를 사용합니다. 이것은 Provider의 특정 필드만 선택적으로 구독하는 강력한 기능입니다.

user.name이 변경될 때만 이 위젯이 리빌드됩니다. 마찬가지로 52번째 줄의 _UserScoreWidget도 55번째 줄에서 score만 선택적으로 구독합니다.

이제 점수가 바뀌면 점수 위젯만 리빌드되고, 이름 위젯은 그대로 유지됩니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 대시보드 화면을 개발한다고 가정해봅시다. 사용자 정보, 통계 차트, 최근 활동, 알림 목록 등 여러 섹션이 있습니다.

각 섹션을 독립된 위젯으로 분리하고, 각자 필요한 Provider만 구독하도록 설계합니다. 통계 데이터가 업데이트되어도 사용자 정보 위젯은 리빌드되지 않습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 과도한 분리입니다.

단 한 줄짜리 Text 위젯까지 분리하면 오히려 코드가 복잡해집니다. 분리의 기준은 "이 부분이 독립적으로 리빌드될 필요가 있는가?"입니다.

또한 위젯 이름도 중요합니다. _UserScoreWidget처럼 목적이 명확한 이름을 사용해야 나중에 찾기 쉽습니다.

또 다른 팁은 select 활용입니다. 위젯을 분리하지 않고도 select만으로 성능을 개선할 수 있는 경우가 많습니다.

먼저 select를 시도하고, 코드가 복잡해지면 그때 위젯을 분리하는 것이 좋은 순서입니다. 다시 이주니 씨의 이야기로 돌아가 봅시다.

최시니어 씨의 조언대로 위젯을 분리한 이주니 씨는 뿌듯한 표정을 지었습니다. "이제 코드도 깔끔하고, 성능도 좋아졌어요!" 위젯 분리 전략을 제대로 이해하면 대규모 앱도 효율적으로 관리할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - ref.watch의 select를 활용하여 필요한 필드만 구독하세요

  • 위젯 이름은 역할을 명확히 드러내도록 작성하세요 (예: _UserScoreWidget)

6. 성능 비교 테스트

마지막으로 최시니어 씨가 이주니 씨에게 과제를 냈습니다. "지금까지 배운 방법들을 실제로 비교해 보세요.

숫자로 확인하는 것이 중요합니다."

성능 비교 테스트는 각 방법의 리빌드 횟수와 성능을 측정하는 과정입니다. Flutter DevTools의 Performance 탭이나 print 문을 활용하여 실제 리빌드 횟수를 확인할 수 있습니다.

이를 통해 어떤 방법이 가장 효율적인지 데이터로 검증할 수 있습니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';

final counterProvider = StateProvider<int>((ref) => 0);

// 테스트 1: ConsumerWidget (전체 리빌드)
class Test1_ConsumerWidget extends ConsumerWidget {
  static int buildCount = 0;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    buildCount++;
    print('ConsumerWidget 빌드 횟수: $buildCount');

    final count = ref.watch(counterProvider);
    return Column(
      children: [
        Text('방법 1: ConsumerWidget'),
        Text('카운트: $count'),
        Text('빌드 횟수: $buildCount'),
      ],
    );
  }
}

// 테스트 2: Consumer (부분 리빌드)
class Test2_Consumer extends StatelessWidget {
  static int widgetBuildCount = 0;
  static int consumerBuildCount = 0;

  @override
  Widget build(BuildContext context) {
    widgetBuildCount++;
    print('StatelessWidget 빌드 횟수: $widgetBuildCount');

    return Column(
      children: [
        Text('방법 2: Consumer'),
        Consumer(
          builder: (context, ref, child) {
            consumerBuildCount++;
            print('Consumer 빌드 횟수: $consumerBuildCount');
            final count = ref.watch(counterProvider);
            return Text('카운트: $count');
          },
        ),
        Text('전체 빌드: $widgetBuildCount, Consumer 빌드: $consumerBuildCount'),
      ],
    );
  }
}

// 테스트 3: 위젯 분리 + select
class Test3_Separated extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('방법 3: 위젯 분리'),
        _CounterDisplay(),
        _BuildCountDisplay(),
      ],
    );
  }
}

class _CounterDisplay extends ConsumerWidget {
  static int buildCount = 0;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    buildCount++;
    print('_CounterDisplay 빌드 횟수: $buildCount');

    final count = ref.watch(counterProvider);
    return Text('카운트: $count');
  }
}

class _BuildCountDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('_BuildCountDisplay는 리빌드되지 않음');
    return Text('카운터는 독립적으로 업데이트됩니다');
  }
}

이주니 씨가 과제를 시작했습니다. "각 방법을 실제로 비교해 보니 정말 차이가 크네요!" 최시니어 씨가 웃으며 대답했습니다.

"맞아요. 이론도 중요하지만 직접 확인하는 것이 가장 확실합니다." 성능 비교는 마치 자동차 연비 테스트와 같습니다.

카탈로그에 나온 숫자(이론)도 중요하지만, 실제로 운전해서 측정한 연비(실전 테스트)가 더 정확합니다. 여러 방법을 동일한 조건에서 테스트하면 어떤 것이 최선인지 명확해집니다.

왜 성능 테스트가 중요할까요? 개발자들은 종종 추측으로 최적화를 시도합니다.

"이 방법이 더 빠를 것 같아"라는 느낌으로 코드를 작성합니다. 하지만 실제로 측정해 보면 예상과 다른 경우가 많습니다.

때로는 과도한 최적화가 오히려 코드를 복잡하게 만들기도 합니다. 데이터가 있어야 올바른 결정을 내릴 수 있습니다.

바로 이런 이유로 성능 비교 테스트가 필수입니다. 테스트를 통해 각 방법의 실제 리빌드 횟수를 확인할 수 있습니다.

병목 지점을 정확히 파악하여 어디를 최적화할지 결정할 수 있습니다. 무엇보다 데이터 기반 의사결정이라는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 7번째 줄의 Test1_ConsumerWidget을 봅시다.

8번째 줄에서 static 변수로 buildCount를 선언합니다. static이므로 모든 인스턴스가 공유하는 카운터입니다.

12번째 줄에서 빌드될 때마다 증가시키고, 13번째 줄에서 콘솔에 출력합니다. 실제로 이 코드를 실행하고 버튼을 10번 누르면 "ConsumerWidget 빌드 횟수: 10"이 출력됩니다.

카운터가 증가할 때마다 전체 위젯이 리빌드되는 것을 확인할 수 있습니다. 27번째 줄의 Test2_Consumer는 두 개의 카운터를 사용합니다.

28번째 줄의 widgetBuildCount는 StatelessWidget 자체의 빌드 횟수이고, 29번째 줄의 consumerBuildCount는 Consumer 내부의 빌드 횟수입니다. 실행해 보면 흥미로운 결과를 볼 수 있습니다.

widgetBuildCount는 1에서 멈추지만, consumerBuildCount는 계속 증가합니다. 전체 위젯은 한 번만 빌드되고, Consumer 부분만 반복적으로 리빌드되는 것을 확인할 수 있습니다.

53번째 줄의 Test3_Separated는 위젯 분리 방식입니다. 67번째 줄의 _CounterDisplay만 buildCount를 가지고 있습니다.

78번째 줄의 _BuildCountDisplay는 카운터를 전혀 참조하지 않으므로 아예 리빌드되지 않습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 복잡한 리스트 화면을 개발한다고 가정해봅시다. 각 아이템이 좋아요, 댓글, 공유 등 여러 상태를 가집니다.

세 가지 방법으로 구현한 후 성능을 측정합니다. Flutter DevTools의 Performance 탭에서 프레임 드롭을 확인하고, 메모리 사용량도 비교합니다.

데이터를 보고 최선의 방법을 선택합니다. 실전 측정 방법은 이렇습니다.

첫째, print 문을 활용합니다. 간단하지만 효과적입니다.

각 위젯의 build 메서드에 print를 넣어 호출 횟수를 확인합니다. 둘째, Flutter DevTools를 사용합니다.

Performance 탭에서 위젯 리빌드를 시각적으로 확인할 수 있습니다. 셋째, 벤치마크 코드를 작성합니다.

Stopwatch를 사용하여 빌드 시간을 직접 측정합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 디버그 모드에서만 테스트하는 것입니다. 디버그 모드는 프로덕션보다 훨씬 느립니다.

정확한 성능 측정을 위해서는 반드시 릴리스 모드에서 테스트해야 합니다. flutter run --release 명령어로 실행하세요.

또 다른 주의점은 조기 최적화의 함정입니다. 성능 문제가 없는데도 무조건 최적화하면 코드만 복잡해집니다.

먼저 앱을 완성하고, 실제로 성능 문제가 발생하는 부분만 측정하여 최적화하는 것이 현명합니다. 다시 이주니 씨의 이야기로 돌아가 봅시다.

테스트를 완료한 이주니 씨는 자신감이 넘쳤습니다. "이제 어떤 상황에서 어떤 방법을 써야 할지 확실히 알겠어요!" 최시니어 씨가 격려했습니다.

"훌륭해요. 이제 진짜 Riverpod 전문가가 됐네요." 성능 비교 테스트를 제대로 이해하면 감이 아닌 데이터로 코드를 개선할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 성능 측정은 반드시 릴리스 모드에서 진행하세요 (flutter run --release)

  • Flutter DevTools의 Performance 탭을 활용하여 시각적으로 확인하세요
  • 조기 최적화보다는 문제가 있는 부분을 찾아 집중적으로 개선하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#ConsumerWidget#Consumer#Riverpod#상태관리#Flutter,Riverpod

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드

Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.

Riverpod 3.0 Retry 자동 재시도 완벽 가이드

Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.

Riverpod 3.0 requireValue로 Provider 결합하기

Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.

Flutter 3.0 Offline 데이터 영속화 완벽 가이드

Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.

Riverpod 3.0 Mutation으로 폼 제출 완벽 가이드

Riverpod 3.0의 새로운 Mutation 기능으로 로그인과 회원가입 폼을 우아하게 처리하는 방법을 배웁니다. 로딩 상태, 에러 처리, 성공 처리까지 실무에서 바로 쓸 수 있는 패턴을 익혀보세요.