learn-flutter/content/docs/part4/inherited-provider.md
ChangJoo Park(박창주) 900b0ab68b update markdown
2025-05-14 09:34:13 +09:00

24 KiB

InheritedWidget과 Provider

이 장에서는 Flutter의 위젯 트리를 통해 상태를 공유하는 방법인 InheritedWidget과 이를 기반으로 한 Provider 패키지에 대해 알아보겠습니다. 이 방식들은 상태 관리를 위한 중요한 도구로, 위젯 트리 전체에 걸쳐 데이터를 효율적으로 공유할 수 있게 해줍니다.

InheritedWidget 이해하기

InheritedWidget은 Flutter 프레임워크에 내장된 위젯으로, 위젯 트리의 하위 항목들에게 데이터를 효율적으로 전달할 수 있게 합니다. 특히 위젯 트리 깊숙한 곳에 있는 위젯이 상위 위젯의 데이터에 접근해야 할 때 매우 유용합니다.

InheritedWidget의 작동 원리

graph TD
    A[InheritedWidget] --> B[Child 1]
    A --> C[Child 2]
    B --> D[Grandchild 1]
    B --> E[Grandchild 2]
    C --> F[Grandchild 3]

    D -.->|"of(context)"| A
    E -.->|"of(context)"| A
    F -.->|"of(context)"| A
  1. 데이터 저장: InheritedWidget은 공유하려는 데이터를 저장합니다.
  2. 위젯 트리 전파: 이 데이터는 위젯 트리 아래로 자동으로 전파됩니다.
  3. 컨텍스트 접근: 하위 위젯들은 BuildContext를 통해 상위의 InheritedWidget에 접근할 수 있습니다.
  4. 변경 알림: InheritedWidget이 업데이트되면, 이에 의존하는 모든 위젯들이 자동으로 재빌드됩니다.

InheritedWidget 구현하기

간단한 테마 데이터를 공유하는 InheritedWidget을 구현해 보겠습니다:

class ThemeData {
  final Color primaryColor;
  final Color secondaryColor;
  final double fontSize;

  const ThemeData({
    required this.primaryColor,
    required this.secondaryColor,
    required this.fontSize,
  });
}

class ThemeInherited extends InheritedWidget {
  final ThemeData themeData;

  const ThemeInherited({
    Key? key,
    required this.themeData,
    required Widget child,
  }) : super(key: key, child: child);

  // of 메서드: 하위 위젯에서 ThemeInherited 인스턴스를 찾는 정적 메서드
  static ThemeInherited of(BuildContext context) {
    final ThemeInherited? result =
        context.dependOnInheritedWidgetOfExactType<ThemeInherited>();
    assert(result != null, 'ThemeInherited를 찾을 수 없습니다.');
    return result!;
  }

  // 위젯이 업데이트되었을 때 하위 위젯들에게 알릴지 결정하는 메서드
  @override
  bool updateShouldNotify(ThemeInherited oldWidget) {
    return themeData.primaryColor != oldWidget.themeData.primaryColor ||
        themeData.secondaryColor != oldWidget.themeData.secondaryColor ||
        themeData.fontSize != oldWidget.themeData.fontSize;
  }
}

InheritedWidget 사용하기

위에서 만든 ThemeInherited 위젯을 사용하는 방법:

// 앱의 루트에 InheritedWidget 설정
void main() {
  runApp(
    ThemeInherited(
      themeData: ThemeData(
        primaryColor: Colors.blue,
        secondaryColor: Colors.green,
        fontSize: 16.0,
      ),
      child: MyApp(),
    ),
  );
}

// 하위 위젯에서 데이터 접근
class ThemedText extends StatelessWidget {
  final String text;

  const ThemedText({Key? key, required this.text}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // InheritedWidget에서 테마 데이터 가져오기
    final theme = ThemeInherited.of(context).themeData;

    return Text(
      text,
      style: TextStyle(
        color: theme.primaryColor,
        fontSize: theme.fontSize,
      ),
    );
  }
}

InheritedWidget의 한계

InheritedWidget은 강력하지만 몇 가지 제한사항이 있습니다:

  1. 상태 변경 메커니즘 없음: 데이터를 공유할 수 있지만, 변경할 수 있는 메커니즘은 제공하지 않습니다.
  2. 복잡한 구현: 직접 구현하려면 상용구 코드가 많이 필요합니다.
  3. 변경 관리 번거로움: 상태 변경 시 새 InheritedWidget을 생성하고 위젯 트리를 다시 빌드해야 합니다.

이러한 한계를 해결하기 위해 Provider 패키지가 개발되었습니다.

Provider 패키지

Provider는 InheritedWidget을 기반으로 구축된 상태 관리 패키지로, 코드를 단순화하고 상태 관리를 더 쉽게 만들어줍니다.

Provider의 주요 특징

  1. 편리한 API: InheritedWidget을 직접 구현하는 것보다 간단한 API 제공
  2. 여러 Provider 유형: 다양한 사용 사례에 맞는 여러 종류의 Provider 제공
  3. 상태 변경 통합: 상태 변경 메커니즘이 내장되어 있음
  4. 의존성 주입: 테스트와 재사용을 위한 의존성 주입 패턴 지원

Provider 패키지 설치

pubspec.yaml 파일에 provider 패키지를 추가합니다:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.0.5 # 최신 버전을 확인하세요

Provider의 종류

Provider 패키지는 다양한 종류의 Provider를 제공합니다:

graph TD
    A[Provider] --> B[기본 Provider<br>읽기 전용 데이터]
    A --> C[ChangeNotifierProvider<br>mutable 상태]
    A --> D[FutureProvider<br>비동기 데이터]
    A --> E[StreamProvider<br>스트림 데이터]
    A --> F[ProxyProvider<br>다른 Provider 의존]
    A --> G[MultiProvider<br>여러 Provider 조합]
  1. Provider: 가장 기본적인 Provider로, 변경되지 않는 데이터를 제공
  2. ChangeNotifierProvider: ChangeNotifier를 사용하여 변경 가능한 상태를 관리
  3. FutureProvider: Future로부터 값을 제공
  4. StreamProvider: Stream으로부터 값을 제공
  5. ProxyProvider: 다른 Provider의 값에 의존하는 값을 제공
  6. MultiProvider: 여러 Provider를 한 번에 제공

기본 Provider 사용하기

가장 간단한 Provider를 사용하는 예제:

// 데이터 모델
class User {
  final String name;
  final int age;

  const User({required this.name, required this.age});
}

// Provider 설정
void main() {
  runApp(
    Provider<User>(
      create: (_) => User(name: '홍길동', age: 30),
      child: MyApp(),
    ),
  );
}

// 데이터 사용
class UserInfoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Provider에서 데이터 가져오기
    final user = Provider.of<User>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Provider 예제'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('이름: ${user.name}'),
            Text('나이: ${user.age}'),
          ],
        ),
      ),
    );
  }
}

ChangeNotifierProvider로 변경 가능한 상태 관리하기

변경 가능한 상태를 관리하는 ChangeNotifierProvider 예제:

// ChangeNotifier 모델
class Counter with ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();  // 변경 사항을 구독자들에게 알림
  }
}

// Provider 설정
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => Counter(),
      child: MyApp(),
    ),
  );
}

// 상태 사용 및 변경
class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Counter 예제'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // Consumer 위젯으로 상태 읽기
            Consumer<Counter>(
              builder: (context, counter, child) {
                return Text(
                  '카운트: ${counter.count}',
                  style: TextStyle(fontSize: 24),
                );
              },
            ),
            SizedBox(height: 20),
            ElevatedButton(
              // Provider에서 읽고 메서드 호출
              onPressed: () => Provider.of<Counter>(context, listen: false).increment(),
              child: Text('증가'),
            ),
          ],
        ),
      ),
    );
  }
}

Provider.of vs Consumer vs context.watch

Provider 패키지는 Provider에서 데이터를 읽는 여러 방법을 제공합니다:

  1. Provider.of(context):

    final counter = Provider.of<Counter>(context);
    
    • listen: true(기본값)이면 데이터 변경 시 위젯 재빌드
    • listen: false이면 데이터만 읽고 변경 감지는 하지 않음
  2. Consumer:

    Consumer<Counter>(
      builder: (context, counter, child) {
        return Text('${counter.count}');
      },
    )
    
    • 위젯 트리의 일부만 재빌드할 때 유용
    • child 매개변수로 재빌드되지 않는 위젯 지정 가능
  3. context.watch() (Dart 확장 메서드):

    final counter = context.watch<Counter>();
    
    • Provider.of와 유사하지만 더 간결한 구문
    • 변경 감지를 통해 위젯 재빌드
  4. context.read() (Dart 확장 메서드):

    // 이벤트 핸들러 내에서 사용
    onPressed: () => context.read<Counter>().increment()
    
    • 변경 감지 없이 현재 값만 읽음 (Provider.of(..., listen: false)와 동일)
  5. context.select<T, R>(R Function(T) selector):

    // UserModel에서 이름만 감시
    final userName = context.select<UserModel, String>((user) => user.name);
    
    • 객체의 특정 속성만 감시하여 불필요한 재빌드 방지

MultiProvider로 여러 Provider 결합하기

여러 Provider를 함께 사용해야 할 때는 MultiProvider를 활용합니다:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => UserModel()),
        ChangeNotifierProvider(create: (_) => CartModel()),
        Provider(create: (_) => ApiService()),
        FutureProvider(create: (_) => loadInitialSettings()),
      ],
      child: MyApp(),
    ),
  );
}

ProxyProvider로 의존성 있는 Provider 만들기

한 Provider가 다른 Provider에 의존할 때 사용합니다:

MultiProvider(
  providers: [
    Provider<ApiService>(
      create: (_) => ApiService(),
    ),
    // ApiService에 의존하는 ProductRepository
    ProxyProvider<ApiService, ProductRepository>(
      update: (_, apiService, __) => ProductRepository(apiService),
    ),
    // ProductRepository에 의존하는 ProductViewModel
    ProxyProvider<ProductRepository, ProductViewModel>(
      update: (_, repository, __) => ProductViewModel(repository),
    ),
  ],
  child: MyApp(),
)

실제 예제: 장바구니 앱

이제 Provider를 사용하여 간단한 장바구니 앱을 구현해 보겠습니다:

1. 모델 클래스 정의

// 상품 모델
class Product {
  final String id;
  final String name;
  final double price;
  final String imageUrl;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
  });
}

// 장바구니 모델 (ChangeNotifier를 상속)
class CartModel extends ChangeNotifier {
  final List<Product> _items = [];

  // 읽기 전용 접근자
  List<Product> get items => List.unmodifiable(_items);
  int get itemCount => _items.length;
  double get totalPrice => _items.fold(0, (sum, item) => sum + item.price);

  // 상품 추가
  void addProduct(Product product) {
    _items.add(product);
    notifyListeners();
  }

  // 상품 제거
  void removeProduct(Product product) {
    _items.remove(product);
    notifyListeners();
  }

  // 장바구니 비우기
  void clearCart() {
    _items.clear();
    notifyListeners();
  }
}

// 상품 저장소
class ProductRepository {
  // 상품 목록 (실제로는 API에서 가져옴)
  List<Product> getProducts() {
    return [
      Product(
        id: '1',
        name: '노트북',
        price: 1200000,
        imageUrl: 'assets/laptop.jpg',
      ),
      Product(
        id: '2',
        name: '스마트폰',
        price: 800000,
        imageUrl: 'assets/smartphone.jpg',
      ),
      Product(
        id: '3',
        name: '헤드폰',
        price: 250000,
        imageUrl: 'assets/headphones.jpg',
      ),
      Product(
        id: '4',
        name: '스마트워치',
        price: 350000,
        imageUrl: 'assets/smartwatch.jpg',
      ),
    ];
  }
}

2. Provider 설정

void main() {
  runApp(
    MultiProvider(
      providers: [
        // 상품 저장소 제공
        Provider<ProductRepository>(
          create: (_) => ProductRepository(),
        ),
        // 장바구니 모델 제공
        ChangeNotifierProvider<CartModel>(
          create: (_) => CartModel(),
        ),
      ],
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '장바구니 앱',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: ProductListPage(),
    );
  }
}

3. 상품 목록 페이지

class ProductListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 상품 저장소에서 상품 목록 가져오기
    final productRepository = Provider.of<ProductRepository>(context);
    final products = productRepository.getProducts();

    return Scaffold(
      appBar: AppBar(
        title: Text('상품 목록'),
        actions: [
          // 장바구니 아이콘과 상품 수 표시
          Stack(
            alignment: Alignment.center,
            children: [
              IconButton(
                icon: Icon(Icons.shopping_cart),
                onPressed: () {
                  Navigator.push(
                    context,
                    MaterialPageRoute(builder: (_) => CartPage()),
                  );
                },
              ),
              Positioned(
                right: 8,
                top: 8,
                child: Consumer<CartModel>(
                  builder: (_, cart, __) {
                    return cart.itemCount > 0
                        ? CircleAvatar(
                            backgroundColor: Colors.red,
                            radius: 8,
                            child: Text(
                              '${cart.itemCount}',
                              style: TextStyle(
                                fontSize: 10,
                                color: Colors.white,
                              ),
                            ),
                          )
                        : SizedBox.shrink();
                  },
                ),
              ),
            ],
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return Card(
            margin: EdgeInsets.all(8.0),
            child: ListTile(
              leading: Image.asset(
                product.imageUrl,
                width: 56,
                height: 56,
                errorBuilder: (context, error, stackTrace) {
                  // 이미지 로드 실패 시 대체 아이콘
                  return Icon(Icons.image, size: 56);
                },
              ),
              title: Text(product.name),
              subtitle: Text('₩${product.price.toStringAsFixed(0)}'),
              trailing: Consumer<CartModel>(
                builder: (_, cart, __) {
                  return IconButton(
                    icon: Icon(Icons.add_shopping_cart),
                    onPressed: () {
                      cart.addProduct(product);
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(content: Text('${product.name} 추가됨')),
                      );
                    },
                  );
                },
              ),
            ),
          );
        },
      ),
    );
  }
}

4. 장바구니 페이지

class CartPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('장바구니'),
      ),
      body: Consumer<CartModel>(
        builder: (context, cart, child) {
          if (cart.items.isEmpty) {
            return Center(
              child: Text('장바구니가 비었습니다'),
            );
          }

          return Column(
            children: [
              Expanded(
                child: ListView.builder(
                  itemCount: cart.items.length,
                  itemBuilder: (context, index) {
                    final product = cart.items[index];
                    return ListTile(
                      leading: Image.asset(
                        product.imageUrl,
                        width: 56,
                        height: 56,
                        errorBuilder: (context, error, stackTrace) {
                          return Icon(Icons.image, size: 56);
                        },
                      ),
                      title: Text(product.name),
                      subtitle: Text('₩${product.price.toStringAsFixed(0)}'),
                      trailing: IconButton(
                        icon: Icon(Icons.remove_circle),
                        onPressed: () {
                          cart.removeProduct(product);
                        },
                      ),
                    );
                  },
                ),
              ),
              Container(
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: Colors.white,
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black12,
                      blurRadius: 4,
                      offset: Offset(0, -2),
                    ),
                  ],
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    Text(
                      '총 결제 금액: ₩${cart.totalPrice.toStringAsFixed(0)}',
                      style: TextStyle(
                        fontSize: 18,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    SizedBox(height: 16),
                    ElevatedButton(
                      child: Text('결제하기'),
                      onPressed: () {
                        // 결제 로직 구현
                        showDialog(
                          context: context,
                          builder: (_) => AlertDialog(
                            title: Text('결제 확인'),
                            content: Text('결제가 완료되었습니다!'),
                            actions: [
                              TextButton(
                                onPressed: () {
                                  cart.clearCart();
                                  Navigator.pop(context);
                                  Navigator.pop(context); // 이전 화면으로 돌아가기
                                },
                                child: Text('확인'),
                              ),
                            ],
                          ),
                        );
                      },
                    ),
                  ],
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

Provider 사용 시 Best Practices

Provider를 효과적으로 사용하기 위한 몇 가지 권장사항:

1. 모델 캡슐화

상태 모델 내부 구현을 캡슐화하여 불변성을 유지합니다:

// 좋은 예시
class UserModel extends ChangeNotifier {
  String _name = '';
  String get name => _name;

  void updateName(String newName) {
    _name = newName;
    notifyListeners();
  }
}

// 나쁜 예시
class UserModel extends ChangeNotifier {
  String name = ''; // public 필드

  void updateName(String newName) {
    name = newName;
    notifyListeners();
  }
}

2. UI에서 비즈니스 로직 분리

비즈니스 로직을 모델 클래스에 위치시킵니다:

// 좋은 예시 - 모델에 로직 포함
class CartModel extends ChangeNotifier {
  double get totalPrice => _items.fold(0, (sum, item) => sum + item.price);

  void checkout() {
    // 결제 처리 로직
    _items.clear();
    notifyListeners();
  }
}

// Widget 코드에서는 간단히 호출
ElevatedButton(
  onPressed: () => context.read<CartModel>().checkout(),
  child: Text('결제하기'),
)

3. 위젯 재빌드 최적화

필요한 부분만 재빌드되도록 설계합니다:

// 좋은 예시 - 필요한 부분만 Consumer로 감싸기
Scaffold(
  appBar: AppBar(
    title: Text('장바구니'),
    actions: [
      // 장바구니 아이콘만 업데이트
      Consumer<CartModel>(
        builder: (_, cart, __) {
          return Badge(
            label: Text('${cart.itemCount}'),
            child: IconButton(
              icon: Icon(Icons.shopping_cart),
              onPressed: () {},
            ),
          );
        },
      ),
    ],
  ),
  // 나머지 UI는 변경되지 않음
  body: ProductListView(),
)

4. 범위에 맞는 Provider 위치 선택

Provider의 위치를 적절하게 선택하여 범위를 제한합니다:

// 앱 전체에서 필요한 Provider는 최상위에 배치
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => ThemeModel(),
      child: MyApp(),
    ),
  );
}

// 특정 화면에서만 필요한 Provider는 해당 화면 위젯에 배치
class ProductsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => ProductsViewModel(),
      child: ProductsContent(),
    );
  }
}

5. Provider 조합 패턴

여러 상태가 함께 작동해야 할 때 ProxyProvider를 활용합니다:

MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => AuthModel()),
    ChangeNotifierProvider(create: (_) => ThemeModel()),
    // AuthModel과 ThemeModel에 의존하는 SettingsModel
    ProxyProvider2<AuthModel, ThemeModel, SettingsModel>(
      update: (_, auth, theme, __) => SettingsModel(auth, theme),
    ),
  ],
  child: MyApp(),
)

InheritedWidget vs Provider vs 기타 상태 관리 솔루션

각 상태 관리 솔루션의 장단점을 비교해보겠습니다:

InheritedWidget

장점:

  • Flutter의 기본 API - 추가 패키지 필요 없음
  • 위젯 트리를 통한 효율적인 데이터 공유

단점:

  • 상태 변경 메커니즘 부재
  • 복잡한 구현
  • 반복적인 코드 필요

Provider

장점:

  • 간단한 API
  • ChangeNotifier와 통합되어 상태 변경 쉬움
  • 여러 유형의 Provider 제공
  • 의존성 주입 패턴

단점:

  • 대규모 앱에서는 구조화가 필요
  • ChangeNotifier의 뮤터블 상태

Riverpod (Provider의 개선된 버전)

장점:

  • 컴파일 시간에 안전성 확인
  • Provider와 유사하지만 개선된 API
  • 고정된 Provider Tree 없음
  • 더 나은 테스트 가능성

단점:

  • Provider보다 약간 더 복잡한 API
  • 러닝 커브가 더 높을 수 있음

기타 솔루션 (Bloc, Redux, MobX 등)

Bloc/Cubit:

  • 사용 흐름(Stream)과 상태 관리를 명확히 분리
  • 테스트하기 쉬움
  • 비동기 작업에 강점
  • 더 많은 코드가 필요

Redux:

  • 예측 가능한 단방향 데이터 흐름
  • 중앙 집중식 상태 관리
  • 디버깅이 쉬움
  • 구현이 더 복잡할 수 있음

MobX:

  • 반응형 프로그래밍
  • 더 적은 코드로 구현
  • 자동 반응 추적
  • 개념을 이해하는 데 시간이 필요

요약

  • InheritedWidget은 Flutter의 내장 메커니즘으로, 위젯 트리를 통해 데이터를 공유합니다.
  • Provider는 InheritedWidget 기반의 패키지로, 더 쉬운 API와 상태 변경 메커니즘을 제공합니다.
  • Provider는 다양한 종류의 Provider(ChangeNotifierProvider, FutureProvider 등)를 통해 다양한 사용 사례를 지원합니다.
  • 효과적인 Provider 사용을 위해 모델 캡슐화, 비즈니스 로직 분리, 재빌드 최적화 등의 Best Practice를 적용해야 합니다.
  • Provider는 중소규모 앱에서 좋은 선택이며, 대규모 앱에서는 Riverpod이나 Bloc 같은 더 구조화된 솔루션을 고려할 수 있습니다.

다음 장에서는 Provider의 다음 세대 기술인 Riverpod에 대해 살펴보겠습니다.