LODY/정리

Flutter / 상태관리와 Riverpod

상태관리와 Riverpod

🟣 setState가 감당 못 하는 순간

앞 장의 setState는 위젯 하나가 자기 상태를 갱신하는 데는 완벽했다. 문제는 상태가 한 위젯에만 머물지 않을 때 생긴다. 로그인한 사용자 정보나 장바구니처럼, 화면 여기저기에서 같은 값을 읽고 고쳐야 하는 상태가 있다.

이런 값을 setState로 다루려면 상태를 공통 조상 위젯까지 끌어올린 뒤, 그 값을 필요한 자식에게 생성자로 계속 내려보내야 한다. 위젯 트리가 깊으면 중간에서 값을 쓰지도 않는 위젯들이 그저 아래로 전달하기 위해 생성자에 그 값을 달고 있어야 한다. 상태 하나 추가하는 데 트리 절반의 생성자를 고치는 상황이 온다. iOS에서 뷰컨트롤러 사이로 의존성을 손으로 넘겨주다 지쳤던 경험과 똑같은 고통이었다.

이 문제를 어떻게 푸느냐가 Flutter 상태관리의 전부다. 해법은 한 번에 나온 게 아니라 단계적으로 발전했고, 그 계보를 따라가면 왜 지금 Riverpod을 쓰는지가 보인다.


🟣 InheritedWidget에서 Provider로

Flutter가 기본으로 제공하는 첫 답은 InheritedWidget이다. 트리 위쪽에 값을 얹어 두면, 그 아래 어느 위젯이든 생성자로 전달받지 않고도 그 값을 꺼내 쓸 수 있다. 중간 위젯들이 값을 릴레이할 필요가 사라진다. 필요한 위젯만 값을 구독하고, 그 값이 바뀌면 구독한 위젯만 다시 빌드된다.

발상은 좋지만 InheritedWidget을 직접 쓰는 건 번거로웠다. 상용구가 많고, 여러 상태를 얹으려면 코드가 금세 지저분해졌다. 그래서 이걸 감싸 쓰기 편하게 만든 Provider 패키지가 사실상 표준이 됐다. 내부는 여전히 InheritedWidget이지만, 상태를 등록하고 꺼내는 과정을 짧게 줄였다.

Provider도 한계가 있었다. 상태를 트리의 특정 위치에 얹기 때문에 그 위젯이 트리에 없으면 접근할 수 없고, 타입만으로 값을 찾다 보니 같은 타입을 여럿 두기 어려웠다. 무엇보다 값을 잘못 꺼내면 컴파일 때가 아니라 실행 중에야 오류가 났다. 이 약점들을 처음부터 다시 설계한 것이 Riverpod이다.


🟣 Riverpod: 트리 밖으로 꺼낸 상태

Riverpod의 핵심 결정은 상태를 위젯 트리에 얹지 않고 트리 바깥의 전역 공간에 provider로 선언하는 것이다. 상태가 트리 위치에 묶이지 않으니, 어느 위젯에서든 같은 방식으로 접근한다. provider는 그냥 최상위에 정의하는 값이고, 위젯은 ref라는 손잡이로 그 값을 읽거나 구독한다.

final counterProvider = StateProvider<int>((ref) => 0);
 
class CounterView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Column(
      children: [
        Text("$count"),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).state++,
          child: Text("+1"),
        ),
      ],
    );
  }
}

ref.watch는 값을 구독한다. 이 값이 바뀌면 해당 위젯만 다시 빌드된다. ref.read는 구독 없이 한 번만 읽을 때, 주로 버튼 콜백에서 상태를 갱신할 때 쓴다. 이 둘의 구분이 성능의 핵심이다. 화면에 보여줄 값은 watch로 구독하고, 이벤트 순간에만 필요한 접근은 read로 처리해 불필요한 재빌드를 막는다.

counterProvider(트리 밖 상태)ref.watch(ConsumerWidget)바뀐 값으로위젯 재빌드ref.read(.notifier)상태 갱신값 구독변경 감지버튼 탭새 상태 반영

이 그림은 Combine과 ObservableObject를 알던 눈에는 익숙했다. ObservableObject가 값을 방출하고 뷰가 @ObservedObject로 구독해 갱신되던 흐름이, 여기서는 provider가 값을 들고 ConsumerWidgetref.watch로 구독하는 흐름으로 나타난다. 상태를 발행하는 원천과 그걸 구독해 반응하는 뷰를 분리한다는 점에서 두 생태계의 결론이 겹쳤다.

Riverpod이 Provider보다 나은 지점도 분명했다. 상태가 트리 위치에 묶이지 않아 위젯 밖에서도 접근할 수 있고, provider가 컴파일 시점에 타입으로 검증돼 실행 중에야 터지던 오류가 사라진다. 같은 문제를 푸는 여러 대안 중 무엇을, 왜 골랐는지를 이렇게 설명할 수 있는 게 중요했다.


🟣 어떤 상태관리를 고를 것인가

Flutter 생태계에는 상태관리 선택지가 여럿이다. 가벼운 순서로 보면 setState, Provider, Riverpod, 그리고 이벤트와 상태를 엄격히 나누는 Bloc이 있다. 정답이 하나인 문제가 아니라 규모에 맞춰 고르는 문제였다.

작은 위젯 안에 갇힌 상태라면 setState로 충분하다. 굳이 외부 패키지를 끌어올 이유가 없다. 여러 화면이 공유하는 상태가 생기고 테스트 가능성과 타입 안정성이 중요해지면 Riverpod이 무게값을 한다. 팀 규모가 크고 상태 변화의 흐름을 엄격한 규칙으로 강제하고 싶으면 Bloc이 맞는다. 규칙이 많은 만큼 상용구도 많아서, 개인 학습 프로젝트에는 과하다고 느꼈다.

이 선택 감각은 iOS에서 상태관리를 고르던 고민과 다르지 않았다. 작은 화면은 @State로 두고, 공유 상태는 ObservableObject로 올리고, 더 엄격한 단방향 흐름이 필요하면 별도 아키텍처를 얹던 판단이 이름만 바뀌어 반복됐다. 도구가 달라도 상태를 어디에 두고 누가 구독하게 할지라는 질문은 플랫폼을 가리지 않고 같았다.


🟣 정리

  • 상태가 여러 위젯에 걸치면 setState로는 값을 생성자로 계속 내려보내야 해서 트리가 지저분해진다. 상태관리는 이 전달 문제를 푼다.
  • InheritedWidget이 트리에 값을 얹어 릴레이를 없앴고, 그걸 쓰기 편하게 감싼 게 Provider다.
  • Riverpod은 상태를 트리 밖 전역 provider로 꺼내 위치 의존을 없애고, 컴파일 시점 타입 검증으로 실행 중 오류를 줄였다.
  • ref.watch는 구독해 재빌드하고 ref.read는 한 번만 읽는다. 이 구분이 불필요한 재빌드를 막는 성능의 핵심이다.
  • 상태관리는 정답이 아니라 규모에 맞춘 선택이다. setState, Riverpod, Bloc 사이의 트레이드오프를 규모로 판단한다.

Riverpod의 구독 모델이 Combine과 겹친다는 점은, 두 생태계를 오가며 같은 개념을 재사용할 수 있다는 신호였다. 여기까지가 Flutter 안에서 상태와 화면을 다루는 이야기였다. 다음 장에서는 Flutter가 스스로 감당하지 못하는 영역, 즉 블루투스 같은 네이티브 기능을 만났을 때 프레임워크의 경계 너머와 어떻게 손을 잡는지를 실제 예제로 다룬다.