안드로이드 개발자가 빠르게 적용할 수 있는 Flutter 프로젝트 구성




시작

나는 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 의 rememberlaunchedEffect 를 호출하는 느낌이 든다. 추가로, 조금만 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 { ... }

Enter fullscreen mode

Exit fullscreen mode

DTO 클래스의 경우 관용적인 코드 패턴을 계속 작성해야 하므로 intelliJ 의 live template 등을 이용하거나, gemini 등에 프로젝트 표준을 잘 가르켜두면 좋다.

화면은 HookConsumerWidget 을 확장해서 선언하면 지저분하게 StatelessWidgetStatefulWidget 두 벌 씩 관리하지 않아도 된다.

물론 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 에 따라 화면 구성 
  }
}
Enter fullscreen mode

Exit fullscreen mode



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 를 시작해보려는 안드로이드 개발자에게 도움이 되었으면 한다.

더 나아가 샘플 프로젝트라도 만들어 보면 좋겠지만 게을러서…



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *