Bloc 패턴 — Event, State, Cubit으로 상태 분리하기
Bloc 패턴 — Event, State, Cubit으로 상태 분리하기
Bloc(Business Logic Component)은 UI와 비즈니스 로직을 완전히 분리하는 패턴입니다. 엔터프라이즈급 앱에서 많이 사용되고, 면접에서도 자주 물어봅니다.
Bloc의 핵심 개념
┌──────────┐ Event ┌──────────┐ State ┌──────────┐
│ UI │ ───────────▶ │ Bloc │ ───────────▶ │ UI │
│ │ │ │ │ (리빌드) │
└──────────┘ └──────────┘ └──────────┘
- Event: UI에서 발생하는 사용자 액션 (버튼 클릭, 입력 등)
- Bloc: 이벤트를 받아 비즈니스 로직을 처리하고 새 State를 방출
- State: UI가 렌더링할 데이터
설치
dependencies:
flutter_bloc: ^8.1.0
equatable: ^2.0.0 # 상태 비교를 위한 패키지
Cubit — 간단한 Bloc
Cubit은 Event 없이 메서드를 직접 호출하는 간소화된 Bloc입니다.
// 상태 정의
class CounterState extends Equatable {
final int count;
const CounterState({this.count = 0});
@override
List<Object?> get props => [count];
CounterState copyWith({int? count}) {
return CounterState(count: count ?? this.count);
}
}
// Cubit 정의
class CounterCubit extends Cubit<CounterState> {
CounterCubit() : super(const CounterState());
void increment() => emit(state.copyWith(count: state.count + 1));
void decrement() => emit(state.copyWith(count: state.count - 1));
void reset() => emit(const CounterState());
}
// UI에서 사용
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterCubit(),
child: const CounterView(),
);
}
}
class CounterView extends StatelessWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: BlocBuilder<CounterCubit, CounterState>(
builder: (context, state) {
return Text(
'${state.count}',
style: const TextStyle(fontSize: 48),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: const Icon(Icons.add),
),
);
}
}
Bloc — 이벤트 기반
복잡한 로직이 필요할 때는 Event를 사용하는 풀 Bloc을 사용합니다.
// 이벤트 정의
sealed class TodoEvent extends Equatable {
const TodoEvent();
@override
List<Object?> get props => [];
}
class TodoAdded extends TodoEvent {
final String title;
const TodoAdded(this.title);
@override
List<Object?> get props => [title];
}
class TodoToggled extends TodoEvent {
final int index;
const TodoToggled(this.index);
@override
List<Object?> get props => [index];
}
class TodoDeleted extends TodoEvent {
final int index;
const TodoDeleted(this.index);
@override
List<Object?> get props => [index];
}
// 상태 정의
sealed class TodoState extends Equatable {
const TodoState();
@override
List<Object?> get props => [];
}
class TodoInitial extends TodoState {}
class TodoLoaded extends TodoState {
final List<Todo> todos;
const TodoLoaded(this.todos);
@override
List<Object?> get props => [todos];
}
class TodoError extends TodoState {
final String message;
const TodoError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc 정의
class TodoBloc extends Bloc<TodoEvent, TodoState> {
TodoBloc() : super(TodoInitial()) {
on<TodoAdded>(_onTodoAdded);
on<TodoToggled>(_onTodoToggled);
on<TodoDeleted>(_onTodoDeleted);
}
void _onTodoAdded(TodoAdded event, Emitter<TodoState> emit) {
final currentState = state;
if (currentState is TodoLoaded) {
final newTodos = [...currentState.todos, Todo(title: event.title)];
emit(TodoLoaded(newTodos));
} else {
emit(TodoLoaded([Todo(title: event.title)]));
}
}
void _onTodoToggled(TodoToggled event, Emitter<TodoState> emit) {
if (state is TodoLoaded) {
final todos = [...(state as TodoLoaded).todos];
todos[event.index] = todos[event.index].copyWith(
isDone: !todos[event.index].isDone,
);
emit(TodoLoaded(todos));
}
}
void _onTodoDeleted(TodoDeleted event, Emitter<TodoState> emit) {
if (state is TodoLoaded) {
final todos = [...(state as TodoLoaded).todos]
..removeAt(event.index);
emit(TodoLoaded(todos));
}
}
}
UI에서 Bloc 사용
// BlocProvider로 Bloc 주입
BlocProvider(
create: (_) => TodoBloc(),
child: const TodoScreen(),
)
// BlocBuilder: 상태에 따라 UI 빌드
BlocBuilder<TodoBloc, TodoState>(
builder: (context, state) {
return switch (state) {
TodoInitial() => const Text('할 일을 추가하세요'),
TodoLoaded(:final todos) => ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => ListTile(
title: Text(todos[index].title),
),
),
TodoError(:final message) => Text('에러: $message'),
};
},
)
// 이벤트 발송
context.read<TodoBloc>().add(const TodoAdded('새 할 일'));
BlocListener — 사이드 이펙트 처리
BlocListener<TodoBloc, TodoState>(
listener: (context, state) {
if (state is TodoError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: const TodoView(),
)
// BlocConsumer = BlocBuilder + BlocListener
BlocConsumer<TodoBloc, TodoState>(
listener: (context, state) {
// 사이드 이펙트
},
builder: (context, state) {
// UI 빌드
return const SizedBox();
},
)
Cubit vs Bloc 선택 기준
| 기준 | Cubit | Bloc |
|---|---|---|
| 복잡도 | 낮음 | 높음 |
| 이벤트 추적 | 불가 | 가능 |
| 디버깅 | 단순 | 이벤트 로그로 상세 |
| 보일러플레이트 | 적음 | 많음 |
| 적합한 상황 | CRUD, 단순 상태 | 복잡한 비즈니스 로직 |
면접 포인트: Cubit은 함수를 직접 호출하고, Bloc은 이벤트를 발송합니다. Bloc은 이벤트 로그를 통해 상태 변화를 추적할 수 있어 디버깅에 유리합니다.
BlocObserver — 전역 디버깅
class AppBlocObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
print('${bloc.runtimeType} $change');
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
print('${bloc.runtimeType} $error');
super.onError(bloc, error, stackTrace);
}
}
// main에서 등록
void main() {
Bloc.observer = AppBlocObserver();
runApp(const MyApp());
}
정리
- Bloc은 Event → Bloc → State 흐름으로 UI와 로직을 완전히 분리합니다
- Cubit은 Event 없이 메서드를 직접 호출하는 간소화 버전입니다
BlocBuilder로 상태에 따른 UI를,BlocListener로 사이드 이펙트를 처리합니다Equatable로 상태 비교를 쉽게 구현할 수 있습니다- sealed class + 패턴 매칭으로 상태 처리를 타입 안전하게 할 수 있습니다
- 단순한 상태에는 Cubit, 복잡한 비즈니스 로직에는 Bloc을 선택하세요
댓글 로딩 중...