내가 Bloc를 이해한 방법

Jeeho Park (aquashdw)·2024년 12월 22일
1
post-thumbnail

이글은 Flutter로 지뢰찾기(코드)를 만들면서, Bloc를 사용해보며 느낀점을 간단히 다루는 글이다. 본격적으로 쓰기에 앞서, 글쓴이는 일로서 Flutter를 사용했던 시점이 꽤 오래되었으며, 본 프로젝트를 진행할 당시에는 이미 개발자가 아닌 강사로 활동 중이었음을 알려둔다. 또한 이 글은 Bloc의 튜토리얼이 아니며 Bloc의 다양한 기능과 역량을 확인하고 정리하는게 목적이 아니다. 그래서 의도적으로 Cubit은 언급하지 않는다. Flutter에 대한 기초 지식이 필요하며, 지뢰찾기를 예시로 들기 때문에 게임의 (엄청) 기초적인 규칙은 알아야 한다.

서론

글을 많이 쓰진 않았지만, 어떤 형태의 프로젝트를 진행하던 기본적으로 안해본걸 하는 것을 선호하는 편이라는걸 여러 방면에서 흘려왔다. 특히 일로서 프로젝트를 하지 않는 상황에서는 더욱 자유도가 중요하다고 생각한다. 업무로 개발을 하는 상황에서는 절대로 내가 원하는 스택을 이유 없이 선택하지 못하기 때문에, 상황적, 시대적으로 올바른(....에 가까운) 기술이라고 하더라도 우리(팀)의 사정에 맞춰서 선택하지 못하는 경우가 있기 때문이다.

이번에 글의 주제가 되는 프로젝트는 시작할 당시 근무 환경이 굉장히 미묘한 상황이었다. 그때는 본래 프리랜서로 일을 하다가 회사에 취업을 한 상태로 일을 하고 반년정도 지난 상황이었는데, 난데없이 회사의 자금 사정 이야기가 수면위로 떠오르면서 미래에 대한 걱정이 솟아오르던 시점이었다. 그러다보니 이력서 업데이트를 해야겠다는 생각이 들었는데, 직전 직장에서 사용했던 Flutter의 경우, 서비스 종료된 것들도 많고, 당시 버전이 워낙 낮았었던 문제가 있어서 이력서에 넣기 애매한 프로젝트들 뿐이었다. 그래서 Flutter를 "써본적은 있습니다" 수준으로 넣기 적당한 프로젝트를 해보자, 월급쟁이로 일하면서 바쁜 사이에도 지속적으로 관심을 가질 수 있는 프로젝트를 진행해보자 하며 선택한게 지뢰찾기이다. 사실 테트리스를 하고 싶었는데, 테트리스는 (사실 그렇게 신경쓸 필요는 없겠지만) 저작권 관련해서 무서운 소문이 많이 돌아서 지뢰찾기로 선회했다.

그리고 Flutter를 사용하기로 한 시점에서, 한가지 선택했어야 하는것이 상태관리 라이브러리였다. 이런 라이브러리를 선택함에 있어서 정답이라는건 없을 것이고, 올바른 방향이라고 할라면 프로젝트의 규모와 성격에 따라 결정해야 한다는 것이겠다. 그리고 사이드 프로젝트에서는 내가 안해본걸 해보기 가장 좋은 기회라고 생각하였기에, 당시 내 머릿속에 있던 두가지 상태 관리 라이브러리 Provider와 Bloc 중 Bloc를 해보자고 결정하였다.

Flutter Bloc 기본

먼저 Bloc에 대해서 알아보자.

Bloc는 Business Logic Component의 약자로, 일종의 디자인 패턴이라고 볼 수 있다. Bloc의 pub.dev 페이지로 가보면 Bloc의 목표는 표현(presentation)과 비즈니스 로직의 분리라고 한다. 다른 표현을 빌리자면 결국 UI와 로직을 분리함으로서 서로의 상호 의존성을 줄인다는 것이며, 결과적으로 다음과 같은 특징을 가져올 수 있다.

  • 빠른 반응형(Reactive) 애플리케이션을 만들 수 있다.
  • 테스트가 용이하다.
  • 애플리케이션의 어느 시점에서든 상태를 확인할 수 있다.

Bloc의 Why Bloc? 페이지에서는 다양한 역량을 가진 개발자들이 쉽게 이해하고 사용할 수 있다고도 하는데, 솔직히 말하자면 이 부분은 라이브러리 개발자의 주관이 많이 반영되었다고 본다. 사실 어떤 라이브러리든 "우리 라이브러리 겁나 쓰기 어려움!"이라고 하면 누가 쓰겠는가. 한번 써본 지금은 Bloc를 다시 사용하는게 어렵지 않겠지만 처음 접하는 입장에서는 준비할 내용이 많다는 점에서 그렇게 쉽다는 생각은 들지 않았다.

StateEvent, 그리고 Bloc

기본적으로 어떤 애플리케이션이든 "상태"를 가지고 있다. 애플리케이션은 개발자가 정한 방식으로 "상태"를 다양한 형태로 표현해주며, UI를 통해 사용자가 "상태"를 변화시킬 수 있게 해준다. 이는 모바일이든 웹이든 동일하다고 볼 수 있다. 그래서 우리는 이 "상태"라는 개념을 쉽게 관리해주는 라이브러리를 상태 관리 라이브러리라고 부른다. Bloc도 상태 관리 라이브러리며, "상태"를 관리하기 위해 애플리케이션의 상태를 나타내는 State와 그 상태가 변화해야 함을 알리는 Event의 개념을 도입한다.

Bloc Concept

State는 앞서 이야기한 "상태"를 나타내는 객체라고 볼 수 있다. 지뢰찾기를 예로 들어보자. 지뢰찾기는 격자무늬의 지뢰밭을 가지고 플레이하는 게임이다. 사용자는 지뢰밭의 각 칸을 열어서 등장하는 단서를 바탕으로, 어디에 지뢰가 있는지를 유추해 내어야 한다. 만약 실수로 지뢰인 칸을 열어버리면 패배하게 되며, 지뢰가 없는 모든 칸을 열면 승리하게 된다. 여기서 지뢰찾기의 상태는 지뢰밭의 칸들이 열려있는지 닫혀있는지 라고 볼 수 있다. 이를 class로 표현하면 다음과 같을 것이다.

class BoardState {
  // 열려있는 칸들을 확인하기 위한 2차원 리스트
  final List<List<boolean>> isOpen;
}

지뢰찾기 게임을 시작하면, 새로운 BoardState 객체를 만들어 게임의 상태를 나타낼 수 있다. 이 객체의 isOpen[y][x]의 값이 true면 열린 칸, false면 닫힌 칸을 나타낸다고 생각할 수 있다. 아래에서 형광색으로 둘러진 사각형의 칸들이 true인 셈이다.

사용자가 UI를 이용해 특정 행동을 하게되면 그 인스턴스의 isOpen의 데이터가 변경되어야 한다. 내가 작업할 때는 구글에 검색하면 나오는 지뢰찾기 게임을 참고하여 만들어서, 클릭하면 다음 행동을 위한 버튼이 나오게 만들었다.

이때, 삽 아이콘을 누르면 해당 칸을 열겠다는 의미이다. 그리고 이 삽 아이콘을 누르는 "사건"이 BoardState가 가지고 있던 isOpen의 데이터를 변형하여야 하는 것이다. Bloc를 사용하면 이렇게 State의 변화를 유발하는 여러가지 "사건"을 Event로 정의한다.

class OpenEvent {
  final int x;
  final int y;
  OpenEvent(this.x, this.y);
}

사용자가 지뢰밭의 특정 칸을 열겠다는 행동(삽 아이콘이 눌리는 "사건")이 발생하면 새로운 OpenEvent 인스턴스를 만든다고 볼 수 있다. 또한 최종적으로 이 OpenEvent에 담긴 x, y 데이터가 사용자가 열고자 한 칸을 나타내고 있음을 유추할 수 있다.


위의 예시는 매우 단순화된 형태로, 실제로 지뢰찾기를 만들때는 헐씬 복잡한 StateEvent들을 만들게 되었다. 예를들어 지뢰밭은 열림 닫힘 외에도 깃발, 숫자, 빈칸 등 다양한 칸들이 나오고, 지뢰밭의 크기와 지뢰의 갯수 등도 파악해야 한다.

// 지금 생각해보면 다른 이름이 좋았을지도
class MineState {
  final List<List<int>> mineBoard;
  // CellState는 "닫힘", "열림", "빈칸", "깃발" 등 다양한 칸들을 나타내는 enum이다.
  final List<List<CellState>> cellStateMap;
  final int mineCount;
  final int sizeX;
  final int sizeY;
  // ...
}

그리고 단순히 여는것 외에도 사용자의 UI를 드러낼지 말지, 게임을 일시중지할지, 숫자를 클릭해 주변의 모든 칸들을 여는 등의 다양한 Event들도 만들어야 했다. 그리고 이러한 Event를 상속관계를 이용해 표현할 필요도 있었는데, 이들은 조금 아래에서 이야기 해보겠다.

abstract class MineEvent {}

class CellEvent extends MineEvent {
  final int x;
  final int y;

  CellEvent(this.x, this.y);
}

class ToggleFlagEvent extends CellEvent {
  ToggleFlagEvent(super.x, super.y);
}

class OpenCellEvent extends CellEvent {
  OpenCellEvent(super.x, super.y);
}

// ...

Bloc를 해보고 싶다면 내 애플리케이션이 가질 수 있는 상태(State)와 그 상태를 변화시킬 수 있는 사건(Event)을 정의하는 과정이 중요하다고 생각된다. 만약 다시 Bloc를 사용한다면 이 부분을 좀더 잘 정리하고 시작했으면 한다. 처음에 개발을 시작할때는 이 부분에 대한 개념을 잘 잡지 않고, 다음 장의 내용을 만드는걸 더 집중해서 점점 더 코드가 더러워짐을 느꼈었다.


Bloc는 Event의 발생에 따라 상태를 관리하여 State를 변화시킨다. 이 변화된 State를 바탕으로 애플리케이션의 UI 등을 개발자가 조정할 수 있다. 이게 Bloc가 상태 관리를 이뤄내는 방법론이라면, Bloc는 실제로 Event에 따른 상태 변화를 정의하고, 상황에 따라 새로운 State를 반환하는 객체이다. Bloc 라이브러리가 제공하는 Bloc 클래스를 상속받으면, 앞서 정의한 EventState를 바탕으로 동작하는 Bloc를 만들 수 있다.

// 위에서 만든 MineEvent와 MineState를 엮는 Bloc
class MineBloc extends Bloc<MineEvent, MineState> {
  MineBloc({required MineState mineState})
      : _ticker = const TimerTicker(),
        super(mineState) {
    on<OpenCellEvent>((event, emit) {
      if (state.status == GameStatus.playing) {
        state.openCell(event.x, event.y);
        emit(state.copy());
      }
    });

    // ...   
}

extends Bloc<MineEvent, MineState>에서 어떤 Event에 대해서 동작하는지, 그 결과 어떤 State가 관리되는지를 제네릭으로 정의하면, 안에서 on 메서드를 호출해서 해당하는 Event가 들어왔을 때 실행될 콜백 함수를 정의해 줄 수 있다.

여기서는 MineEvent를 상속받은 OpenCellEvent가 발생했을 때, 즉 사용자가 어떤 특정 칸을 열고자 했을 때 해당 칸을 열어보고(openCell), 그거에 따라 변화된 State를 복사해서 다시 발생(emit)하고 있는 모습이다. 그 전에 게임의 상태를 확인하는 부분은 이미 종료된 (승리 또는 패배) 게임을 대상으로는 동작하지 않도록 하기 위해서이다. 또한 하나의 State를 변형할 수 있는 다양한 이유를 하나의 Event 클래스로 표현하기 위해 최상위의 MineEvent를 만들고, 칸을 클릭하는 이벤트인 CellEvent 등으로 상속하여 만들었음을 확인할 수 있다.

copy()를 만들고 사용한 이유는 예전에 문서에서 새로운 State 객체를 만들라고 했던걸 보아서 인거 같은데......어디서 봤는지 찾을 수 없다...

Bloc를 사용하면 애플리케이션의 UI 부분에서는 어떤 상황에서 어떤 Event를 발생시키기만 한다면, Bloc 내부에서 해당하는 Event에 대해서 실행되어야 하는 Buisness Logic을 처리해 줄 수 있으며, 그에 따라 반영되어야 하는 State를 다시 UI 측에 반환해주는 방식으로 개발이 가능하다. 그 결과, 위에서 봤던 그림처럼

Bloc Concept

비즈니스 로직은 Bloc라고 하는 하나의 객체에 격리할 수 있다는 것이다!

BlocProviderBlocBuilder

Bloc를 내가 원하는데로 정의하였다면, UI를 그릴 때 사용할 수 있어야 한다. 이때 등장하는게 BlocProviderBlocBuilder이다.

BlocProvider는 Bloc 객체를 반환하는 특수한 Provider 클래스이다. Provider가 뭔지 모른다면 찾아보는것을 권장하는 바이지만, 이 문맥상에서는 간단하게 Bloc 객체를 자식 위젯들이 언제든 접근할 수 있게 해주는 특수한 위젯이라고 이야기 해보자.

class GameView extends StatelessWidget {
  final int sizeX;
  final int sizeY;
  final int mineCount;

  const GameView({
    super.key,
    required this.sizeX,
    required this.sizeY,
    required this.mineCount,
  });

  
  Widget build(BuildContext context) {
    return BlocProvider<MineBloc>(
      create: (context) => newGame(sizeX, sizeY, mineCount),
      child: // ...
    )
  }
}

GameView는 첫 화면에서 게임 시작 버튼을 누르면 Navigator에 푸시되는 위젯인데, 여기서 가장 상위의 자식 위젯이 BlocProvider<MineBloc>임을 확인할 수 있다. 이렇게 작성하게 되면 child: Widget로 전달된 자식 Widget은 언제든지 context.read<MineBloc>를 통해 MineBloc에 접근할 수 있게 된다. 그러면 반환받은 Bloc 객체의 add() 메서드를 통해 특정 Event를 전달할 수 있다.

// 위의 BlocProvider의 자식 트리에 포함된 AppBar이다.
appBar: AppBar(
  leading: IconButton(
    onPressed: () =>
        // BlocProvider가 제공하는 MineBloc를 읽어들여, 게임 종료 시도 Event를 발생시킨다.
        context.read<MineBloc>().add(GameTryQuitEvent()),
    icon: const Icon(Icons.close),
  ),
  // ...
)

그리고 Bloc에 Event가 전달되면, State가 변하게 된다. 그리고 그 변한 State에 맞춰서 UI가 변경되는 위젯이 바로 BlocBuilder이다. 어떤 오래걸리는 메서드의 종료와 함께 UI를 그리는 FutureBuilder, 시간에 따라 변하는 데이터에 대하여 UI를 동기화하는 StreamBuilder와 유사하다.

class GameView extends StatelessWidget {
  final int sizeX;
  final int sizeY;
  final int mineCount;

  const GameView({
    super.key,
    required this.sizeX,
    required this.sizeY,
    required this.mineCount,
  });

  
  Widget build(BuildContext context) {
    return BlocProvider<MineBloc>(
      create: (context) => newGame(sizeX, sizeY, mineCount),
      child: BlocBuilder<MineBloc, MineState>(
        builder: (context, state) {
          return Scaffold(
            appBar: AppBar(
              leading: IconButton(
                onPressed: () =>
                    context.read<MineBloc>().add(GameTryQuitEvent()),
                icon: const Icon(Icons.close),
              ),
            // ...
          );
        },
      ),
    );
  }
}

대신 BlocBuilderBloc가 발생시키는 새로운 State에 동기화 되어 애플리케이션을 다시 그리는 것이라고 생각하면 된다.

근데 이거 가만 보니

여기까지 개발을 하면서, Bloc라는 것과 Event에 따른 State의 변화라는 측면을 경험하면서, 묘한 기시감에 빠진 백엔드 개발자 출신의 강사였다.

Bloc는 마치 Backend within Frontend 같은데?

우리가 기초적인 Frontend와 Backend를 나누면서 하는 말들이 있을 것이다.

  • Frontend: 사용자의 눈에 보이는 UI를 구현하고, 사용자의 동작에 따라 UI를 업데이트한다.
  • Backend: 서비스에 필요한 데이터를 관리하고, 사용자의 요청에 따라 데이터를 업데이트 한다.

Frontend와 Backend를 큰 그림에서 나누게 된다면, 브라우저에서 돌어가는 HTML, CSS, JS를 Frontend에서 개발하고, Frontend는 상황에 따라 Backend에 요청을 보낸다. 요청을 받은 Backend는 그 요청에 해당하는 데이터를 Frontend에 전달한다. Frontend는 해당 데이터를 바탕으로 사용자에게 보여줄 UI를 완성하게 된다.

여기서 Frontend를 Flutter의 위젯들이라고 생각하고, 요청을 Event, 데이터를 State라고 생각해보자. 이렇게 대입해서 생각해보면,

Bloc는 Event라는 형태의 요청을 받아서 State라는 형태의 데이터를 반환하는 객체이다.

라는 생각을 지우기가 어려운 것이었다. 실제로 Bloc를 처음 쓴 입장에서, 거의 완성되어 갈 때 아쉬웠던 점이 많았고, 그 중 제일 아쉬웠던 부분은 앞에서 언급했었다. StateEvent에 대한 정의를 좀더 열심히 할것. 왜냐하면 그게 (주관적인 관점에서) 백엔드 개발자의 가장 기초적인 역할에 가장 가까운 부분이라고, 나중에서야 느꼈기 때문이었다. Frontend에서 UI를 그리기 위해 필요한 데이터를 관리, 어떤 상황에서 어떻게 요청하면 어떤 데이터를 주는지를 관리하는 부분이 그야말로 Backend의 역할 아니었는가?

조금더 비약을 해보자. 이제는 조금 옛날 이야기긴 하지만 아직은 수많은 개발자의 머릿속에 박힌 RESTful에 대한 이야기이다. REST는 REpresentational State Transfer의 약자이다. 여기서도 State, 즉 상태라는 말이 나온다. 즉 RESTful API의 설계 원칙도 결국 이 상태를 어떻게 주고받는지를 정의하는 방법론 이라고 생각해볼 수 있다.

물론 Event와 State가 오고가는 형태를 생각해보면 전통적인 HTTP 요청으로 오고가는 RESTful 보다는 오히려 WebSocket과 Socket.IO에 가깝기는 하다. 그래도 애플리케이션의 데이터, 즉 상태가 오고가는 형태로 동작하는 상태관리 라이브러리 Bloc의 개발 방식은 백엔드 개발과 매우 유사하다는 것이, 프로젝트의 (당시) 1차 목표점에 도달했을 때의 나의 감상이었다.

Flutter Bloc는, 위젯들을 위한 백엔드 개발과 그 느낌이 비슷하다!

마무리

그리고 그 1차 프로젝트가 마무리되기 직전, 정말 급한 연락을 받고 거의 준비기간도 없이 (2~3주 정도?) 다음 강의를 준비해서 진행해야 하는 요청을 받았었다. 미친듯이 살인적인 일정이라 조금 고민을 했었지만 어쨋든 승낙을 했었고, 그 뒤 이 프로젝트는 뒷전이 되버렸다. 그래서 지금 Flutter의 업계의 인기있는 상태관리 라이브러리 등은 잘 알지 못한다. 애초에 그 뒤로 Flutter 코드를 들여다본것도 이 글을 쓰다가 일 정도니까?!

하지만 지금와서도 이때의 감정이 기억나는건, Bloc는 첫인상과는 다르게 정말 재밌는 패턴이었다는 것이다. 이 글의 주제이기도 한, 백엔드와 유사하다는 느낌을 받았던 만큼 나의 성향에 가까이 있다는 것이다. 물론 내가 느끼는 감정이 이상한 걸 수도 있다. 다른 상태 관리 라이브러리도 비슷한데, 내가 열심히 안써봐서 그런걸지도 모른다. 이런 것보다 다른 라이브러리들이 더 다양한 상황에 더 쉽게 적용이 가능할지도 모른다. 하지만 Vue에서 Vuex와 Pinia를 사용할 때 느꼈던 이해한되는 미묘한 감정을 Bloc를 사용하면서는 느끼지 못했다는 것 또한 사실이다. 내가 프런트엔드를 전문적으로 다뤄보지 않아서일까?

앞으로 언제 다시 일로서 Flutter를 손대게 될지는 알 수 없다. 애초에 저 프로젝트도 일은 아니었으니까. 그래도 정말 재밌는 경험이었다는 것은 부정할 수 없다. 기회가 생긴다면 다시 한번, 비슷한 감정을 느낄 수 있는 재밌는 프로젝트를 시작할 수 있기를 바라면서 글을 마친다.

0개의 댓글

관련 채용 정보