복잡한 플러터 앱을 위한 BLoC 아키텍처 설계

Flutter(플러터) 개발의 여정을 시작하면, 우리는 초기에 setState()라는 편리한 도구를 만납니다. 간단한 카운터 앱이나 토글 스위치 정도는 이 마법 같은 함수 하나로 충분히 해결할 수 있습니다. 하지만 애플리케이션의 규모가 커지고, 비즈니스 로직이 복잡해지는 순간, 우리는 거대한 스파게티 코드의 미로 속에서 길을 잃게 됩니다. 서버와의 수많은 비동기 통신, 여러 화면에 걸쳐 공유되어야 하는 데이터, 사용자의 예측 불가능한 상호작용이 뒤섞이면서 setState()의 호출은 걷잡을 수 없이 퍼져나가고, 위젯 트리의 최하단까지 상태를 전달하기 위한 'prop drilling'의 악몽이 시작됩니다. 바로 이 지점에서 우리는 단순한 '상태 변경'을 넘어선 체계적인 '상태 관리 아키텍처'의 필요성을 절감하게 됩니다.

이러한 문제에 대한 해답으로 Flutter 커뮤니티에서는 Provider, Riverpod, GetX, MobX 등 다양한 상태 관리 솔루션이 제시되어 왔습니다. 그중에서도 BLoC(Business Logic Component) 패턴은 특히 대규모의 복잡한 애플리케이션을 위한 견고하고 예측 가능한 아키텍처를 구축하는 데 있어 가장 강력한 선택지 중 하나로 손꼽힙니다. BLoC은 처음 배울 때 다소 많은 보일러플레이트 코드와 추상적인 개념 때문에 높은 진입장벽을 느끼게 할 수 있습니다. 하지만 그 구조적 철학을 이해하고 나면, 어떤 복잡한 요구사항에도 흔들리지 않는 테스트 가능하고, 확장 가능하며, 유지보수하기 쉬운 애플리케이션의 뼈대를 세울 수 있는 강력한 무기를 얻게 될 것입니다. 이 글은 BLoC을 단순한 상태 관리 라이브러리 사용법으로 접근하는 것을 넘어, 풀스택 개발자의 관점에서 애플리케이션 전체의 아키텍처를 어떻게 설계하고 비즈니스 로직을 UI로부터 완벽하게 분리하여 견고한 소프트웨어를 만들 수 있는지에 대한 깊이 있는 전략과 실전 코드를 제시합니다.

이 글에서 다룰 내용:
  • BLoC이 단순한 상태 관리를 넘어 '아키텍처 패턴'인 이유
  • Event, State, BLoC의 핵심 구성 요소를 통한 단방향 데이터 흐름의 이해
  • flutter_bloc 라이브러리의 핵심 위젯(BlocProvider, BlocBuilder, BlocListener, BlocSelector) 완벽 분석
  • 실무에서 가장 중요한 Repository 패턴을 적용한 비동기 API 통신 구현 전략
  • BLoC의 가벼운 대안, Cubit과의 명확한 비교 및 올바른 선택 가이드
  • bloc_test를 활용한 비즈니스 로직 단위 테스트 작성법

BLoC, 비즈니스 로직을 봉인하는 검은 상자

BLoC은 그 이름, Business Logic Component에서 알 수 있듯, 애플리케이션의 '비즈니스 로직'을 담는 독립적인 '컴포넌트'입니다. BLoC 패턴의 가장 핵심적인 철학은 UI와 비즈니스 로직의 완벽한 분리(Separation of Concerns)입니다. 즉, 사용자가 보는 화면(UI)은 오직 '어떻게 보여줄 것인가'에만 집중하고, 그 화면 뒤에서 일어나는 모든 데이터 처리, 계산, API 통신 등의 '무엇을 할 것인가'는 BLoC이라는 검은 상자 안에 완벽하게 캡슐화되어야 한다는 원칙입니다.

이러한 분리를 위해 BLoC은 'Stream'을 기반으로 한 매우 엄격하고 예측 가능한 데이터 흐름을 강제합니다. 이 흐름은 세 가지 핵심 요소로 구성됩니다.

  1. 이벤트 (Events): UI 레이어에서 발생하는 모든 의미 있는 상호작용이나 요청을 나타내는 객체입니다. 예를 들어 '사용자가 로그인 버튼을 눌렀다' (LoginButtonPressed), '화면이 로드되어 데이터를 요청한다' (FetchUserData) 와 같은 '사건'들이 이벤트에 해당합니다. 중요한 것은, UI는 이 이벤트를 BLoC에 던질 뿐, 그 결과로 어떤 일이 벌어질지에 대해서는 전혀 알지 못하고 관여하지도 않는다는 점입니다.
  2. BLoC (Business Logic Component): UI로부터 이벤트를 입력(input)으로 받습니다. BLoC은 전달받은 이벤트를 기반으로 사전에 정의된 비즈니스 로직을 수행합니다. 이 과정에서 데이터베이스에 접근하거나, 원격 서버와 통신하는 등의 작업이 일어날 수 있습니다. 모든 결정과 처리는 BLoC 내부에서만 이루어집니다.
  3. 상태 (States): 비즈니스 로직 처리의 결과물로, UI가 그려야 할 모든 정보를 담고 있는 불변(Immutable) 객체입니다. 예를 들어 '데이터를 불러오는 중인 상태' (UserLoading), '데이터 로딩에 성공한 상태' (UserLoadSuccess), '에러가 발생한 상태' (UserLoadFailure) 등이 있습니다. BLoC은 이 상태를 출력(output)으로 내보냅니다.

이러한 이벤트(입력) → BLoC(처리) → 상태(출력)로 이어지는 단방향 데이터 흐름은 마치 컨베이어 벨트처럼 명확하고 예측 가능합니다. 어떤 상태가 어떤 이벤트로부터 비롯되었는지 추적하기가 매우 용이해지며, 이는 복잡한 애플리케이션에서 버그를 찾고 수정하는 디버깅 과정을 획기적으로 개선합니다. 또한, UI는 오직 BLoC이 전달하는 '상태'에만 의존하여 화면을 그리기 때문에, 동일한 BLoC을 여러 다른 UI에서 재사용하거나, UI 없이 BLoC의 비즈니스 로직만을 독립적으로 테스트하는 것이 완벽하게 가능해집니다. 이것이 바로 BLoC이 대규모 프로젝트의 복잡성을 제어하는 핵심적인 아키텍처 패턴으로 자리매김한 이유입니다.

BLoC 아키텍처의 구성 요소: Event, State, Bloc 클래스 심층 분석

개념을 이해했다면, 이제 실제 코드를 통해 각 구성요소를 어떻게 구조화하는지 살펴보겠습니다. 모든 예제는 equatable 패키지를 사용하여 객체 비교를 용이하게 하는 것을 전제로 합니다. 이는 BLoC 라이브러리에서 상태 변화를 감지하는 데 매우 유용하게 사용되므로 거의 필수적입니다.

1. 이벤트 (Events): '무엇이 일어났는가'에 대한 명세서

이벤트는 UI에서 발생하는 행위를 추상화한 클래스입니다. 단순히 '버튼 클릭'과 같은 저수준의 표현보다는, '사용자가 아이템을 장바구니에 추가했다'와 같이 비즈니스 관점에서 의미 있는 이름으로 정의하는 것이 중요합니다. 관련된 이벤트들을 그룹화하기 위해 추상 클래스를 기반으로 작성하는 것이 일반적입니다.


// counter_event.dart
import 'package:equatable/equatable.dart';

// 모든 카운터 관련 이벤트의 부모가 될 추상 클래스
abstract class CounterEvent extends Equatable {
  const CounterEvent();

  // equatable을 사용하기 위해 props를 오버라이드합니다.
  // 이 리스트에 포함된 프로퍼티들이 모두 같으면 같은 인스턴스로 취급됩니다.
  @override
  List<Object> get props => [];
}

// '증가' 행위를 나타내는 이벤트
class CounterIncremented extends CounterEvent {}

// '감소' 행위를 나타내는 이벤트
class CounterDecremented extends CounterEvent {}

// 특정 값만큼 '추가'하는 행위를 나타내는 이벤트 (이벤트가 데이터를 가질 수 있음)
class CounterAdded extends CounterEvent {
  final int value;

  const CounterAdded(this.value);

  @override
  List<Object> get props => [value];
}

CounterIncrementedCounterDecremented처럼 데이터가 없는 이벤트도 있지만, CounterAdded처럼 특정 값을 함께 전달해야 하는 이벤트도 정의할 수 있습니다. 이것이 바로 이벤트가 단순한 신호가 아니라, 행위에 필요한 모든 정보를 담은 '명세서' 역할을 한다는 의미입니다.

2. 상태 (States): 'UI가 어때야 하는가'에 대한 스냅샷

상태는 특정 시점의 UI가 그려져야 할 모습을 완벽하게 설명하는 불변(immutable) 데이터 클래스입니다. BLoC의 모든 출력은 이 상태 객체를 통해 이루어집니다. 상태 클래스는 반드시 final 프로퍼티를 사용하여 불변성을 유지해야 합니다.


// counter_state.dart
import 'package:equatable/equatable.dart';

// CounterBloc이 가질 수 있는 모든 상태를 표현하는 클래스
// 상태가 여러 개일 수 있으므로, 이벤트와 마찬가지로 추상 클래스를 기반으로 설계하는 것이 좋습니다.
// 하지만 이 예제에서는 단일 클래스로도 충분합니다.
class CounterState extends Equatable {
  final int count;
  final bool isLoading; // 예시: 상태가 여러 데이터를 가질 수 있음을 보여줌

  const CounterState({this.count = 0, this.isLoading = false});

  // 상태를 쉽게 복사하고 일부 값만 변경할 수 있도록 copyWith 메서드를 구현하는 것이 매우 유용합니다.
  CounterState copyWith({
    int? count,
    bool? isLoading,
  }) {
    return CounterState(
      count: count ?? this.count,
      isLoading: isLoading ?? this.isLoading,
    );
  }

  @override
  List<Object> get props => [count, isLoading];
}

위 예제에서는 count라는 핵심 데이터 외에도 isLoading과 같은 UI 표현에 필요한 부가적인 정보도 함께 관리할 수 있음을 보여줍니다. 특히 copyWith 메서드는 BLoC에서 새로운 상태를 만들 때 매우 유용합니다. 기존 상태 객체를 그대로 유지하면서 특정 프로퍼티 값만 변경하여 새로운 상태 객체를 생성할 수 있기 때문에, 불변성을 지키면서 코드를 간결하게 유지할 수 있습니다.

3. BLoC 클래스: 이벤트와 상태를 잇는 비즈니스 로직의 심장

BLoC 클래스는 flutter_bloc 패키지의 Bloc 클래스를 상속받아 구현하며, 이벤트와 상태를 제네릭 타입으로 받습니다. 이 클래스 내부에서 들어온 이벤트를 처리하고, 그 결과로 새로운 상태를 발행(emit)하는 모든 로직이 구현됩니다.


// counter_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_event.dart';
import 'counter_state.dart';

// class CounterBloc extends Bloc<이벤트 타입, 상태 타입>
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  // 생성자에서 super()를 통해 BLoC의 초기 상태를 지정합니다.
  CounterBloc() : super(const CounterState()) {
    // on<이벤트 타입>((event, emit) { ... })을 사용하여 각 이벤트에 대한 핸들러를 등록합니다.
    on<CounterIncremented>(_onIncremented);
    on<CounterDecremented>(_onDecremented);
    on<CounterAdded>(_onAdded);
  }

  // 이벤트 핸들러는 별도의 메소드로 분리하여 가독성을 높일 수 있습니다.
  void _onIncremented(CounterIncremented event, Emitter<CounterState> emit) {
    // emit 함수를 호출하여 새로운 상태를 발행합니다.
    // 현재 상태(state)를 기반으로 copyWith를 사용해 새로운 상태를 생성합니다.
    emit(state.copyWith(count: state.count + 1));
  }

  void _onDecremented(CounterDecremented event, Emitter<CounterState> emit) {
    if (state.count > 0) {
      emit(state.copyWith(count: state.count - 1));
    }
  }
  
  // 이벤트가 데이터를 가지고 있는 경우, event 객체를 통해 접근할 수 있습니다.
  void _onAdded(CounterAdded event, Emitter<CounterState> emit) {
    emit(state.copyWith(count: state.count + event.value));
  }
}

CounterBloc 생성자에서 초기 상태(CounterState()의 기본값, 즉 count: 0)를 설정합니다. 그리고 on 메서드를 통해 각 이벤트 타입에 해당하는 핸들러 함수를 매핑합니다. 핸들러 함수는 event 객체와 상태를 발행하는 emit 함수를 인자로 받습니다. 비즈니스 로직(예: state.count > 0과 같은 조건 확인)을 수행한 후, emit을 호출하여 UI에 새로운 상태를 전달합니다. state 키워드를 통해 BLoC이 현재 가지고 있는 최신 상태 값에 언제든지 접근할 수 있습니다.

flutter_bloc: BLoC 아키텍처를 UI에 생명 불어넣기

핵심 로직을 모두 구현했다면, 이제 flutter_bloc 라이브러리가 제공하는 강력한 위젯들을 사용하여 UI와 연결할 차례입니다. 먼저 pubspec.yaml 파일에 의존성을 추가해야 합니다.


dependencies:
  flutter:
    sdk: flutter
  
  flutter_bloc: ^8.1.3 # pub.dev에서 최신 버전을 확인하세요.
  equatable: ^2.0.5

터미널에서 flutter pub get을 실행하여 패키지를 설치합니다.

1. 의존성 주입: `BlocProvider`

BLoC 인스턴스를 위젯 트리 하위의 위젯들이 사용할 수 있도록 '제공'하는 역할을 합니다. 이를 의존성 주입(Dependency Injection)이라고 부릅니다. 해당 BLoC이 필요한 화면의 최상단이나, 여러 화면에서 공유된다면 MaterialApp 위를 감싸는 것이 일반적입니다.


// main.dart
void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // BlocProvider를 통해 CounterBloc 인스턴스를 생성하고,
    // 자식 위젯(MaterialApp)에게 제공(주입)합니다.
    // 이렇게 제공된 BLoC은 하위 위젯 어디서든 context를 통해 접근 가능합니다.
    return BlocProvider(
      create: (context) => CounterBloc(),
      child: const MaterialApp(
        title: 'Flutter BLoC Architecture Demo',
        home: CounterPage(),
      ),
    );
  }
}

create 콜백은 BlocProvider가 위젯 트리에 삽입될 때 단 한 번 호출되어 BLoC 인스턴스를 생성합니다. BlocProvider는 기본적으로 지연 생성(lazy loading)을 지원하여, BLoC이 실제로 사용되기 전까지는 생성되지 않습니다.

2. UI와 BLoC 연결: 핵심 위젯 4인방

flutter_bloc은 상태 변화에 따라 UI를 그리거나 특정 액션을 수행하기 위한 다양한 위젯을 제공합니다. 상황에 맞는 위젯을 사용하는 것이 성능 최적화와 코드 가독성에 매우 중요합니다.

위젯 핵심 역할 주요 사용 사례 리빌드 여부
BlocBuilder 상태(State)가 변경될 때마다 새로운 위젯을 반환하여 UI를 다시 그립니다. BLoC의 상태에 따라 화면의 모습이 직접적으로 바뀌어야 할 때 (예: 텍스트 변경, 로딩 스피너 표시) O (리빌드 함)
BlocListener 상태(State)가 변경될 때마다 일회성 액션(콜백 함수)을 수행합니다. UI 리빌드와 상관없는 부수 효과(Side Effect) 처리 (예: 스낵바 표시, 다른 화면으로 이동, 다이얼로그 띄우기) X (리빌드 안 함)
BlocConsumer BlocBuilderBlocListener의 기능을 하나로 합친 위젯입니다. 상태 변화에 따라 UI도 리빌드하고, 동시에 특정 액션도 수행해야 할 때 (예: 로그인 성공 시 화면을 홈으로 바꾸면서 '환영합니다' 스낵바 표시) O (리빌드 함)
BlocSelector 상태 객체 내부의 특정 값(selected value)이 변경될 때만 위젯을 다시 그리는, 고성능 버전의 BlocBuilder입니다. 복잡한 상태 객체에서 일부 데이터의 변화에만 반응하여 불필요한 리빌드를 최소화하고 싶을 때 (성능 최적화) O (조건부 리빌드)

3. 카운터 앱 UI 최종 구현

위 위젯들을 활용하여 카운터 앱의 UI를 완성해 보겠습니다. UI는 오직 BLoC에 이벤트를 보내고, BLoC이 주는 상태에 따라 화면을 그리는 역할만 충실히 수행합니다.


// counter_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_bloc.dart';
import 'counter_event.dart';
import 'counter_state.dart';

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('BLoC Architecture')),
      body: Center(
        // BlocListener를 사용하여 count가 10의 배수가 될 때마다 스낵바를 띄워봅니다.
        child: BlocListener<CounterBloc, CounterState>(
          // listenWhen으로 특정 조건에서만 listener가 동작하게 할 수 있습니다.
          listenWhen: (previous, current) {
            return current.count % 10 == 0 && previous.count != current.count;
          },
          listener: (context, state) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text('짝짝! ${state.count} 달성!'),
                backgroundColor: Colors.blueAccent,
              ),
            );
          },
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Text('You have pushed the button this many times:'),
              // BlocSelector를 사용하여 count 값이 변경될 때만 Text 위젯을 리빌드합니다.
              BlocSelector<CounterBloc, CounterState, int>(
                selector: (state) => state.count,
                builder: (context, count) {
                  print('Counter text is rebuilding!'); // 콘솔 로그로 리빌드 확인
                  return Text(
                    '$count',
                    style: Theme.of(context).textTheme.headlineMedium,
                  );
                },
              ),
            ],
          ),
        ),
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            onPressed: () {
              // context.read<BlocType>()로 BLoC 인스턴스에 접근하여 이벤트를 추가합니다.
              // read는 콜백 외부에서 BLoC에 접근할 때 주로 사용됩니다.
              context.read<CounterBloc>().add(CounterIncremented());
            },
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 8),
          FloatingActionButton(
            onPressed: () => context.read<CounterBloc>().add(CounterDecremented()),
            child: const Icon(Icons.remove),
          ),
        ],
      ),
    );
  }
}

이 코드의 흐름은 명확합니다. `FloatingActionButton`이 눌리면 context.read().add(...)를 통해 이벤트가 BLoC으로 전달됩니다. BLoC은 해당 이벤트를 처리하고 새로운 CounterState를 발행합니다. BlocSelector는 새로운 상태의 count 값이 이전과 다를 경우에만 builder를 다시 실행하여 Text 위젯을 업데이트합니다. 동시에 BlocListener는 상태를 감지하여 count가 10의 배수가 되는 특정 조건에만 스낵바를 표시하는 부수 효과를 처리합니다. UI는 계산 로직을 전혀 모르며, 오직 이벤트 전송과 상태 그리기에만 집중합니다.

실전 아키텍처: BLoC과 Repository 패턴으로 API 통신 구현하기

지금까지의 카운터 앱은 BLoC의 기본을 이해하기 좋은 예제였습니다. 이제 실제 애플리케이션에서 가장 흔한 시나리오인 '서버 API를 호출하여 데이터 목록을 가져오는' 기능을 BLoC 아키텍처로 구현해 보겠습니다. 여기서 핵심은 BLoC이 직접 HTTP 클라이언트를 호출하는 것이 아니라, Repository 패턴을 도입하여 데이터 계층을 한 번 더 추상화하는 것입니다.

BLoC Architecture with Repository Pattern
데이터 계층(Repository) - 비즈니스 로직 계층(BLoC) - 표현 계층(UI)으로 나뉘는 BLoC 아키텍처

Repository Pattern 이란?
데이터의 출처(로컬 DB, 원격 API 등)에 대한 구체적인 구현을 숨기고, 일관된 인터페이스를 통해 데이터에 접근할 수 있도록 하는 디자인 패턴입니다. BLoC은 오직 Repository의 메소드만 호출할 뿐, 그 데이터가 네트워크를 통해 오는지, 캐시에서 오는지 전혀 알 필요가 없습니다. 이는 BLoC과 데이터 소스를 완전히 분리하여 테스트 용이성과 유연성을 극대화합니다.

1. 데이터 계층: Model과 Repository 구현


// post_model.dart (데이터 모델)
class Post {
  final int id;
  final String title;
  final String body;

  Post({required this.id, required this.title, required this.body});

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(id: json['id'], title: json['title'], body: json['body']);
  }
}

// post_repository.dart (Repository)
import 'dart:convert';
import 'package:http/http.dart' as http;

class PostRepository {
  final http.Client _client;

  PostRepository({http.Client? client}) : _client = client ?? http.Client();

  Future<List<Post>> fetchPosts() async {
    final response = await _client
        .get(Uri.parse('https://jsonplaceholder.typicode.com/posts'));
    
    if (response.statusCode == 200) {
      final List<dynamic> data = jsonDecode(response.body);
      return data.map((json) => Post.fromJson(json)).toList();
    } else {
      throw Exception('Failed to load posts');
    }
  }
}

2. 비즈니스 로직 계층: Event, State, BLoC 정의

API 호출은 명확한 상태(초기, 로딩, 성공, 실패)를 가집니다. 이를 상태 클래스로 정의합니다.


// post_state.dart
abstract class PostState extends Equatable {
  const PostState();
  @override
  List<Object> get props => [];
}

class PostInitial extends PostState {} // 초기 상태
class PostLoadInProgress extends PostState {} // 로딩 중 상태

class PostLoadSuccess extends PostState { // 로딩 성공 상태
  final List<Post> posts;
  const PostLoadSuccess(this.posts);
  @override
  List<Object> get props => [posts];
}

class PostLoadFailure extends PostState { // 로딩 실패 상태
  final String error;
  const PostLoadFailure(this.error);
  @override
  List<Object> get props => [error];
}

// post_event.dart
abstract class PostEvent extends Equatable {
  const PostEvent();
  @override
  List<Object> get props => [];
}

class PostFetched extends PostEvent {} // 게시물 요청 이벤트

이제 PostRepository를 주입받아 사용하는 PostBloc을 구현합니다.


// post_bloc.dart
class PostBloc extends Bloc<PostEvent, PostState> {
  final PostRepository postRepository;

  PostBloc({required this.postRepository}) : super(PostInitial()) {
    on<PostFetched>((event, emit) async {
      // 1. 로딩 상태를 먼저 emit하여 UI에 로딩 인디케이터를 표시하게 함
      emit(PostLoadInProgress());
      try {
        // 2. Repository를 통해 실제 API 호출
        final posts = await postRepository.fetchPosts();
        // 3. 성공 시, 받아온 데이터를 담아 성공 상태를 emit
        emit(PostLoadSuccess(posts));
      } catch (e) {
        // 4. 실패 시, 에러 메시지를 담아 실패 상태를 emit
        emit(PostLoadFailure(e.toString()));
      }
    });
  }
}

3. 표현 계층: UI 구현

UI는 BLoC이 제공하는 4가지 상태에 따라 각기 다른 위젯을 보여주기만 하면 됩니다.


// post_page.dart
class PostsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Posts (BLoC + Repository)')),
      // 페이지가 처음 빌드될 때 PostFetched 이벤트를 보내 데이터를 요청
      body: BlocProvider(
        create: (context) => PostBloc(
          // RepositoryProvider 등을 통해 Repository를 주입받는 것이 이상적
          postRepository: PostRepository(), 
        )..add(PostFetched()),
        child: BlocBuilder<PostBloc, PostState>(
          builder: (context, state) {
            // 로딩 중 상태일 경우
            if (state is PostLoadInProgress) {
              return const Center(child: CircularProgressIndicator());
            } 
            // 로딩 성공 상태일 경우
            else if (state is PostLoadSuccess) {
              return ListView.builder(
                itemCount: state.posts.length,
                itemBuilder: (context, index) {
                  final post = state.posts[index];
                  return ListTile(
                    leading: Text(post.id.toString()),
                    title: Text(post.title),
                    subtitle: Text(post.body, maxLines: 2),
                  );
                },
              );
            } 
            // 로딩 실패 상태일 경우
            else if (state is PostLoadFailure) {
              return Center(child: Text('Error: ${state.error}'));
            }
            // 초기 상태일 경우
            return const Center(child: Text('Press a button to fetch posts.'));
          },
        ),
      ),
    );
  }
}

이처럼 BLoC과 Repository 패턴을 함께 사용하면, 비동기 로직과 데이터 소스가 복잡하게 얽히는 문제를 매우 체계적이고 깔끔하게 관리할 수 있습니다. 각 계층은 자신의 책임에만 집중하게 되어 코드의 품질이 비약적으로 향상됩니다.

BLoC의 가벼운 형제, Cubit: 언제 사용해야 할까?

BLoC의 엄격한 구조가 때로는 너무 과하거나 번거롭게 느껴질 수 있습니다. 특히 간단한 상태 관리 로직을 위해 매번 Event 클래스를 정의하는 것은 비효율적일 수 있습니다. 이런 개발자들의 목소리에 귀 기울여 flutter_bloc 라이브러리는 Cubit이라는 더 가볍고 단순한 대안을 제공합니다.

Cubit은 BLoC에서 '이벤트(Event)'라는 개념을 제거한 버전입니다. 대신, UI에서 직접 Cubit 클래스의 퍼블릭 메소드를 호출하여 상태 변경을 지시합니다. BLoC이 '무슨 일이 일어났는지(Event)'를 선언적으로 전달하는 방식이라면, Cubit은 '무엇을 할지(Method Call)'를 명령적으로 호출하는 방식에 가깝습니다.

특징 BLoC (Bloc<Event, State>) Cubit (Cubit<State>)
핵심 개념 이벤트(Event)를 받아 상태(State)를 출력 함수(Function)를 호출하여 상태(State)를 출력
데이터 흐름 UI → Event → BLoC → State → UI (선언적) UI → Function Call → Cubit → State → UI (명령적)
장점 모든 상태 변화의 원인(이벤트) 추적이 명확함. 복잡한 비즈니스 로직 표현에 유리. 코드가 간결하고 보일러플레이트가 적음. 배우기 쉽고 빠르게 구현 가능.
단점 비교적 많은 보일러플레이트 코드 필요. 간단한 로직에는 과할 수 있음. 상태 변화의 원인을 추적하기 어려움. 로직이 복잡해지면 관리가 힘들어질 수 있음.
추천 사용 사례 사용자 입력, 시스템 이벤트 등 여러 소스에서 비롯된 복잡한 상태 변화를 다룰 때. 이벤트 로깅/분석이 중요할 때. 대규모 팀 프로젝트. 간단한 데이터 페칭, UI 상태 토글 등 단순한 상태 관리. BLoC 패턴 입문용. 개인 프로젝트나 소규모 팀.

카운터 앱을 Cubit으로 리팩토링하기


// counter_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';

// Cubit은 상태 타입만 제네릭으로 받습니다.
class CounterCubit extends Cubit<int> {
  // 초기 상태값을 super() 생성자를 통해 전달합니다.
  CounterCubit() : super(0);

  // UI에서 직접 호출할 퍼블릭 메소드들을 정의합니다.
  void increment() {
    // emit()을 통해 새로운 상태를 발행합니다.
    // 'state'는 Cubit이 현재 가지고 있는 상태값을 의미합니다.
    emit(state + 1);
  }

  void decrement() {
    if (state > 0) {
      emit(state - 1);
    }
  }
}

// --- UI에서의 사용법 ---
// BlocProvider<CounterBloc> 대신 BlocProvider<CounterCubit> 사용
// BlocBuilder<CounterBloc, CounterState> 대신 BlocBuilder<CounterCubit, int> 사용
FloatingActionButton(
  child: const Icon(Icons.add),
  onPressed: () {
    // .add(Event()) 대신, 정의한 메소드를 직접 호출합니다.
    context.read<CounterCubit>().increment();
  },
),

보시다시피 Event 클래스 파일이 통째로 사라지면서 코드가 훨씬 간결해졌습니다. 로직이 복잡하지 않고, UI의 모든 액션이 특정 함수 호출과 1:1로 매핑될 수 있다면 Cubit은 매우 훌륭하고 효율적인 선택입니다. 프로젝트의 복잡성과 팀의 컨벤션에 따라 BLoC과 Cubit을 적절히 혼용하는 것도 좋은 전략이 될 수 있습니다.

결론: BLoC, 복잡성을 길들이는 견고한 아키텍처

지금까지 Flutter의 상태 관리 아키텍처 패턴인 BLoC에 대해 깊이 있게 탐험했습니다. BLoC은 단순히 상태를 관리하는 라이브러리를 넘어, 애플리케이션의 아키텍처를 어떻게 설계하고 성장시켜야 하는지에 대한 하나의 철학을 제시합니다. UI와 비즈니스 로직의 명확한 분리라는 핵심 원칙을 통해 우리는 다음과 같은 강력한 이점들을 얻을 수 있습니다.

  • 최상의 테스트 용이성 (Testability): 비즈니스 로직이 UI로부터 완전히 독립되어 있기 때문에, bloc_test와 같은 패키지를 사용하여 BLoC의 모든 동작을 정밀하게 단위 테스트할 수 있습니다. 이는 코드의 안정성을 비약적으로 높여줍니다.
  • 탁월한 재사용성 (Reusability): 잘 설계된 BLoC은 특정 UI에 종속되지 않습니다. 동일한 사용자 인증 BLoC을 모바일 앱과 웹 앱에서 동시에 사용하는 등 코드 중복을 최소화할 수 있습니다.
  • 예측 가능한 코드 (Predictability): Event → BLoC → State로 이어지는 엄격한 단방향 데이터 흐름은 애플리케이션의 동작을 이해하고 버그를 추적하는 과정을 단순하고 명확하게 만듭니다.
  • 압도적인 확장성 (Scalability): 새로운 기능이 추가될 때, 기존 코드를 수정하는 대신 새로운 Event, State, 그리고 로직을 BLoC에 추가하는 방식으로 안전하게 시스템을 확장해 나갈 수 있습니다.

물론, 모든 프로젝트에 BLoC이 유일한 정답은 아닙니다. 작은 프로토타입이나 간단한 앱에는 Provider나 Riverpod, 혹은 BLoC의 가벼운 버전인 Cubit이 더 빠르고 효율적인 선택일 수 있습니다. 하지만 당신의 애플리케이션이 수많은 기능과 복잡한 비즈니스 규칙을 담아 지속적으로 성장해야 하는 운명이라면, BLoC이라는 튼튼한 아키텍처 기반을 다져놓는 것은 장기적으로 엄청난 생산성 향상과 안정성을 선물할 것입니다. 처음의 학습 곡선을 넘어 BLoC의 구조적 아름다움에 익숙해지는 순간, 당신은 어떤 복잡한 요구사항 앞에서도 자신감을 잃지 않는 한 단계 더 성장한 Flutter 아키텍트가 되어 있을 것입니다.

Post a Comment