Flutter Redux 코드 분석
1. 데이터 흐름 및 구조
Redux
UI → Action → Reducer → 새로운 State → UI
모든 변경은 Action(명령 객체)을 통해 발생
Reducer 함수가 불변 상태(State)를 만듦
Store에서 모든 상태를 관리 (전역 Singleton)
BLoC
UI → Event → Bloc → Stream<State> → UI
Event를 sink에 넣으면 Bloc이 처리, State를 stream으로 방출
로직(Bloc)과 상태 표시(UI) 분리
여러 BLoC 인스턴스를 만들어 모듈화 용이
3. 폴더 구조 예시 및 파일 역할
Redux 폴더 구조 예시
text|- lib/
|- redux/
|- actions/
|- counter_actions.dart
|- reducers/
|- counter_reducer.dart
|- models/
|- app_state.dart
|- middleware/
|- logging_middleware.dart
|- store/
|- store.dart
actions: 액션 객체와 타입 정의 (상태 변화 트리거)
reducers: 상태 변경 함수
models: 상태 구조 정의 (State 클래스)
middleware: 사이드이펙트, 비동기 로직
store: 단일 Store 객체 생성 및 초기화
1. models/app_state.dart
class AppState {
final int counter;
final String username;
AppState({required this.counter, required this.username});
AppState copyWith({int? counter, String? username}) {
return AppState(
counter: counter ?? this.counter,
username: username ?? this.username,
);
}
factory AppState.initial() => AppState(counter: 0, username: '');
}
2. actions/counter_actions.dart
class IncrementCounter {}
class DecrementCounter {}
3. actions/user_actions.dart
class SetUsername {
final String username;
SetUsername(this.username);
}
4. reducers/counter_reducer.dart
import 'package:redux/redux.dart';
import '../actions/counter_actions.dart';
final counterReducer = combineReducers<int>([
TypedReducer<int, IncrementCounter>((state, action) => state + 1),
TypedReducer<int, DecrementCounter>((state, action) => state - 1),
]);
5. reducers/user_reducer.dart
import '../actions/user_actions.dart';
String userReducer(String state, dynamic action) {
if (action is SetUsername) {
return action.username;
}
return state;
}
6. reducers/app_reducer.dart
import '../models/app_state.dart';
import 'counter_reducer.dart';
import 'user_reducer.dart';
AppState appReducer(AppState state, dynamic action) {
return AppState(
counter: counterReducer(state.counter, action),
username: userReducer(state.username, action),
);
}
7. store/store.dart
import 'package:redux/redux.dart';
import '../models/app_state.dart';
import '../reducers/app_reducer.dart';
final Store<AppState> store = Store<AppState>(
appReducer,
initialState: AppState.initial(),
);
구조 요약
models/app_state.dart : 전체 상태 설계
actions/: 상태를 바꿀 때 사용할 명령 정의
reducers/: 상태 변경 함수(리듀서) 정의
도메인별 reducer, 이를 합치는 appReducer
store/: Store 객체 생성, 앱의 중앙 상태 저장소
사용 예시 (dispatch)
store.dispatch(IncrementCounter()); // 카운터 +1
store.dispatch(SetUsername('FlutterDev')); // username 변경
1. UI 코드 예시 (Counter와 Username 변경)
아래는 Flutter에서 Redux 구조를 사용할 때의 UI 코드 예시입니다. StoreProvider, StoreConnector를 활용해 상태 변경과 구독, 그리고 dispatch를 보여줍니다.
import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';
import 'models/app_state.dart';
import 'store/store.dart';
import 'actions/counter_actions.dart';
import 'actions/user_actions.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StoreProvider<AppState>(
store: store,
child: MaterialApp(
home: CounterScreen(),
),
);
}
}
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StoreConnector<AppState, _ViewModel>(
converter: (Store<AppState> store) => _ViewModel.fromStore(store),
builder: (context, vm) {
return Scaffold(
appBar: AppBar(title: Text('Redux Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Counter: ${vm.counter}', style: TextStyle(fontSize: 24)),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: vm.increment,
child: Text('+'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: vm.decrement,
child: Text('-'),
),
],
),
SizedBox(height: 40),
Text('Username: ${vm.username}', style: TextStyle(fontSize: 20)),
SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0),
child: TextField(
onChanged: vm.setUsername,
decoration: InputDecoration(labelText: 'Enter username'),
),
),
],
),
),
);
},
);
}
}
class _ViewModel {
final int counter;
final String username;
final VoidCallback increment;
final VoidCallback decrement;
final Function(String) setUsername;
_ViewModel({
required this.counter,
required this.username,
required this.increment,
required this.decrement,
required this.setUsername,
});
factory _ViewModel.fromStore(Store<AppState> store) {
return _ViewModel(
counter: store.state.counter,
username: store.state.username,
increment: () => store.dispatch(IncrementCounter()),
decrement: () => store.dispatch(DecrementCounter()),
setUsername: (username) => store.dispatch(SetUsername(username)),
);
}
}
2. store와 dispatch 구조 쉽게 설명
Store란?
우리 앱의 상태가 저장되어 있는 "큰 박스" 역할.
박스 안에는
counter
,username
등 앱에서 중요한 값들이 전부 들어있음.
dispatch란?
"뭔가 바꿔주세요!"라고 store에 **명령(Action)**을 보내는 것.
예를 들어, + 버튼을 누르면
IncrementCounter()
라는 액션을 만들고,store.dispatch(IncrementCounter())
이렇게 박스에게 전달함.
어떻게 작동할까?
버튼을 누름 → dispatch로 액션 보냄
Store가 "이 액션이 오면 어떻게 상태를 바꿔야 하지?" 하고 Reducer에 전달
Reducer가 새 상태(AppState)를 만듦
Store가 이 새 상태를 보관, UI는 자동으로 최신 상태를 다시 그림
쉽게 예를 들어
text[UI 버튼] → [dispatch(Action)] → [Store가 Reducer 호출해서 상태 갱신] → [UI가 새로 그림]
이런 구조라서 얻게 되는 장점
상태 추적과 로그가 쉬움 (지금 상태가 왜 이렇게 됐는지 추적 가능)
테스트가 쉽고, 변경이 예측 가능함
모든 상태 변화가 액션을 통해 한 곳으로 모여 관리! (버그 찾기가 수월)
이렇게 UI는 오로지 dispatch만 담당하고, 상태 변화의 로직은 store+reducer가 책임집니다. 즉, "내가 직접 상태를 바꿔!"가 아니라 "이런 변화 요청(액션)이 있어 → store가 알아서 변경 후 결과 알려줘!" => 명확한 역할 분리!
3. ViewModel
과 fromStore
의 역할
ViewModel
과 fromStore
의 역할ViewModel이란?
**UI(Widget)**와 store 상태를 연결해주는 중간 "데이터 전달자" 역할.
Flutter에서는 보통 Store의 상태와 액션(콜백)을 UI 위젯에서 바로 직접 다루는 대신, ViewModel로 “일회용 데이터 바구니”를 만들어 깔끔하게 연결.
UI 위에는 “오로지 UI에 필요한 값과 함수”(counter, username, increment 등)만 담김.
fromStore
factory란?
fromStore
factory란?Store에서 옵저버블 상태(state)와 액션(변경 함수)을 뽑아 ViewModel로 변환하는 생성자(factory) 메서드.
즉, Store의 값을
counter
,username
처럼 UI에서 바로 사용할 수 있게, 그리고 액션(버튼 누르면 dispatch되는 함수)도 같이 ViewModel에 포함.덕분에 Widget은 store의 실제 타입, dispatch 방법 등은 몰라도 되고, 오로지 ViewModel만 보고 UI에 집중할 수 있음.
ViewModel은 꼭 필요한가?
반드시 필요한 건 아님!
하지만, ViewModel을 쓰면 이런 장점이 생김:
UI와 상태 관리가 명확히 분리 (코드 읽기, 테스트, 확장에 매우 유리)
UI 재사용/Mocking/Test가 쉬워짐
UI 코드가 깔끔해짐 (복잡한 store/state/액션 처리 logic은 ViewModel 쪽에서 다 처리)
아주 간단한 앱(상테, 액션이 1~2개)은 생략 가능. 하지만 실전/규모 있는 프로젝트에서는 거의 항상 ViewModel 패턴을 사용함.
2. StoreConnector
의 역할
StoreConnector
의 역할Redux 패턴에서, UI 위젯이 store와 연결되는 곳.
StoreConnector의 구조는 이렇게 해석할 수 있음:
store가 바뀔 때마다, StoreConnector가 다시 빌드됨
StoreConnector가 내부적으로 store.state를 구독(감시)함 → 상태 변하면 UI에 자동 반영
builder:에 넘겨주는 값은 항상 converter에서 가공한 값(ViewModel)
3. converter
란? 왜 필요한가?
converter
란? 왜 필요한가?converter는
(Store<AppState> store) => T
타입의 함수.즉, Store(AppState 전체 객체)를 받아서, 우리가 builder에서 실제로 쓸 값(T = ViewModel 등)으로 변환해줌.
converter의 목적:
Store 구조가 복잡해도, UI가 오로지 필요한 정보(예: counter 값, username, onPressed 콜백 등)만 가질 수 있게 중간 가공.
성능 최적화 (안쓰는 값까지 rebuild하지 않게 원하는 정보만 넘김)
UI는 Store 구조를 몰라도 됨 (OCP/캡슐화, 테스팅 장점)
예시로 구조 다시 해석
StoreConnector<AppState, _ViewModel>(
converter: (store) => _ViewModel.fromStore(store),
builder: (context, vm) { ... });
converter: store → ViewModel로 변환
builder: 변환된 ViewModel(vm)을 받아 실제 위젯 빌드
요약
ViewModel
UI가 쓸 데이터·액션만 뽑아주는 전달자(분리, 깔끔함, 테스트 강화)
fromStore
Store → ViewModel 변환, store 구조 노출 없이 UI가 쓸 값 전달
StoreConnector
store 구독(연결) 위젯, 상태 변화 시 UI 자동 새로고침
converter
store 전체 ➔ UI 필요한 정보만 추출하는 함수 (ViewModel 생성 담당)
비동기 처리는 어떻게 해야할까?
1. Repository 역할 및 폴더 구조
Repository는 외부 데이터 소스(API, DB, 로컬 등)와 앱 비즈니스 로직을 분리
Store/State는 내부 데이터만 다룸, Repository는 데이터 “가져오기/저장하기”만 책임
폴더 구조 예시
textlib/
|- repository/
|- user_repository.dart
|- actions/
|- user_actions.dart
|- reducers/
|- user_reducer.dart
|- app_reducer.dart
|- models/
|- app_state.dart
|- store/
|- store.dart
|- screens/
|- main_screen.dart
|- blocs/ // 이 패턴에서는 사용 안함 (BLoC 아니라면)
2. Repository 및 API(Mock) 예시
repository/user_repository.dart
class UserRepository {
Future<String> fetchUsername() async {
// 실제 API라면 http.get 등으로 호출
// 여기선 mock으로 2초 후 "MockUser" 반환
await Future.delayed(Duration(seconds: 2));
return "MockUser";
}
}
3. 액션: 비동기 요청을 위한 Action(Thunk)
actions/user_actions.dart
dartimport 'package:redux/redux.dart';
import '../models/app_state.dart';
import '../repository/user_repository.dart';
sealed class UserAction {}
class FetchUsernameRequest extends UserAction {}
class FetchUsernameSuccess extends UserAction {
final String username;
FetchUsernameSuccess(this.username);
}
class FetchUsernameFailure extends UserAction {
final String error;
FetchUsernameFailure(this.error);
}
// thunk 액션 (redux_thunk 패키지 필요)
void fetchUsernameThunk(Store<AppState> store, UserRepository repo) async {
store.dispatch(FetchUsernameRequest());
try {
final username = await repo.fetchUsername();
store.dispatch(FetchUsernameSuccess(username));
} catch (e) {
store.dispatch(FetchUsernameFailure(e.toString()));
}
}
4. 리듀서 예시
reducers/user_reducer.dart
import '../actions/user_actions.dart';
class UserState {
final String username;
final bool loading;
final String? error;
UserState({required this.username, required this.loading, this.error});
factory UserState.initial() => UserState(username: '', loading: false);
UserState copyWith({
String? username,
bool? loading,
String? error,
}) =>
UserState(
username: username ?? this.username,
loading: loading ?? this.loading,
error: error,
);
}
UserState userReducer(UserState state, dynamic action) {
if (action is UserAction) {
switch (action) {
case FetchUsernameRequest():
return state.copyWith(loading: true, error: null);
case FetchUsernameSuccess(:final username):
return state.copyWith(username: username, loading: false);
case FetchUsernameFailure(:final error):
return state.copyWith(loading: false, error: error);
}
}
return state;
}
5. models/app_state.dart 업데이트
import '../reducers/user_reducer.dart';
class AppState {
final int counter;
final UserState user;
AppState({required this.counter, required this.user});
factory AppState.initial() =>
AppState(counter: 0, user: UserState.initial());
AppState copyWith({int? counter, UserState? user}) {
return AppState(
counter: counter ?? this.counter,
user: user ?? this.user,
);
}
}
6. reducers/app_reducer.dart (전체 상태 합치기)
import '../models/app_state.dart';
import 'counter_reducer.dart';
import 'user_reducer.dart';
AppState appReducer(AppState state, dynamic action) {
return AppState(
counter: counterReducer(state.counter, action),
user: userReducer(state.user, action),
);
}
7. store/store.dart (Thunk 미들웨어 추가)
import 'package:redux/redux.dart';
import 'package:redux_thunk/redux_thunk.dart';
import '../models/app_state.dart';
import '../reducers/app_reducer.dart';
final store = Store<AppState>(
appReducer,
initialState: AppState.initial(),
middleware: [thunkMiddleware], // thunk 추가!
);
8. UI에서 호출하는 예시
screens/main_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';
import '../models/app_state.dart';
import '../repository/user_repository.dart';
import '../actions/user_actions.dart';
final userRepository = UserRepository(); // Get it 등 의존성 주입 추후 적용
class MainScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StoreConnector<AppState, _ViewModel>(
converter: (store) => _ViewModel.fromStore(store),
builder: (context, vm) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('User: ${vm.username}'),
vm.loading
? CircularProgressIndicator()
: ElevatedButton(
onPressed: vm.fetchUsername,
child: Text('Fetch Username'),
),
if (vm.error != null)
Text('Error: ${vm.error}', style: TextStyle(color: Colors.red)),
],
),
),
);
},
);
}
}
class _ViewModel {
final String username;
final bool loading;
final String? error;
final VoidCallback fetchUsername;
_ViewModel({
required this.username,
required this.loading,
required this.error,
required this.fetchUsername,
});
factory _ViewModel.fromStore(Store<AppState> store) {
return _ViewModel(
username: store.state.user.username,
loading: store.state.user.loading,
error: store.state.user.error,
fetchUsername: () =>
fetchUsernameThunk(store, userRepository),
// thunk 호출 , 추후 Get It으로 의존성 주입 고려
);
}
}
요약 흐름
버튼 클릭 → fetchUsername 콜백 → thunk fetchUsernameThunk가 실행
thunk에서
UserRepository().fetchUsername()
호출성공/실패에 따라 Success/Failure 액션 dispatch
리듀서에서 상태를 바꿈 → UI에 변경사항 자동 반영
MiddleWare ,Thunk 는 왜 필요할까?
1. 미들웨어(Middleware)가 없다면?
순수 Redux 플로우:
UI → dispatch(Action) → Reducer → Store State 변경
이 때 "Action"
은 반드시 동기적 데이터여야 하고,
dispatch에는 단순 객체만 들어갈 수 있음.
예시 (미들웨어 없이):
// 단순 증가 액션 객체
class IncrementCounter {}
// 사용 예시:
store.dispatch(IncrementCounter()); // 바로 리듀서로 들어감! 비동기 처리가 불가능
이 구조에서는 아래처럼 API 호출, 딜레이, 로그 기록 등을 중간에 넣을 방법이 없음!
2. 미들웨어로 비동기/로깅 등을 삽입
예시: 간단 로그 미들웨어
void loggingMiddleware<State>(
Store<State> store,
dynamic action,
NextDispatcher next,
) {
print('Action dispatched: $action');
next(action); // 꼭 next로 넘겨야 리듀서로 전달됨!
}
등록:
final store = Store<AppState>(
appReducer,
initialState: AppState.initial(),
middleware: [loggingMiddleware],
);
이렇게 하면 모든 액션 dispatch 때마다 로그를 남길 수 있음. (이외에도 인증 토큰 갱신, Crashlytics, 외부 통신 등 다 여기서 처리 가능!)
3. Thunk 미들웨어 상세 예시
Thunk가 없다면?
void fetchData() async {
// 이렇게 비동기 함수 내에서 API 호출하고 결과 받아서 상태 바꾸고 싶음
final data = await fetchFromApi();
// store.dispatch(어떤 액션) → 불가! 함수 dispatch가 안 됨
}
Redux 기본 dispatch에는 오직 “객체”만 들어감(비동기 함수 X)
비동기 액션, API 콜백, await 같은 것을 데이터 흐름에 넣을 수 없음
Thunk 미들웨어가 있으면?
Thunk 미들웨어는 "함수"를 dispatch해도 동작! (dispatch에 진짜 동작하는 함수까지 통과시켜줌)
1) 비동기 액션(함수) 정의
import 'package:redux/redux.dart';
// thunk 액션 타입: Store, callback, thunkMiddleware에서 인식
void fetchUsernameThunk(Store<AppState> store) async {
store.dispatch(FetchUsernameRequest()); //로딩 중 표시
try {
await Future.delayed(Duration(seconds: 2)); // 비동기 mock API
final username = "API_user";
store.dispatch(FetchUsernameSuccess(username));
} catch (e) {
store.dispatch(FetchUsernameFailure(e.toString()));
}
}
2) thunkMiddleware 등록
import 'package:redux_thunk/redux_thunk.dart';
// thunkMiddleware 추가
final store = Store<AppState>(
appReducer,
initialState: AppState.initial(),
middleware: [thunkMiddleware],
);
3) 사용 예시(함수 dispatch 가능!)
store.dispatch(fetchUsernameThunk); // 🚩함수를 바로 dispatch!
thunk 없으면 → 에러
thunk 있으면 → 함수를 받아서 실행해줌, 그 함수 안에서 여러 번 액션 dispatch OK!
Last updated