저는 Flutter를 통해 앱을 만들 때 Provider 패턴을 적용해서 만들었기 때문에 다른 디자인 패턴인 Redux 패턴에 대해 글을 써보려 합니다.
Redux 패턴은 Flutter 앱의 상태 관리를 위한 패턴 중 하나입니다. 원래 Redux의 경우 초기에는 React에서 상태 관리를 위해 만들어졌습니다. 그렇지만 이후 다른 프레임워크 및 라이브러리에서도 사용할 수 있도록 일반화가 되었습니다. 저는 Vue.js를 통해 Vuex라는 상태 관리 패턴을 사용해 봤었는데 Redux를 공부하다 보니 많은 부분이 비슷하다고 생각했습니다. 제가 사용하던 Provider 패턴의 경우 소규모 앱에서 적합하고 Redux 패턴은 대규모 앱에 적합하다고 나와있습니다.
Redux 개념
- 액션 (Action) : 애플리케이션에서 상태 변경을 나타내는 객체입니다. 상태를 변경하기 위한 명령이며, 리듀서에 의해 처리됩니다.
- 리듀서 (Reducer) : 이전 상태와 액션을 받아서 새로운 상태를 반환하는 함수입니다. Redux에서 상태를 업데이트하는 로직은 모두 리듀서에서 구현됩니다. 순수 함수로 작성되어야 하며, 이전 상태를 변경하지 않고 새로운 상태를 반환해야 합니다.
- 미들웨어 (Middleware) : 액션이 디스패치되어서 리듀서에 도달하기 전에 실행되는 코드입니다. 미들웨어는 액션의 처리를 가로채고, 필요한 로직을 수행한 뒤에 액션을 계속 진행하거나 중단시킬 수 있습니다. 주로 API 통신을 진행할 때 이 미들웨어에서 진행합니다.
- 스토어 (Store) : 애플리케이션의 상태를 저장하는 객체입니다. 스토어는 애플리케이션의 상태를 단일 불변 객체로 저장하며(애플리케이션 하나 당 하나의 Store만을 생성), 액션을 디스패치하고 리듀서를 호출하여 상태를 업데이트합니다.
위 개념들은 Redux 패턴에서 상태 관리를 위한 요소로 사용됩니다.
Redux 패턴은 Provider 패턴과 같이 단방향 데이터 흐름을 따라 애플리케이션의 상태를 관리하고 업데이트합니다.
아래는 위 개념에 따라 상태 관리를 하는 흐름입니다.
Redux 3원칙
1. Single Source of Truth
애플리케이션의 상태를 하나의 스토어에 저장합니다. 애플리케이션의 전체 상태는 단일 스토어에 집중되어 있어야 하며, 모든 컴포넌트는 이 스토어를 참조하여 상태를 읽고 변경해야 합니다.
2. State is Read-Only
Redux의 상태는 불변(immutable) 해야 합니다. 상태를 변경하려면 새로는 상태 객체를 생성하여 기존 상태를 변경하는 대신에 이를 대체해야 합니다. 이를 통해 상태의 변경이 예측 가능하고 추적 가능하게 되며, 애플리케이션의 상태 변화를 더 쉽게 이해할 수 있습니다. 상태 교체는 오직 액션(Action)에서만 가능합니다. 액션에서 받은 요청은 Reducer가 처리하여 상태를 변경해 줍니다.
3. Changes are Made with Pure Functions
Redux에서 상태 변화는 리듀서 함수를 통해서 이루어집니다. 리듀서는 이전 상태와 액션을 받아 새로운 상태를 반환하는 순수 함수여야 합니다. 이는 side effect가 없고 이전 상태나 액션 외의 외부 상태에 의존하지 않는 함수여야 한다는 것입니다. 이전 상태를 변경하는 것이 아닌 새로운 상태 객체를 생성하여 반환해야 합니다.
Flutter Redux 패턴 예제
1. 패키지 추가
Flutter에서 Redux를 사용하기 위해서는 위 3가지 패키지를 추가해야 합니다.
redux
액션(Action) 이벤트를 사용하여 AppState를 관리하고 업데이트할 수 있게 해주는 코어 라이브러리입니다.
flutter_redux
Flutter 애플리케이션에서 Redux 상태 관리 패턴을 구현하기 위한 도구이며, Redux 패턴을 Flutter와 통합하고 Redux 스토어의 상태를 Flutter 위젯 트리에 연결하여 상태 변화를 자동으로 반영할 수 있게 해 줍니다.
redux_thunk
async operation을 쉽게 처리할 수 있게 해주는 패키지, 즉 비동기 작업을 처리하기 위한 패키지입니다. redux_thunk를 사용하면 Redux 액션 생성자가 일반적인 객체 대신 함수를 반환할 수 있게 해 줍니다. 이 함수는 비동기 작업을 수행한 후 액션을 디스패치 할 수 있습니다.
2. 디렉토리 구조 및 코드
간단한 Todo 앱으로 테스트해보기 위해 디렉토리 구조는 이렇게 잡았습니다.
../actions/todo_actions.dart
class TodoAction {
final Todo todo;
final ActionType type;
TodoAction(this.todo, this.type);
}
enum ActionType {
add,
toggle,
remove,
}
// Action Creators
void dispatchTodoActionCreator(Store<AppState> store, Todo todo, ActionType type) {
store.dispatch(TodoAction(todo, type));
}
../models/app_state.dart
class AppState {
final List<Todo> todos;
AppState({required this.todos});
factory AppState.initialState() {
return AppState(todos: []);
}
}
../models/todo.dart
class Todo {
final String title;
final bool isCompleted;
Todo({
required this.title,
required this.isCompleted,
});
Todo copyWith({
String? title,
bool? isCompleted,
}) {
return Todo(
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
);
}
}
../reducers/todo_reducer.dart
AppState appReducer(AppState state, dynamic action) {
if (action is TodoAction) {
switch (action.type) {
case ActionType.add:
return AppState(todos: [...state.todos, action.todo]);
case ActionType.toggle:
return AppState(
todos: state.todos.map((todo) {
if (todo.title == action.todo.title) {
return todo.copyWith(isCompleted: !todo.isCompleted);
}
return todo;
}).toList(),
);
case ActionType.remove:
return AppState(
todos: state.todos.where((todo) => todo.title != action.todo.title).toList(),
);
default:
return state;
}
}
return state;
}
../screens/todo_screen.dart
class TodoScreen extends StatelessWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('To-Do List'),
),
body: StoreConnector<AppState, List<Todo>>(
converter: (store) => store.state.todos,
builder: (context, todos) {
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.isCompleted,
onChanged: (value) {
dispatchTodoActionCreator(
StoreProvider.of(context), todo, ActionType.toggle);
},
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {
dispatchTodoActionCreator(
StoreProvider.of(context), todo, ActionType.remove);
},
),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Add Todo'),
content: TextField(
autofocus: true,
decoration: InputDecoration(labelText: 'Todo'),
onSubmitted: (value) {
final newTodo = Todo(title: value, isCompleted: false);
dispatchTodoActionCreator(
StoreProvider.of(context), newTodo, ActionType.add);
Navigator.pop(context);
},
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text('Cancel'),
),
],
);
},
);
},
child: Icon(Icons.add),
),
);
}
}
../store/store.dart
class StoreContainer extends StatelessWidget {
final Store<AppState> store;
final Widget child;
const StoreContainer({super.key, required this.store, required this.child});
@override
Widget build(BuildContext context) {
return StoreProvider(
store: store,
child: child,
);
}
}
Store<AppState> createStore() {
return Store<AppState>(
appReducer,
initialState: AppState.initialState(),
middleware: [thunkMiddleware],
);
}
main.dart
void main() {
final store = createStore(); // createStore() 함수를 호출하여 Redux store 생성
runApp(MyApp(store: store)); // 생성된 Redux store를 MyApp 위젯에 주입
}
class MyApp extends StatelessWidget {
final Store<AppState> store;
const MyApp({super.key, required this.store});
@override
Widget build(BuildContext context) {
return StoreProvider(
store: store,
child: MaterialApp(
title: 'To-Do App',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: TodoScreen(),
),
);
}
}
위 앱의 간단한 흐름은
1. 초기 상태 설정
앱이 시작될 때 main() 함수에서 createStore() 함수를 호출하여 Redux Store를 생성합니다. 이 때 createStore() 함수 내부에서는 초기 상태를 설정합니다. AppState.initialState()를 호출하여 초기 상태를 생성하고 이를 Redux store의 초기 상태로 설정합니다.
2. 액션 디스패치 및 리듀서 실행
사용자가 앱에서 새로운 항목을 추가하거나 등의 동작을 수행하면 해당 동작에 대한 액션이 디스패치됩니다. 액션은 store.dispatch() 메서드를 통해 디스패치됩니다. 디스패치된 액션은 Reducer 함수로 전달되어 현재 상태와 함께 실행됩니다.
3. 리듀서 함수 실행
appReducer 함수 내부에서는 디스패치된 액션에 따라 상태를 변경하는 로직이 실행됩니다. 새로운 항목을 추가하는 액션이라면 todosReducer 함수가 호출되어 현재의 Todo 목록에 새 항목을 추가하는 등의 로직이 실행됩니다.
4. 새로운 상태 반환
리듀서 함수가 실행된 결과로 새로운 상태가 반환됩니다.
5. UI 업데이트
새로운 상태가 반환되면 flutter_redux 패키지의 StoreConnector를 사용하여 UI가 새로운 상태를 반영하도록 업데이트됩니다.
간단하게 Todo앱을 통해 Flutter에서 Redux를 사용하는 방법을 진행해 봤습니다. 이건 너무 간단한 앱이라 미들웨어를 선언만 하고 사용하진 않았는데 실제로 앱을 만든다면 기존 데이터를 가져오는 등 API를 호출해야 하는 경우가 무조건 있을 텐데 이때 사용하는 게 미들웨어입니다. 미들웨어를 선언하고 아래와 같이 액션 크리에이터를 기존의 todo_actions.dart 파일 안에 만들어서 사용하면 됩니다.
// 초기 데이터를 가져오는 비동기 작업
ThunkAction<AppState> fetchDataAction() {
return (Store<AppState> store) async {
// 비동기 작업 수행
try {
// 비동기 작업을 통해 데이터를 가져옴
final data = await fetchDataFromAPI();
// 데이터를 가져온 후에 액션을 디스패치하여 스토어에 저장
store.dispatch(FetchDataSuccessAction(data));
} catch (error) {
// 오류 발생 시에는 에러 액션을 디스패치하여 처리
store.dispatch(FetchDataFailureAction(error));
}
};
}
// 네트워크를 통해 데이터를 가져오는 비동기 함수
Future<String> fetchDataFromAPI() {
//데이터를 가져오는 비동기 작업을 수행
return Future.delayed(Duration(seconds: 2), () => 'Data from API');
}
제가 기존에 사용하던 Provider 패턴에 비해 조금 더 복잡하긴 합니다만 Redux 패턴을 사용하여 앱을 만들어 본다면 조금 더 익숙해질 것 같습니다. 앞으로 사이드 프로젝트로 Redux 패턴을 사용한 앱을 하나 만들어봐야 할 것 같습니다.
'App > Flutter' 카테고리의 다른 글
Flutter 테스트 유형 및 방법 (0) | 2024.04.04 |
---|---|
Flutter Animation 만들기 (with Provider) (0) | 2024.04.02 |
Flutter로 만든 앱 aab 파일 추출하여 Google Play Store 업로드하기 (0) | 2024.03.16 |
Flutter Color 설정 방법 (0) | 2021.10.02 |
Flutter BuildContext에 대해 알아보기 (1) | 2021.07.10 |
댓글