Flutter 애플리케이션 개발의 여정을 시작하면, 우리는 필연적으로 '상태 관리(State Management)'라는 거대한 산을 마주하게 됩니다. 단순한 카운터 앱이라면 setState()
만으로 충분할지 모릅니다. 하지만 사용자와의 상호작용이 복잡해지고, 서버로부터 데이터를 받아오며, 여러 화면이 데이터를 공유해야 하는 실무 레벨의 앱으로 확장되는 순간, setState()
의 한계는 명확해집니다. 코드는 얽히고설켜 유지보수가 어려워지며, '위젯 트리(Widget Tree)' 깊숙한 곳까지 상태를 전달하기 위한 끝없는 'prop drilling'에 지쳐갈 것입니다. 바로 이 지점에서 우리는 구조화된 상태 관리 패턴의 필요성을 절실히 느끼게 됩니다.
과거에는 'Scoped Model'이나 'Provider'와 같은 패턴들이 많은 사랑을 받았습니다. 특히 Provider는 여전히 많은 프로젝트에서 훌륭한 선택지로 사용되고 있습니다. 하지만 애플리케이션의 복잡도가 극도로 높아지고, 비즈니스 로직과 UI의 완벽한 분리를 통해 테스트 용이성과 확장성을 극대화하고자 하는 요구가 커지면서, BLoC(Business Logic Component) 패턴이 강력한 대안으로 떠올랐습니다. BLoC은 처음 접했을 때 다소 복잡하게 느껴질 수 있는 진입 장벽이 있지만, 그 구조를 이해하고 나면 어떤 복잡한 요구사항에도 흔들리지 않는 견고하고 예측 가능한 애플리케이션을 구축할 수 있는 강력한 무기가 되어줍니다.
이 글에서는 BLoC 패턴이 무엇인지, 왜 필요한지, 그리고 가장 인기 있는 라이브러리인 flutter_bloc
을 활용하여 실무 프로젝트에 어떻게 적용할 수 있는지에 대한 깊이 있는 탐구를 진행할 것입니다. 단순히 개념을 나열하는 것을 넘어, 구체적인 코드 예제와 실전 시나리오를 통해 BLoC이 어떻게 당신의 Flutter 개발 경험을 한 차원 높여줄 수 있는지 명확하게 보여드리겠습니다.
BLoC (Business Logic Component)란 정확히 무엇인가?
BLoC은 이름 그대로 '비즈니스 로직 컴포넌트'입니다. 이 패턴의 핵심 철학은 UI 코드로부터 비즈니스 로직을 완전히 분리하는 데 있습니다. 사용자가 버튼을 누르거나, 화면을 스크롤하거나, 텍스트를 입력하는 등의 모든 행위는 UI에서 일어나지만, 그 행위에 따라 '무엇을 할지' 결정하는 로직(예: API 호출, 데이터베이스 저장, 계산 수행)은 UI가 전혀 모르는 곳, 즉 BLoC 내부에서 처리되어야 한다는 것입니다.
이러한 분리를 위해 BLoC은 매우 명확한 데이터 흐름을 강제합니다.
- Events: UI는 사용자의 상호작용이나 생명주기 이벤트가 발생했을 때, 이를 '이벤트(Event)'라는 객체 형태로 BLoC에 전달합니다. 이벤트는 "사용자가 '추가' 버튼을 눌렀다" 또는 "화면이 처음 로드되었다"와 같은 '사건'을 나타냅니다. UI는 이 이벤트가 어떤 결과를 초래할지 전혀 알지 못합니다. 그저 BLoC에 '이런 일이 일어났어'라고 보고할 뿐입니다.
- BLoC: BLoC은 UI로부터 이벤트를 전달받습니다. 그리고 해당 이벤트에 매핑된 비즈니스 로직을 수행합니다. 서버와 통신하거나, 데이터를 가공하는 등의 작업이 여기서 이루어집니다.
- States: 비즈니스 로직을 처리한 결과로 BLoC은 새로운 '상태(State)'를 만들어냅니다. 상태는 "데이터 로딩 중", "데이터 로딩 성공(성공한 데이터 포함)", "오류 발생(오류 메시지 포함)"과 같이 현재 UI가 그려져야 할 모습에 대한 모든 정보를 담고 있는 불변(immutable) 객체입니다.
- UI Update: BLoC이 새로운 상태를 발행(emit)하면, 이 BLoC을 구독(listen)하고 있던 UI는 새로운 상태를 전달받아 화면을 다시 그립니다. 예를 들어, '데이터 로딩 중' 상태를 받으면 로딩 스피너를 보여주고, '데이터 로딩 성공' 상태를 받으면 실제 데이터를 화면에 표시합니다.
이러한 이벤트(Event) → BLoC → 상태(State) → UI로 이어지는 단방향 데이터 흐름은 코드의 흐름을 예측 가능하게 만들어 디버깅을 매우 용이하게 합니다. 또한, UI와 비즈니스 로직이 완전히 분리되어 있기 때문에, 동일한 BLoC을 여러 다른 UI에서 재사용하거나, UI 없이 BLoC의 로직만을 독립적으로 테스트하는 것도 가능해집니다. 이는 대규모 프로젝트에서 빛을 발하는 BLoC의 가장 큰 장점 중 하나입니다.
BLoC의 핵심 구성 요소: Events, States, BLoC 클래스 파헤치기
개념을 이해했다면 이제 실제 코드로 각 구성 요소를 어떻게 만드는지 살펴보겠습니다. 간단한 '카운터 앱'을 예시로 들어 각 요소를 정의해보겠습니다.
1. 이벤트 (Events) 정의하기
이벤트는 UI에서 발생하여 BLoC으로 전달될 모든 '행위'를 나타내는 클래스입니다. 일반적으로 추상 클래스를 상속받는 형태로 구현하여 관련 이벤트를 그룹화합니다.
// 이벤트를 위한 기반이 될 추상 클래스
// equatable을 상속받으면, 같은 타입의 이벤트 객체가 동일한 프로퍼티를 가질 경우 같은 객체로 취급할 수 있어 편리합니다.
import 'package:equatable/equatable.dart';
abstract class CounterEvent extends Equatable {
const CounterEvent();
@override
List<Object> get props => [];
}
// '증가' 버튼을 눌렀을 때 BLoC으로 전달될 이벤트
class CounterIncrementPressed extends CounterEvent {}
// '감소' 버튼을 눌렀을 때 BLoC으로 전달될 이벤트
class CounterDecrementPressed extends CounterEvent {}
여기서 equatable
패키지를 사용하는 이유는 클래스의 인스턴스 비교를 용이하게 하기 위함입니다. 기본적으로 Dart에서 클래스 인스턴스는 메모리 주소를 기준으로 비교하지만, equatable
을 상속받고 props
를 오버라이드하면, props
에 명시된 프로퍼티 값들이 모두 같을 경우 같은 인스턴스로 취급해줍니다. BLoC에서는 상태 비교 등을 위해 equatable
사용이 거의 표준처럼 여겨집니다.
2. 상태 (States) 정의하기
상태는 특정 시점의 UI 모습을 정의하는 클래스입니다. 이벤트와 마찬가지로, 관련된 상태들을 하나의 추상 클래스로 묶고, 각 상태가 가져야 할 데이터를 프로퍼티로 정의합니다.
import 'package:equatable/equatable.dart';
abstract class CounterState extends Equatable {
final int count;
const CounterState(this.count);
@override
List<Object> get props => [count];
}
// 카운터의 초기 상태
class CounterInitial extends CounterState {
// 초기값은 0으로 시작
const CounterInitial() : super(0);
}
// 카운터 값이 변경되었을 때의 상태
class CounterValueUpdated extends CounterState {
const CounterValueUpdated(int newCount) : super(newCount);
}
위 코드에서 CounterState
는 현재 카운트 값인 count
를 가지고 있습니다. CounterInitial
은 앱이 처음 시작될 때의 상태를 나타내며, CounterValueUpdated
는 값이 변경되었을 때의 상태를 나타냅니다. 두 상태 모두 count
값을 가지고 있으므로, UI는 어떤 상태를 받든 현재 숫자를 화면에 표시할 수 있습니다.
3. BLoC 클래스 구현하기
BLoC 클래스는 이벤트와 상태를 연결하는 다리 역할을 합니다. flutter_bloc
라이브러리의 Bloc
클래스를 상속받아 구현합니다.
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_event.dart';
import 'counter_state.dart';
// Bloc<이벤트, 상태> 형태로 상속
class CounterBloc extends Bloc<CounterEvent, CounterState> {
// BLoC이 생성될 때 초기 상태를 지정
CounterBloc() : super(const CounterInitial()) {
// on<이벤트>((event, emit) { ... }) 형태로 각 이벤트에 대한 핸들러를 등록
// CounterIncrementPressed 이벤트가 들어왔을 때 실행될 로직
on<CounterIncrementPressed>((event, emit) {
// 'emit' 함수를 사용해 새로운 상태를 발행
// 현재 상태(state)의 count 값에 1을 더한 새로운 상태를 발행
emit(CounterValueUpdated(state.count + 1));
});
// CounterDecrementPressed 이벤트가 들어왔을 때 실행될 로직
on<CounterDecrementPressed>((event, emit) {
// 현재 상태(state)의 count 값에 1을 뺀 새로운 상태를 발행
emit(CounterValueUpdated(state.count - 1));
});
}
}
CounterBloc
은 CounterEvent
를 입력으로 받고 CounterState
를 출력으로 내보냅니다. 생성자에서 super()
를 통해 초기 상태(CounterInitial
)를 설정합니다. 그리고 on<EventType>(...)
메서드를 사용해 특정 이벤트가 들어왔을 때 어떤 로직을 수행할지 정의합니다. 로직 수행 후에는 emit()
함수를 호출하여 새로운 상태를 UI로 전달합니다. state
키워드를 통해 현재 BLoC이 가지고 있는 최신 상태에 접근할 수 있습니다.
실전! flutter_bloc
패키지로 UI와 BLoC 연결하기
핵심 구성 요소를 모두 만들었다면, 이제 실제 Flutter UI와 연결해 생명을 불어넣을 차례입니다. 이를 위해 먼저 flutter_bloc
라이브러리를 프로젝트에 추가해야 합니다.
1. 의존성 추가 (pubspec.yaml
)
pubspec.yaml
파일에 아래와 같이 flutter_bloc
과 equatable
을 추가합니다.
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.3 # 최신 버전 확인 후 사용 권장
equatable: ^2.0.5
터미널에서 flutter pub get
명령어를 실행하여 패키지를 설치합니다.
2. BLoC 인스턴스 제공하기: `BlocProvider`
생성한 CounterBloc
을 위젯 트리 하위의 위젯들이 사용할 수 있도록 '제공'해야 합니다. 이때 사용하는 것이 BlocProvider
위젯입니다. 보통 해당 BLoC을 사용할 화면의 최상단이나, 앱 전역에서 사용한다면 MaterialApp
위를 감싸는 형태로 사용합니다.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_bloc.dart';
import 'counter_page_ui.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// BlocProvider를 통해 CounterBloc의 인스턴스를 생성하고,
// 자식 위젯(MaterialApp)에게 제공합니다.
return BlocProvider(
create: (context) => CounterBloc(),
child: const MaterialApp(
title: 'Flutter BLoC Demo',
home: CounterPage(),
),
);
}
}
create
콜백은 BuildContext
를 받아 BLoC 인스턴스를 생성하여 반환합니다. 이렇게 제공된 CounterBloc
은 이제 MaterialApp
하위의 어떤 위젯에서든 context
를 통해 접근할 수 있게 됩니다.
3. 상태 변화에 따라 UI 업데이트하기: `BlocBuilder`
UI에서 BLoC의 상태 변화를 감지하고, 새로운 상태에 따라 화면을 다시 그리기 위해서는 BlocBuilder
위젯을 사용합니다. BlocBuilder
는 상태가 변경될 때마다 builder
콜백을 실행하여 새로운 위젯을 반환합니다.
4. UI에서 이벤트 전송하기
사용자가 버튼을 누르면 BLoC으로 이벤트를 보내야 합니다. 이때는 context.read
를 사용하여 BLoC 인스턴스에 접근하고, .add()
메서드를 호출하여 이벤트를 추가합니다.
이제 위 개념들을 모두 합쳐 카운터 앱의 UI를 완성해 보겠습니다.
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('Counter with BLoC')),
body: Center(
// BlocBuilder는 특정 Bloc과 State를 지정하여 사용합니다.
// 상태가 변할 때마다 이 builder 부분이 다시 실행됩니다.
child: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
// builder는 context와 현재 state를 인자로 받습니다.
// state.count 값을 Text 위젯에 표시합니다.
return Text(
'${state.count}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
FloatingActionButton(
key: const Key('counter_increment_fab'),
child: const Icon(Icons.add),
onPressed: () {
// context.read<CounterBloc>()로 BlocProvider가 제공한 인스턴스에 접근합니다.
// add() 메소드를 통해 이벤트를 BLoC으로 보냅니다.
context.read<CounterBloc>().add(CounterIncrementPressed());
},
),
const SizedBox(height: 8),
FloatingActionButton(
key: const Key('counter_decrement_fab'),
child: const Icon(Icons.remove),
onPressed: () {
// 감소 이벤트 전송
context.read<CounterBloc>().add(CounterDecrementPressed());
},
),
],
),
);
}
}
이 코드의 흐름을 다시 한번 정리해 보겠습니다.
1. `FloatingActionButton`이 눌리면 onPressed
콜백이 실행됩니다.
2. `context.read<CounterBloc>()`를 통해 위젯 트리 상위에 있는 `CounterBloc` 인스턴스를 찾습니다.
3. .add(CounterIncrementPressed())
를 호출하여 이벤트를 `CounterBloc`에 전달합니다.
4. `CounterBloc`은 `on<CounterIncrementPressed>` 핸들러를 실행하여 현재 `count`에 1을 더한 `CounterValueUpdated` 상태를 `emit`합니다.
5. `BlocBuilder`는 새로운 `CounterValueUpdated` 상태를 감지하고, `builder` 콜백을 다시 실행합니다.
6. `builder`는 새로운 상태의 `count` 값을 받아 `Text` 위젯을 업데이트하여 화면에 보여줍니다.
이처럼 UI는 오직 '이벤트를 보낸다'와 '상태를 받아 그린다'는 두 가지 역할만 수행합니다. 숫자 계산 로직은 완전히 `CounterBloc` 안에 캡슐화되어 있습니다.
한 걸음 더: BLoC를 더 스마트하게 사용하는 방법
flutter_bloc
라이브러리는 BlocBuilder
외에도 특정 상황에 유용한 다양한 위젯과 기능을 제공합니다. 이를 잘 활용하면 코드를 더욱 깔끔하고 효율적으로 만들 수 있습니다.
`BlocListener`: UI 리빌드 없이 특정 동작 수행하기
모든 상태 변화가 UI 위젯의 리빌드를 필요로 하는 것은 아닙니다. 예를 들어, '에러가 발생했다'는 상태를 받았을 때 화면 전체를 다시 그리는 대신, `SnackBar`를 띄우거나 다른 화면으로 이동하고 싶을 수 있습니다. 이런 '사이드 이펙트(Side Effect)' 처리에 특화된 위젯이 바로 BlocListener
입니다.
BlocListener<LoginBloc, LoginState>(
// listenWhen: 특정 조건의 상태에서만 listener를 실행하고 싶을 때 사용
listenWhen: (previous, current) => current is LoginFailure,
// listener: 상태가 변경되었을 때 호출되는 콜백
listener: (context, state) {
if (state is LoginFailure) {
// LoginFailure 상태일 때 스낵바를 표시
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text('로그인 실패: ${state.error}')),
);
}
},
child: // ... 여기에 화면을 그리는 위젯들이 위치
)
BlocListener
는 `child` 위젯을 리빌드하지 않습니다. 오직 listener
콜백만을 실행하여 네비게이션, 다이얼로그 표시 등과 같은 일회성 액션을 수행하는 데 이상적입니다.
`BlocConsumer`: 빌더와 리스너를 한 번에
만약 특정 상태 변화에 따라 UI도 리빌드하고, 동시에 사이드 이펙트도 처리해야 한다면 어떻게 할까요? BlocBuilder
와 BlocListener
를 중첩해서 사용할 수도 있지만, flutter_bloc
은 이를 위한 `BlocConsumer`라는 편리한 위젯을 제공합니다.
BlocConsumer<AuthBloc, AuthState>(
// BlocListener의 listener와 동일
listener: (context, state) {
if (state is AuthSuccess) {
// 인증 성공 시 홈 화면으로 이동
Navigator.of(context).pushReplacementNamed('/home');
}
},
// BlocBuilder의 builder와 동일
builder: (context, state) {
if (state is AuthLoading) {
// 로딩 상태일 때는 프로그레스 인디케이터 표시
return const Center(child: CircularProgressIndicator());
}
// 기본 상태일 때는 로그인 폼 표시
return LoginForm();
},
)
BlocConsumer
는 `builder`와 `listener` 프로퍼티를 모두 가지고 있어, 두 위젯의 기능을 하나로 합쳐 코드의 가독성과 구조를 개선해 줍니다.
`BlocSelector`: 불필요한 리빌드를 막는 성능 최적화 도구
하나의 상태 객체가 여러 데이터를 가지고 있을 때, 그 중 특정 데이터가 변경될 때만 위젯을 리빌드하고 싶을 수 있습니다. 예를 들어, UserState
가 name
, email
, profileImageUrl
세 가지 정보를 담고 있다고 가정해봅시다. 이메일 주소만 보여주는 위젯은 name
이 변경되었을 때는 리빌드될 필요가 없습니다. 이때 BlocSelector
가 활약합니다.
// UserState가 변경될 때마다 전체가 리빌드되는 BlocBuilder
BlocBuilder<UserBloc, UserState>(
builder: (context, state) {
// name이 바뀌든 email이 바뀌든 항상 리빌드됨
return Text('이름: ${state.name}');
},
)
// UserState의 name 값이 변경될 때만 리빌드되는 BlocSelector
BlocSelector<UserBloc, UserState, String>(
// selector: 전체 상태(UserState)에서 감시하고 싶은 특정 값(name)만 선택
selector: (state) => state.name,
// builder: 선택된 값(selectedName)이 변경될 때만 실행됨
builder: (context, selectedName) {
return Text('이름: $selectedName');
},
)
BlocSelector
의 selector
콜백은 전체 상태 객체에서 우리가 관심 있는 일부 데이터만 추출하는 역할을 합니다. flutter_bloc
은 이 `selector`가 반환한 값의 변경 여부만을 비교하여 builder
콜백을 실행할지 결정합니다. 이는 복잡한 상태 객체를 다룰 때 불필요한 위젯 리빌드를 최소화하여 앱 성능을 크게 향상시킬 수 있는 강력한 기능입니다.
실전 시나리오: API 데이터 호출과 BLoC 패턴 적용
이제 카운터 앱을 넘어, 실제 앱에서 가장 흔한 시나리오 중 하나인 '서버 API를 호출하여 게시물 목록을 가져오는' 기능을 BLoC으로 구현해 보겠습니다.
1. 상태 (States) 정의
API 호출에는 여러 상태가 존재할 수 있습니다. (초기 상태, 로딩 중, 성공, 실패)
// post_state.dart
import 'package:equatable/equatable.dart';
import 'post_model.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];
}
2. 이벤트 (Events) 정의
이 경우에는 '게시물을 가져와라'는 단 하나의 이벤트만 필요합니다.
// post_event.dart
import 'package:equatable/equatable.dart';
abstract class PostEvent extends Equatable {
const PostEvent();
@override
List<Object> get props => [];
}
class FetchPosts extends PostEvent {} // 게시물 요청 이벤트
3. BLoC 구현 (API 호출 로직 포함)
BLoC 내부에서 비동기 작업인 API 호출을 처리합니다. 실제 API 호출 로직은 별도의 Repository 클래스로 분리하는 것이 좋습니다.
// post_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'post_event.dart';
import 'post_state.dart';
import 'post_repository.dart'; // API 통신을 담당하는 클래스
class PostBloc extends Bloc<PostEvent, PostState> {
final PostRepository postRepository;
PostBloc({required this.postRepository}) : super(PostInitial()) {
on<FetchPosts>((event, emit) async {
// 로딩 중 상태를 먼저 emit하여 UI에 로딩 인디케이터를 표시하게 함
emit(PostLoadInProgress());
try {
// Repository를 통해 실제 API 호출
final posts = await postRepository.fetchPosts();
// 성공 시, 받아온 데이터를 담아 성공 상태를 emit
emit(PostLoadSuccess(posts));
} catch (e) {
// 실패 시, 에러 메시지를 담아 실패 상태를 emit
emit(PostLoadFailure(e.toString()));
}
});
}
}
이벤트 핸들러를 async
로 선언하고, try-catch
구문을 사용하여 API 호출의 성공/실패를 처리하는 것이 핵심입니다. 각 단계마다 적절한 상태를 emit
하여 UI가 현재 어떤 일이 벌어지고 있는지 정확히 알 수 있도록 합니다.
4. UI 구현
UI에서는 BlocBuilder
를 사용하여 BLoC이 보내주는 각 상태에 맞는 위젯을 그려줍니다.
// post_page.dart
class PostPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 페이지가 로드될 때 바로 FetchPosts 이벤트를 전송
context.read<PostBloc>().add(FetchPosts());
return Scaffold(
appBar: AppBar(title: Text('게시물 목록')),
body: BlocBuilder<PostBloc, PostState>(
builder: (context, state) {
// 로딩 중 상태일 경우
if (state is PostLoadInProgress) {
return Center(child: CircularProgressIndicator());
}
// 로딩 성공 상태일 경우
else if (state is PostLoadSuccess) {
final posts = state.posts;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(posts[index].title),
subtitle: Text(posts[index].body),
);
},
);
}
// 로딩 실패 상태일 경우
else if (state is PostLoadFailure) {
return Center(child: Text('데이터 로딩 실패: ${state.error}'));
}
// 초기 상태 또는 기타 상태일 경우
return Center(child: Text('게시물을 불러와주세요.'));
},
),
);
}
}
이처럼 BLoC 패턴을 사용하면, 비동기 데이터 흐름이 복잡하게 얽히는 API 통신 로직을 매우 체계적이고 깔끔하게 관리할 수 있습니다.
BLoC의 가벼운 형제, Cubit
BLoC 패턴의 구조가 때로는 너무 과하다고 느껴질 수 있습니다. 간단한 상태 관리에는 이벤트와 상태 클래스를 모두 정의하는 것이 번거로울 수 있습니다. 이런 경우를 위해 `flutter_bloc` 라이브러리는 **Cubit**이라는 더 가볍고 간단한 대안을 제공합니다.
Cubit은 BLoC에서 '이벤트' 부분을 없앤 것입니다. 대신, UI에서 직접 Cubit의 메소드를 호출하여 상태를 변경합니다. BLoC이 '무엇이 일어났는지(Event)'를 알리는 선언적인 방식이라면, Cubit은 '무엇을 할지(Method Call)'를 지시하는 명령적인 방식에 가깝습니다.
카운터 앱 Cubit으로 구현하기
import 'package:flutter_bloc/flutter_bloc.dart';
// Cubit은 상태 타입만 제네릭으로 받습니다.
class CounterCubit extends Cubit<int> {
// 초기 상태값을 super()로 전달합니다.
CounterCubit() : super(0);
// UI에서 직접 호출할 메소드들을 정의합니다.
void increment() {
// emit()을 통해 새로운 상태를 발행합니다.
// 'state'는 현재 상태값을 의미합니다.
emit(state + 1);
}
void decrement() {
emit(state - 1);
}
}
// --- UI에서의 사용법 ---
// BlocBuilder 대신 BlocBuilder<CounterCubit, int> 사용
FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
// .add(Event()) 대신, 정의한 메소드를 직접 호출
context.read<CounterCubit>().increment();
},
),
보시다시피, Event 클래스를 정의할 필요가 없어 코드가 훨씬 간결해졌습니다. 상태 로직이 복잡하지 않고, 사용자의 모든 액션이 BLoC 내부의 함수 호출과 1:1로 매핑될 수 있는 단순한 경우에는 Cubit이 훌륭한 선택이 될 수 있습니다. 반면, 여러 이벤트가 하나의 복잡한 로직으로 귀결되거나, 이벤트 자체를 추적하고 로깅하는 것이 중요한 대규모 애플리케이션에서는 BLoC이 제공하는 명시적인 구조가 더 큰 이점을 가질 수 있습니다.
결론: BLoC, 복잡성을 길들이는 기술
지금까지 Flutter의 상태 관리 패턴인 BLoC에 대해 깊이 있게 알아보았습니다. BLoC은 단순히 상태를 관리하는 도구를 넘어, 애플리케이션의 아키텍처를 견고하게 설계하도록 유도하는 하나의 철학에 가깝습니다. UI와 비즈니스 로직의 명확한 분리를 통해 얻게 되는 이점들은 실로 막대합니다.
- 향상된 테스트 용이성: 비즈니스 로직이 UI와 완전히 독립적이므로, BLoC 클래스만을 대상으로 유닛 테스트를 쉽게 작성할 수 있습니다.
- 뛰어난 재사용성: 잘 만들어진 하나의 BLoC은 여러 다른 UI 위젯에서 가져다 쓸 수 있어 코드 중복을 줄여줍니다.
- 예측 가능한 코드: 이벤트 → BLoC → 상태로 이어지는 단방향 데이터 흐름은 코드의 동작을 이해하고 디버깅하는 것을 매우 쉽게 만듭니다.
- 탁월한 확장성: 새로운 기능이 추가될 때, 기존 코드를 건드리지 않고 새로운 이벤트, 상태, 로직을 BLoC에 추가하는 것만으로 확장이 가능합니다.
물론, 모든 프로젝트에 BLoC이 정답은 아닙니다. 작은 규모의 프로젝트나 간단한 상태 로직에는 Provider나 Cubit이 더 빠르고 효율적인 선택일 수 있습니다. 하지만 애플리케이션이 성장하고 복잡성이 증가할 미래를 내다본다면, BLoC이라는 튼튼한 기반을 다져놓는 것은 장기적으로 엄청난 생산성과 안정성을 가져다줄 것입니다. 처음의 학습 곡선을 넘어 BLoC의 구조에 익숙해지는 순간, 당신은 어떤 복잡한 요구사항 앞에서도 자신감을 잃지 않는 한 단계 더 성장한 Flutter 개발자가 되어 있을 것입니다.
0 개의 댓글:
Post a Comment