Sure, Why not?

Flutter 기본 상태관리 - setState, ValueNotifier, ChangeNotifier, InheritedWidget 본문

Flutter/🖥️

Flutter 기본 상태관리 - setState, ValueNotifier, ChangeNotifier, InheritedWidget

joho2022 2026. 5. 17. 14:36

 

 

 

 

Flutter의 상태관리는 결국 한 문장으로 정리할 수 있다.

데이터가 바뀌었을 때, 어떤 위젯을 다시 그릴 것인가?

 

 

iOS를 해온 나로서는,

@State, @Binding, @ObservableObject, @EnvironmentObject 등 어떻게 나눠 쓸지 고민하는 것과 비슷하다.


1. Flutter 기본 상태관리

 

1-1. setState

Flutter 프로젝트를 처음 생성하면 가장 먼저 볼 수 있는 예시 코드가 있다.

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

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

 @override
  Widget build(BuildContext context) { ... }
}

이 예제는 Flutter에서 가장 기본적인 상태관리 방식인 setState()를 보여준다.

 

setState()는 StatefulWidget의 상태가 변경되었음을 Flutter 프레임워크에 알려주는 메서드이다.

 

상태가 바뀌었으니 위젯을 다시 렌더링하라고 시키는 역할을 한다.

여기서 중요한 점은 \_counter++ 자체가 화면을 다시 그리게 만드는 것이 아니라
화면 갱신을 유발하는 것은 setState() 이다.

 

따라서 내부에는 빠르게 끝나는 상태 변경만 작성하는 것이 좋다.

하지만 화면이 복잡해질수록 하나의 State 클래스 안에 많은 상태가 몰리게 된다.

 

또한 setState()는 build()를 다시 실행시키기 때문에 불필요한 rebuild가 발생할 수 있다.

 

여기서 Flutter는 Widget Tree를 비교(diff)하여

이전과 동일한 const Widget은 재사용할 수 있다.

 

즉, setState()는 간단한 지역 상태에는 적합하지만, 큰 규모의 상태 관리에는 한계가 있다.

 

1.2 ValueNotifier

setState()는 가장 기본적인 상태관리 방식이지만, 상태가 변경될 때마다 해당 Statebuild()가 다시 실행된다.

 

따라서 작은 값 하나만 변경되더라도 상위 위젯의 build() 전체가 다시 호출될 수 있다.

 

그래서 조금 더 가볍게 상태를 관리할 수 있도록 ValueNotifier를 제공한다.

 

 

class MyHomePage extends StatelessWidget {
  MyHomePage({super.key});

  final ValueNotifier<int> counterNotifier = ValueNotifier<int>(0);

  @override
  Widget build(BuildContext context) {
    print('MyHomePage build');

    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counterNotifier.value++;
        },
        child: const Icon(Icons.add),
      ),
      body: Center(
        child: ValueListenableBuilder<int>(
          valueListenable: counterNotifier,
          builder: (context, value, child) {
            print('Text rebuild');

            return Text(
              '$value',
              style: const TextStyle(fontSize: 30),
            );
          },
        ),
      ),
    );
  }
}

 

ValueNotifier는 하나의 값을 감시하고 있다가 값이 변경되면 리스너에게 알려주는 클래스이다.

초기값을 지니고 있고,

값은 .value를 통해 접근한다.

 

ValueNotifier는 값 변경만 관리하기 때문에, 실제 UI를 다시 그리기 위해서는 이를 구독하는 위젯이 필요하다.

Flutter에서는 이를 위해 ValueListenableBuilder를 제공한다.

 

 

setState()와의 차이점

setState()는 해당 Statebuild()를 다시 실행한다.

-> State 전체 rebuild

 

반면 ValueNotifier는 값을 구독하고 있는 위젯만 다시 빌드할 수 있다.

-> 특정 구독 위젯만 rebuild

 

즉 상태 변경 범위를 조금 더 좁게 관리할 수 있다.

 

그리고 ValueNotifier는 기본적으로 하나의 값만 관리하는 데 적합하다.

 

 

 

1.3 ChangeNotifier

상태가 많아질수록 여러 ValueNotifier를 따로 관리해야 하는 문제가 생긴다.

그래서 ChangeNotifier를 제공한다.

 

ChangeNotifier는 여러 상태를 하나의 객체로 묶어서 관리할 수 있도록 도와주는 클래스이다.

상태가 변경되면 notifyListeners()를 호출하여 구독 중인 위젯들에게 변경 사실을 알린다.

 

import 'package:flutter/material.dart';

class CounterViewModel extends ChangeNotifier {
  int count = 0;
  bool isLoading = false;

  void increment() {
    count++;
    notifyListeners();
  }

  void toggleLoading() {
    isLoading = !isLoading;
    notifyListeners();
  }
}

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

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final CounterViewModel viewModel = CounterViewModel();

  @override
  Widget build(BuildContext context) {
    print('MyHomePage build');

    return Scaffold(
      appBar: AppBar(
        title: const Text('ChangeNotifier Example'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: viewModel.increment,
        child: const Icon(Icons.add),
      ),
      body: AnimatedBuilder(
        animation: viewModel,
        builder: (context, child) {
          print('AnimatedBuilder rebuild');

          return Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                '${viewModel.count}',
                style: const TextStyle(fontSize: 30),
              ),
              const SizedBox(height: 20),
              Text(
                viewModel.isLoading ? 'Loading...' : 'Idle',
                style: const TextStyle(fontSize: 20),
              ),
              const SizedBox(height: 20),
              ElevatedButton(
                onPressed: viewModel.toggleLoading,
                child: const Text('Toggle Loading'),
              ),
            ],
          );
        },
      ),
    );
  }
}

 

notifyListeners()ChangeNotifier의 핵심 메서드이다.

이 메서드가 호출되면 해당 객체를 구독하고 있는 위젯들에게 상태가 변경되었음을 알린다.

 

화면을 다시 그리는 역할을 하지만, 상태를 위젯 밖 객체로 분리할 수 있다.

 

SwiftUI의 ObservableObject와 가장 유사한 개념이다.

 

ChangeNotifier 한계

그러나

해당 객체를 구독 중인 위젯들이 모두 다시 빌드될 수 있기 때문에,

상태 변경 범위를 세밀하게 나누지 않으면 불필요한 rebuild가 발생할 수 있다.

 

또한 화면이 많아질수록 객체 생성, 생명주기 관리, 의존성 관리가 복잡해질 수 있다.

이러한 문제를 해결하기 위해 이후 Provider, Riverpod, BLoC 같은 상태관리 방식들이 등장하게 된다.

 

 

1.4 InheritedWidget

setState(), ValueNotifier, ChangeNotifier는 상태를 변경하고 UI를 다시 그리는 방법을 이해하는 데 중요하다.

 

하지만 여기서 한 가지 문제가 생긴다.

 

상위 위젯에서 만든 상태를 하위 위젯 여러 곳에서 사용해야 한다면 어떻게 전달해야 할까?

 

가장 단순한 방법은 생성자를 통해 값을 계속 내려주는 방식이다.

이 방식을 props drilling이라고도 부른다.

 

그래서 

InheritedWidget은 상위 위젯에 있는 데이터를 하위 위젯들이 쉽게 접근할 수 있도록 도와주는 Flutter 기본 위젯이다.

 

즉 중간 위젯들이 직접 값을 전달하지 않아도, 아래쪽 위젯에서 context를 통해 상위 데이터를 가져올 수 있다.

 

class UserScope extends InheritedWidget {
  const UserScope({
    super.key,
    required this.userName,
    required super.child,
  });

  final String userName;

  static UserScope of(BuildContext context) {
    final UserScope? result =
        context.dependOnInheritedWidgetOfExactType<UserScope>();

    assert(result != null, 'No UserScope found in context');

    return result!;
  }

  @override
  bool updateShouldNotify(UserScope oldWidget) {
    return userName != oldWidget.userName;
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const UserScope(
      userName: '홍길동',
      child: Scaffold(
        body: ParentWidget(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const ChildWidget();
  }
}

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

  @override
  Widget build(BuildContext context) {
    final userName = UserScope.of(context).userName;

    return Center(
      child: ElevatedButton(
        onPressed: () {
          Navigator.of(context).push(
            MaterialPageRoute<void>(
              builder: (_) => GrandChildPage(userName: userName),
            ),
          );
        },
        child: const Text('하위뷰로 이동'),
      ),
    );
  }
}

class GrandChildPage extends StatelessWidget {
  const GrandChildPage({
    super.key,
    required this.userName,
  });

  final String userName;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('GrandChildPage'),
      ),
      body: Center(
        child: Text(
          '안녕하세요, $userName님',
          style: const TextStyle(fontSize: 24),
        ),
      ),
    );
  }
}

 

InheritedWidget에서 가장 중요한 메서드는 updateShouldNotify()이다.

 

이 메서드는 이전 값과 새로운 값을 비교해서, 하위 위젯들에게 변경 사실을 알려야 하는지 결정한다.

 

@override
bool updateShouldNotify(UserScope oldWidget) {
  return userName != oldWidget.userName;
}

true를 반환하면 해당 InheritedWidget에 의존하고 있는 하위 위젯들이 다시 빌드될 수 있다.

false를 반환하면 값이 변경되어도 하위 위젯에게 알리지 않는다.

 

SwiftUI의 @Environment 또는 @EnvironmentObject와 비슷하게 이해할 수 있다.

 

상위에서 값을 주입하고, 하위 View에서는 직접 전달받지 않아도 환경에서 꺼내 쓰는 구조와 유사하다.

 

 

InheritedWidget의 한계

InheritedWidget은 Flutter의 중요한 기반 개념이지만, 직접 사용하기에는 코드가 다소 번거롭다.

of(context) 메서드를 직접 만들어야 하고, updateShouldNotify()도 직접 구현해야 한다.

 

또한 상태 변경 로직까지 함께 다루려면 StatefulWidget, ChangeNotifier 같은 구조와 조합해야 한다.

 

그래서 

이를 더 편하게 감싼 Provider나 Riverpod을 사용하거나,

상태 흐름을 더 명확히 분리하기 위해 BLoC,

간결한 개발 경험을 위해 GetX 같은 상태관리 도구를 선택하기도 한다.