| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
- github 시작하기
- png
- 클린 아키텍처
- Tuist
- 팀 개발을 위한 git
- TestFlight
- nidthirdpartylogin
- webp
- JPG
- 무한스크롤
- JPEG
- xcode 엔터 표시
- 테스트 타겟
- 캐러셀
- 코드스쿼드
- fetchdescriptor
- Firestore
- NSTextStorage
- contentalignmentpoint
- NSTextStorageDelegate
- heic
- Cocoa Pod
- swift 모듈화
- 함께자라기
- .pbxproj
- spm 에러
- 타뷸레이션
- swiftdata
- xcode 공백 표시
- SwiftUI
- Today
- Total
Sure, Why not?
Flutter 상태관리 - BLoC 본문

Flutter에서는 다양한 상태관리 방식이 존재한다.
간단한 화면은 setState만으로 충분하고,
조금 더 상태가 많아지면 ValueNotifier나 ChangeNotifier를 사용할 수도 있다.
하지만 앱 규모가 커질수록 다음과 같은 문제들이 발생하기 시작한다.
- UI와 비즈니스 로직이 섞인다.
- 상태 변경 흐름을 추적하기 어렵다.
- 여러 이벤트가 동시에 발생하면 관리가 복잡해진다.
- 로딩, 성공, 실패 상태 처리가 반복된다.
이러한 문제를 해결하기 위해 등장한 패턴 중 하나가 바로 BLoC(Business Logic Component) 패턴이다.
여기서 말하는 Business Logic은 우리가 일반적으로 생각하는 Domain Business Logic 과는 조금 다르다.
Presentation과 Data 사이에서 상태 흐름을 관리하고, 새로운 State를 만들어내는 데 더 가깝다.
그래서 BLoC의 비즈니스 로직은 상태 흐름을 제어하는 중간 관리자 역할으로 이해한다.
BLoC란?
BLoC는 Business Logic Component의 약자이다.

Flutter에서 상태를 예측 가능하게 관리하기 위해 만들어진 패턴이며, 핵심은 다음 두 가지이다.
- Event
- State
BLoC는 Event를 입력받고, 새로운 State를 출력하는 구조로 동작한다.
BLoC는 다음과 같은 흐름으로 동작한다.
→ User Action
→ Event 발생
→ BLoC가 Event 처리
→ State 변경
→ UI가 State를 보고 다시 그려짐
결국 UI가 직접 로직을 처리하지 않는다는 점이다.
그래서 iOS의 ReactorKit이나 TCA와 같은 MVI에 가깝다.
Cubit과 Bloc의 차이
Bloc 라이브러리에는 크게 두 가지 방식이 있다.
- Cubit
- Bloc
둘 다 상태를 관리한다는 점은 같지만,
상태를 변경시키는 방식이 다르다.
Cubit이란?
Cubit은 BlocBase를 상속한 클래스이다.
Cubit은 Event 없이
외부에 공개된 메서드를 호출해서 State를 변경한다.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() {
emit(state + 1);
}
}
UI에서 increment() 메서드를 호출하면
Cubit 내부에서 emit()을 통해 새로운 State를 내보낸다.
Cubit은 함수 호출 기반 상태 관리라고 볼 수 있다.
Cubit 사용 예시
final cubit = CounterCubit();
print(cubit.state); // 0
cubit.increment();
print(cubit.state); // 1
cubit.close();
Cubit은 현재 상태를 state로 확인할 수 있고,
새로운 상태는 emit()으로 내보낸다.
Cubit의 장점
Cubit의 가장 큰 장점은 단순함이다.
Event를 따로 만들 필요가 없다.
void increment() {
emit(state + 1);
}
이렇게 메서드 하나로 상태 변경을 표현할 수 있다.
그래서 화면 단위의 단순한 상태 관리에 적합하다.
Bloc이란?
Bloc도 BlocBase를 상속하지만,
Cubit과 다르게 Event를 기반으로 State를 변경한다.
sealed class CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) {
emit(state + 1);
});
}
}
즉, 직접 메서드를 호출하는 것이 아니라
Event를 Bloc에 전달한다.
Bloc 사용 예시
final bloc = CounterBloc();
print(bloc.state); // 0
bloc.add(CounterIncrementPressed());
await Future.delayed(Duration.zero);
print(bloc.state); // 1
await bloc.close();
Bloc은 add()를 통해 Event를 전달한다.
Cubit vs Bloc 핵심 차이
Cubit은 메서드를 호출해서 상태를 바꾼다.
-> 구조가 단순하고 코드가 적다.
Bloc은 Event를 전달해서 상태를 바꾼다.
-> 구조는 조금 더 복잡하지만,
상태가 왜 바뀌었는지 추적하기 좋다.
Change와 Transition
Cubit과 Bloc은 상태가 변경될 때 Change를 확인할 수 있다.
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) => emit(state + 1));
}
@override
void onChange(Change<int> change) {
super.onChange(change);
print(change);
}
@override
void onTransition(Transition<CounterEvent, int> transition) {
super.onTransition(transition);
print(transition);
}
}
Change {
currentState: 0,
nextState: 1
}
이 정보는 현재 상태에서 다음 상태로 바뀐 것을 보여준다.
하지만 Bloc은 Event 기반이기 때문에
상태 변경의 원인까지 확인할 수 있다.
Transition {
currentState: 0,
event: CounterIncrementPressed,
nextState: 1
}
즉, Bloc은 다음 정보를 모두 알 수 있다.
어떤 상태에서
어떤 Event 때문에
어떤 상태로 바뀌었는가
이게 Bloc의 가장 큰 장점이다.
언제 Cubit을 쓰고 언제 Bloc을 쓸까?
Cubit이 어울리는 경우
Cubit은 단순한 상태 관리에 적합하다.
예를 들면:
- 버튼 클릭으로 값 증가
- 토글 on/off
- 탭 인덱스 변경
- 간단한 UI 상태 변경
- 복잡한 Event 흐름이 필요 없는 화면
이런 경우에는 Cubit이 더 깔끔하다.
Cubit은 간단하고 빠르게 작성하기 좋다.
Bloc이 어울리는 경우
Bloc은 구조가 더 명확하고
상태 변경의 원인을 추적하기 좋다.
예를 들면:
- 로그인 상태 관리
- 검색 debounce 처리
- 페이지네이션
- 채팅 이벤트 처리
- 인증 만료로 인한 자동 로그아웃
- 사용자가 직접 로그아웃한 경우와 토큰 만료 로그아웃을 구분해야 하는 경우
iOS 개발 관점에서 보면
Cubit은 간단한 ViewModel에 가깝고,
Bloc은 ReactorKit이나 TCA처럼 Action/Event 기반 구조에 더 가깝다.
BLoC를 사용할 때 알아두면 좋은 규칙과 실전 사용법
Event 작명 규칙
Event는 BLoC 입장에서 “이미 발생한 일”이다. 그래서 이름은 보통 과거형으로 작성한다.
// BlocSubject + 명사(선택) + 과거형 동사
sealed class CounterEvent {}
final class CounterStarted extends CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
final class CounterDecrementPressed extends CounterEvent {}
final class CounterIncrementRetried extends CounterEvent {}
State 작명 규칙
State는 특정 시점의 화면 상태를 나타내는 스냅샷이다.
State는 동사보다는 명사 형태로 작성하는 것이 좋다.
State를 표현하는 방식은 크게 두 가지가 있다.
1. 단일 State 클래스 + Status enum
2. sealed class + 하위 State 클래스
// 1. 단일 State 클래스 + Status enum
enum CounterStatus {
initial,
loading,
success,
failure,
}
final class CounterState {
const CounterState({
this.status = CounterStatus.initial,
this.count = 0,
this.errorMessage,
});
final CounterStatus status;
final int count;
final String? errorMessage;
}
이 방식은 상태가 서로 완전히 분리되어 있지 않고, 여러 상태가 공통 데이터를 공유할 때 좋다.
예를 들어 에러가 발생했지만 기존 데이터를 계속 보여주면서 스낵바만 띄우고 싶은 경우가 있다.
이런 상황에서는 단일 State 방식이 편하다.
// 2.sealed class + 하위 State 클래스
sealed class CounterState {
const CounterState();
}
final class CounterInitial extends CounterState {
const CounterInitial();
}
final class CounterLoadInProgress extends CounterState {
const CounterLoadInProgress();
}
final class CounterLoadSuccess extends CounterState {
const CounterLoadSuccess({
required this.count,
});
final int count;
}
final class CounterLoadFailure extends CounterState {
const CounterLoadFailure({
required this.exception,
});
final Exception exception;
}
이 방식은 상태가 명확하게 분리될 때 좋다.
상태별로 필요한 값만 가질 수 있지만, 코드가 길어진다.
Bloc Widget 정리
BLoC를 UI와 연결할 때는 flutter_bloc에서 제공하는 여러 위젯을 사용한다.
BlocBuilder
BlocBuilder는 Bloc의 state가 변경될 때 UI를 다시 그리는 위젯이다.
Flutter의 StreamBuilder와 비슷하지만, Bloc에 맞게 더 간단하게 사용할 수 있다.
BlocBuilder<CounterBloc, int>(
builder: (context, count) {
return Text('$count');
},
);
builder는 state가 변경될 때 여러 번 호출될 수 있다.
따라서 builder 안에서는 화면을 반환하는 순수한 작업만 하는 것이 좋다.
보통 BlocBuilder는 현재 BuildContext에서 가까운 BlocProvider를 찾아 Bloc을 자동으로 가져온다.
BlocBuilder<CounterBloc, int>(
bloc: counterBloc,
builder: (context, count) {
return Text('$count');
},
);
하지만 상위 BlocProvider를 통해 접근할 수 없는 특정 Bloc 인스턴스를 직접 넣어야 한다면 bloc 파라미터를 사용할 수 있다.
buildWhen
buildWhen은 어떤 state 변경에서 UI를 다시 그릴지 제어하는 옵션이다.
BlocBuilder<CounterBloc, int>(
buildWhen: (previous, current) {
return previous != current;
},
builder: (context, count) {
return Text('$count');
},
);
buildWhen이 true를 반환하면 builder가 호출되고, false를 반환하면 리빌드가 발생하지 않는다.
예를 들어 state 안에 name, age, isLoading이 있는데 화면에서는 name만 보여준다면,
name이 바뀔 때만 리빌드하도록 최적화할 수 있다.
BlocSelector
BlocSelector는 Bloc의 전체 state 중 필요한 값만 선택해서 UI를 다시 그리는 위젯이다.
BlocSelector<ProfileBloc, ProfileState, String>(
selector: (state) => state.name,
builder: (context, name) {
return Text(name);
},
);
위 코드는 ProfileState 전체가 아니라 name 값이 바뀔 때만 다시 빌드된다.
그래서
BlocBuilder = 전체 state를 보고 리빌드
BlocSelector = state 중 선택한 값만 보고 리빌드
불필요한 리빌드를 줄이고 싶을 때 유용하다.
단, 선택한 값은 변경 불가능한 값, 즉 immutable하게 관리하는 것이 좋다.
그래야 값이 바뀌었는지 정확히 비교할 수 있다.
BlocProvider
BlocProvider는 하위 위젯 트리에서 Bloc을 사용할 수 있도록 제공하는 위젯이다.
BlocProvider(
create: (context) => CounterBloc(),
child: CounterPage(),
);
쉽게 말하면 Bloc을 화면에 주입하는 역할이다.
BlocProvider가 create로 Bloc을 생성한 경우,
해당 Bloc을 자동으로 close해준다. 그래서 대부분의 경우 직접 close를 호출하지 않아도 된다.
BlocProvider의 lazy 옵션
BlocProvider는 기본적으로 lazy하게 동작한다. 즉, Bloc이 실제로 조회될 때 create가 실행된다.
BlocProvider(
lazy: false,
create: (context) => CounterBloc(),
child: CounterPage(),
);
만약 화면이 만들어지는 즉시 Bloc을 생성하고 싶다면 lazy: false를 사용할 수 있다.
BlocProvider.value
이미 만들어진 Bloc 인스턴스를 다른 위젯 트리에 넘기고 싶을 때는 BlocProvider.value를 사용한다.
BlocProvider.value(
value: context.read<CounterBloc>(),
child: CounterDetailPage(),
);
주로 새로운 route로 이동할 때 기존 Bloc을 그대로 넘기고 싶을 때 사용한다.
주의할 점은 BlocProvider.value는 Bloc을 새로 생성한 것이 아니기 때문에 자동으로 close하지 않는다.
MultiBlocProvider
여러 개의 Bloc을 제공해야 할 때 BlocProvider를 중첩하면 코드가 깊어진다.
BlocProvider<BlocA>(
create: (context) => BlocA(),
child: BlocProvider<BlocB>(
create: (context) => BlocB(),
child: BlocProvider<BlocC>(
create: (context) => BlocC(),
child: ChildA(),
),
),
);
이럴 때 MultiBlocProvider를 사용하면 더 깔끔하게 정리할 수 있다.
MultiBlocProvider(
providers: [
BlocProvider<BlocA>(
create: (context) => BlocA(),
),
BlocProvider<BlocB>(
create: (context) => BlocB(),
),
BlocProvider<BlocC>(
create: (context) => BlocC(),
),
],
child: ChildA(),
);
BlocListener
BlocListener는 Bloc의 state 변경에 반응해서 특정 동작을 수행하는 위젯이다.
BlocListener<LoginBloc, LoginState>(
listener: (context, state) {
if (state.status == LoginStatus.success) {
Navigator.pushNamed(context, '/home');
}
},
child: LoginPage(),
);
BlocListener는 UI를 그리는 용도가 아니다.
화면 이동, 스낵바, 다이얼로그, 토스트처럼 state 변경에 따라 한 번만 실행되어야 하는 작업에 사용한다.
listener는 초기 state에는 호출되지 않고, 이후 state 변경마다 한 번씩 호출된다.
listenWhen
listenWhen은 어떤 state 변경에서 listener를 실행할지 제어한다.
BlocListener<LoginBloc, LoginState>(
listenWhen: (previous, current) {
return previous.status != current.status;
},
listener: (context, state) {
if (state.status == LoginStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('로그인 실패')),
);
}
},
child: LoginForm(),
);
listenWhen이 true를 반환하면 listener가 실행되고, false를 반환하면 실행되지 않는다.
MultiBlocListener
여러 개의 BlocListener를 사용해야 할 때 중첩이 깊어질 수 있다.
MultiBlocListener(
listeners: [
BlocListener<BlocA, BlocAState>(
listener: (context, state) {},
),
BlocListener<BlocB, BlocBState>(
listener: (context, state) {},
),
BlocListener<BlocC, BlocCState>(
listener: (context, state) {},
),
],
child: ChildA(),
);
BlocConsumer
BlocConsumer는 BlocBuilder와 BlocListener를 합친 위젯이다.
BlocConsumer<LoginBloc, LoginState>(
listener: (context, state) {
if (state.status == LoginStatus.success) {
Navigator.pushNamed(context, '/home');
}
},
builder: (context, state) {
return LoginForm(state: state);
},
);
UI도 다시 그리고, 동시에 state 변경에 따라 화면 이동이나 스낵바 같은 동작도 처리해야 할 때 사용한다.
BlocConsumer<LoginBloc, LoginState>(
listenWhen: (previous, current) {
return previous.status != current.status;
},
listener: (context, state) {
if (state.status == LoginStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('로그인 실패')),
);
}
},
buildWhen: (previous, current) {
return previous.form != current.form;
},
builder: (context, state) {
return LoginForm(state: state);
},
);
BlocConsumer는 listenWhen과 buildWhen을 둘 다 사용할 수 있다.
RepositoryProvider
RepositoryProvider는 Repository를 하위 위젯 트리에 제공하는 위젯이다.
RepositoryProvider(
create: (context) => WeatherRepository(),
child: WeatherPage(),
);
BlocProvider가 Bloc을 제공한다면, RepositoryProvider는 Repository를 제공한다.
RepositoryProvider(
create: (context) => WeatherRepository(),
child: BlocProvider(
create: (context) => WeatherCubit(
context.read<WeatherRepository>(),
),
child: WeatherPage(),
),
);
Bloc은 이 Repository를 주입받아 비즈니스 로직을 처리한다.
RepositoryProvider의 dispose
Repository가 내부에서 API Client나 Stream 등을 가지고 있다면 정리가 필요할 수 있다. 이때 dispose를 사용할 수 있다.
RepositoryProvider(
create: (_) => WeatherRepository(),
dispose: (repository) => repository.dispose(),
child: WeatherPage(),
);
예를 들어 Repository 안에서 API Client를 사용하고 있고, 해당 Client를 닫아야 한다면 dispose에서 정리할 수 있다.
MultiRepositoryProvider
여러 Repository를 제공해야 할 때는 MultiRepositoryProvider를 사용한다.
MultiRepositoryProvider(
providers: [
RepositoryProvider<UserRepository>(
create: (context) => UserRepository(),
),
RepositoryProvider<AuthRepository>(
create: (context) => AuthRepository(),
),
RepositoryProvider<ProductRepository>(
create: (context) => ProductRepository(),
),
],
child: App(),
);
context.read
context.read<T>()는 가장 가까운 상위의 T 타입 객체를 한 번 읽는다. state 변경을 구독하지 않는다.
FloatingActionButton(
onPressed: () {
context.read<CounterBloc>().add(
CounterIncrementPressed(),
);
},
child: const Icon(Icons.add),
);
주로 버튼 클릭, 콜백 내부에서 Event를 전달할 때 사용한다.
context.read는 listen하지 않기 때문에 state가 변경되어도 UI를 다시 그리지 않는다.
그래서 build 메서드에서 state를 읽어 화면에 표시하는 용도로 쓰면 위험하다.
// 좋지 않은 예시
Widget build(BuildContext context) {
final state = context.read<CounterBloc>().state;
return Text('$state');
}
이 경우 state가 바뀌어도 Text가 다시 그려지지 않을 수 있다.
state 변경에 따라 UI를 다시 그리고 싶다면 BlocBuilder나 context.watch를 사용해야 한다.
context.watch
context.watch<T>()는 가장 가까운 상위의 T 타입 객체를 읽고, 변경 사항도 구독한다.
final state = context.watch<CounterBloc>().state;
state가 변경되면 해당 위젯이 다시 빌드된다.
다만 주의할 점이 있다. build 메서드의 최상단에서 context.watch를 사용하면, 큰 범위가 다시 빌드될 수 있다.
// 좋지 않은 예시
Widget build(BuildContext context) {
final state = context.watch<CounterBloc>().state;
return MaterialApp(
home: Scaffold(
body: Text('$state'),
),
);
}
리빌드 범위를 줄이고 싶다면 BlocBuilder를 사용하거나 Builder로 범위를 제한하는 것이 좋다.
// 1
Widget build(BuildContext context) {
return Scaffold(
body: BlocBuilder<CounterBloc, int>(
builder: (context, count) {
return Text('$count');
},
),
);
}
// 2
Widget build(BuildContext context) {
return Scaffold(
body: Builder(
builder: (context) {
final state = context.watch<CounterBloc>().state;
return Text('$state');
},
),
);
}
context.select
context.select는 state 전체가 아니라 특정 값만 선택해서 구독한다.
final name = context.select(
(ProfileBloc bloc) => bloc.state.name,
);
위 코드는 ProfileBloc의 state 중 name이 변경될 때만 다시 빌드된다.
context.watch = 전체 변경 감지
context.select = 선택한 값의 변경만 감지
그러나 이것도 build 메서드의 너무 상위에서 사용하면 큰 범위가 리빌드될 수 있다.
리빌드 범위를 명확하게 하고 싶다면 BlocSelector를 사용하는 것이 좋다.
Bloc 테스트 정리
Bloc의 가장 큰 장점 중 하나는 테스트하기 쉽다는 점이다.
왜냐하면 UI와 비즈니스 로직이 분리되어 있기 때문이다.
iOS로 비유하면 ViewModel만 테스트하는 느낌
Bloc 테스트를 위해 다음 패키지를 설치한다.
flutter pub add dev:test dev:bloc_test
test → Dart 기본 테스트 패키지
bloc_test → Bloc 테스트 전용 헬퍼 패키지
테스트 그룹 만들기
void main() {
group(CounterBloc, () {
});
}
group은 관련 테스트를 묶는 역할이다.
setUp으로 Bloc 생성하기
group(CounterBloc, () {
late CounterBloc counterBloc;
setUp(() {
counterBloc = CounterBloc();
});
});
각 테스트마다 새로운 Bloc 인스턴스를 생성한다.
초기 상태 테스트
test(
'initial state is 0',
() {
expect(
counterBloc.state,
equals(0),
);
},
);
blocTest 사용하기
이제 실제 Event → State 흐름을 테스트한다.
blocTest<CounterBloc, int>(
'emits [1] when increment event is added',
build: () => counterBloc,
act: (bloc) {
bloc.add(
CounterIncrementPressed(),
);
},
expect: () => [1],
);
전체 테스트 코드
import 'package:test/test.dart';
import 'package:bloc_test/bloc_test.dart';
void main() {
group(CounterBloc, () {
late CounterBloc counterBloc;
setUp(() {
counterBloc = CounterBloc();
});
test(
'initial state is 0',
() {
expect(
counterBloc.state,
equals(0),
);
},
);
blocTest<CounterBloc, int>(
'emits [1] when increment event is added',
build: () => counterBloc,
act: (bloc) {
bloc.add(
CounterIncrementPressed(),
);
},
expect: () => [1],
);
blocTest<CounterBloc, int>(
'emits [-1] when decrement event is added',
build: () => counterBloc,
act: (bloc) {
bloc.add(
CounterDecrementPressed(),
);
},
expect: () => [-1],
);
});
}
마무리
BLoC는 단순히 상태를 관리하는 것이 아니라, 상태흐름을 예측 가능하게 만들고
UI와 상태관련 로직을 분리하기 위한 아키텍처 패턴에 가깝다.
처음에는 Cubit처럼 단순한 구조로 시작하더라도,
앱 규모가 커지고, 자연스럽게 복잡한 이벤트 흐름이 필요해질수록
BLoC의 장점이 명확하게 드러나 보인다.
Reference
Bloc 상태 관리 라이브러리
Bloc 상태 관리 라이브러리에 대한 공식 문서입니다. Dart, Flutter, 그리고 AngularDart를 지원합니다. 예제 및 튜토리얼이 포함되어 있습니다.
bloclibrary.dev
'Flutter > 🖥️' 카테고리의 다른 글
| Flutter 기본 상태관리 - setState, ValueNotifier, ChangeNotifier, InheritedWidget (0) | 2026.05.17 |
|---|