BLoC 패턴이란 무엇이며 Flutter에서 어떻게 구현되나요?
질문
BLoC 패턴이란 무엇이며 Flutter에서 어떻게 구현되나요?
답변
BLoC(Business Logic Component) 패턴은 프레젠테이션 레이어와 비즈니스 로직을 분리하기 위해 설계된 상태 관리 패턴입니다. Google에서 개발하였으며, 특히 Flutter 애플리케이션에서 널리 사용됩니다.
BLoC 패턴의 핵심 개념
Streams와 Sinks: BLoC 패턴은 Dart의 Stream과 StreamController를 기반으로 합니다. 이벤트는 Sink를 통해 BLoC으로 들어가고, 상태는 Stream을 통해 UI로 나옵니다.
단방향 데이터 흐름:
- 사용자 입력 → 이벤트 → BLoC → 상태 변경 → UI 업데이트
관심사 분리:
- UI 레이어: 상태 표시 및 사용자 입력 처리
- BLoC 레이어: 비즈니스 로직 처리
- 데이터 레이어: 데이터 액세스 및 저장
BLoC 구현 방법
1. 순수 Dart Streams 사용
class CounterBloc {
// 이벤트를 처리하기 위한 StreamController
final _eventController = StreamController<CounterEvent>();
// 이벤트를 받는 Sink
Sink<CounterEvent> get eventSink => _eventController.sink;
// 상태를 출력하기 위한 StreamController
final _stateController = StreamController<int>();
// 상태를 방출하는 Stream
Stream<int> get state => _stateController.stream;
int _counter = 0;
CounterBloc() {
// 이벤트 스트림을 리스닝하고 적절한 액션 실행
_eventController.stream.listen(_mapEventToState);
}
void _mapEventToState(CounterEvent event) {
if (event is IncrementEvent) {
_counter++;
} else if (event is DecrementEvent) {
_counter--;
}
// 새 상태 방출
_stateController.add(_counter);
}
void dispose() {
_eventController.close();
_stateController.close();
}
}
// 이벤트 클래스
abstract class CounterEvent {}
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}
2. flutter_bloc 패키지 사용
flutter_bloc 패키지는 BLoC 패턴을 더 쉽게 구현할 수 있게 도와주는 라이브러리입니다.
// 의존성 추가
// pubspec.yaml
// dependencies:
// flutter_bloc: ^8.1.2
import 'package:flutter_bloc/flutter_bloc.dart';
// 이벤트 정의
abstract class CounterEvent {}
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}
// 상태 정의 (선택적)
// 단순한 int 대신 전용 상태 클래스를 사용할 수 있습니다
class CounterState {
final int count;
CounterState(this.count);
}
// BLoC 구현
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<IncrementEvent>((event, emit) => emit(state + 1));
on<DecrementEvent>((event, emit) => emit(state - 1));
}
}
// 또는 상태 클래스 사용 시:
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(CounterState(0)) {
on<IncrementEvent>((event, emit) => emit(CounterState(state.count + 1)));
on<DecrementEvent>((event, emit) => emit(CounterState(state.count - 1)));
}
}
3. UI에서 BLoC 사용하기
void main() {
runApp(
BlocProvider(
create: (context) => CounterBloc(),
child: MyApp(),
),
);
}
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('BLoC 예제')),
body: Center(
child: BlocBuilder<CounterBloc, int>(
builder: (context, count) {
return Text(
'$count',
style: TextStyle(fontSize: 24),
);
},
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
context.read<CounterBloc>().add(IncrementEvent());
},
),
SizedBox(height: 10),
FloatingActionButton(
child: Icon(Icons.remove),
onPressed: () {
context.read<CounterBloc>().add(DecrementEvent());
},
),
],
),
);
}
}
더 복잡한 BLoC 예제
다음은 API에서 데이터를 로드하는 더 복잡한 BLoC 예제입니다:
// 이벤트
abstract class UserEvent {}
class LoadUserEvent extends UserEvent {
final String userId;
LoadUserEvent(this.userId);
}
class RefreshUserEvent extends UserEvent {}
// 상태
abstract class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
final User user;
UserLoaded(this.user);
}
class UserError extends UserState {
final String message;
UserError(this.message);
}
// BLoC 구현
class UserBloc extends Bloc<UserEvent, UserState> {
final UserRepository repository;
UserBloc({required this.repository}) : super(UserInitial()) {
on<LoadUserEvent>(_onLoadUser);
on<RefreshUserEvent>(_onRefreshUser);
}
Future<void> _onLoadUser(LoadUserEvent event, Emitter<UserState> emit) async {
emit(UserLoading());
try {
final user = await repository.getUser(event.userId);
emit(UserLoaded(user));
} catch (e) {
emit(UserError('사용자 정보를 불러오지 못했습니다: $e'));
}
}
Future<void> _onRefreshUser(RefreshUserEvent event, Emitter<UserState> emit) async {
if (state is UserLoaded) {
final currentUser = (state as UserLoaded).user;
emit(UserLoading());
try {
final user = await repository.getUser(currentUser.id);
emit(UserLoaded(user));
} catch (e) {
emit(UserError('사용자 정보를 새로고침하지 못했습니다: $e'));
}
}
}
}
UI에서 BLoC 위젯 사용하기
flutter_bloc 패키지는 BLoC 패턴과 함께 사용할 수 있는 여러 유용한 위젯을 제공합니다:
1. BlocProvider
BLoC 인스턴스를 위젯 트리에 제공합니다.
BlocProvider(
create: (context) => UserBloc(repository: userRepository),
child: UserScreen(),
)
여러 BLoC을 제공하려면 MultiBlocProvider를 사용합니다:
MultiBlocProvider(
providers: [
BlocProvider<UserBloc>(create: (context) => UserBloc(repository: userRepository)),
BlocProvider<SettingsBloc>(create: (context) => SettingsBloc()),
],
child: MyApp(),
)
2. BlocBuilder
BLoC의 상태 변화에 따라 UI를 다시 빌드합니다.
BlocBuilder<UserBloc, UserState>(
builder: (context, state) {
if (state is UserInitial) {
return Center(child: Text('사용자 ID를 입력하세요'));
} else if (state is UserLoading) {
return Center(child: CircularProgressIndicator());
} else if (state is UserLoaded) {
return UserProfileWidget(user: state.user);
} else if (state is UserError) {
return Center(child: Text(state.message));
}
return Container();
},
)
3. BlocListener
상태 변화에 반응하지만 UI를 다시 빌드하지 않습니다 (스낵바, 다이얼로그, 네비게이션 등에 유용).
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
Navigator.of(context).pushReplacementNamed('/home');
} else if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: LoginForm(),
)
4. BlocConsumer
BlocBuilder와 BlocListener의 기능을 결합합니다.
BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
Navigator.of(context).pushReplacementNamed('/home');
}
},
builder: (context, state) {
if (state is AuthLoading) {
return LoadingIndicator();
}
return LoginForm();
},
)
BLoC 패턴의 장점
코드 분리: 비즈니스 로직과 UI 코드를 명확하게 분리합니다.
테스트 용이성: 비즈니스 로직을 UI와 분리하여 테스트하기 쉽습니다.
재사용성: BLoC은 여러 위젯에서 재사용할 수 있습니다.
확장성: 복잡한 앱에서도 잘 작동하며 확장이 용이합니다.
예측 가능성: 단방향 데이터 흐름은 앱 상태를 예측 가능하게 합니다.
BLoC 패턴의 단점
학습 곡선: 초기 학습 곡선이 다소 가파를 수 있습니다.
Boilerplate 코드: 단순한 기능에도 많은 코드가 필요할 수 있습니다.
복잡성: 작은 앱에는 과도한 패턴일 수 있습니다.
BLoC 패턴과 다른 상태 관리 솔루션 비교
특성 | BLoC | Provider | Redux | GetX |
---|---|---|---|---|
복잡성 | 높음 | 중간 | 높음 | 낮음 |
보일러플레이트 | 중간 | 적음 | 많음 | 적음 |
확장성 | 매우 좋음 | 좋음 | 매우 좋음 | 좋음 |
학습 곡선 | 가파름 | 평탄함 | 가파름 | 평탄함 |
테스트 용이성 | 매우 좋음 | 좋음 | 매우 좋음 | 좋음 |
대규모 앱 적합성 | 매우 좋음 | 좋음 | 매우 좋음 | 좋음 |
BLoC 패턴을 사용하기 적합한 경우
복잡한 비즈니스 로직: 앱에 복잡한 비즈니스 로직이 포함된 경우
대규모 팀: 여러 개발자가 작업하는 대규모 프로젝트
상태 관리 규모: 여러 화면에서 공유되는 복잡한 상태가 많은 경우
테스트 중요도: 철저한 테스트가 필요한 경우
비동기 작업이 많은 경우: API 호출, 데이터베이스 작업 등 비동기 작업이 많은 앱
BLoC 패턴을 사용한 전체 예제 앱
다음은 투두 리스트 앱에서 BLoC 패턴을 사용하는 예입니다:
// 모델
class Todo {
final String id;
final String task;
final bool complete;
Todo({
required this.id,
required this.task,
this.complete = false,
});
Todo copyWith({String? id, String? task, bool? complete}) {
return Todo(
id: id ?? this.id,
task: task ?? this.task,
complete: complete ?? this.complete,
);
}
}
// 이벤트
abstract class TodoEvent {}
class LoadTodosEvent extends TodoEvent {}
class AddTodoEvent extends TodoEvent {
final String task;
AddTodoEvent(this.task);
}
class ToggleTodoEvent extends TodoEvent {
final String id;
ToggleTodoEvent(this.id);
}
class DeleteTodoEvent extends TodoEvent {
final String id;
DeleteTodoEvent(this.id);
}
// 상태
abstract class TodoState {}
class TodoInitial extends TodoState {}
class TodoLoading extends TodoState {}
class TodoLoaded extends TodoState {
final List<Todo> todos;
TodoLoaded(this.todos);
}
class TodoError extends TodoState {
final String message;
TodoError(this.message);
}
// 저장소 (Repository)
class TodoRepository {
// 실제로는 API나 데이터베이스에 연결
List<Todo> _todos = [];
Future<List<Todo>> getTodos() async {
// 데이터를 불러오는 시간을 시뮬레이션
await Future.delayed(Duration(milliseconds: 500));
return _todos;
}
Future<void> addTodo(Todo todo) async {
await Future.delayed(Duration(milliseconds: 300));
_todos.add(todo);
}
Future<void> deleteTodo(String id) async {
await Future.delayed(Duration(milliseconds: 300));
_todos.removeWhere((todo) => todo.id == id);
}
Future<void> updateTodo(Todo todo) async {
await Future.delayed(Duration(milliseconds: 300));
final index = _todos.indexWhere((t) => t.id == todo.id);
if (index >= 0) {
_todos[index] = todo;
}
}
}
// BLoC
class TodoBloc extends Bloc<TodoEvent, TodoState> {
final TodoRepository repository;
TodoBloc({required this.repository}) : super(TodoInitial()) {
on<LoadTodosEvent>(_onLoadTodos);
on<AddTodoEvent>(_onAddTodo);
on<ToggleTodoEvent>(_onToggleTodo);
on<DeleteTodoEvent>(_onDeleteTodo);
}
Future<void> _onLoadTodos(LoadTodosEvent event, Emitter<TodoState> emit) async {
emit(TodoLoading());
try {
final todos = await repository.getTodos();
emit(TodoLoaded(todos));
} catch (e) {
emit(TodoError('할 일을 불러오지 못했습니다: $e'));
}
}
Future<void> _onAddTodo(AddTodoEvent event, Emitter<TodoState> emit) async {
if (state is TodoLoaded) {
final currentTodos = (state as TodoLoaded).todos;
emit(TodoLoading());
try {
final newTodo = Todo(
id: DateTime.now().toString(),
task: event.task,
);
await repository.addTodo(newTodo);
final updatedTodos = await repository.getTodos();
emit(TodoLoaded(updatedTodos));
} catch (e) {
emit(TodoError('할 일을 추가하지 못했습니다: $e'));
}
}
}
Future<void> _onToggleTodo(ToggleTodoEvent event, Emitter<TodoState> emit) async {
if (state is TodoLoaded) {
final currentTodos = (state as TodoLoaded).todos;
emit(TodoLoading());
try {
final index = currentTodos.indexWhere((todo) => todo.id == event.id);
if (index >= 0) {
final todo = currentTodos[index];
final updatedTodo = todo.copyWith(complete: !todo.complete);
await repository.updateTodo(updatedTodo);
}
final updatedTodos = await repository.getTodos();
emit(TodoLoaded(updatedTodos));
} catch (e) {
emit(TodoError('할 일 상태를 변경하지 못했습니다: $e'));
}
}
}
Future<void> _onDeleteTodo(DeleteTodoEvent event, Emitter<TodoState> emit) async {
if (state is TodoLoaded) {
emit(TodoLoading());
try {
await repository.deleteTodo(event.id);
final updatedTodos = await repository.getTodos();
emit(TodoLoaded(updatedTodos));
} catch (e) {
emit(TodoError('할 일을 삭제하지 못했습니다: $e'));
}
}
}
}
// UI
void main() {
final todoRepository = TodoRepository();
runApp(
BlocProvider(
create: (context) => TodoBloc(repository: todoRepository)..add(LoadTodosEvent()),
child: TodoApp(),
),
);
}
class TodoApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Todo BLoC App',
theme: ThemeData(primarySwatch: Colors.blue),
home: TodoScreen(),
);
}
}
class TodoScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Todo BLoC App'),
),
body: BlocBuilder<TodoBloc, TodoState>(
builder: (context, state) {
if (state is TodoInitial) {
return Center(child: Text('할 일을 불러오는 중...'));
} else if (state is TodoLoading) {
return Center(child: CircularProgressIndicator());
} else if (state is TodoLoaded) {
return ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
final todo = state.todos[index];
return ListTile(
title: Text(
todo.task,
style: TextStyle(
decoration: todo.complete ? TextDecoration.lineThrough : null,
),
),
leading: Checkbox(
value: todo.complete,
onChanged: (_) {
context.read<TodoBloc>().add(ToggleTodoEvent(todo.id));
},
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {
context.read<TodoBloc>().add(DeleteTodoEvent(todo.id));
},
),
);
},
);
} else if (state is TodoError) {
return Center(child: Text(state.message));
}
return Container();
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
_showAddTodoDialog(context);
},
),
);
}
void _showAddTodoDialog(BuildContext context) {
final textController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('할 일 추가'),
content: TextField(
controller: textController,
decoration: InputDecoration(hintText: '할 일을 입력하세요'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('취소'),
),
TextButton(
onPressed: () {
if (textController.text.isNotEmpty) {
context.read<TodoBloc>().add(AddTodoEvent(textController.text));
Navigator.pop(context);
}
},
child: Text('추가'),
),
],
),
);
}
}
결론
BLoC 패턴은 특히 중대형 Flutter 애플리케이션에서 강력한 상태 관리 솔루션으로, 코드 구조화와 유지보수성을 크게 향상시킬 수 있습니다. 학습 곡선이 있지만, 한번 익숙해지면 복잡한 상태 관리 문제를 해결하는 데 강력한 도구가 됩니다. 작은 앱에는 Provider와 같은 더 단순한 솔루션이 적합할 수 있지만, 애플리케이션의 규모와 복잡성이 증가함에 따라 BLoC 패턴의 이점은 더욱 명확해집니다.