Bloc 패턴 — Event, State, Cubit으로 상태 분리하기

Bloc(Business Logic Component)은 UI와 비즈니스 로직을 완전히 분리하는 패턴입니다. 엔터프라이즈급 앱에서 많이 사용되고, 면접에서도 자주 물어봅니다.


Bloc의 핵심 개념

PLAINTEXT
┌──────────┐     Event     ┌──────────┐     State     ┌──────────┐
│    UI    │ ───────────▶  │   Bloc   │ ───────────▶  │    UI    │
│          │               │          │               │ (리빌드)  │
└──────────┘               └──────────┘               └──────────┘
  • Event: UI에서 발생하는 사용자 액션 (버튼 클릭, 입력 등)
  • Bloc: 이벤트를 받아 비즈니스 로직을 처리하고 새 State를 방출
  • State: UI가 렌더링할 데이터

설치

YAML
dependencies:
  flutter_bloc: ^8.1.0
  equatable: ^2.0.0  # 상태 비교를 위한 패키지

Cubit — 간단한 Bloc

Cubit은 Event 없이 메서드를 직접 호출하는 간소화된 Bloc입니다.

DART
// 상태 정의
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을 사용합니다.

DART
// 이벤트 정의
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 사용

DART
// 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 — 사이드 이펙트 처리

DART
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 선택 기준

기준CubitBloc
복잡도낮음높음
이벤트 추적불가가능
디버깅단순이벤트 로그로 상세
보일러플레이트적음많음
적합한 상황CRUD, 단순 상태복잡한 비즈니스 로직

면접 포인트: Cubit은 함수를 직접 호출하고, Bloc은 이벤트를 발송합니다. Bloc은 이벤트 로그를 통해 상태 변화를 추적할 수 있어 디버깅에 유리합니다.


BlocObserver — 전역 디버깅

DART
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을 선택하세요
댓글 로딩 중...