[Flutter 공부] 상태관리, BLoC 패턴
- Developer/Flutter
- 2025. 3. 17.
당신의 Flutter 앱이 상태 관리 문제로 복잡해지고 있나요? BLoC 패턴으로 코드를 정리하고 유지보수성을 높일 시간입니다.
안녕하세요, 오늘은 Flutter 개발자라면 반드시 알아야 할 BLoC 패턴에 대해 심도있게 다뤄보려고 합니다. Flutter 프로젝트에서 다양한 상태 관리 기법을 시도해봤는데, 결국 대규모 프로젝트에서는 BLoC 패턴이 가장 효율적이더군요. 물론 GetX나 Provider도 나쁘지 않지만, 복잡한 비즈니스 로직을 처리하기에는 BLoC만한 게 없습니다. 이번 글에서는 제가 실전에서 얻은 인사이트를 공유하겠습니다.

목차
BLoC 패턴 기초: 아키텍처 이해하기
BLoC(Business Logic Component)는 구글 개발자들이 Flutter와 AngularDart 간의 코드 공유를 위해 만든 패턴입니다. 솔직히 처음 접하면 오버엔지니어링처럼 느껴질 수 있습니다. "이렇게까지 해야 해?"라는 의문이 들 수도 있죠. 하지만 앱이 복잡해질수록 BLoC의 진가가 드러납니다.
BLoC의 기본 원칙은 간단합니다. 비즈니스 로직을 UI에서 완전히 분리하는 것이죠. 데이터 흐름은 단방향이며 예측 가능합니다:
- UI에서 이벤트(Event)를 BLoC에 전달
- BLoC은 이 이벤트를 처리하고 비즈니스 로직 실행
- 처리 결과에 따라 새로운 상태(State)를 스트림으로 내보냄
- UI는 이 상태 스트림을 구독하여 화면 갱신
이런 구조는 Flux나 Redux와 유사하지만, Flutter의 StreamController와 Reactive Programming을 기반으로 구현된다는 점이 다릅니다. 테스트 가능성이 극대화되고, 관심사 분리가 명확해지는 장점이 있습니다.
BLoC vs 다른 상태 관리 패턴: 언제 BLoC을 선택해야 할까?
Flutter 생태계에는 여러 상태 관리 솔루션이 있습니다. 소규모 프로젝트에서는 Provider나 GetX가 더 간편할 수 있지만, 복잡한 비즈니스 로직과 대규모 팀 협업에서는 BLoC이 빛을 발합니다. 아래 표는 주요 상태 관리 솔루션들의 특징을 비교한 것입니다.
상태 관리 솔루션 | 학습 곡선 | 복잡성 | 확장성 | 테스트 용이성 | 적합한 프로젝트 규모 |
---|---|---|---|---|---|
BLoC | 높음 | 높음 | 매우 좋음 | 매우 좋음 | 중-대규모 |
Provider | 중간 | 중간 | 좋음 | 좋음 | 소-중규모 |
GetX | 낮음 | 낮음 | 보통 | 보통 | 소규모 |
Redux/Redux | 높음 | 높음 | 매우 좋음 | 매우 좋음 | 중-대규모 |
Riverpod | 중간 | 중간 | 좋음 | 매우 좋음 | 소-중규모 |
BLoC을 선택해야 하는 상황은 다음과 같습니다:
- 여러 팀원이 함께 작업하는 대규모 프로젝트
- 복잡한 비즈니스 로직이 존재하는 앱
- 백엔드 API와의 통신이 많고 상태 변화가 복잡한 경우
- 코드 재사용성과 테스트 용이성이 중요한 경우
- 명확한 아키텍처 구조를 원하는 경우
물론 소규모 프로젝트에 BLoC을 적용하면 오히려 생산성이 떨어질 수 있습니다. 모든 도구가 그렇듯 상황에 맞게 적용하는 것이 중요합니다.
BLoC 구현 단계: 이벤트, 상태, 로직 분리하기
BLoC을 실제로 구현하는 방법을 단계별로 살펴보겠습니다. 먼저 bloc 패키지를 설치해야 합니다. pubspec.yaml에 다음을 추가하세요:
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
equatable: ^2.0.5
BLoC 패턴을 구현하는 주요 단계는 다음과 같습니다:
- 이벤트 정의하기: UI에서 발생할 수 있는 모든 이벤트를 클래스로 정의
- 상태 정의하기: BLoC이 내보낼 수 있는 모든 상태를 클래스로 정의
- BLoC 구현하기: 이벤트를 받아 상태로 변환하는 로직 구현
- UI 연결하기: BlocBuilder, BlocListener, BlocConsumer 위젯 사용
간단한 카운터 앱을 BLoC 패턴으로 구현해 보겠습니다. 먼저 이벤트와 상태를 정의합니다:
BLoC 패턴에서는 명확한 명명 규칙을 따르는 것이 중요합니다. 일반적으로 FeatureBloc
, FeatureEvent
, FeatureState
형식으로 이름을 지정합니다. 예를 들어 인증 기능의 경우 AuthBloc
, AuthEvent
, AuthState
가 될 수 있습니다. 이벤트와 상태는 구체적인 행동이나 상황을 나타내는 이름으로 지정하세요. 예: LoginSubmitted
, AuthLoading
, AuthSuccess
등.
// 1. 다른 BLoC의 스트림을 구독하는 방법
class CartBloc extends Bloc {
final AuthBloc _authBloc;
late final StreamSubscription _authSubscription;
CartBloc({required AuthBloc authBloc}) : _authBloc = authBloc, super(CartInitial()) {
// AuthBloc의 상태 변화를 구독
_authSubscription = _authBloc.stream.listen((authState) {
if (authState is AuthLoggedOut) {
add(CartClear()); // 로그아웃 시 장바구니 비우기
}
});
on((event, emit) {
emit(CartEmpty());
});
}
@override
Future close() {
_authSubscription.cancel(); // 구독 취소
return super.close();
}
}
이런 방식으로 BLoC 간 의존성을 관리할 수 있지만, BLoC이 많아질수록 복잡해질 수 있습니다. 이런 경우 이벤트 버스나 중앙 집중식 상태 관리를 고려해볼 수 있습니다.
2. 디바운싱과 쓰로틀링
실시간 검색이나 자동 완성 기능을 구현할 때는 사용자 입력마다 API 요청을 보내면 과도한 네트워크 트래픽이 발생합니다. 디바운싱과 쓰로틀링을 통해 이벤트 처리 빈도를 제한할 수 있습니다.
class SearchBloc extends Bloc {
final ApiClient _apiClient;
SearchBloc({required ApiClient apiClient})
: _apiClient = apiClient,
super(SearchInitial()) {
// 디바운스 적용 - 마지막 이벤트 후 300ms 대기 후 처리
on(
(event, emit) async {
emit(SearchLoading());
try {
final results = await _apiClient.search(event.term);
emit(SearchSuccess(results));
} catch (e) {
emit(SearchFailure(e.toString()));
}
},
transformer: debounce(const Duration(milliseconds: 300)),
);
}
}
// 디바운스 트랜스포머 함수
EventTransformer debounce(Duration duration) {
return (events, mapper) {
return events
.debounceTime(duration)
.switchMap(mapper);
};
}
디바운싱은 연속된 이벤트 중 마지막 이벤트만 처리하는 방식이고, 쓰로틀링은 일정 시간 동안 하나의 이벤트만 처리하는 방식입니다. 검색 기능에는 디바운싱이, 스크롤 이벤트 같은 경우에는 쓰로틀링이 더 적합합니다.
3. 오류 처리 전략
어떤 앱에서든 오류는 발생할 수 있습니다. BLoC에서 오류 처리를 효과적으로 하는 방법은 여러 가지가 있습니다. 대표적으로는 상태에 오류 정보를 포함시키는 방법과 전용 오류 스트림을 사용하는 방법이 있습니다.
BLoC 테스팅: 격리된 비즈니스 로직 테스트
BLoC 패턴의 가장 큰 장점 중 하나는 비즈니스 로직을 UI와 완전히 분리하여 테스트하기 쉽다는 점입니다. bloc_test 패키지를 사용하면 BLoC의 동작을 효과적으로 테스트할 수 있습니다.
다음은 bloc_test 패키지를 사용한 테스트 사례입니다:
테스트 유형 | 설명 | 이점 |
---|---|---|
단위 테스트 | 개별 BLoC의 기능 테스트 | 빠른 피드백, 쉬운 디버깅 |
통합 테스트 | 여러 BLoC 간의 상호작용 테스트 | 시스템 레벨 오류 발견 |
상태 순서 테스트 | 특정 이벤트 후 상태 변화 순서 검증 | 비동기 로직 오류 발견 |
예외 테스트 | 오류 발생 시 BLoC 동작 테스트 | 예외 처리 로직 검증 |
타임아웃 테스트 | 시간 제한 내 상태 변화 검증 | 성능 이슈 조기 발견 |
다음은 카운터 BLoC 테스트 예제입니다.
import 'package:bloc_test/bloc_test.dart';
import 'package:test/test.dart';
void main() {
group('CounterBloc', () {
late CounterBloc counterBloc;
setUp(() {
counterBloc = CounterBloc();
});
tearDown(() {
counterBloc.close();
});
test('초기 상태는 0이어야 함', () {
expect(counterBloc.state, 0);
});
blocTest(
'CounterIncrementPressed 이벤트는 상태를 1 증가시켜야 함',
build: () => counterBloc,
act: (bloc) => bloc.add(CounterIncrementPressed()),
expect: () => [1],
);
blocTest(
'CounterDecrementPressed 이벤트는 상태를 1 감소시켜야 함',
build: () => counterBloc,
act: (bloc) => bloc.add(CounterDecrementPressed()),
expect: () => [-1],
);
});
}
실제 프로젝트 사례: BLoC으로 대규모 앱 구조화하기
대규모 Flutter 앱에서 BLoC 패턴을 효과적으로 적용하려면 프로젝트 구조를 체계적으로 설계해야 합니다. 많은 기업들이 Clean Architecture와 BLoC을 결합하여 사용하는데, 이것이 유지보수성과 확장성을 극대화하는 방법입니다.
다음은 BLoC 패턴을 적용한 대규모 앱의 일반적인 폴더 구조인데요,
-
lib/
- core/ - 공통 기능 (네트워크, 저장소, 유틸리티 등)
- data/ - 데이터 소스 및 리포지토리 구현
- domain/ - 비즈니스 로직, 엔티티, 리포지토리 인터페이스
-
presentation/ - UI 및 BLoC
-
feature1/
- bloc/ - 해당 기능의 BLoC, 이벤트, 상태
- screens/ - 화면 구현
- widgets/ - 해당 기능만의 위젯
- feature2/
-
feature1/
- di/ - 의존성 주입
- main.dart - 애플리케이션 진입점
대규모 프로젝트에서 BLoC을 효과적으로 관리하기 위한 몇 가지 팁:
- 글로벌 BLoC과 로컬 BLoC을 구분하세요. 앱 전체에서 필요한 상태(인증, 테마 등)는 글로벌 BLoC으로, 특정 화면에만 필요한 상태는 로컬 BLoC으로 관리하는 것이 좋습니다.
- BLoC 접근자를 만들어 BLoC 인스턴스에 쉽게 접근할 수 있게 하세요. get_it 같은 의존성 주입 패키지를 활용하면 효율적입니다.
- 불변 상태를 유지하세요. 모든 상태 객체는 불변(immutable)이어야 하며, 상태 변경 시 항상 새 객체를 생성해야 합니다.
많은 개발자들이 BLoC 패턴을 사용하면서 몇 가지 일반적인 실수를 범합니다. 다음은 실제 프로젝트에서 BLoC을 효과적으로 사용하기 위한 몇 가지 추가 팁입니다:
- 과도한 BLoC 생성 피하기: 각 화면마다 BLoC을 만들지 마세요. 대신 관련 기능을 그룹화하여 하나의 BLoC으로 관리하는 것이 효율적입니다.
- BLoC에서 UI 의존성 제거: BLoC은 BuildContext나 위젯에 의존해서는 안 됩니다. 이렇게 하면 테스트가 어려워지고 관심사 분리 원칙이 깨집니다.
- 리포지토리 패턴 활용: BLoC은 직접 API 호출이나 데이터베이스 작업을 수행하지 않아야 합니다. 대신 리포지토리 계층을 통해 데이터에 접근하여 코드 재사용성을 높이세요.
- BLoC Observer 활용: BlocObserver를 상속받아 모든 BLoC의 이벤트와 상태 변화를 로깅하면 디버깅이 훨씬 쉬워집니다.
실전 예제: 사용자 인증 시스템 구현
이제 실제로 자주 구현해야 하는 사용자 인증 시스템을 BLoC 패턴으로 구현하는 방법을 살펴보겠습니다. 로그인, 로그아웃, 토큰 관리 등의 기능을 갖춘 인증 시스템을 어떻게 구현할 수 있는지 알아봅시다.
다음은 전체 인증 시스템의 핵심 코드입니다. Clean Architecture를 적용하여 코드 유지보수성을 극대화했습니다.
// 1. 도메인 계층 - 인증 관련 엔티티 정의
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
// 2. 도메인 계층 - 인증 리포지토리 인터페이스
abstract class AuthRepository {
Future login(String email, String password);
Future logout();
Future getCurrentUser();
Stream get user;
}
// 3. 데이터 계층 - 인증 리포지토리 구현
class AuthRepositoryImpl implements AuthRepository {
final AuthApi _authApi;
final TokenStorage _tokenStorage;
final _userController = StreamController.broadcast();
AuthRepositoryImpl({
required AuthApi authApi,
required TokenStorage tokenStorage,
}) : _authApi = authApi, _tokenStorage = tokenStorage;
@override
Stream get user => _userController.stream;
@override
Future login(String email, String password) async {
try {
final response = await _authApi.login(email, password);
await _tokenStorage.saveToken(response.token);
final user = User(
id: response.userId,
name: response.name,
email: response.email,
);
_userController.add(user);
return user;
} catch (e) {
throw AuthException('로그인 실패: ${e.toString()}');
}
}
@override
Future logout() async {
try {
await _tokenStorage.deleteToken();
_userController.add(null);
} catch (e) {
throw AuthException('로그아웃 실패: ${e.toString()}');
}
}
@override
Future getCurrentUser() async {
try {
final token = await _tokenStorage.getToken();
if (token == null) {
_userController.add(null);
return null;
}
final response = await _authApi.getUserInfo();
final user = User(
id: response.userId,
name: response.name,
email: response.email,
);
_userController.add(user);
return user;
} catch (e) {
_userController.add(null);
return null;
}
}
}
// 4. 프레젠테이션 계층 - Authentication 이벤트
abstract class AuthEvent extends Equatable {
@override
List
이제 위 코드를 UI와 연결해 보겠습니다. 로그인 화면에서 AuthBloc을 사용하는 방법입니다:
class LoginScreen extends StatelessWidget {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('로그인')),
body: BlocConsumer(
listener: (context, state) {
if (state is AuthAuthenticated) {
// 로그인 성공 시 홈 화면으로 이동
Navigator.of(context).pushReplacementNamed('/home');
} else if (state is AuthFailure) {
// 로그인 실패 시 에러 메시지 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _emailController,
decoration: InputDecoration(labelText: '이메일'),
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: InputDecoration(labelText: '비밀번호'),
obscureText: true,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: state is AuthLoading
? null
: () {
// 로그인 이벤트 발생
context.read().add(
LoginRequested(
email: _emailController.text,
password: _passwordController.text,
),
);
},
child: state is AuthLoading
? CircularProgressIndicator()
: Text('로그인'),
),
],
),
);
},
),
);
}
}
마지막으로, 앱의 진입점에서 BloC을 제공하는 방법입니다:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 의존성 주입 설정
final authApi = AuthApiImpl();
final tokenStorage = TokenStorageImpl();
final authRepository = AuthRepositoryImpl(
authApi: authApi,
tokenStorage: tokenStorage,
);
// 글로벌 BlocObserver 설정
Bloc.observer = MyBlocObserver();
runApp(MyApp(authRepository: authRepository));
}
class MyApp extends StatelessWidget {
final AuthRepository authRepository;
const MyApp({Key? key, required this.authRepository}) : super(key: key);
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AuthBloc(
authRepository: authRepository,
)..add(AppStarted()),
),
// 다른 글로벌 BLoC 추가
],
child: MaterialApp(
title: 'BLoC 패턴 데모',
theme: ThemeData(primarySwatch: Colors.blue),
home: BlocBuilder(
builder: (context, state) {
if (state is AuthInitial || state is AuthLoading) {
return SplashScreen();
} else if (state is AuthAuthenticated) {
return HomeScreen();
} else {
return LoginScreen();
}
},
),
),
);
}
}
이 예제는 인증 시스템을 구현하는 일반적인 패턴을 보여주지만, 실제 프로젝트에서는 토큰 갱신, 소셜 로그인, 권한 관리 등 추가 기능이 필요할 수 있습니다. 그러나 BLoC 패턴의 기본 구조는 이런 추가 기능을 쉽게 통합할 수 있도록 설계되어 있습니다.
마무리: BLoC 패턴의 미래와 발전 방향
BLoC 패턴은 Flutter 앱 개발의 중요한 부분이 되었지만, 모든 상황에 완벽한 해결책은 아닙니다. 각 프로젝트의 특성과 팀의 역량에 맞게 선택하는 것이 중요합니다. 앞으로 BLoC 패턴은 다음과 같은 방향으로 발전할 것으로 예상됩니다:
첫째, 더 좋은 개발자 경험을 위한 도구와 코드 생성기가 만들어질 것입니다. BLoC 패턴은 보일러플레이트 코드가 많아 피로감을 줄 수 있는데, 코드 생성을 통해 이를 최소화하는 도구들이 등장하고 있습니다.
둘째, Riverpod와 같은 다른 상태 관리 솔루션의 장점을 흡수하는 방향으로 진화할 가능성이 있습니다. 이미 flutter_bloc 패키지는 지속적으로 개선되고 있으며, 더 직관적인 API를 제공하기 위해 노력하고 있습니다.
셋째, Flutter 웹과 데스크톱 지원이 강화됨에 따라 BLoC 패턴도 이에 맞게 최적화될 것입니다. 특히 대규모 웹 애플리케이션에서 상태 관리는 더욱 중요해질 텐데, BLoC 패턴이 이러한 요구에 적응하리라 예상됩니다.
이 글에서 우리는 BLoC 패턴의 기본 개념부터 고급 기법, 실제 구현 사례까지 살펴보았습니다. 상태 관리는 Flutter 앱 개발에서 가장 중요한 부분 중 하나이며, BLoC 패턴은 확장성과 유지보수성을 고려한 대규모 앱에 특히 적합합니다. 물론 모든 앱에 BLoC이 필요한 것은 아니지만, 복잡한 비즈니스 로직을 다루는 프로젝트라면 반드시 고려해볼 가치가 있습니다.
마지막으로, BLoC 패턴을 배우는 것은 단순히 하나의 패턴을 익히는 것 이상의 가치가 있습니다. 상태 관리의 핵심 원칙, 관심사 분리, 테스트 가능한 코드 작성 등 소프트웨어 개발의 근본적인 개념을 이해하는 데 도움이 됩니다. 이러한 지식은 다른 프레임워크나 플랫폼으로 전환하더라도 계속해서 가치를 발휘할 것입니다.
BLoC 패턴을 마스터하는 길은 처음에는 험난할 수 있지만, 그 투자는 결국 더 유지보수하기 쉽고, 테스트하기 쉬우며, 확장성 있는 앱으로 돌아옵니다. 여러분의 다음 프로젝트에서 BLoC 패턴을 적용해보시길 추천합니다. 물론 소규모 프로젝트라면 Provider나 GetX를 사용하는 것이 더 효율적일 수 있지만, 장기적인 관점에서 앱이 성장함에 따라 BLoC의 가치는 더 분명해질 것입니다.
'Developer > Flutter' 카테고리의 다른 글
[Flutter 공부] Riverpod (0) | 2025.03.18 |
---|---|
[Flutter 공부] GetX 정리 (0) | 2025.03.18 |
[Flutter 공부] 성능 최적화, 앱 성능 모니터링과 개선 방법 (0) | 2025.03.16 |
[Flutter 공부] 반응형 UI 구현하기 - 다양한 화면 크기에 적응하는 앱 개발 (0) | 2025.03.16 |
[Flutter 공부] Flutter 멀티 플랫폼 고려사항 - iOS와 Android 플랫폼별 차이점 (0) | 2025.03.16 |