Flutter 상태관리 Provider면 충분하다 - React Context API와 비교
4년 동안 어림잡아 50개가 넘는 웹사이트와 앱을 개발하면서 많은 상태관리 라이브러리를 사용했습니다.
웹은 Context api, Redux, Zustand, Flutter은 Provider, GetX, MobX, Bloc, Riverpod, 안드로이드는 MVVM과 LiveData 등등.
2년 전에는 자체적으로 antenna라는 상태관리 라이브러리를 개발해서 사용하기도 했습니다. 지금 보니까 Eventbus 패턴과 가깝고 Zustand와 닮아있네요. Zustand는 간소화된 Redux라고 느껴지기도 하지만요.
그러다가 결국 요즘은 Provider만 사용하고 있습니다. React 버전으로는 RxJs와 Context API를 함께 사용합니다. 그 이유는 간결함과 간결함에서 나오는 확장성 때문입니다.
대부분 상태관리 라이브러리는 그다지 간결하지 않습니다. 문법이 간결하지 않다는 말이 아닙니다. 상태관리 라이브러리를 사용하기 시작하면 모든 코드를 라이브러리에 맞게끔 짜야 하는 경우가 많습니다. 심지어 쉬운 코드도 어렵게 써야 할때가 많죠. 더 좋은 방법이 있는 경우조차 라이브러리에서 벗어나기가 어려워집니다.
예를 들어 Redux를 사용한다면 비동기 함수를 처리하기 위해 redux-thunk까지 써야합니다. GetX를 사용한다면 MaterialApp
이 아니라 GetMaterialApp
으로 앱을 감싸야 하죠.
상태관리를 구현하기 위해서 단순히 Redux나 GetX 같은 라이브러리가 있다는 사실을 찾아보고 문서에서 알려주는 대로 코드를 작성하는 것도 방법입니다.
하지만 상태관리의 목적을 이해하고 필요한 기능만 사용하는 건 더 좋은 방법이라고 생각합니다.
상태관리를 왜 해야 할까요?
첫째로, 관심사를 분리해서 더 읽기 쉬운 코드를 만들기 위해서입니다.
이건 굳이 상태관리가 아니어도 모든 코드에 적용되는 말입니다.
둘째로, UI 전환에도 일관된 데이터를 유지하기 위해서입니다.
특히 앱 같은 경우에는 Form Submit이 여러 페이지에 걸쳐서 진행되는 경우도 많고, 모던 웹에서도 마찬가지입니다. 이런 경우에는 UI에 묶여 있는 데이터들 - 예컨대 input 태그의 value를 읽어들이는 방법으로는 데이터 처리에 한계가 있습니다.
여기에서 상태관리를 위해서는 UI 라이프사이클보다 더 긴 라이프사이클로 상태를 담아두는 객체가 필요하다는 첫번째 조건이 나옵니다.
Flutter Provider에서는 이런 식으로 구현할 수 있습니다.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ExampleViewmodel {
String text = 'Hello World';
}
class ExamplePage extends StatelessWidget {
const ExamplePage({super.key});
@override
Widget build(BuildContext context) {
return Provider(
create: (context) => ExampleViewmodel(),
child: Consumer<ExampleViewmodel>(
builder: (context, viewmodel, child) {
return Scaffold(body: Text(viewmodel.text));
},
),
);
}
}
여기서 ExampleViewmodel
이 아무것도 상속하지 않는다는 사실이 좋습니다. 물론 이후에 UI를 갱신하기 위해서는 ChangeNotifier
를 상속해야 하지만, 그것 없이도 Provider가 작동한다는 사실에 주목하세요.
Provider는 단순히 객체를 하위 위젯 라이프사이클보다 긴 라이프사이클에 저장하기 위해 사용합니다. Consumer는 클래스 이름을 참조해서 해당 Provider 하위 위젯이라면 어디에서든지 저장한 객체를 가져올 수 있게끔 합니다.
Context API도 정확히 같은 역할을 합니다. 하위 컴포넌트 라이프사이클보다 더 긴 라이프사이클에 상태를 저장하고, 하위 컴포넌트에서 언제든 쉽게 참조해서 상태를 가져올 수 있게 하는 역할입니다.
Context API가 Prop Drilling을 방지하기 위해 만들어졌듯, 상태관리를 목적으로 만들어진 기능은 아닙니다. 하지만 상태관리에 유용하게 사용할 수 있음은 분명합니다.
UI 렌더링 사이클
여기서 한 가지 더 일반적으로 개발자들은 상태가 바뀌었을 때 UI가 바뀌는 동작을 원합니다.
예를 들어서, 로그인 화면에서 아이디 비밀번호가 다 채워졌을 때 로그인 버튼이 활성화되는 동작 같은 것들입니다.
이때 Flutter Provider에서는 다음과 같이 구현할 수 있습니다.
import 'package:provider/provider.dart';
import 'package:flutter/material.dart';
class ExampleViewmodel extends ChangeNotifier {
String text = 'Hello World';
void updateText(String newText) {
text = newText;
notifyListeners();
}
}
class ExamplePage extends StatelessWidget {
const ExamplePage({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => ExampleViewmodel(),
child: Scaffold(
body: Column(
children: [
TextField(
onChanged: (value) {
context.read<ExampleViewmodel>().updateText(value);
},
),
Consumer<ExampleViewmodel>(
builder: (context, viewmodel, child) {
return Text(viewmodel.text);
},
),
],
),
),
);
}
}
ExampleViewmodel
에 ChangeNotifier
를 상속시키고 ChangeNotifierProvider
를 사용해 객체를 생성 및 저장했습니다.
텍스트필드를 조작하면 onChanged
콜백이 updateText()
함수를 호출하고, 이어서 notifiyListeners()
함수로 UI에 변화를 알립니다.
여기서 Provider가 정말 마음에 드는 점은 바로 상태가 변화할 때 Consumer
를 사용해 상태를 구독한 하위 위젯만 다시 렌더링 된다는 점입니다. 이점은 Context API보다 더 낫습니다. Context API는 useState를 통해 상태를 변화시키면 모든 하위 위젯도 다시 렌더링됩니다.
import { createContext, useState } from "react";
const MyContext = createContext();
export default function MyProvider({ children }) {
const [state, setState] = usestate(0);
return (
<MyContext.Provider value={state}>
{children}
</MyContext.Provider>
)
}
이렇게 원치 않는 광범위한 상태 변화는 예상하지 못한 동작을 낳기 때문에 저는 Context API에서 곧바로 useState를 사용하지 않습니다.
RxJs를 쓰는 이유가 여기에 있습니다. 상태를 변화하는 컴포넌트만 다시 렌더링하기 위해서입니다.
import { createContext, useContext, useEffect, useState } from "react";
import { BehaviorSubject } from "rxjs";
const MyContext = createContext();
export function useCounter() {
const subject = useContext(MyContext);
const [state, setState] = useState(subject.getValue());
useEffect(() => {
const sub = subject.subscribe((v) => setState(v));
return () => {
sub.unsubscribe();
};
}, []);
return state;
}
export default function MyProvider({ children }) {
const subject = useMemo(() => new BehaviorSubject(0), []);
return <MyContext.Provider value={subject}>{children}</MyContext.Provider>;
}
export function MyComponent() {
const state = useCounter();
return <p>{state}</p>
}
이렇게 하면 MyProvider
의 모든 children
을 다시 렌더링하지 않은 채로 MyComponent
만 다시 렌더링할 수 있습니다.
저는 이 방법이 MobX나 LiveData 같은 방법보다 낫다고 생각합니다. 상태 객체에서 특정한 속성 자체를 구독 가능한 대상, 다시 말해 Observable로 만드는 방법은 확장성이 떨어집니다. 특히 두 속성을 동시에 구독하거나 두 속성을 참조해 새로운 값을 만들어내야 할 때 그렇습니다.
상태관리를 위해 필요한 두 가지 조건
다시 정리해 보면 상태관리를 위해 우리가 필요로 하는 두 가지 조건은 이렇습니다.
1. 상태 객체를 UI보다 더 긴 라이프사이클을 갖는 위치에 저장할 수 있어야 합니다.
2. 상태 객체를 갱신할 때 상태를 구독한 UI만 국소적으로 리렌더링할 수 있어야 합니다.
이 두 가지 조건은 모두 Provider만으로 구현할 수 있습니다. ChangeNotifier + Provider + Consumer/Selector만으로 웬만한 요구는 다 충족시킵니다. React라면 Context API와 RxJS가 필요합니다.
과한 추상화보다 간결함이 유지보수성과 확장성을 보장합니다. 필요한 순간에만 도구를 더해서 씁니다. 오늘은 이만 마치겠습니다.