오늘은 로그인 예제를 BloC 구조로 작성해보았다. 이는 bloc 패키지 공식 문서에 나와있는 예제를 따라 만든 것이며, 일부 직관적이지 않은 구조 등을 수정하였다. 물론 공식 문서 예제의 구조가 어떤 점들에 있어 훨씬 유리한 점이 분명 있겠지만, 처음 시작하는 단계에서 이해하기 쉬운 구조가 더 중요하다고 생각되어 구조를 임의로 변경해보았다. 본인은 이 구조가 이해 잘되고 보기도 좋은데, 각자 편한 구조로 구현하면 될 것 같다. 어차피 이 Felix 양반도 무슨 예제마다 구조가 다 다르고 남의 구조나 프로젝트도 막 가져다 쓰고 있거니와, 다른 개발자들의 구조를 봐도 다 다르다. 각자 프로젝트 상황에 맞게 구조를 짜면 되는 것 같은데, 일단 간단한 구조에서는 아래처럼 진행해도 충분히 괜찮아보인다.
lib 폴더 내부를 보면 main.dart를 중심으로 4개의 큰 폴더가 있다. 각각 repositories, models, blocs, views 인데, 이는 앞서 봤던 그림 구조와 동일하다.
그러면 개발하는 과정을 한단계씩 나아가며 패턴을 익혀보자.
일단 시작 전에 lib 내에 blocs, models, repositories, views 폴더를 만들고 시작하자.
1. Repositories
우선은 제일 하단 레이어라고 볼 수 있는 Data 영역부터 구현하겠다. Data 영역은 외부 API나 DB 등과 연결되어 데이터를 가져오는 Data Provider와, 여러 Data Provider들을 관리하고 BloC 영역에 데이터를 제공해줄 수 있는 Repository로 구분된다. 이번에 구현할 로그인 예제는 외부와 연동되지 않고 따로 DB도 사용하지 않기 때문에 Data Provider는 생략된다. 대신 UUID라는 라이브러리로 느낌만 비슷하게 낼 것이며, 관련하여 Data Provider로부터 데이터를 가져왔다고 가정하고 Repository를 작성해본다.
Felix의 예제들을 보면 Repository를 패키지로 따로 빼는 경우가 많다. 이는 Repository의 재사용을 위함인데, 생각해보면 확실히 앱마다 BloC 영역이나 Data Provider는 달라질 수 있지만 Repository는 같을 수도 있겠다 싶다. 하지만 조금 복잡해지고 직관성이 떨어지니 lib/repositories 폴더를 만들어서 관리하자.
우선 예제 코드에서 필요한 레포지토리는 authentication_repository.dart와 user_repository.dart이다.
authentication_repository
authentication_repository는 인증 관련 데이터에 대한 실제 처리를 진행하는 곳이다. 위에서도 말했던 것처럼 레포지토리는 외부 API나 DB와 연동되는 Data Provider를 통합 관리하는 곳이다. 대신 이번 경우에는 실제 Data Provider가 없기 때문에 마치 Data Provider가 있는 것처럼 비슷하게 (데이터를 가져오는 것처럼) 구현할 것이다. 뿐만 아니라 레포지토리의 두번째 역할은 BloC과 통신하여 데이터를 제공할 수 있어야 한다는 것이다. 앱 전역에 있는 BloC들이 해당 데이터에 접근할 수 있도록 하기 위해 Stream으로 데이터를 제공해야 한다.
아주 쉽고 간단하게 위의 설명을 요약하자면 레포지토리는 API 호출과 데이터 관리를 하는 영역이라는 것이다. 그렇기 때문에 authentication_repository에서는 서버와 연결될 로그인, 로그아웃 기능을 구현해야 하고(실제 서버 API에 로그인/로그아웃을 반영시키기 위해) 인증 상태 데이터를 스트림으로 제공해야 한다. 이를 코드로 구현하면 아래와 같겠다.
우선 맨 위에 열거형인 enum으로 AuthenticationStatus를 정의하고 있다. 외부 API와 연동이 된다면 그 데이터를 해당 열거형 포맷으로 맞춰주게 될 것이다. (“인증된 상태”라는 response를 서버로부터 받는다면 그 response를 AuthenticationStatus.authenticated로 바꿔주는 것이다.)
await Future.delayed()의 경우 실제 서버와 연결하는 것처럼 시간 소요를 구현하기 위한 코드이며, 겸사겸사 async*를 사용할 수 있게 된다.
그리고 logIn과 logOut을 각각 함수로 구현하고 있다. logIn의 경우 아이디와 비밀번호를 넣어 서버로부터 인증을 받아와야 하기 때문에 Future 타입의 함수이며, logOut은 서버와 관계 없이 클라이언트에서 인증을 해제하기 때문에 그냥 void이다. 왜 로그아웃 기능은 서버와의 연결이 필요없는가에 대해서는 토큰 기반의 인증 관련 내용을 확인하자.
레포지토리 개념과 코드를 이해했다면 BloC의 반은 해냈다고 볼 수 있다. 그만큼 중요하고 조금은 이해하기 어려울 수 있는 부분이라 예시를 하나 더 보고 넘어가겠다.
Todo 리스트 앱을 만들기 위해 todo_repository를 만든다고 가정해보자. 그러면 우선 외부 API가 있다는 가정하에, Future<> createTodo(), readTodo(), updateTodo(), deleteTodo() 함수를 각각 만들고 이것이 BloC의 요청과 외부 API 사이에서 연동될 수 있도록 해야 하겠다. 앱에서 Todo 목록을 가져오고 싶다면 todo_repository의 readTodo()를 실행시키고, readTodo()는 외부 API와 연동되어 Todo 목록을 가져오는 것이다. 이 과정에서 데이터를 가져온다면, 이를 가공해서(fromJsonToMap이라던지..) 스트림으로 뿌려줘야 할 것이다. 이것이 레포지토리의 역할이자 개념의 전부이다.
user_repository
이제 레포지토리를 잘 이해했으니 user_repository도 간단히 만들 수 있을 것이다. user_repository는 회원 정보를 외부로부터 가져온다. 만약 이미 우리 앱에서 로그인한 유저가 있으면 그 유저를 반환한다. 이때 실제 DB에서 관리되는 회원 아이디인 것처럼 UUID로 아이디 값을 생성하여 반환해주도록 하겠다.
레포지토리를 완성했다. 이제 우리는 데이터 관련 처리를 담당해줄 레포지토리가 생성된 것이니, 다음 단계로 넘어가겠다. 다음은 대망의 Bloc이다.
2. Blocs
Bloc 영역 또한 마찬가지로 lib/blocs 폴더를 구성해놓았다. vscode의 bloc extension을 사용하면 bloc 폴더와 함께 _bloc.dart, _event.dart, _state.dart 파일을 함께 만들어준다. 이 세가지 파일은 이름에서 알 수 있듯, 앞서 살펴본 Bloc 영역과 UI 영역 사이의 구성요소들을 정의한 것이다. Bloc 객체는 UI로부터 event를 받아서 변경된 state를 전달해줄 것이다.
각 파일들을 열어보면 미리 작성된 구조 코드가 있다.
1 2 3 4 5 6 7 8 9 10 11
// counter_state.dart part of 'counter_bloc.dart';
abstractclassCounterStateextendsEquatable{ const CounterState(); @override List<Object> get props => []; }
classCounterInitialextendsCounterState{}
1 2 3 4 5 6 7 8 9
// counter_event.dart part of 'counter_bloc.dart';
일단 상태부터 정의해보자. 내가 Felix의 코드를 보면서 제일 헷갈렸던 부분이 같은 의미를 갖는 것 같은 변수들이 너무 많다는 것이다. 도대체 status와 state를 왜 동시에 쓰는 것인가… 뭐 그만큼 대체할 만한 단어가 없었던 것이겠지만 상당히 헷갈린다. 나중에 내 프로젝트를 할 때에는 헷갈리지 않게 정의해볼 것이며, 일단은 Felix의 코드를 헷갈리지 않고 이해하는 것이 중요하겠다.
@override List<Object> get props => [this.status, this.user]; }
AuthenticationState, 말그대로 인증 관련 상태를 클래스로 만들어주고 있다. 해당 클래스로 만든 객체 자체가 상태인 것이다.
AuthenticationState를 구성하는 요소에는 authentication_repository에서 정의해놓은 AuthenticationStatus와 User가 있다. 즉 인증 관련 상태는 레포지토리에서 스트림으로 관리되고 있는 인증 현황 데이터(데이터 영역 내에 있으니 말그대로 데이터)와 유저 데이터, 이렇게 두가지 데이터로 구성되어 있는 셈이다.
아래 코드들을 살펴보면 AuthenticationState.authenticated() 등 몇 가지 정의되어 있는 것을 볼 수 있는데, 생긴건 함수처럼 생겨서 헷갈릴 수 있지만 이는 단일 상속이라는 문법이다. AuthenticationState를 상속받아 unknown, authenticated 등의 서브 클래스를 정의하는 것이다. 즉, 해당 코드 내에 있는 3가지의 클래스 모두 AuthenticationState를 상속받은 것이다.
마지막으로 get props를 하면 해당 state의 status와 user 값을 가져올 수 있다.
authentication_event
다음은 event이다. 이벤트 역시 앞선 상태와 마찬가지로 서브 클래스를 선언해주어야 한다. AuthenticationStatus 즉 데이터가 변경되었다는 이벤트와 로그아웃이 요청되었다는 이벤트 두가지가 있기 때문이다.
classAuthenticationBloc extendsBloc<AuthenticationEvent, AuthenticationState> { final AuthenticationRepository _authenticationRepository; final UserRepository _userRepository; late StreamSubscription<AuthenticationStatus> _authenticationStatusSubscription;
Future<AuthenticationState> _mapAuthenticationStatusChangedToState( AuthenticationStatusChanged event, ) async { switch (event.status) { case AuthenticationStatus.unauthenticated: returnconst AuthenticationState.unauthenticated(); case AuthenticationStatus.authenticated: final user = await _tryGetUser(); return user != null ? AuthenticationState.authenticated(user) : const AuthenticationState.unauthenticated(); default: returnconst AuthenticationState.unknown(); } }
Future<User?> _tryGetUser() async { try { final user = await _userRepository.getUser(); return user; } on Exception { returnnull; } } }
코드가 긴데, 차근차근 보면 일단 맨 위에 생성자가 있다. 앞서 말한대로 데이터 영역과 연결되기 위해 레포지토리를 연결해주고 있다. 그리고 _authenticationStatusSubscription을 통해 authentication_repository에 있는 스트림 데이터를 구독한다. 그 데이터가 변경되면 AuthenticationStatusChanged(status) 이벤트를 발생시킨다.
다음으로는 이벤트를 상태의 변화로 이어지게 해주는 bloc의 기본 필수 메소드인 mapEventToState를 정의해야 한다. 당연히 event를 받아서 각 이벤트의 종류에 따라 무언가 행동을 시키는데, 이 행동은 각각 상태의 변화를 발생하게 하는 것이며 변경된 상태를 반환하게 한다. 즉 mapEventToState는 함수 이름에서 알 수 있듯 event를 받아 state를 반환한다. 지금과 같은 경우에는 이벤트가 두 종류라서 두개의 조건문으로 처리하고 있다.
첫번째 이벤트인 AuthenticationStatusChanged 이벤트에 대한 처리는 _mapAuthStatusChangedToState로 정의해두었다. 말그대로 AuthenticationStatusChanged 이벤트를 State의 변화로 이어지게 한다는 것이며, 실제 데이터인 AuthenticationStatus 데이터의 변화가 State의 변화로 이어지게 되는 것이다. 이벤트에는 방금 말한 AuthenticationStatus 데이터가 있고, 그 데이터가 지금 어떤 값이냐에 따라 상태를 변화시키고 있다.
정리해보면 다음과 같다. 데이터를 관리하는 레포지토리가 있고, 여기엔 실제 데이터가 있다. 그리고 Bloc은 이벤트가 발생했을 때 상태를 변화시키는 일을 하며 여기서 이벤트는 데이터의 변경, 변경 요청 등이 있다. 이러한 이벤트가 발생했을 때 각 이벤트에 따라 상태를 변화시키고, 이 상태의 변화는 UI로 반영될 것이다. 그럼 UI만 구성하면 하나의 Bloc 패턴은 완성될 것이다.
login_bloc
이 영역은 깃헙 레포로 대체하겠다. TIL인데 너무 설명이 길어지는 것 같아 생략하겠다.
3. views
views는 빠르게 넘어가겠다. 여기까지 봤다면 플러터로 UI를 구성하는 것 정도는 쉽게 할 수 있는 사람일 것이다. 그저 UI에 Bloc이 어떻게 연결되는지를 중점적으로 살펴보도록 하자.
나는 screens와 widgets로 폴더를 구성했다. 이는 각자 스타일대로 하면 될 것이다.
main.dart
본격적인 화면 디자인에 앞서 main.dart에서 App부터 정의하고 넘어가야 한다. 보통 MaterialApp으로 구성되는 App에 Bloc을 어떻게 적용하는지 확인해보자.
우선 void run()에 들어갈 MyApp에는 레포지토리가 선언되어 있다. 이는 앱 전체를 감싸는 Bloc과 연결되어야 하기에 선언한 것이다. 바로 밑에 Widget build()를 보면 BlocProvider에서 AuthenticationBloc을 생성하며 인자로 선언한 레포지토리들을 넣는 것을 확인할 수 있다.
Widget build()를 좀 더 자세히 살펴보면, RepositoryProvider.value()를 통해 앱 전역에 레포지토리를 제공하고 있다. 즉, 앱 전역에서 데이터로 접근할 수 있다는 말이다. 또한 RepositoryProvider의 child는 BlocProvider인데, 역시 앱 전역에 AuthenticationBloc을 제공하기 위함이다.
child에는 MainApp()이 있고, 이는 우리가 익히 잘 알고 있는 메인 앱의 구조이다. 대신 여기에는 BlocListener가 들어간다. 예전에 flutter_bloc 위젯들을 공부하며 BlocBuilder와 BlocListener의 차이점을 확인했었는데, BlocListener는 페이지 라우팅과 같이 상태 변화 한번에 대해 한번만 일어나는 이벤트들을 처리하기 위한 위젯이었다. MaterialApp 내에서 구현해야 하는 기능 중 하나인 라우팅을 처리하기 위해 BlocListener를 쓰고, Bloc의 State에 따라 어느 화면으로 이동할지에 대해 다루고 있다.
home_screen.dart
홈 화면에서는 로그인이 된 경우 나타나는 화면이며 UUID를 출력하고 로그아웃 버튼이 활성화된다. 이때 UUID 값은 회원 정보이므로 Bloc.state.user로부터 가져와야 하고, 로그아웃의 경우 로그아웃 요청 이벤트인 AuthenticationLogoutRequested 이벤트를 발생시켜야 한다.
home_screen은 하위 위젯이므로, 하위 위젯이 Bloc에 접근하는 방법에 대해 알 수 있는 부분이다. context.select((Bloc) => data);를 통해 Bloc의 상태 값을 말그대로 골라 가져올 수 있다. context.read().add(event)를 통해 해당 Bloc에 이벤트를 발생시킬 수 있다.
마치며
이제 BloC 패턴이 어느정도 잘 이해가 된 느낌이다. 이제 남은 것은 이렇게 공부한 BloC 패턴을 프로젝트에 적용하면서 연습하는 것이다. 드디어 본 개발에 들어갈 수 있게 되었다..!