Provider 심화 — ChangeNotifier, Consumer, Selector

이전 글에서 Provider의 기본을 다뤘습니다. 이번에는 실무에서 자주 쓰는 심화 패턴들을 정리해보겠습니다.


ChangeNotifier 패턴

DART
class TodoModel extends ChangeNotifier {
  final List<Todo> _todos = [];

  // 불변 리스트로 외부에 노출 (외부에서 직접 수정 방지)
  List<Todo> get todos => List.unmodifiable(_todos);
  int get completedCount => _todos.where((t) => t.isDone).length;
  int get totalCount => _todos.length;

  void add(String title) {
    _todos.add(Todo(title: title));
    notifyListeners();
  }

  void toggle(int index) {
    _todos[index] = _todos[index].copyWith(
      isDone: !_todos[index].isDone,
    );
    notifyListeners();
  }

  void remove(int index) {
    _todos.removeAt(index);
    notifyListeners();
  }
}

class Todo {
  final String title;
  final bool isDone;

  const Todo({required this.title, this.isDone = false});

  Todo copyWith({String? title, bool? isDone}) {
    return Todo(
      title: title ?? this.title,
      isDone: isDone ?? this.isDone,
    );
  }
}

Consumer — 리빌드 범위 제한

context.watch는 해당 위젯 전체를 리빌드합니다. Consumer를 쓰면 필요한 부분만 리빌드할 수 있습니다.

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('할 일 목록'),
        // Consumer로 완료 카운트만 리빌드
        actions: [
          Consumer<TodoModel>(
            builder: (context, model, child) {
              return Padding(
                padding: const EdgeInsets.all(16),
                child: Text('${model.completedCount}/${model.totalCount}'),
              );
            },
          ),
        ],
      ),
      // 이 부분은 리빌드되지 않음
      body: Consumer<TodoModel>(
        builder: (context, model, child) {
          return ListView.builder(
            itemCount: model.todos.length,
            itemBuilder: (context, index) {
              final todo = model.todos[index];
              return ListTile(
                title: Text(
                  todo.title,
                  style: TextStyle(
                    decoration: todo.isDone
                        ? TextDecoration.lineThrough
                        : null,
                  ),
                ),
                leading: Checkbox(
                  value: todo.isDone,
                  onChanged: (_) => model.toggle(index),
                ),
                trailing: IconButton(
                  icon: const Icon(Icons.delete),
                  onPressed: () => model.remove(index),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

Consumer의 child 파라미터

child는 리빌드되지 않는 부분을 최적화합니다.

DART
Consumer<CounterModel>(
  // child는 리빌드되지 않음 (성능 최적화)
  child: const Text('변하지 않는 텍스트'),
  builder: (context, model, child) {
    return Column(
      children: [
        child!,  // 리빌드되지 않는 부분
        Text('${model.count}'),  // 이 부분만 리빌드
      ],
    );
  },
)

Selector — 특정 값만 감시

Selector는 모델의 특정 속성만 감시합니다. 해당 속성이 변경될 때만 리빌드합니다.

DART
// count 값이 변경될 때만 리빌드
Selector<TodoModel, int>(
  selector: (context, model) => model.completedCount,
  builder: (context, completedCount, child) {
    return Text('완료: $completedCount');
  },
)

// context.select도 같은 역할
Widget build(BuildContext context) {
  final completedCount = context.select<TodoModel, int>(
    (model) => model.completedCount,
  );
  return Text('완료: $completedCount');
}

면접 포인트: watch vs Consumer vs Selector의 차이를 물어볼 수 있습니다. watch는 위젯 전체 리빌드, Consumer는 builder 범위만 리빌드, Selector는 특정 값이 변할 때만 리빌드합니다.


Provider 종류

Provider (읽기 전용)

DART
// 변하지 않는 값을 제공할 때
Provider<ApiService>(
  create: (_) => ApiService(),
  child: const MyApp(),
)

ChangeNotifierProvider

DART
// ChangeNotifier를 제공할 때 (가장 흔함)
ChangeNotifierProvider(
  create: (_) => CartModel(),
  child: const MyApp(),
)

FutureProvider

DART
// 비동기 초기화가 필요한 값
FutureProvider<User>(
  create: (_) => fetchCurrentUser(),
  initialData: User.empty(),
  child: const MyApp(),
)

StreamProvider

DART
// 스트림으로 변하는 값
StreamProvider<int>(
  create: (_) => countStream(),
  initialData: 0,
  child: const MyApp(),
)

ProxyProvider

DART
// 다른 Provider에 의존하는 Provider
MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => AuthModel()),
    // AuthModel에 의존하는 ApiService
    ProxyProvider<AuthModel, ApiService>(
      update: (_, auth, __) => ApiService(token: auth.token),
    ),
  ],
  child: const MyApp(),
)

에러 처리 패턴

DART
class DataModel extends ChangeNotifier {
  List<Item> _items = [];
  bool _isLoading = false;
  String? _error;

  List<Item> get items => _items;
  bool get isLoading => _isLoading;
  String? get error => _error;

  Future<void> fetchItems() async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    try {
      _items = await apiService.getItems();
    } catch (e) {
      _error = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

// UI에서 사용
Consumer<DataModel>(
  builder: (context, model, _) {
    if (model.isLoading) {
      return const CircularProgressIndicator();
    }
    if (model.error != null) {
      return Text('에러: ${model.error}');
    }
    return ListView.builder(
      itemCount: model.items.length,
      itemBuilder: (context, index) =>
          ListTile(title: Text(model.items[index].name)),
    );
  },
)

Provider 테스트

DART
testWidgets('카운터 증가 테스트', (tester) async {
  await tester.pumpWidget(
    ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: const MaterialApp(home: CounterScreen()),
    ),
  );

  // 초기값 확인
  expect(find.text('0'), findsOneWidget);

  // 버튼 클릭
  await tester.tap(find.byType(FloatingActionButton));
  await tester.pump();

  // 증가 확인
  expect(find.text('1'), findsOneWidget);
});

Provider 주의사항

DART
// 1. build 안에서 read 사용하면 상태 변화를 감지 못함
@override
Widget build(BuildContext context) {
  // 잘못된 사용: build에서 read
  final count = context.read<CounterModel>().count;

  // 올바른 사용: build에서 watch
  final count = context.watch<CounterModel>().count;
}

// 2. 콜백에서 watch 사용하면 에러
onPressed: () {
  // 잘못된 사용: 콜백에서 watch
  // final model = context.watch<CounterModel>();

  // 올바른 사용: 콜백에서 read
  final model = context.read<CounterModel>();
  model.increment();
}

// 3. dispose에서 notifyListeners 호출하지 않기
@override
void dispose() {
  // notifyListeners();  // 에러 발생!
  super.dispose();
}

정리

  • Consumer로 리빌드 범위를 제한하여 성능을 최적화하세요
  • Selector로 특정 속성만 감시하면 불필요한 리빌드를 줄일 수 있습니다
  • Provider 종류(Provider, ChangeNotifier, Future, Stream, Proxy)를 용도에 맞게 선택하세요
  • watch는 build에서, read는 콜백에서 사용하는 규칙을 지키세요
  • 로딩, 에러, 데이터 3가지 상태를 항상 관리하세요
댓글 로딩 중...