[Flutter] Bloc 사용해보기

leeeeeoy·2023년 5월 5일
4

이 글은 공식 문서를 참고하여 정리한 글입니다.

Bloc

Flutter에서 자주 쓰이는 상태관리 패턴 중 하나인 Bloc은 Business Logic Component의 약자로 비지니스 로직과 화면 영역간의 분리를 목적으로 한다. Flutter에서 자주 쓰이긴 하지만 사실 개념적으론 디자인 패턴이기 때문에 Flutter가 아닌 다른 어떤 프로젝트에도 적용 가능하다.

Bloc의 경우 지금 회사의 프로젝트에서도 그렇고, 예전 사이드 프로젝트를 하면서도 자주 써왔던 패턴 중에 하나이다. 예전에 작성했던 Riverpod와 유사한 부분이 있으면서도 각각 장단점이 있는데, 써왔던 경험을 바탕으로 간단하게 되짚어보려 한다.

Why is Bloc?

먼저 Bloc을 사용하는 이유에 대해서 알아보자. 공식 페이지에 보면 이렇게 되어있다.

Bloc makes it easy to separate presentation from business logic, making your code fast, easy to test, and reusable.

즉 각 목적에 맞게 로직을 분리해서 코드를 작성하는데 있어서 더 효율적으로 작성할 수 있고, 테스트를 쉽게 하고, 재사용성을 높이는데 있다고 한다. 추가로 설명글을 읽어보면 3가지 목적을 강조하고 있는데,

Simple: Easy to understand & can be used by developers with varying skill levels.
Powerful: Help make amazing, complex applications by composing them of smaller components.
Testable: Easily test every aspect of an application so that we can iterate with confidence.

간단하고, 강력하고, 테스트에 유리하다고 설명하고 있다. 사실 Bloc을 써보는 사람이라면 대부분 공감을 하겠지만, Bloc은 이러한 목적에 굉장히 유리하게 설계되었는데 더 자세한건 코드를 작성하면서 살펴보자


Package 설정

pubspec.yaml

dependencies:
	flutter_bloc: 

flutter_bloc를 추가해주면 된다. 이렇게 만으로도 bloc을 사용할 수 있지만, bloc은 equatable 또는 freezed 와 같은 패키지와 자주 쓰이는데, 그 이유는 Bloc의 State에 객체 비교가 아닌 값 비교를 통해 동작을 유도하기 때문이다. 두 패키지 중 어느것을 사용해도 큰 차이는 없기 때문에 사용하기 편한 것을 쓰면 된다. 이 글에서는 freezed를 적용했다.

기본 개념

앞서 설명한대로 Bloc은 비지니스 로직과 화면 로직을 분리하는데 목적이 있다. 예시로 서버로부터 데이터를 요청하는 과정이 있다고 해보자. 그럼 화면에서는 데이터를 요청하는 행위(Event)를 Bloc에 밀어넣고(add), Bloc은 해당 요청에 대한 로직을 처리한 후, 처리 결과(State)를 다시 화면에 알려준다(emit).

조금 더 구체적으로 설명을 붙이면,
Event는 화면에서 데이터를 변경하기 위한 모든 행위에 해당된다. 화면에서는 어떤 로직으로 데이터가 변경되는지에 대한 것을 모른채로 원하는 행위에 대한 요청을 Bloc에 요청한다.
State는 Event를 요청한 결과이다. 예를 들어 앞서 예시처럼 데이터를 요청했을 때, 성공적으로 데이터를 받아올수도 있고, 데이터를 받아오다가 에러가 발생할수도 있고, 또는 데이터를 받아오는데 오래 걸려서 로딩중일수도 있다. 즉 이러한 Event에 대한 결과 상태를 나타내며, 화면에서는 비지니스 로직에 관계없이 State에 따라 어떻게 보여줄지, 혹은 어떻게 처리할지에 대한 것만 관여하면 된다.

이론적인 내용도 좋지만 역시 이해는 코드다. 바로 예시 코드를 작성해보자


작성 예시

간단하게 email과 password를 입력받는 SignInPage를 작성해보았다. 글에서는 설명 부분에 대해서만 코드를 첨부하겠다. 전체 코드는 여기에서 확인 할 수 있다.

먼저 비지니스 로직을 처리하는 SignInBloc이다. Bloc은 앞서 설명한것처럼 Event와 State로 구분되는데, 먼저 Event를 정의해보겠다. SignInPage에서 처리해야 될 비지니스 로직은 입력된 email과 password를 서버에 요청해 결과를 받아오는 것이다. 따라서 Event는 다음과 같이 작성 할 수 있다.

SignInEvent

part of 'sign_in_bloc.dart';


class SignInEvent with _$SignInEvent {
  const factory SignInEvent.request({
    required String email,
    required String password,
  }) = SignInRequested;
}

서버에 요청하는 Event를 다음과 같이 작성해주면 된다. 이어서 State를 작성해보자

SignInState

part of 'sign_in_bloc.dart';


class SignInState with _$SignInState {
  const factory SignInState.initial() = SignInInitial;

  const factory SignInState.loading() = SignInLoading;

  const factory SignInState.succeed() = SignInSucceed;

  const factory SignInState.error() = SignInError;
}

서버에 요청했을 때, 받을 수 있는 상태는 여러가지가 있을 수 있겠지만 일반적으로는 다음과 같을 수 있다.

  1. 요청을 하기 전 최초 상태
  2. 요청을 한 후 요청을 기다리는 상태
  3. 요청을 성공적으로 받은 상태
  4. 요청에 실패한 상태

물론 세부적으로 더 많은 상태로 분류 할 수 있지만 예시를 위해 가장 간단한 케이스로 나누어보았다. 앞서 Event와 마찬가지로 각 상태에 대해서 State를 작성해주며 된다. 이렇게 작성해주면 Bloc을 작성해주기 위한 기본 세팅이 끝난다. 그럼 Bloc을 작성해보자.

SignInBloc

import 'dart:async';

import 'package:bloc_architecture/feature/auth/domain/usecase/param/sign_in_usecase_param.dart';
import 'package:bloc_architecture/feature/auth/domain/usecase/sign_in_usecase.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

part 'sign_in_event.dart';
part 'sign_in_state.dart';
part 'sign_in_bloc.freezed.dart';

class SignInBloc extends Bloc<SignInEvent, SignInState> {
  SignInBloc({
    required this.signInUseCase,
  }) : super(const SignInInitial()) {
    on<SignInRequested>(_onSignInRequested);
  }

  final SignInUseCase signInUseCase;

  FutureOr<void> _onSignInRequested(
    SignInRequested event,
    Emitter<SignInState> emit,
  ) async {
    emit(const SignInLoading());

    try {
      await signInUseCase.execute(SignInUseCaseParam(
        email: event.email,
        password: event.password,
      ));

      emit(const SignInSucceed());
    } catch (_) {
      emit(const SignInError());
    }
  }
}

앞서 Event와 State를 작성했다면 Bloc에서는 적절한 로직을 통해 해당 Event가 들어왔을 때 어떻게 State를 반영해줄 것인지에 대한 부분을 작성하면 된다. 앞서 SignInBloc에는 1개의 Event가 있으므로 1개의 Event에 대해 작성해주며 된다.

flutter_bloc을 사용하게 되면 on Extension을 사용해서 각 Evnet에 맞는 함수를 쉽게 매핑 할 수 있다.

class SignInBloc extends Bloc<SignInEvent, SignInState> {
  SignInBloc({
    required this.signInUseCase,
  }) : super(const SignInInitial()) {
    on<SignInRequested>(_onSignInRequested);
  }
}

이렇게 생성자 부분에 on을 이용해 매핑할 함수를 등록해주고

FutureOr<void> _onSignInRequested(
  SignInRequested event,
  Emitter<SignInState> emit,
) async {
  emit(const SignInLoading());

  try {
    await signInUseCase.execute(SignInUseCaseParam(
      email: event.email,
      password: event.password,
    ));

    emit(const SignInSucceed());
  } catch (_) {
    emit(const SignInError());
  }
}

아래와 같이 함수를 구현해주면 화면에서 해당 Event를 요청 했을 때, 해당 Event에 맞는 함수를 호출해 동작하게 된다. 해당 함수 내에서는 각 비지니스 로직과 함께 State를 반영해주게 되는데, emit 함수를 통해 화면에서 받을 상태를 넘겨 줄 수 있다. 즉 로직을 처리하는 도중 어떤 State를 넘겨줄 지 진행상황과 결과에 맞게 작성해주면 된다. 예시 코드에서는 try catch 구문으로 요청을 보낸 후 요청 상태를 반영해주고, 에러가 없으면 성공을, 실패하면 에러 상태를 반영해주도록 작성했다.

이렇게 되면 비지니스 로직을 처리하는 Bloc의 작성은 끝났다. 그럼 SignInPage를 작성해보자

SignInPage

import 'package:bloc_architecture/feature/auth/presentation/sign_in/bloc/sign_in_bloc.dart';
import 'package:bloc_architecture/routes/app_routes.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';

class SignInPage extends StatefulWidget {
  const SignInPage({super.key});

  
  State<SignInPage> createState() => _SignInPageState();
}

class _SignInPageState extends State<SignInPage> {
  final emailController = TextEditingController();
  final passwordController = TextEditingController();

  final formKey = GlobalKey<FormState>();

  
  void dispose() {
    emailController.dispose();
    passwordController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return BlocListener<SignInBloc, SignInState>(
      listener: (context, state) => state.maybeWhen(
        orElse: () => null,
        succeed: () => GoRouter.of(context).pushNamed(AppRouteState.home.name),
        error: () => showDialog(
          context: context,
          builder: (context) => CupertinoAlertDialog(
            title: const Text('로그인 실패'),
            content: const Text('이메일 혹은 비밀번호를 확인해주세요'),
            actions: [
              CupertinoDialogAction(
                child: const Text('확인'),
                onPressed: () => Navigator.of(context).pop(),
              ),
            ],
          ),
        ),
      ),
      child: GestureDetector(
        onTap: () => FocusScope.of(context).unfocus(),
        child: Scaffold(
          appBar: AppBar(title: const Text('SignInPage')),
          body: Form(
            key: formKey,
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Column(
                children: [
                  const SizedBox(height: 24),
                  const FlutterLogo(size: 48),
                  const SizedBox(height: 16),
                  TextFormField(
                    key: const Key('emailTextField'),
                    controller: emailController,
                    textInputAction: TextInputAction.done,
                    maxLength: 20,
                    decoration: InputDecoration(
                      label: const Text('Email'),
                      counterText: '',
                      suffixIcon: InkWell(
                        onTap: () => emailController.clear(),
                        child: const Icon(Icons.cancel),
                      ),
                    ),
                    validator: (value) {
                      if (value?.isNotEmpty == true) {
                        return null;
                      }

                      return '아이디를 입력해주세요';
                    },
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    key: const Key('passwordTextField'),
                    controller: passwordController,
                    textInputAction: TextInputAction.done,
                    maxLength: 15,
                    obscureText: true,
                    decoration: InputDecoration(
                      label: const Text('Password'),
                      counterText: '',
                      suffixIcon: InkWell(
                        onTap: () => passwordController.clear(),
                        child: const Icon(Icons.cancel),
                      ),
                    ),
                    validator: (value) {
                      if (value?.isNotEmpty == true) {
                        return null;
                      }

                      return '비밀번호를 입력해주세요';
                    },
                  ),
                  const SizedBox(height: 16),
                  BlocBuilder<SignInBloc, SignInState>(
                    builder: (context, state) => state.maybeWhen(
                      loading: () => const CircularProgressIndicator.adaptive(),
                      orElse: () => ElevatedButton(
                        onPressed: () {
                          if (!formKey.currentState!.validate()) {
                            return;
                          }

                          context.read<SignInBloc>().add(
                                SignInRequested(
                                  email: emailController.text,
                                  password: passwordController.text,
                                ),
                              );
                        },
                        child: const Text('Login'),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Form을 이용해 2개의 TextField와 확인 버튼이 있는 간단한 페이지를 작성해주었다.
아까 작성한 SignInBloc의 State를 화면에서 받기 위해선 BlocBuilder, BlocListener 등을 이용해야 되는데, 이 Widget들은 State에 따라 처리할 화면 혹은 콜백 함수 등을 등록 할 수 있게 해준다.

BlocBuilder

                  BlocBuilder<SignInBloc, SignInState>(
                    builder: (context, state) => state.maybeWhen(
                      loading: () => const CircularProgressIndicator.adaptive(),
                      orElse: () => ElevatedButton(
                        onPressed: () {
                          if (!formKey.currentState!.validate()) {
                            return;
                          }

                          context.read<SignInBloc>().add(
                                SignInRequested(
                                  email: emailController.text,
                                  password: passwordController.text,
                                ),
                              );
                        },
                        child: const Text('Login'),
                      ),
                    ),
                  )

BlocBuilder는 State에 따라 화면을 변경하고 싶을 때 사용한다. builder안에 각 state에서 화면을 어떻게 보여줄지 필요에 따라 작성하면 된다. 예시 코드에서는 로딩 상태일 때 로딩 인디케이터를 보여주고, 나머지 상태에서는 버튼을 보여주도록 작성했다. 예시 코드에서는 사용하지 않았지만 buildWhen이라는 프로퍼티를 이용해 지정한 State에서만 rebuild 되도록 조건을 걸어줄 수도 있다. builderWhen 참고자료

BlocListener

    BlocListener<SignInBloc, SignInState>(
      listener: (context, state) => state.maybeWhen(
        orElse: () => null,
        succeed: () => GoRouter.of(context).pushNamed(AppRouteState.home.name),
        error: () => showDialog(
          context: context,
          builder: (context) => CupertinoAlertDialog(
            title: const Text('로그인 실패'),
            content: const Text('이메일 혹은 비밀번호를 확인해주세요'),
            actions: [
              CupertinoDialogAction(
                child: const Text('확인'),
                onPressed: () => Navigator.of(context).pop(),
              ),
            ],
          ),
        ),
      ),
    )

BlocListener는 화면 변경이 아닌 특정 State에 따라 함수를 실행하고 싶을 때 사용한다. 예시 코드에서는 성공 상태가 반영된다면 다음 페이지로 이동하고, 에러 상태가 반영되면 다이얼로그를 띄워주도록 작성했다. BlocBuilder와 비슷하게 listenWhen이라는 프로퍼티를 통해 지정한 State에서만 함수가 동작하도록 역시 조건을 걸어줄 수 있다. listenWhen 참고자료

비지니스 로직과 화면 로직을 작성하는건 OK! 그럼 여기서 끝...?

이렇게 작성하면 앞서 작성한 SignInBloc의 State에 맞춰 SignInPage의 화면이 변하거나 혹은 다음 페이지로 이동하는 등의 동작이 가능해진거 같다. 그렇다면 이대로 작성하면 기대한대로 동작을 할까? 아쉽게도 아니다. SignInPage에서 SignInBloc에 접근하고 또 State를 받기 위해선 SignInBloc을 제공(공급)해주어야 되는데 위의 예시코드에서는 그런 부분이 존재하지 않는다. SignInPage는 어떻게 SignInBloc을 찾을 수 있을까?

BlocProvider

        BlocProvider(
          create: (context) => SignInBloc(signInUseCase: getIt()),
          child: const SignInPage(),
        ),

바로 이 BlocProvider를 통해 사용할 Bloc을 제공해주어야 한다. pub.dev에 flutter_bloc 문서를 보신 분들은 눈치챘겠지만 flutter_bloc은 내부적으로 provider를 사용하고 있다. 즉 이 BlocProvider는 BuildContext를 타는 Widget tree를 이용해 공급되고 또 사라진다.

BlocProvider를 이용해 Bloc을 공급해 주는 시점은 해당 Page에서 Bloc을 사용하기 전이면 어떤 상위의 BuildContext인지 상관이 없지만 만약 적절한 시기에 Bloc이 사라지는 것을 원한다면 사용되는 Widget tree에서 크게 벗어나지 않도록 공급해주는게 중요하다.

예시 코드에서는 SignInPage에 이동하기 직전에 공급해주었기 때문에, 만약 SignInPage를 벗어나게 되면 SignInBloc도 SignInPage와 같이 사라질 것이다. 이렇게 공급된 Bloc은 해당 페이지에서 자연스럽게 context를 통해 주입되게 되며, 만약 직접 Bloc을 접근해야 될 경우에는 다음과 같이 찾을 수 있다.

context.read<SignInBloc>();

context.read<SignInBloc>().add(
	SignInRequested(
	  email: emailController.text,
      password: passwordController.text,
 	),
);

context를 이용해 가장 가까운 SignInBloc을 찾을 수 있고, 원한다면 예시 코드처럼 Event를 요청할수도 있다.

정리해보면 다음과 같다.

  1. SignInBloc 작성
  2. SignInPage 작성
  3. SignInBloc 공급

이렇게 해주면 Bloc에 따라 원하는 State에 맞춰 원하는 동작을 할 수 있다.
글에 소개된 flutter_bloc의 Widget 말고도 여러가지 Widget들이 더 있는데, 주로 사용하는 Widget 위주로 작성을 했다. 다른 Widget들은 공식 문서를 참고하면 좋을 것 같다.

Bloc의 장단점

이미 예시 코드를 작성해보면서도 느끼신 분들도 있겠지만 간단하게 Bloc이 주는 가장 명확한 장점은

  • 철저한 로직 분리다.

설계 목적에 맞게 철저한 로직 분리는 규모가 커지면 커질수록 더 유리하게 코드를 작성 할 수 있고, 후에 글을 또 작성 하겠지만 테스트에 유리하다는 점도 있다. 궁극적으로는 읽기 쉬운 코드, 이해하기 쉬운 코드, 안전한 코드를 작성하는데 있어서 유리하다는 점에서는 상당 부분 적합하다고 생각된다. 다만 이건 어느정도 규모를 갖추어 나갔을 때 이야기다. 이와 반대로 가장 많이 언급되는 Bloc의 단점 중 하나는 바로

  • 너무 많은 코드의 양이다.

예시 코드만 봐도 알겠지만 하나의 Bloc을 작성하기 위해선 각 Event와 State를 모두 작성해주어야 한다. 만약 Event가 늘어나거나 혹은 하나의 화면에서 사용해야 될 Bloc의 갯수가 많아지게 된다면 이는 더더욱 늘어나게 된다. 초기 MVP 형태의 작업 혹은 규모가 적은 상황에서는 이러한 코드 부담은 오히려 안좋은 영향을 끼칠수도 있다. 결국 규모와 형태에 맞게 적절한 상태관리를 선택해 사용하는 것 또한 개발의 일부분이니 각 상황에 맞게 적절하게 사용할 필요가 있는 것 같다.

정리

Bloc은 사실 그동안 가장 많이 쓴 상태관리 중에 하나이다. 그만큼 많이 익숙해지기도 하고 또 잘 사용했다고 생각했는데, 이것저것 찾으면서 정리를 하다보니 놓쳤던 부분들도 있고, 또 몰랐던 부분들도 있어서 차근차근 다시 복습을 하고 있다. 회사에서는 Bloc말고도 Riverpod도 사용해보고, 또 회사 프로젝트 이외에 사이드 프로젝트에서는 GetX를 이용해서 작업도 해봤는데, 역시 각 상태관리마다 적절한 시기와 규모에 맞는 장단점이 있는 것 같다. 어떤 상태관리를 택하던지 결국 목적은 같기 때문에 사용하기 편하고 어울리는 적절한 것을 택하는게 베스트가 되지 않을까 싶다. 다만 '이게 좋다더라~', '이게 대세던데?' 등의 식의 접근은 조금 위험하다고 생각된다. 언제나 그렇듯 정답은 없기 때문에 무엇을 사용하던 신중하게 고민하고 선택하는 부분은 꼭 필요할 것 같다. 최근 게을러져서 글도 잘 못쓰고 쉬는 날이면 낮잠 자는 시간이 늘고...회고 때 반성한다고 했던거 같은데 아직 반성 ing 같다...


참고자료

profile
100년 후엔 풀스택

0개의 댓글