Flutter에서 BLoC 패턴을 어떻게 구현하나요?

질문

Flutter에서 상태 관리를 위한 BLoC(Business Logic Component) 패턴을 어떻게 구현하나요?

답변

BLoC(Business Logic Component) 패턴은 Flutter 애플리케이션에서 상태 관리를 위한 아키텍처 패턴으로, 비즈니스 로직을 UI로부터 분리하여 코드의 가독성, 재사용성, 테스트 용이성을 높이는 것을 목표로 합니다. BLoC 패턴은 Flutter 팀의 멤버인 Paolo Soares와 Cong Hui에 의해 소개되었으며, 이벤트와 상태 스트림을 통해 컴포넌트 간 통신을 처리합니다.

BLoC 패턴의 핵심 개념

BLoC 패턴의 핵심 개념은 다음과 같습니다:

  1. 이벤트(Events): 사용자 동작이나 외부 데이터 변경과 같은 시스템 입력
  2. 상태(States): UI에 반영되는 애플리케이션의 현재 상태
  3. BLoC(Business Logic Component): 이벤트를 받아 상태로 변환하는 비즈니스 로직 컴포넌트

이러한 구조는 단방향 데이터 흐름을 만들어 애플리케이션 상태를 예측 가능하게 관리할 수 있게 합니다.

BLoC 패턴 구현 방법

Flutter에서 BLoC 패턴을 구현하는 방법은 여러 가지가 있지만, 가장 널리 사용되는 방법은 다음과 같습니다:

1. 순수 Dart Stream을 사용한 BLoC 구현

import 'dart:async';

// 이벤트 정의
enum CounterEvent { increment, decrement }

// BLoC 클래스
class CounterBloc {
  // 상태
  int _counter = 0;

  // 이벤트를 받는 스트림 컨트롤러
  final _eventController = StreamController<CounterEvent>();
  // 상태를 내보내는 스트림 컨트롤러
  final _stateController = StreamController<int>();

  // 이벤트 싱크(입력)
  Sink<CounterEvent> get eventSink => _eventController.sink;
  // 상태 스트림(출력)
  Stream<int> get stateStream => _stateController.stream;

  CounterBloc() {
    // 이벤트 스트림 리스너
    _eventController.stream.listen(_mapEventToState);
  }

  // 이벤트를 상태로 변환
  void _mapEventToState(CounterEvent event) {
    if (event == CounterEvent.increment) {
      _counter++;
    } else if (event == CounterEvent.decrement) {
      _counter--;
    }

    // 새 상태 발행
    _stateController.add(_counter);
  }

  // 리소스 해제
  void dispose() {
    _eventController.close();
    _stateController.close();
  }
}

사용 예시:

class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  final _bloc = CounterBloc();

  @override
  void dispose() {
    _bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Counter BLoC')),
      body: Center(
        child: StreamBuilder<int>(
          stream: _bloc.stateStream,
          initialData: 0,
          builder: (context, snapshot) {
            return Text(
              '${snapshot.data}',
              style: TextStyle(fontSize: 24),
            );
          },
        ),
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            heroTag: 'increment',
            child: Icon(Icons.add),
            onPressed: () => _bloc.eventSink.add(CounterEvent.increment),
          ),
          SizedBox(height: 10),
          FloatingActionButton(
            heroTag: 'decrement',
            child: Icon(Icons.remove),
            onPressed: () => _bloc.eventSink.add(CounterEvent.decrement),
          ),
        ],
      ),
    );
  }
}

2. bloc 패키지를 사용한 구현

bloc 패키지는 BLoC 패턴 구현을 단순화하기 위해 Felix Angelov에 의해 개발되었습니다. 다음은 bloc 패키지를 사용한 동일한 카운터 예제입니다:

먼저 패키지를 추가합니다:

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3
  bloc: ^8.1.2
  equatable: ^2.0.5

이벤트와 상태 정의:

import 'package:equatable/equatable.dart';

// 이벤트 정의
abstract class CounterEvent extends Equatable {
  const CounterEvent();

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

class CounterIncrement extends CounterEvent {}
class CounterDecrement extends CounterEvent {}

// 상태 정의 (이 예제에서는 간단히 int 사용)

BLoC 구현:

import 'package:bloc/bloc.dart';

class CounterBloc extends Bloc<CounterEvent, int> {
  // 초기 상태는 0
  CounterBloc() : super(0) {
    on<CounterIncrement>((event, emit) {
      emit(state + 1);
    });

    on<CounterDecrement>((event, emit) {
      emit(state - 1);
    });
  }
}

UI에서 사용:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => CounterBloc(),
      child: _CounterView(),
    );
  }
}

class _CounterView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Counter BLoC')),
      body: Center(
        child: BlocBuilder<CounterBloc, int>(
          builder: (context, count) {
            return Text(
              '$count',
              style: TextStyle(fontSize: 24),
            );
          },
        ),
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            heroTag: 'increment',
            child: Icon(Icons.add),
            onPressed: () => context.read<CounterBloc>().add(CounterIncrement()),
          ),
          SizedBox(height: 10),
          FloatingActionButton(
            heroTag: 'decrement',
            child: Icon(Icons.remove),
            onPressed: () => context.read<CounterBloc>().add(CounterDecrement()),
          ),
        ],
      ),
    );
  }
}

복잡한 예제: 사용자 인증 BLoC

다음은 사용자 인증을 처리하는 더 복잡한 BLoC 예제입니다:

이벤트와 상태 정의:

import 'package:equatable/equatable.dart';

// 이벤트
abstract class AuthEvent extends Equatable {
  const AuthEvent();

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

class AuthLoginRequested extends AuthEvent {
  final String username;
  final String password;

  AuthLoginRequested({required this.username, required this.password});

  @override
  List<Object> get props => [username, password];
}

class AuthLogoutRequested extends AuthEvent {}

// 상태
abstract class AuthState extends Equatable {
  const AuthState();

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

class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
  final String token;

  AuthAuthenticated({required this.token});

  @override
  List<Object> get props => [token];
}
class AuthUnauthenticated extends AuthState {}
class AuthFailure extends AuthState {
  final String message;

  AuthFailure({required this.message});

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

사용자 리포지토리:

class UserRepository {
  Future<String> authenticate({
    required String username,
    required String password,
  }) async {
    // 실제로는 API를 호출하여 인증
    await Future.delayed(Duration(seconds: 1));

    if (username == 'admin' && password == 'password') {
      return 'token_abc123';
    } else {
      throw Exception('인증 실패');
    }
  }

  Future<void> logout() async {
    // 로그아웃 로직
    await Future.delayed(Duration(milliseconds: 300));
  }
}

BLoC 구현:

import 'package:bloc/bloc.dart';

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final UserRepository userRepository;

  AuthBloc({required this.userRepository}) : super(AuthInitial()) {
    on<AuthLoginRequested>(_onLoginRequested);
    on<AuthLogoutRequested>(_onLogoutRequested);
  }

  Future<void> _onLoginRequested(
    AuthLoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthLoading());

    try {
      final token = await userRepository.authenticate(
        username: event.username,
        password: event.password,
      );

      emit(AuthAuthenticated(token: token));
    } catch (e) {
      emit(AuthFailure(message: e.toString()));
    }
  }

  Future<void> _onLogoutRequested(
    AuthLogoutRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthLoading());

    try {
      await userRepository.logout();
      emit(AuthUnauthenticated());
    } catch (e) {
      emit(AuthFailure(message: e.toString()));
    }
  }
}

UI 구현:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('로그인')),
      body: BlocProvider(
        create: (context) => AuthBloc(
          userRepository: RepositoryProvider.of<UserRepository>(context),
        ),
        child: _LoginForm(),
      ),
    );
  }
}

class _LoginForm extends StatefulWidget {
  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<_LoginForm> {
  final _usernameController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return BlocListener<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state is AuthAuthenticated) {
          // 로그인 성공 시 홈 화면으로 이동
          Navigator.pushReplacementNamed(context, '/home');
        } else if (state is AuthFailure) {
          // 오류 표시
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(state.message)),
          );
        }
      },
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _usernameController,
              decoration: InputDecoration(labelText: '사용자 이름'),
            ),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(labelText: '비밀번호'),
              obscureText: true,
            ),
            SizedBox(height: 20),
            BlocBuilder<AuthBloc, AuthState>(
              builder: (context, state) {
                return state is AuthLoading
                  ? CircularProgressIndicator()
                  : ElevatedButton(
                      onPressed: () {
                        context.read<AuthBloc>().add(
                          AuthLoginRequested(
                            username: _usernameController.text,
                            password: _passwordController.text,
                          ),
                        );
                      },
                      child: Text('로그인'),
                    );
              },
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _usernameController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}

BLoC 패턴의 장점

  1. 관심사 분리: 비즈니스 로직을 UI와 분리하여 코드 구성이 명확해집니다.
  2. 테스트 용이성: 비즈니스 로직이 UI와 분리되어 있어 단위 테스트가 쉬워집니다.
  3. 재사용성: BLoC은 여러 위젯에서 재사용할 수 있습니다.
  4. 확장성: 복잡한 상태 관리도 구조적으로 처리할 수 있습니다.
  5. 예측 가능성: 단방향 데이터 흐름으로 앱 상태를 예측하기 쉽습니다.

BLoC 패턴 모범 사례

1. 상태 설계

상태는 가능한 한 불변(immutable)해야 하며, 애플리케이션의 현재 상태를 완전히 나타내야 합니다:

abstract class WeatherState extends Equatable {
  const WeatherState();

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

class WeatherInitial extends WeatherState {}
class WeatherLoading extends WeatherState {}
class WeatherLoaded extends WeatherState {
  final Weather weather;

  const WeatherLoaded({required this.weather});

  @override
  List<Object> get props => [weather];
}
class WeatherError extends WeatherState {
  final String message;

  const WeatherError({required this.message});

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

2. 이벤트 설계

이벤트는 BLoC에 전달되는 입력으로, 명확하고 의도를 잘 나타내야 합니다:

abstract class WeatherEvent extends Equatable {
  const WeatherEvent();

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

class WeatherRequested extends WeatherEvent {
  final String city;

  const WeatherRequested({required this.city});

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

class WeatherRefreshRequested extends WeatherEvent {
  final String city;

  const WeatherRefreshRequested({required this.city});

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

3. BLoC 구성

BLoC은 단일 책임 원칙을 따라야 합니다. 복잡한 비즈니스 로직은 여러 BLoC으로 나누는 것이 좋습니다:

// 사용자 인증 관련 BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> { /* ... */ }

// 사용자 프로필 관련 BLoC
class ProfileBloc extends Bloc<ProfileEvent, ProfileState> { /* ... */ }

// 설정 관련 BLoC
class SettingsBloc extends Bloc<SettingsEvent, SettingsState> { /* ... */ }

4. BLoC 테스트

BLoC은 단위 테스트를 통해 검증해야 합니다:

void main() {
  group('CounterBloc', () {
    late CounterBloc counterBloc;

    setUp(() {
      counterBloc = CounterBloc();
    });

    tearDown(() {
      counterBloc.close();
    });

    test('초기 상태는 0이어야 함', () {
      expect(counterBloc.state, 0);
    });

    blocTest<CounterBloc, int>(
      'CounterIncrement 이벤트는 상태를 1 증가시켜야 함',
      build: () => counterBloc,
      act: (bloc) => bloc.add(CounterIncrement()),
      expect: () => [1],
    );

    blocTest<CounterBloc, int>(
      'CounterDecrement 이벤트는 상태를 1 감소시켜야 함',
      build: () => counterBloc,
      act: (bloc) => bloc.add(CounterDecrement()),
      expect: () => [-1],
    );
  });
}

BLoC과 다른 상태 관리 솔루션 비교

BLoC 패턴은 다음과 같은 다른 상태 관리 솔루션과 비교할 수 있습니다:

  1. Provider: 간단한 상태 관리에 적합하며 학습 곡선이 낮습니다. BLoC보다 설정이 간단하지만 복잡한 상태 관리에는 덜 구조화되어 있습니다.

  2. Redux: 매우 예측 가능하고 구조화된 상태 관리 솔루션이지만, 보일러플레이트 코드가 많이 필요합니다.

  3. MobX: 반응형 프로그래밍을 기반으로 하며 간결한 코드를 작성할 수 있지만, 디버깅과 테스트가 더 어려울 수 있습니다.

  4. Riverpod: Provider의 개선된 버전으로, 보다 타입 안전한 접근 방식을 제공합니다.

BLoC은 중간 정도의 복잡성을 가진 앱에 이상적이며, 스트림 기반 접근 방식이 익숙한 개발자에게 유리합니다.

실제 애플리케이션에서의 BLoC 아키텍처

대규모 애플리케이션에서는 다음과 같이 구조화할 수 있습니다:

lib/
├── blocs/                # BLoC 클래스들
│   ├── auth/             # 인증 관련 BLoC
│   ├── user/             # 사용자 관련 BLoC
│   └── settings/         # 설정 관련 BLoC
├── repositories/         # 데이터 액세스 계층
├── data/                 # 모델 및 데이터 소스
├── ui/                   # UI 컴포넌트
│   ├── screens/          # 화면
│   ├── widgets/          # 공통 위젯
│   └── theme/            # 테마 설정
└── main.dart             # 앱 진입점

메인 파일에서는 BLoC 제공자를 설정합니다:

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiRepositoryProvider(
      providers: [
        RepositoryProvider(
          create: (context) => UserRepository(),
        ),
        RepositoryProvider(
          create: (context) => SettingsRepository(),
        ),
      ],
      child: MultiBlocProvider(
        providers: [
          BlocProvider(
            create: (context) => AuthBloc(
              userRepository: context.read<UserRepository>(),
            ),
          ),
          BlocProvider(
            create: (context) => SettingsBloc(
              settingsRepository: context.read<SettingsRepository>(),
            ),
          ),
        ],
        child: AppView(),
      ),
    );
  }
}

class AppView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<SettingsBloc, SettingsState>(
      buildWhen: (previous, current) => previous.themeMode != current.themeMode,
      builder: (context, state) {
        return MaterialApp(
          theme: ThemeData.light(),
          darkTheme: ThemeData.dark(),
          themeMode: state.themeMode,
          home: BlocBuilder<AuthBloc, AuthState>(
            builder: (context, state) {
              if (state is AuthAuthenticated) {
                return HomePage();
              }
              return LoginPage();
            },
          ),
        );
      },
    );
  }
}

결론

BLoC 패턴은 Flutter 애플리케이션에서 상태 관리를 위한 강력한 솔루션을 제공합니다. 이 패턴은 비즈니스 로직과 UI를 명확하게 분리하여 코드의 가독성, 테스트 용이성, 유지보수성을 향상시킵니다.

단순한 애플리케이션에서는 BLoC이 과도한 설정처럼 느껴질 수 있지만, 복잡한 상태 관리가 필요한 중대형 애플리케이션에서는 장기적으로 큰 이점을 제공합니다. 특히 여러 화면과 기능 간에 상태를 공유해야 하는 애플리케이션에서 BLoC 패턴은 매우 유용합니다.

처음에는 이벤트, 상태, BLoC 간의 관계를 이해하는 데 시간이 걸릴 수 있지만, 패턴을 마스터하면 확장 가능하고 유지보수하기 쉬운 코드베이스를 구축할 수 있습니다.

results matching ""

    No results matching ""