반응형

안녕하세요, 성조입니다.
요즘 주력 외주 개발로 사용하고 있는 플러터에서 상태 관리 방법에 대해 스터디를 통해 한 번 정리했는데 해당 내용들을 오랜만에 블로그 포스팅으로 다뤄보려 해요!
사실 공식 문서 보고 학습하면서 정리했던 내용이지만, 참조 자료로 보면 좋을 것 같아서 올려보며, 화면이 깨질 수 있으므로 pdf 파일로 정리해 둔 내용은 최하단에 업로드 해놓겠습니다!
🎯 1. Bloc 상태 관리란?
Bloc은 Business Logic Component의 줄임말로, Flutter 앱에서 UI와 비즈니스 로직을 명확하게 분리하여 관리하는 상태 관리 패턴이다.
들어가기 전 기본 숙지되어야 하는 개념
- 동기 (Synchronous)
- 작업이 호출된 순서대로 순차적으로 실행되며, 이전 작업이 완료될 때까지 다음 작업이 블로킹(blocking)되어 대기하는 방식이다.
- 비동기 (Asynchronous)
- 작업을 요청한 뒤 결과가 준비될 때까지 기다리지 않고 즉시 다음 코드를 실행하며, 완료 시 콜백(callback), Promise/Future, async/await 등의 메커니즘으로 결과를 처리한다.
- 스트림 (Stream)
- 비동기 이벤트의 연속된 시퀀스를 표현하며, 데이터, 에러, 완료 이벤트를 순차적으로 전달해 listen()이나 await for 구문으로 각 이벤트를 처리할 수 있는 구조이다. BLoC 학습자는 해당 개념을 숙지하는 것을 공식 홈페이지에서 권장하고 있다.
1-2. BLoC 정의
- BLoC (Business Logic Component)
- UI와 비즈니스 로직을 분리해 관리하는 디자인 패턴이다.
- Input과 Output이 모두 Dart의 **Stream(시간 경과에 따라 데이터를 순차적으로 처리하기 위해 사용되는 통로라는 중요한 개념)**와 Sinks(Sink는 스트림의 입력(input) 측을 담당하는 객체로, 데이터(이벤트)를 스트림으로 흘려보내는 역할)로 이루어지며, 의존성은 주입 가능해야 하고, 플랫폼 분기 로직이 없어야 한다.
- UI 가이드라인
- “충분히 복잡한” 각 컴포넌트마다 대응하는 BLoC를 만든다.
- 위젯은 입력을 “있는 그대로(as-is)” BLoC에 전달하고, 출력도 “거의 그대로(as-is)” UI에 반영하는 것을 목적으로 한다.
1-3. BLoC가 사용되는 곳
- Feature Layer
- 각 기능(로그인, 투두 리스트, 무한 스크롤, 유저 이벤트 단위 등)의 비즈니스 로직을 BLoC에서 관리한다.
- Presentation/UI Layer
- BlocBuilder, BlocListener 같은 위젯이 BLoC의 상태 스트림을 구독해 UI를 갱신하거나 사이드 이펙트를 수행한다.
- 앱 전역 상태 관리
- MultiBlocProvider를 이용해 여러 BLoC를 한꺼번에 주입하고, 페이지 간 데이터 흐름을 제어 할 수 있다.
1-4. Bloc 상태 관리의 3가지 특징
- Unidirectional Data Flow (단방향 데이터 흐름 구조를 갖는다.)
- 사용자의 입력(Event)이 Bloc으로 전달되고, Bloc은 로직을 처리한 후 새로운 상태(State)를 방출하기 때문에 View는 이 상태를 기반으로 UI(뷰)를 업데이트해서 뷰 영역과 비즈니스 영역이 명확하게 구분되기에 관리하기 쉽다.
- Separation of Concerns (역할 분리를 명확히 한다.)
- 1번 단방향 데이터 흐름을 기반으로 UI와 비즈니스 로직이 철저히 분리되는 패턴을 가져가는 특징으로 인해 View는 단순히 상태를 구독하고 보여주는 역할만, Bloc은 이벤트 처리와 상태 전환을 담당하여 유지보수 및 테스트 코드의 효율이 높아진다.
- Event-Driven Architecture (이벤트 기반 구조)
- 모든 상태 변화는 이벤트(Event)로부터 시작되며, 개발자는 Bloc에 이벤트를 add() 하여 상태 전환을 유도하며, 다수의 플러터 개발자가 협업 할 때 입력은 있는 그대로(as-is), 출력도 거의 그대로(as-is)를 지향하기 때문에 해당 패턴으로 코드 베이스를 통합하기 좋다.
🧩 1-5. Bloc 구조와 작동 방식

- 정의 → Bloc 패턴은 이벤트(event), 상태(state), Bloc이라는 3가지 요소로 구성되며, 다음 테이블처럼 정리할 수 있다.요소 설명 예시
Event 사용자의 행동이나 시스템의 변화를 전달 버튼 클릭, 데이터 로딩 State 화면에서 표현될 데이터 로딩 중, 로딩 완료 상태 Bloc 이벤트를 처리해 새로운 상태로 변환하는 곳 이벤트 → 상태 변경 관리
작동 방식은 다음과 같다.
- 이벤트 큐(Event Queue)
- bloc.add(SomeEvent()) 호출 시 Bloc 내부 큐에 이벤트가 쌓인다.
- 이벤트 핸들러
- on<SomeEvent>((event, emit) { … }) 또는 mapEventToState 안에서 로직을 처리하고 emit(NewState(...))로 새 상태를 방출한다.
- 상태 스트림(State Stream)
- 방출된 상태는 Stream<State>로 구독되며, BlocBuilder 등 위젯이 이를 받아 UI를 업데이트한다.
🔖 1-6. Cubit이란?

- 정의 → Cubit은 BlocBase를 상속한 간단한 상태 관리 클래스이다. 즉, BLoC의 매운 맛을 순하게 만들어서 순하게 제공하는 것이 Cubit이다. 또한, 이벤트 클래스 없이 메서드 호출로 상태를 변경한다는 점이 특징이다.구분 Bloc Cubit
복잡성 상대적으로 복잡 간단한 로직 처리 이벤트 관리 명시적인 이벤트 클래스 사용 함수 호출로 이벤트 처리 적합한 상황 명확한 이벤트-상태 관계 필요 빠르고 간단한 로직
- 구조와 작동 방식
- 초기 상태 지정
// Cubit을 생성할 때 첫 번째 미션. 상속 받아서 타입을 지정한다. (제너릭 타입)
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0); // 생성자의 초기 상태를 0으로 설정
}
- Cubit을 생성할 때, Cubit이 관리할 상태의 타입을 정의해야 한다. → 제너릭 타입(int 등)을 의미한다.
- CounterCubit의 경우 state 타입은 int로 표현할 수 있지만, 더 복잡해 지는 경우 Primitive type 대신 class단위를 상속받아 사용 할 수 있다.
1-2. 초기 상태 지정
// Cubit을 생성할 때 두 번째 미션. 초기 상태를 지정하는 것
class CounterCubit extends Cubit<int> {
CounterCubit(int initialState) : super(initialState);
}
제너릭 타입을 지정하고, 생성자로 초기 상태를 초기화 해주는 것이 Cubit 사용 방법의 핵심이다.
2. 인스턴스 생성 방법
final cubitA = CounterCubit(0); // 초기 값 0
final cubitB = CounterCubit(10); // 초기 값 10
- 초기 상태를 지정한 다음 다양한 초기 상태를 가진 값(인스턴스)들을 가져올 수 있게 된다. (하나의 보일러 플레이트가 된다.)
3. 상태 정리
class CounterCubit extends Cubit<int> { // 상속 받아서 초기 상태를 지정한다.
CounterCubit() : super(0);
void increment() => emit(state + 1); // 더하기 상태 변경
}
- 각 Cubit을 다루는 메서드 호출의 경우 emit(...)이 내부 스트림에서 즉시 새로운 State를 방출하며, 기존 state는 getter으로 즉시 값 반영된다.
- 현재 상태는 cubit.state로, 상태 변화를 지켜보려면 cubit.stream을 구독한다.
4. Stream 사용법 (3번의 조금 더 심화 예제)
// 공식 홈 예제
Future<void> main() async {
final cubit = CounterCubit(); // 큐빗을 구독한다. 즉, listen을 등록한다.
final subscription = cubit.stream.listen(print); // 1
cubit.increment();
await Future.delayed(Duration.zero);
await subscription.cancel();
await cubit.close();
}
// 각 state 변경 시마다 print를 호출하고 있는 구조이다.
// 구독을 의미하는 listen은 이벤트를 받아보기 위한 단위이다.
- 현업 사용 사례
- 간단한 카운터, 토글, 폼 유효성 검사 등 단일 책임 로직(싱글톤을 적용해야 될 때 사용하기 좋다.)
- 보일러플레이트를 최소화하면서 빠른 구현이 필요할 때
- 주의할 점
- Cubit은 BLoc와 다르게 이벤트 클래스를 별도 정의하고 사용하지 않으므로, 이벤트에 대한 우선순위 관리나 동시성 제어 로직은 직접 구현해야 한다.
- 복잡한 이벤트-상태 관계 관리가 필요할 경우에는, 명시적 이벤트 클래스를 지원하는 상위 클래스 Bloc을 사용하는 것이 좋다.
2. BLoC 기본 사용 예시
2.1 이벤트(Event) 정의
// counter_event.dart 추상화 클래스로 정의하는 단위이다.
abstract class CounterEvent {}
class CounterIncremented extends CounterEvent {} // 증가 정의
class CounterDecremented extends CounterEvent {} // 감소 정의
2.2 상태(State) 정의
// counter_state.dart 실제 클래스에서 사용되는 단위이다.
class CounterState {
final int count;
CounterState(this.count);
}
2.3 BLoC 클래스 구현 예시
// counter_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart'; // Bloc 라이브러리 가져오기
import 'counter_event.dart'; // 정의된 이벤트 클래스 가져오기
import 'counter_state.dart'; // 정의된 상태 클래스 가져오기
/// - CounterEvent 이벤트를 받아 CounterState 상태로 변환하는 BLoC 클래스
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc(): super(CounterState(0)) { // 생성자로 값(0) 초기화
on<CounterIncremented>((event, emit) { // emit(): 새로운 상태를 스트림에 방출하며,
// state.count는 현재 상태의 count 값.
emit(CounterState(state.count + 1)); // +1 한 값으로 새로운 CounterState 생성
});
on<CounterDecremented>((event, emit) { // 감소 이벤트로 위와 동일한 로직
emit(CounterState(state.count - 1));
});
}
}
- class CounterBloc: Bloc<CounterEvent, CounterState>를 상속하여, CounterEvent를 입력받아 CounterState를 출력.
- super(CounterState(0)): Bloc의 초기 상태를 count = 0으로 설정 함.
- on<Event>: 특정 이벤트가 add()될 때 실행될 콜백을 등록함.
- 콜백 내부에서 emit(...)을 호출해 새로운 상태를 방출하면, 이를 구독하고 있는 UI가 자동으로 업데이트 함.
2.4.1 on<E>() 등록
- 제네릭 타입 E 에 해당하는 이벤트가 들어올 때마다 콜백이 실행.
- 콜백 시그니처: (E event, Emitter<State> emit) → void
- 예시
- on<LoadItemsEvent>((event, emit) { // 비즈니스 로직 수행… emit(ItemsLoaded(items)); });
2.4.2 emit() 으로 상태 방출
- emit(newState) 호출 시, Bloc 내부의 상태 스트림에 새로운 상태가 추가.
- 이전 상태는 그대로 유지되며, UI는 변경된 부분만 리빌드.
2.4.3 비동기 매핑
- async 핸들러 안에서 await를 사용해 API 호출 등 비동기 로직 수행 가능
- 상태 전환 예
- on<FetchUserEvent>((event, emit) async { emit(UserLoading()); try { final user = await userRepository.getUser(event.userId); emit(UserLoadSuccess(user)); } catch (e) { emit(UserLoadFailure(e.toString())); } });
2.4.4 (구버전) mapEventToState()
- Bloc 6.x 이전에는 mapEventToState를 오버라이드해 이벤트를 처리했으나, 현재는 on<E>() API가 권장.
2.5 불변성(Immutable State)
2.5.1 State 클래스 설계
- 모든 필드를 final로 선언하고 const 생성자를 사용
- 변경 시 복사본을 만들어야 예측 가능한 상태 관리가 가능.
- class CounterState { final int count; const CounterState(this.count); }
2.5.2 copyWith 패턴
- 일부 필드만 변경할 때 유용
- class AuthState { final bool loading; final User? user; final String? error; const AuthState({this.loading = false, this.user, this.error}); AuthState copyWith({bool? loading, User? user, String? error}) { return AuthState( loading: loading ?? this.loading, user: user ?? this.user, error: error ?? this.error, ); } }
2.5.3 Equatable 으로 효율적 비교
- ==, hashCode 자동 구현으로, 동일 내용일 땐 UI 리빌드를 방지
- class CounterState extends Equatable { final int count; const CounterState(this.count); @override List<Object> get props => [count]; }
2.6.1 메서드 정리(Extension Methods)
- context.read →context.read<T>()는 T타입에 가장 가까운 상위 인스턴스를 조회하며 기능적으로 BlocProvider.of<T>(context)와 동일하다
- context.watch →context.read<T>()와 마찬가지로, context.watch<T>()는 T타입에 가장 가까운 상위 인스턴스를 조회하며 인스턴스의 변경 사항도 구독(listen)한다.
- context.select→ context.watch<T>()와 마찬가지로, context.select<T, R>(R function(T value))는 T타입에 가장 가까운 상위 인스턴스를 조회하며 인스턴스의 변경 사항도 listen한다.
2.6.2 비교 context.read() vs context.watch()
- read<T>(): 상태 구독 없이, 이벤트 추가나 메서드 호출만
- watch<T>(): 상태가 변경될 때마다 빌드 메서드 다시 호출
3. Widget에서(UI) BLoC 사용하는 방법
- BlocProvider로 BLoC 주입 (단일 DI로 사용 됨)
- // main.dart void main() { runApp( BlocProvider( create: (_) => CounterBloc(), child: MyApp(), ), ); } // 역할: 단일 Bloc 인스턴스를 하위 위젯 트리에 주입(DI)
- MultiBlocProvider로 여러 상태 한 번에 묶어서 주입 (즉, 복수 타입)
- MultiBlocProvider( providers: [ BlocProvider(create: (_) => AuthBloc()), BlocProvider(create: (_) => ThemeBloc()), ], child: MyApp(), );
- RepositoryProvider - 비즈니스 로직에서 사용할 Repository(혹은 서비스) 인스턴스를 주입
- RepositoryProvider( create: (_) => UserRepository(), child: MyApp(), );
- MultiRepositoryProvider - 여러 RepositoryProvider를 한 번에 묶어 주입상황 사용 방식
단일 repository RepositoryProvider 여러 개의 repository MultiRepositoryProvider 테스트 가능성 확보 생성자 주입 (DI) 형태로 작성 자원 해제 필요 dispose: 콜백 지정 가능 (예: API 연결 해제) - MultiRepositoryProvider( providers: [ RepositoryProvider(create: (_) => AuthRepository()), RepositoryProvider(create: (_) => AnalyticsRepository()), ], child: App(), );
- BlocBuilder - Bloc의 상태(State)를 구독(listen)해 UI를 빌드하는 방법
- BlocBuilder<CounterBloc, CounterState>( builder: (context, state) => Text('${state.count}'), ); // buildWhen으로 재빌드 조건 제어 가능
- BlocSelector - Bloc 상태에서 특정 값(slice) 만 선택해 UI를 빌드
- BlocSelector<SettingsBloc, SettingsState, bool>( selector: (state) => state.isDarkMode, builder: (context, isDark) => Switch(value: isDark, onChanged: ...), ); // 불필요한 리빌드를 더 효과적으로 방지
- BlocListener - 상태 변경에 따른 사이드 이펙트 처리 (Snackbar, Navigation 등)요소 설명
BlocListener 상태 변화 감지 전용 위젯, UI는 리빌드하지 않음 AuthBloc 인증 관련 Bloc 클래스 AuthState 인증 상태를 나타내는 클래스 (예: 성공, 실패 등) AuthFailure 로그인 실패나 에러 등의 상태 listener 상태가 바뀔 때 한 번 실행할 사이드 이펙트 로직 등록 child 항상 렌더링되는 UI (LoginForm) – 상태 변화로 인해 UI가 다시 그려지지 않음 - BlocListener<AuthBloc, AuthState>( // AuthBloc의 상태(AuthState)를 감지하여 반응하는 BlocListener. listener: (context, state) { // 상태가 AuthFailure인 경우 (로그인 실패, 인증 에러 등) if (state is AuthFailure) { // 사이드 이펙트 발생: 스낵바를 통해 에러 메시지 출력 ScaffoldMessenger.of(context).showSnackBar(...); } }, child: LoginForm(), ); // UI는 리빌드하지 않음 -> 오직 리스닝만 수행하는 위젯
- MultiBlocListener - 여러 BlocListener를 한 번에 묶어 사용
- MultiBlocListener( listeners: [ // A_Bloc 상태에 따른 사이드 이펙트 처리 (예: Snackbar, Dialog, Navigation 등) BlocListener<A, AState>(listener: ...), // BBloc의 상태가 바뀔 때 실행할 로직을 정의. BlocListener<B, BState>(listener: ...), ], child: HomePage(), );
- BlocConsumer - Builder와 Listener를 한 번에 사용
- BlocConsumer<TodoBloc, TodoState>( listener: (ctx, state) { /* 사이드 이펙트 */ }, builder: (ctx, state) { /* UI */ }, );
BlocConsumer<TodoBloc, TodoState>(
listener: (ctx, state) { /* 사이드 이펙트 */ },
builder: (ctx, state) { /* UI */ },
);
4. 리소스 관리: Bloc 생명주기 & close()
4.1 생성자와 핸들러 등록
- 초기 상태는 super(initialState) 에서 설정.
- on<E>() 호출 시 내부적으로 StreamController<Event>에 핸들러가 등록.
- 이 시점에 모든 이벤트 → 상태 파이프라인이 준비.
class MyBloc extends Bloc<MyEvent, MyState> {
MyBloc(): super(MyInitial()) {
// 이벤트 핸들러 등록
on<MyEvent>((event, emit) {
// business logic...
emit(MyState(...));
});
}
}
4.2 close() 오버라이드
- Bloc이 더 이상 사용되지 않을 때 반드시 close()를 호출해 내부 스트림을 닫아야 메모리 누수를 방지.
- BlocProvider로 생성된 Bloc은 위젯 트리가 dispose될 때 자동으로 close() 됨.
- 수동으로 생성한 Bloc이나 테스트 환경에서는 직접 호출해야 한다.
@override
Future<void> close() {
// 커스텀 정리 로직 (필요 시)
return super.close();
}
4.3 테스트에서의 정리
- 테스트마다 Bloc을 생성한 뒤, tearDown()에서 bloc.close()를 호출.
late CounterBloc bloc;
setUp(() {
bloc = CounterBloc();
});
tearDown(() {
bloc.close();
});
5. 디버깅 & 테스팅
5.1 BlocObserver로 전역 감시
- 앱 전역에서 발생하는 모든 이벤트, 상태 전환, 에러를 로깅할 수 있다.
class AppBlocObserver extends BlocObserver {
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
print('[Event] ${bloc.runtimeType} → $event');
}
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
print('[Trans] ${bloc.runtimeType} → $transition');
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
super.onError(bloc, error, stackTrace);
print('[Error] ${bloc.runtimeType} → $error');
}
}
void main() {
Bloc.observer = AppBlocObserver();
runApp(MyApp());
}
5.2 Flutter DevTools & 플러그인
- Flutter DevTools에 Bloc 플러그인을 활성화하면 실시간으로 이벤트·상태 흐름을 시각화할 수 있다.
- VSCode/IntelliJ에서도 bloc 관련 확장(extension)을 설치하면 코드 생성, 파이프라인 추적이 가능.
5.3 Unit Test: bloc_test 패키지
- bloc_test를 사용하면 이벤트에 대한 기대 상태 시퀀스를 손쉽게 검증할 수 있다.
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('CounterBloc', () {
blocTest<CounterBloc, CounterState>(
'Increment 이벤트 처리 시 카운트가 +1 된다',
build: () => CounterBloc(),
act: (bloc) => bloc.add(CounterIncremented()),
expect: () => [CounterState(1)],
);
blocTest<CounterBloc, CounterState>(
'Decrement 이벤트 처리 시 카운트가 –1 된다',
build: () => CounterBloc(),
seed: () => CounterState(5),
act: (bloc) => bloc.add(CounterDecremented()),
expect: () => [CounterState(4)],
);
});
}
5.4 Widget Test: Bloc과 함께 위젯 검증
- pumpWidget으로 BlocProvider를 감싼 위젯을 렌더링 후, 상태 변화에 따른 UI 변화를 확인.
testWidgets('CounterPage 에서 버튼 클릭 시 텍스트 업데이트', (tester) async {
await tester.pumpWidget(
BlocProvider(
create: (_) => CounterBloc(),
child: MaterialApp(home: CounterPage()),
),
);
expect(find.text('Count: 0'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // 상태 변화 반영
expect(find.text('Count: 1'), findsOneWidget);
});
해당 내용을 보다 편하게 확인하시려면, 아래 PDF 파일로 정리된 문서를 참고해 주세요!
Flutter_Bloc_상태_관리._기초부터_심화까지_deep_dive_스터디_자료.pdf
0.87MB
감사합니다, 다음 포스트에서 또 뵙겠습니다.
반응형
'Flutter' 카테고리의 다른 글
| [Flutter] SingleChildScrollView 정리하기 (0) | 2026.01.01 |
|---|---|
| [Flutter] AppLifecycleState 이론 한 스푼 (7) | 2025.08.04 |
| [Flutter] StatelessWidget과 StatefulWidget의 차이점 정리 (0) | 2023.08.19 |
| [Flutter] 플러터 핫 리로드(Hot Reload)와 핫 리스타트(Hot Restart) 정리 (0) | 2023.08.16 |
| [Flutter] Dart 언어 시작하기 (0) | 2023.08.01 |