Flutter DI: get_it + injectable

김종식
22 min readSep 6, 2023

--

Photo by Artur Shamsutdinov on Unsplash

DI (Dependency Injection, 의존성 주입) 은 외부에서 의존 객체를 생성하여 넘겨주는 것을 의미합니다. 예를들어, A 클래스가 B 클래스를 의존할 때 B 클래스의 인스턴스를 A 가 직접 생성하지 않고 외부에서 생성하여 넘겨주면 (생성자나 setter 등의 방법을 통해) 의존성을 주입했다고 합니다.

DI 를 위해서는 객체를 생성하고 넘겨주는 외부의 무엇인가가 필요합니다. 이 일을 DI Framework 이 수행합니다. DI 는 의존성이 있는 객체의 제어를 외부 프레임워크로 올리면서 IoC(Inversion of Control) 의 개념을 구현합니다. 클래스나 함수에서 사용할 인스턴스의 결정권을 자신이 아닌 외부에 위임하여(=분리함으로써) 유연한 구조를 갖게 하는 방식입니다.

DI 의 장점은 다음과 같습니다.

  • Unit test 가 용이해진다.
  • 코드의 재사용성을 높여줍니다.
  • 객체간 의존성을 줄이거나 없앨 수 있습니다. 이는 곧 객체간 결합도가 느슨해져서 좀 더 유연한 코드 작성이 가능해집니다.

반대로, DI 의 단점은 다음과 같습니다.

  • 주입된 객체들에 대한 코드 추적이 어려워집니다.
  • 러닝 커브가 있습니다.
  • 책임이 분리되는 것을 지향하므로 클래스 수가 증가하고, 복잡성이 증가할 수 있습니다.

Flutter 에서 DI 를 get_itinjectable 패키지를 이용하여 구현하고 가벼운 예제 코드를 정리해 봤습니다.

Introduce DI in Flutter

Setup

pubspec.yaml 에 아래와 같이 추가합니다.
여기에서는 get_it : 7.6.0, injectable : 2.2.0 버전으로 확인 결과를 정리합니다.

dependencies:
get_it: <version>
injectable: <version>
dev_dependencies:
injectable_generator:
build_runner:

그리고 configurations.dart 를 아래와 같이 작성합니다.
injectableInit 어노테이션에서 제공되는 옵션은 코드에서 확인이 가능합니다.

import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'configurations.config.dart';

final getIt = GetIt.instance;

@injectableInit
void configureDependencies() => getIt.init();

그리고 build_runner 를 이용하여 code generating 을 수행합니다. 코드 생성 명령은 다음과 같습니다.

  • flutter pub run build_runner build — delete-conflicting-outputs

실행 결과는 다음과 같습니다.

이제 준비가 완료되었으므로, DI 구성을 위해 만든 함수를 main 에서 호출합니다.

void main() {
configureDependencies();
runApp(const MyApp());
}

IoC 구성을 위해 필요한 ServiceLocator 은 get_it 패키지를 활용하고, injectable 패키지는 DI 구성에 필요한 코드를 자동으로 생성하도록 지원합니다.

Registering & use basic

IoC(Inversion of Control) Container 에 등록된 정보를 활용할 때는 configurations 에 정의한 GetIt.instance 로 사용합니다. 아래는 임의의 뷰 모델을 선언하고, 내부에 헬퍼 클래스가 있다고 가정할 때 이를 선언할 경우 생성되는 코드와 실제 사용하는 예시입니다.

import 'package:injectable/injectable.dart';

@injectable
class AViewModel {
AViewModelHelper _helper;

AViewModel(this._helper);
}

@injectable
class AViewModelHelper {}

생성 결과는 다음과 같습니다.

생성된 코드는 아래와 같이 접근해서 얻어올 수 있습니다.

final _viewModel = getIt<AViewModel>();

// 또는

AViewModel _viewModel = getIt();

IoC 로 클래스 사용에 대한 방법을 등록하는 것은 보통 3가지 어노테이션을 활용합니다.

  • Injectable
  • Singleton
  • LazySingleton

각 어노테이션으로 설정한 클래스들에 대하여 생성되는 코드는 아래와 같습니다.

import 'package:injectable/injectable.dart';

@injectable
class StringUtils {
// implement this
}

@singleton
class SingleStringUtils {
// implement this
}

@lazySingleton
class LazyStringUtils {
// implement this
}

Singleton 의 경우 객체의 라이프사이클 제어가 필요할 수 있습니다. GetIt 은 dispose 콜백 함수를 등록하여 dispose 할 수 있는 방법을 이용하여 이를 제어합니다.
@disposeMethod 어노테이션을 활용하거나 @singleton 어노테이션에 dispose 함수를 참조로 등록하는 방법으로 사용 가능합니다. 이와 관련된 자세한 내용은 Disposing of singletons 을 참조하세요.

Passing parameters to factories

객체를 획득할 때, @factoryParam 을 설정하여 필요한 파라메터를 전달하여 객체를 얻어올 수도 있습니다. 아래는 Api 통신을 담당하는 클래스가 있는데, 생성 시 url 정보를 받아 동작하는 경우 이것을 선언하고 사용하는 예제입니다.

import 'package:injectable/injectable.dart';

@injectable
class ApiService {
final ApiServiceHelper helper;
final String url;
ApiService(this.helper, @factoryParam this.url);
}

@injectable
class ApiServiceHelper {}
final _api = getIt.get<ApiService>(param1: "https://www.google.com");

factoryParam 은 최대 두 개까지 paramter 설정이 가능합니다. getIt 에서 get / getAsync 등이 모두 param1, param2 까지 지원하기 때문입니다.

FactoryMethod and PostConstruct Annotations

@factoryMethod 는 이름 그대로 (dart language 의) factory 메소드를 지원하는 어노테이션입니다. 이것은 static, factory 키워드에 모두 사용이 가능합니다.

@postConstruct 는 의존성 주입 시 초기화를 위한 코드블록을 지원하는 어노테이션입니다. 여기에는 public 요소만 포함할 수 있습니다.

이 두가지 어노테이션은 모두 preResolve flag 를 지원합니다. 생성 및 초기화 시 future 이고 preResolve 플래그가 true 라면, Future 종속성은 GetIt 내부에 awaited 로 등록됩니다. 그렇지 않으면 종속성이 비동기로 동작되도록 등록됩니다. 자세한 내용은 아래를 참조하세요.

Registering asynchronous injectables

정적 초기화가 필요할 경우, @injectable (singleton / lazy 또한) 와 @factoryMethod 를 이용하여 비동기로 초기화가 가능합니다.

@injectable 은 리턴타입이 Future 일 경우, 자동으로 비동기로 얻어오도록 등록됩니다. (note, GetIt 을 이용하여 getAsync<T>() 함수를 이용하여 비동기로 객체를 반환하여 사용해야합니다.)

아래는, Repository 객체 생성 시 async 로 초기화하는 동작이 있을 경우 이를 IoC 에 등록하는 방법입니다.

import 'package:injectable/injectable.dart';

@injectable
class AsyncRepository {
@factoryMethod
static Future<AsyncRepository> from() async {
await Future.delayed(const Duration(seconds: 1));
return AsyncRepository();
}
}
final _repository = await getIt.getAsync<AsyncRepository>();

Registering abstract & actual type injections

인터페이스 정의된 클래스 타입으로 실제 구현 클래스를 얻어올 수 있습니다. @Injectable 에서 as 파라메터를 활용하면 됩니다.

import 'package:injectable/injectable.dart';

abstract class ARepository {}

@Injectable(as: ARepository)
class ARepositoryImpl implements ARepository {}
// get ARepositoryImpl instance 
final _repository = getIt<ARepository>();

만약, 동일한 타입이지만 여러 구현체가 생긴다면 @Named 어노테이션을 설정합니다. 이 경우, IoC 로부터 얻어올 때는 ‘instanceName’ 에 정의한 이름으로 구현체를 얻어올 수 있습니다.

import 'package:injectable/injectable.dart';

abstract class ARepository {}

@Named('ARepositoryImpl')
@Injectable(as: ARepository)
class ARepositoryImpl implements ARepository {}

@Injectable(as: ARepository)
class ARepositoryImpl2 implements ARepository {}

@injectable
class AViewModel {
AViewModel(@Named('ARepositoryImpl') ARepository repository);
}
AViewModel _viewModel = getIt.get();

ARepository _repository1 = getIt.get(instanceName: 'ARepositoryImpl');
ARepository _repository2 = getIt.get(instanceName: 'ARepositoryImpl2');

Register under different environments

Injectable 환경 구성을 다르게 하고 싶을 경우, @Environment 어노테이션으로 정의 가능합니다. (@Injectable 어노테이션의 env 프로퍼티로 설정이 가능합니다.) Environment 는 기본값으로 dev, prod, test 가 정의되어 있어, 아래 두 가지 선언 모두 동일하게 prod 환경에서 객체를 얻어올 수 있습니다.

@Environment('prod')
@injectable
class ARepositoryImpl implements ARepository {}

// 또는

@prod
@injectable
class ARepositoryImpl implements ARepository {}

또한, Environment 어노테이션은 여러번 설정이 가능합니다. @injectable 의 dev 프로퍼티를 활용하는 방법도 있습니다.

@Environment('dev')
@Environment('staging')
@Environment('production')
@injectable
class ARepositoryImpl implements ARepository {}

// 또는

@injectable(env: ['dev', 'staging', 'production'])
class ARepositoryImpl implements ARepository {}

Environment 에 따라 IoC 구성을 다르게 선언하려면 getIt.init 함수에 environment 또는 environmentFilter 를 설정합니다.

// main.dart
void main() {
configureDependencies(environment: 'prod');
runApp(const MyApp());
}

// configurations.dart
final getIt = GetIt.instance;

@injectableInit
void configureDependencies({String? environment}) =>
getIt.init(environment: environment);

And others

그 외 제공되는 기능들 중 참조할 만한 내용을 정리합니다.

  • Using a register module — 다른 디펜던시한 객체를 DI 쪽으로 등록할 때 유용합니다.
  • Using Scopes — Scope 설정을 통해 필요한 상황별로 동일 인스턴스를 각각 생성해서 활용 가능합니다. (ex. 인증 flow)
  • Manual order — 객체간 의존 관계에 순서가 있을 수 있는데, 특정 종속성의 순서를 수동으로 설정 가능합니다.

DI Example

Flutter Codelab — Write your first flutter app part 2 를 예로 활용해 만들어보겠습니다. (불행히도, 현재는 이 코드랩이 사라졌습니다;)

기존 코드에서 상태관리 방식을 ChangeNotifier 로 변경하고, 데이터를 가져오는 부분은 Repository 로 분리가 되어있다고 가정하고 정리합니다. (데이터는 english_words 패키지를 활용합니다.) 대략적으로 아래처럼 파일 및 폴더가 분리가 될 것입니다.

<environment 를 dev에서 prod 로 변경 후 hot reload>

여기서 두 가지 요구사항을 추가해 보겠습니다.

  • prod 환경에서는 material 3 테마를 지원한다.
  • dev 환경에서는 데이터가 모두 소문자로, prod 환경에서는 데이터가 모두 대문자로 표시된다.

material3 테마는 MaterialApp 위젯에 테마 설정을 하면 가능합니다. 따라서, 이 부분을 아래처럼 MaterialApp 이 다르게 생성되도록 구성해볼 수 있습니다.

import 'package:counter_app/navigation/routes.dart';
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';

abstract class AppProvider {
MaterialApp get app;
}

@dev
@Injectable(as: AppProvider)
class DevThemeProvider implements AppProvider {
@override
MaterialApp get app => const MaterialApp(
onGenerateRoute: Routes.generateRoute,
initialRoute: Routes.main,
);
}

@prod
@Injectable(as: AppProvider)
class ProdThemeProvider implements AppProvider {
@override
MaterialApp get app => MaterialApp(
onGenerateRoute: Routes.generateRoute,
initialRoute: Routes.main,
theme: ThemeData(
useMaterial3: true,
),
);
}
// 변경 전 main.dart
void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) => const MaterialApp(
onGenerateRoute: Routes.generateRoute,
initialRoute: Routes.main,
);
}

// 변경 후 main.dart
void main() {
configureDependencies(environment: environment);
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) => getIt<AppProvider>().app;
}

데이터를 대, 소문자 표현하는 것을 구분하는 것은 아래와 같이 Repository 객체를 다르게 얻어와 동작하도록 구성할 수 있습니다.

typedef WordGenerator = Iterable<WordPair> Function();

abstract class WordPairRepository {
Future<List<WordPairResponse>> list([int size = 10]);
}

@dev
@Injectable(as: WordPairRepository)
class DevWordPairRepository implements WordPairRepository {
@override
Future<List<WordPairResponse>> list([int size = 10]) =>
Future.value(generateWordPairs()
.take(size)
.map((e) => WordPairResponse(first: e.first, second: e.second))
.toList());
}

@prod
@Injectable(as: WordPairRepository)
class ProdWordPairRepository implements WordPairRepository {
@override
Future<List<WordPairResponse>> list([int size = 10]) =>
Future.value(generateWordPairs()
.take(size)
.map((e) => WordPairResponse(
first: e.first.toUpperCase(), second: e.second.toUpperCase()))
.toList());
}
// 변경 전 main_page.dart
class MainPage extends StatelessWidget {
const MainPage({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) => ChangeNotifierProvider(
create: (_) => MainPageChangeNotifier(
wordPairRepository: WordPairRepositoryImpl(),
),
child: const MainPageView(),
);
}

class MainPageChangeNotifier extends ChangeNotifier {
MainPageChangeNotifier({required this.wordPairRepository});

final WordPairRepository wordPairRepository;
...
}

// 변경 후 main_page.dart
class MainPage extends StatelessWidget {
const MainPage({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) => ChangeNotifierProvider(
create: (_) => MainPageChangeNotifier(),
child: const MainPageView(),
);
}

class MainPageChangeNotifier extends ChangeNotifier {
MainPageChangeNotifier() : wordPairRepository = getIt<WordPairRepository>();

final WordPairRepository wordPairRepository;
...
}

만약, WordPairRepository 라는 클래스를 테스트 하고 싶을 경우, 직접 mock 동작을 수행되도록 구현하던가, mockito 패키지 등을 활용할 수도 있습니다.

@test
@Injectable(as: WordPairRepository)
class MockWordPairRepository implements WordPairRepository {
@override
Future<List<WordPairResponse>> list([int size = 10]) =>
Future.value(List.generate(
size,
(index) => WordPairResponse(
first: 'first_$index', second: 'second_$index')));
}

// mockito
@test
@Injectable(as: WordPairRepository)
class MockWordPairRepsitory extends Mock implements WordPairRepository {}
void main() {
setUpAll(() {
configureDependencies(environment: 'test');
});

test(
'getIt<WordPairRepository> return MockWordPairRepository',
() async {
// arrange
final mock = getIt<WordPairRepository>();
expect(mock is MockWordPairRepository, true);

// action
final result = await mock.list();

// assert
expect(result.first.first, 'first_0');
expect(result.first.second, 'second_0');
},
);
}

Summary

Flutter 앱 개발에서 일반적으로 생각할 수 있는 아키텍처는 보통 아래와 같습니다.

상태 관리는 앱마다 조금씩 차이가 있고 그에 따라 프로젝트마다 구조가 차이는 있습니다만, DI 를 잘 활용한다면 의존관계 역전을 통해 좀 더 Testable 한 구조를 달성하고 다양한 요구사항에 유연하게 대처가 가능할 것으로 기대합니다.

--

--

김종식
김종식

Written by 김종식

앱 개발자 / 꿈은 축구선수 / 쌍둥이 아빠

No responses yet