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()) 이렇게 박스에게 전달함.

어떻게 작동할까?

  1. 버튼을 누름 → dispatch로 액션 보냄

  2. Store가 "이 액션이 오면 어떻게 상태를 바꿔야 하지?" 하고 Reducer에 전달

  3. Reducer가 새 상태(AppState)를 만듦

  4. Store가 이 새 상태를 보관, UI는 자동으로 최신 상태를 다시 그림

쉽게 예를 들어

text[UI 버튼] → [dispatch(Action)] → [Store가 Reducer 호출해서 상태 갱신] → [UI가 새로 그림]

이런 구조라서 얻게 되는 장점

  • 상태 추적과 로그가 쉬움 (지금 상태가 왜 이렇게 됐는지 추적 가능)

  • 테스트가 쉽고, 변경이 예측 가능함

  • 모든 상태 변화가 액션을 통해 한 곳으로 모여 관리! (버그 찾기가 수월)


이렇게 UI는 오로지 dispatch만 담당하고, 상태 변화의 로직은 store+reducer가 책임집니다. 즉, "내가 직접 상태를 바꿔!"가 아니라 "이런 변화 요청(액션)이 있어 → store가 알아서 변경 후 결과 알려줘!" => 명확한 역할 분리!

3. ViewModelfromStore의 역할

ViewModel이란?

  • **UI(Widget)**와 store 상태를 연결해주는 중간 "데이터 전달자" 역할.

  • Flutter에서는 보통 Store의 상태와 액션(콜백)을 UI 위젯에서 바로 직접 다루는 대신, ViewModel로 “일회용 데이터 바구니”를 만들어 깔끔하게 연결.

  • UI 위에는 “오로지 UI에 필요한 값과 함수”(counter, username, increment 등)만 담김.

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의 역할

  • Redux 패턴에서, UI 위젯이 store와 연결되는 곳.

  • StoreConnector의 구조는 이렇게 해석할 수 있음:

    1. store가 바뀔 때마다, StoreConnector가 다시 빌드됨

    2. StoreConnector가 내부적으로 store.state를 구독(감시)함 → 상태 변하면 UI에 자동 반영

    3. builder:에 넘겨주는 값은 항상 converter에서 가공한 값(ViewModel)


3. 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으로 의존성 주입 고려 
    );
  }
}

요약 흐름

  1. 버튼 클릭 → fetchUsername 콜백 → thunk fetchUsernameThunk가 실행

  2. thunk에서 UserRepository().fetchUsername() 호출

  3. 성공/실패에 따라 Success/Failure 액션 dispatch

  4. 리듀서에서 상태를 바꿈 → 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