시작
나는 10년 넘게 안드로이드 앱만 개발해왔는데 Flutter 로 간단한 앱을 만들 일이 생겨 Flutter 작업을 처음 해 봤다. 작업을 시작할 때 안드로이드 개발과 익숙하지만, 그렇다고 Flutter의 관행을 벗어나지 않는 구성을 고민을 했었고, 어느정도 내 목표에 맞는 구성을 만들 수 있어 정리해본다. 물론 Flutter 를 진지하게 오래 해 온 분들이 봤을 땐 구식이거나 더 나은 대안이 있을 수 있겠으나, 안드로이드에 익숙한 개발자가 익숙한 방식으로 후딱 Flutter 에 뛰어들기엔 괜찮은 접근이 아닐까 싶다.
기술 선택
안드로이드 프로젝트도 구성이 굉장히 다양한데, 내가 염두에 둔 안드로이드 프로젝트 구성은 다음과 같다.
- DI – hilt 까진 안가더라도 Koin 정도는 될, 하여간 DI 는 있어야 한다. Koin 이 DI 가 맞냐, service locator 냐 하는 논쟁이 있는데, 하여간 필요한 컴포넌트를 주입받을 수 있는 도구가 필요하다. 싱글턴 이런거 말구.
- Retrofit – 다양한 API 호출을 해야 하는데, 일일이 http 호출 함수를 직접 호출하지 않고 인터페이스 등으로 호출 스펙을 정의해서 사용하고 싶다.
- JSON <-> 객체 매퍼 : Kotlin Serialization 과 같은, JSON 과 객체 간 상호 매핑 도구를 이용한다.
- UI/로직 분리 + Single Ui State : UI 와 로직을 분리하고, 로직에서 단일 Ui State 를 View 쪽에 전달하면 View 는 이걸 가지고 그리는 역할만 한다. 이 때 안드로이드에서 익숙한 MVVM 이면 더 좋겠지만, 뭐가 되었건 상관없다.
- 러닝 커브가 깊은 기술 배제(당장 대충 공부해서 제품 만들어야 함) / all-in-one 기술 배제 (대부분 커스터마이징해야 하는데 쉽지 않음)
- 기왕이면 copilot 등 AI 에게 일 시키기 좋은 기술 선택 – 남들이 많이 쓰고, 변경의 범위가 좁아야 일 시키기가 좋다. 이런 측면에서 손이 좀 더 가더라도 변경이 직관적인 기술이 좋은데 (손은 내가 안쓰고 AI가 한다…), 복잡한 기술은 변경이 눈에 잘 안보여서 무식하지만 간단한 기술이 낫다.
위 기준으로 내가 선택한 기술은 다음과 같다.
- dio – 안드로이드의 OkHttp 를 생각하면 된다. Http 호출 처리를 돕는 도구이다.
- retrofit – 이름마저 같은, 안드로이드의 Retrofit 역할의 도구이다. Api 스펙을 추상적으로 선언하면 구체적인 Api 호출 코드를 만드는 역할을 한다. 안드로이드 Retofit 이 OkHttp 를 호출 도구로 사용했듯, 플러터 Retrofit 은 dio 를 호출 도구로 사용한다. 안드로이드 Retrofit 은 런타임에 api 를 선언한 interface 클래스를 읽어들여 proxy 기술을 이용해 구체 클래스를 만들어내는 반면, 플러터 retrofit 은 build runner 를 이용해 dart 코드를 생성하는 차이가 있다.
- json_serializable – JSON – 객체 매핑을 담당한다. 직접 호출할 일은 거의 없고, 아래 freezed 에 얹어서 사용한다. 매핑 도구들은 몇가지 더 있는데, freezed 와 궁합을 생각해서 이 도구를 선택했다.
- freezed & freezed annotation – kotlin 의 data clsss 역할, 즉 불변 객체를 만들어주는 역할을 생각하면 된다. json serializable 과 엮으면 json 매핑 코드도 생성해준다.
- go_router & go_router builder – 앱 내 화면 전환을 담당한다. 안드로이드에선 Jetpack Navigation을 쓰거나 직접 Intent 를 날리는 반면, 플러터에선 직접 내비게이션을 관리해야 하기 때문에 go_router 를 써야 한다. go_router builder 를 함께 사용할 경우, 내비게이션 이동을 위한 함수들이 자동생성되어 인자들을 안정적으로 호출할 수 있다. 참고로 난 지금도 activity 가 분리된 안드로이드 프로젝트라면 왜 복잡하게 Navigation을 쓰는지 모르겠다.
- flutter riverpod & hooks riverpod & riverpod_annotation – DI 역할을 하는, 각종 상태 관리 프레임워크는 riverpod 를 사용했다. 프레임워크다 보니 앱 구성에 아마도 가장 큰 역할을 미칠텐데, 몇 가지 framework 를 비교하다 riverpod 를 선택했다. 전신인 provider 는 너무 구식이고, GetX 는 이거저거 다 해주는 종합선물세트인데 러닝커브도 그렇고, 나에겐 과했다. 만약 내가 flutter 를 좀 더 진지하게 하겠다면 GetX 를 선택했을 지도 모르겠으나, 가볍게 접근하기엔 너무 복잡하여 결과적으로 rivderpod 를 선택했다. hooks riverpod 는 선택사항이지만, 아래에 소개한 flutter_hooks 와 함께 사용해서 코드량을 크게 줄여줄 수 있다. 공부해야 할 게 좀 늘어나지만, ai 도구들에게 일을 시켜보면 빠르게 감을 잡을 수 있다.
- fpdart – dart 에 함수형 프로그래밍의 기분을 느낄 수 있게 해 준다. Either / Option 등 없으면 아쉬운 기능들을 제공해준다.
- easy_localization – 다국어 지원을 위한 라이브러리이다. 안드로이드에서 문자열을 xml 리소스로 관리하듯, json 으로 문자열을 관리할 수 있다. 나의 경우엔 구글 시트에 문자열을 선언하고, 이걸 json 으로 내려받아 asset 디렉터리에 만들어주는 스크립트를 만들어서 사용했다.
-
flutter_hooks – React Hooks 에 영감을 받은 프로젝트라는데, 나는 React Hooks 도 모른다. 하지만 flutter 로 UI 작업을 조금만 하다보면 dispose 에서 리소스 해제한다거나 하는 과정 때문에 코드가 금새 더러워지는 걸 볼 수 있다. 이 경우 flutter hooks 를 사용하면 코드를 꽤 간결하게 유지할 수 있다. 뭔가 compose 의
remember나launchedEffect를 호출하는 느낌이 든다. 추가로, 조금만 widget 이 복잡해져도StatelessWidget과 쌍을 이루는StatefulWidget를 선언해야 하는데, flutter hooks 를 사용하면 이 부분을HookWidget하나로 해결할 수 있다. 어떤 hook 을 사용해야하는지 러닝커브가 있긴 한데, gemini 에게 물어보면 잘 알려주기 때문에 도움을 많이 받았다. - flutter_launcher_icons – 멀티플랫폼이다보니 플랫폼 별 앱 아이콘을 관리하는 것도 일인데, 이 부분을 한결 쉽게 해 주는 빌드 도구이다. 안드로이드의 경우 adaptive icon 도 지원한다.
- firebase – 안드로이드와 동일하게 Crashlytics 나 Analytics 등의 기능은 Firebase 를 사용한다. 안드로이드와 다르게 firebase 명령줄 도구를 설치하고, 이 도구를 이용해 코드 생성 등의 밑작업을 해 줘야 해서 좀 더 복잡하긴 하다.
- permission_handler – 안드로이드 뿐 아니라 iOS 쪽 런타임 권한까지 고민해야 한다. 내 프로젝트는 이미지나 파일 접근 권한 정도만 사용했는데, 위치 권한같이 복잡한 권한도 깔끔하게 잘 지원해줄지 모르겠다.
- flutter_local_notifications – 알림 처리를 위한 패키지이다. 안드로이드와 iOS 의 알림 처리 방식이 완전히 달라 도움을 받았다.
API 호출
Http Api를 호출해서 응답을 받는 절차는 안드로이드와 거의 비슷하다. 안드로이드가 Retrofit 으로 선언한 interface 에서 coroutine 으로 특정 타입의 응답을 받는 것 처럼, 플러터에선 Retrofit 으로 선언한 abstract class 를 기반으로 생성된 함수를 이용해 Future 응답을 받는다.
이 때 응답 DTO 는 freezed 로 받고, json 매핑은 json serializable 로 이뤄진다. 대강의 코드는 다음과 같다.
@Riverpod(keepAlive: true) // riverpod 에서 singleton 으로 관리
UserApi userApi(Ref ref) {
final dio = ref.watch(dioProvider);
return UserApi(dio);
}
@RestApi(baseUrl: "user/", callAdapter: EitherCallAdapter)
abstract class UserApi {
factory UserApi(Dio dio, {String baseUrl}) = _UserApi;
@GET("me") // api 호출 후 json 응답을 dto 로 매핑하는 처리까지 retrofit 에서 담당
Future<ApiEither<UserDto>> me();
}
// dto
@freezed
abstract class UserDto with _$UserDto {
const factory UserDto({
@JsonKey(name: "name") required String name,
}) = _UserDto;
factory UserDto.fromJson(Map<String, dynamic> json) => _$UserDtoFromJson(json);
}
// call adatper - api 호출 예외를 Either 로 추상화
class EitherCallAdapter<T> extends CallAdapter<Future<T>, Future<Either<ApiException, T>>> {
@override
Future<Either<ApiException, T>> adapt(Future<T> Function() call) async {
try {
final resp = await call();
return Right(resp);
} on DioException catch (e) {
return Left(ApiException.fromDioException(e));
} catch (e, s) {
return Left(NonHttpException(e.toString(), s));
}
}
}
typedef ApiEither<T> = Either<ApiException, T>;
sealed class ApiException implements Exception { ... }
DTO 클래스의 경우 관용적인 코드 패턴을 계속 작성해야 하므로 intelliJ 의 live template 등을 이용하거나, gemini 등에 프로젝트 표준을 잘 가르켜두면 좋다.
화면은 HookConsumerWidget 을 확장해서 선언하면 지저분하게 StatelessWidget 과 StatefulWidget 두 벌 씩 관리하지 않아도 된다.
물론 UI 를 구성할 때 state 가 변경될 때 전체 ui 를 다시 그리지 않게 하려면 select 함수 등을 이용해 ui 의 변경 범위를 최소화하도록 세심하게 구현해야 한다.
// repository
@Riverpod(keepAlive: true)
UserRepository userRepository(Ref ref) {
return UserRepositoryImpl( ref.read(userApiProvider);
}
abstract class UserRepository {
Future<Either<Exception,Profile> getProfile();
}
class UserRepositoryImpl implements UserRepository {
final UserApi _api;
UserRepositoryImpl(this._api);
@override
Future<Either<Exception,Profile>> getProfile() async {
final user = await _api.me();
return user.mapToProfile(); // dto to profile
}
}
// vm & state
@freezed
abstract class ProfileVmState with _$ProfileVmState {
const factory ProfileVmState({
@Default(AsyncResult.uninitialized()) AsyncResult<Profile> fetch,
@Default(AsyncResult.uninitialized()) AsyncResult<Profile> update,
}) = _ProfileVmState;
}
@riverpod
class ProfileVm extends _$ProfileVm {
void fetch() async {
final userRepo = ref.read(userRepositoryProvider);
state = state.copyWith(fetch: const AsyncResult.loading());
final result = await userRepo.getProfile();
state = state.copyWith(fetch: result.toAsyncResult());
}
@override
ProfileVmState build() {
return ProfileVmState();
}
}
// screen
class ProfileScreen extends HookConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen( profileVmProvider().select((state) => state.updateResult), (prev,next)) {
// 프로필 업데이트 실패, 성공 처리
next.fold(...);
}
return Scaffold( ...); // ui state 에 따라 화면 구성
}
}
FCM
FCM 수신 시 iOS 동작까지 고려해야하는데, iOS 플랫폼에 익숙치 않아 이 부분이 좀 헷갈렸다. 실험적으로 알아낸 결론은 다음과 같다. 참고로 FCM 에선 notification 필드 사용 방식이 아닌 data 필드 사용 방식을 택했다.
- 안드로이드
-
FirebaseMessaging.onMessage.listen()으로 payload 수신 -
FlutterLocalNotificationsPlugin.show()로 알림센터에 등록 - 사용자가 알림을 누른 경우
- 앱 프로세스가 살아있는 경우:
FlutterLocalNotificationsPlugin.initialize()의onDidReceiveNotificationResponse호출 - 앱 프로세스가 죽은 경우: 앱 실행 후
FlutterLocalNotificationsPlugin().getNotificationAppLaunchDetails()값으로 어떤 스킴으로 앱이 구동되었는지 확인한 후, 스킴 처리
- 앱 프로세스가 살아있는 경우:
-
- iOS
- 알림은 시스템에서 자동으로 등록
- 사용자가 알림을 누른 경우
- 앱 프로세스가 살아있는 경우:
FirebaseMessaging.onMessageOpenedApp.listen()콜백 호출됨 - 앱 프로세스가 죽은 경우: 앱 실행 후
FirebaseMessaging.instance.getInitialMessage()값으로 메시지 확인
- 앱 프로세스가 살아있는 경우:
맺음말
Flutter 의 라이브러리 생태계도 상당히 잘 되어있어 뒤져보는 재미가 있는데, 너무 방대하다보니 안드로이드 개발자가 선뜻 시작하다가 지쳐버릴 수 있다. 이 글에 소개한 구성이 Flutter 개발자 입장에서 구닥다리이거나, 더 나은 선택지들이 분명히 있겠지만 안드로이드 개발자가 친숙하게, 후딱 Flutter 개발을 시작하기엔 괜찮은 선택이 아닐까 싶다. 모쪼록 Flutter 를 시작해보려는 안드로이드 개발자에게 도움이 되었으면 한다.
더 나아가 샘플 프로젝트라도 만들어 보면 좋겠지만 게을러서…
