Dagger
요즘 핫한 Jetpack Hilt를 알아보기 전에 그 기반이 되는 Dagger를 간략하게 정리해 보자
- DI, IoC : 클래스는 흔히 다른 클래스를 필요로 한다. 예를들어 네트워크 통신기능을 사용하는 Activity는 NetworkModule을 사용할 수 있다. 그러면 Activity는 어떻게든 NetworkModule을 참조하여 사용해야 하는데, 이 때 Activity가 NetworkModule을 얻는 방법은 3가지가 있다.
- Activity내에서 직접 생성(자바에서의 new 키워드)
- 다른 클래스로부터 getNetworkModule()같은 메소드를 통해 전달받음
- 매개변수로 제공받음
여기서 ‘3. 매개변수로 제공받음’을 다른 표현으로 종속 항목 삽입(Dependency Injection, DI)이라고 하고 이 경우 NetworkModule은 종속 항목이라고 이야기 한다. Android에서 종속 항목을 삽입 받는 방법은 첫째 생성자에서 삽입과 둘째 필드(setter) 삽입이 있다. Activity의 생성자는 시스템에서 호출하기 때문에 개발자가 직접 생성자에서의 DI는 할 수 없고, 이런 경우는 종속 항목을 setter 삽입한 후 사용하도록 구성한다.
아래 링크는 이러한 직접 종속 항목 삽입을 구현한 아키텍쳐의 구현 예시이다. 직접 따라해 보면서 이 부조리함을 한번 느껴보자
위와 같은 직접 종속 항목 삽입코드에는 몇 가지 문제점이 보인다.
- 종속 항목이 많아지면 대량의 보일러 플레이트 코드가 필요하다.
- 종속 항목이 특정 스코프내에서만 활성화 되게 해야 한다면(예를들어 로그인을 하는 동안만 사용하고, 로그인 플로우가 끝나면 초기화 해야하는 종속 항목) 종속 항목의 lifetime을 직접 관리해 줘야 한다.
이러한 문제점을 해결하기 위해 종속 항목을 생성하고 관리하는 프로세스를 자동화 하기 위한 라이브러리가 바로 Dagger이다.
- 종속 항목을 자동으로 관리함으로써 프로젝트의 복잡성에 제한을 둘 수 있다.
- 개발자의 코드를 래핑하는 코드를 컴파일 타임에 자동으로 생성한다. 따라서 런타임 시 성능이 좋고, 오류 추적이 간편하다.
Dagger의 이점을 구현하는 원리로 공식문서에서는 다음과 같이 이야기 한다.
- 수동 DI 섹션에서 수동으로 구현한 AppContainer 코드(애플리케이션 그래프)를 생성합니다.
- 애플리케이션 그래프에서 사용할 수 있는 클래스의 팩토리를 만듭니다. 이는 종속 항목이 내부적으로 충족되는 방식입니다.
- 스코프를 사용하여 유형을 구성하는 방법에 따라 종속 항목을 재사용하거나 유형의 새 인스턴스를 생성합니다.
- Dagger SubComponent를 사용하여 이전 섹션의 로그인 흐름에서와 같이 특정 흐름의 컨테이너를 만듭니다. 이렇게 하면 더 이상 필요하지 않은 객체를 메모리에서 해제함으로써 앱 성능이 향상됩니다.
사용 예시) Dagger에게 UserRepository를 생성하는 방법을 다음과 같이 정의 해 줍니다.
// @Inject는 이 객체를 어떻게 인스턴스화 하는지 Dagger에게 알려준다.
class UserRepository @Inject constructor(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) { ... }
위 코드의 @Inject는 Dagger에게 2가지를 말해 줍니다.
- 해당 클래스의 인스턴스는 애너테이션이 붙은 생성자를 통해 만들 수 있다
- UserRepository의 종속 항목은 UserLocalDataSource, UserRemoteDataSource 두 가지이다.
-
UserLocalDataSource, UserRemoteDataSource에도 같은 방식으로 생성하는 법을 Dagger에게 알려주면 최종적으로 UserRepository도 Dagger가 자동적으로 만들 수 있게 된다.
-
Dagger는 필요한 종속 항목을 가져올 위치를 찾을 수 있도록 그래프를 형성합니다. 그러기 위해서는 위
수동 종속성 삽입
예시에서의 AppContainer와 같은 컨테이너를 @Component를 붙인 인터페이스를 통해 정의하고, 또한 필요한 종속 항목들을 반환 하는 함수를 선언해 주셔야 합니다. 이렇게 하면 Dagger는 이 종속 항목들을 가진 컨테이너(DaggerComponent)를 생성하게 되는데 컴파일 시 이러한 인터페이스의 이름에 접두사 Dagger를 붙인 컨테이너 구현체를 자동 생성합니다.
@Component // DaggerApplicationGraph 클래스를 컴파일 시 자동생성함
interface ApplicationGraph {
fun repository(): UserRepository
...
}
val applicationGraph: ApplicationGraph = DaggerApplicationGraph.create()
val userRepository: UserRepository = applicationGraph.repository()
- Dagger는 Annotation을 토대로 applicationGraph.repository()라는 하나의 진입 구문으로 UserRepository -> UserLocalDataSource, UserRemoteDataSource에 이르는 종속 항목 그래프를 만들게 됩니다.
-
repository()는 호출될 때 마다 늘 새로운 인스턴스를 생성합니다.
-
하나의 인스턴스가 여러 곳에 종속되어야 하는 경우가 있습니다. UserRepository나 UserLocalDataSource, UserRemoteDataSource는 복수의 인스턴스가 필요없는 경우입니다. Dagger의 기본동작은 늘 새 인스턴스를 생성하도록 되어있기 때문에 이러한 경우에는 또 다른 Annotation이 필요합니다.
- Scope Annotation을 사용하여 종속 항목의 라이프사이클을 Component의 라이프 사이클과 일치 시킬 수 있습니다. 즉 Component에 종속 항목을 요청할 때 마다 라이프사이클이 동기화 된 종속 항목을 동일한 인스턴스로써 제공할 수 있습니다. 사용 방법은 Component와 종속 항목(UserRepository)의 class 선언부에 똑같이 @Singleton을 써 넣는 것 입니다.
@Singleton
@Component
interface ApplicationGraph {
fun repository(): UserRepository
...
}
...
// Scope this class to a component using @Singleton scope (i.e. ApplicationGraph)
@Singleton
class UserRepository @Inject constructor(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) { ... }
- 커스텀 Scope 생성법
// Creates MyCustomScope
@Scope
@MustBeDocumented
@Retention(value = AnnotationRetention.RUNTIME)
annotation class MyCustomScope
- 이 경우는 @Singleton자리에 @MyCustomScope를 넣습니다.
-
Component와 UserRepository의 생명주기를 일치시켰으므로 Component가 재생성되지 않는 한 repository() 호출 시 동일한 인스턴스의 UserRepository를 반환하게 됩니다.
- 디펜던시 추가(at app.gradle)
apply plugin: 'kotlin-kapt'
dependencies {
implementation 'com.google.dagger:dagger:2.x'
kapt 'com.google.dagger:dagger-compiler:2.x'
}
- 일반적으로 애플리케이션 그래프 인스턴스(위의 ApplicationGraph 인스턴스)는 앱 객체가 살아있는동안 쭉 사용하기도 하고, applicationContext를 가지고 초기화 하는 Module도 많기 때문에 Application(또는 MultiDexApplication)객체를 커스텀하여 그 안에서 생성하고 그래프 인스턴스를 멤버로 가지도록 합니다. 또 Activity와 같은 다른 컨택스트에서도 사용할 수가 있게 됩니다.
// Application graph의 정의
@Component
interface ApplicationComponent { ... }
// appComponent lives in the Application class to share its lifecycle
class MyApplication: Application() {
// Reference to the application graph that is used across the whole app
val appComponent = DaggerApplicationComponent.create()
}
- Activity와 같은 시스템에서 생성하는 객체의 경우 시스템에서 인스턴스화 되므로 Dagger가 이 객체를 자동 생성할 수 없습니다. 모든 초기화 코드는 onCreate()로 이동해야 하며 생성자 대신 필드(setter) 삽입을 해야 합니다.
class LoginActivity: Activity() {
// You want Dagger to provide an instance of LoginViewModel from the graph
@Inject lateinit var loginViewModel: LoginViewModel
}
-
필드앞에 @Inject를 붙이면 Dagger는 LoginViewModel 필드를 자동으로 채워 넣는다. 이 필드는 private일 수 없고 최소한 package 내에서는 접근 가능해야 합니다.
-
Activity Injection : Component로부터 LoginActivity가 필요한 LoginViewModel 객체를 제공받기 위해서는 Activity 스스로를 Dagger에게 알려야 합니다. 이를 위해 종속 항목을 필요로 하는 객체(LoginActivity)를 매개변수로 하는 함수를 Component는 노출해야 합니다.
@Component
interface ApplicationComponent {
// LoginActivity가 그래프에 엑세스 하여 Injection을 원한다는 사실을 Dagger에게 알린다.
fun inject(activity: LoginActivity)
}
class LoginActivity: Activity() {
@Inject lateinit var loginViewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
// ApplicationComponent에게 삽입을 요청함. @Inject가 붙은 멤버가 채워진다.
(applicationContext as MyApplication).appComponent.inject(this)
...
}
}
- 위 inject()가 호출되면 Component는 LoginActivity가 필요한(@Inject가 붙은) 매개변수들을 그래프로부터 삽입해 줍니다.
- 매개변수가 있을 때 inject()의 함수명은 바뀌어서는 안되며 인자 타입은 Concrete 클래스여야 합니다.
- Activity든 Fragment든 종속 항복 삽입이 필요한 클래스별로 모두 inject함수를 선언해야 합니다.
- Activity 내에 Fragment복원 문제를 방지하기 위해 Activity의 super.onCreate()가 호출되기 전에 삽입해야 합니다.
-
Fragment에서의 경우는 onAttach()메소드에서 삽입하도록 합니다.
-
Dagger Module : UserRemoteDataSource에는 LoginRetrofitService 종속 항목을 사용하고 있고, 이 Service는 단순 생성자로 생성할 수 없습니다. Builder를 통해 생성하는 이 클래스처럼 생성 방법을 지정해 줘야 하는 경우 @Inject가 아닌 @Provides를 사용하여야 하고, 이 함수들은 @Module로 지정된 클래스에 포함시킵니다. 이 Module들은 Component의 어노테이션의 매개변수를 통해 연결됩니다.
-
다시 말하면 Module은 객체의 제공 방법들(@Provides들)을 캡슐화하는 역할을 하는 것입니다.
-
@inject constructor(LoginRetrofitService)로 생성방법이 정의되었으므로 NetworkModule내의 LoginRetrofitService을 반환타입으로 지정한 @Provides가 붙은 함수를 호출하여 종속 항목을 삽입하도록 합니다.
- @Provides 함수가 또 다른 @Provides함수의 리턴 타입을 종속 항목으로 갖고 있다면 Dagger는 그래프로 부터 그 객체를 제공하기 위해 이 또 다른 함수를 실행시킬 것입니다.
@Module
class NetworkModule {
@Provides
fun provideLoginRetrofitService(
okHttpClient: OkHttpClient
): LoginRetrofitService { ... }
}
- 이 모듈을 그래프에 포함시키기 위해 Component선언을 통해 아래와 같이 연결해 줍니다.
@Component(modules = [NetworkModule::class])
interface ApplicationComponent {
...
}
- Dagger Scope : @Singleton을 통해 Application객체의 생명주기와 AppComponent의 생명주기를 맞추어 이 주기동안은 새 인스턴스 생성을 하지 못하도록 한 것 처럼 Component의 범위를 지정하기 위해 Scope를 사용합니다.
- 스코프 어노테이션을 사용한 Module은 동일한 스코프 어노테이션을 사용한 Component만 사용할 수 있습니다.
-
@Inject와 함께 생성사 주입을 할 때는 클래스에 Scope를, @Provides를 사용할 때는 해당 함수에 Scope를 지정합니다.
-
Dagger Subcomponent : @Singleton을 붙인 경우와는 정반대로 어떤 스코프 밖에서는 Component가 가진 객체를 제거하도록 한정할 필요가 있습니다. 예를들어 로그인이 끝나면 LoginViewModel를 메모리에서 제거하도록 하고싶을 때 AppComponent가 아닌 새 @Subcomponet(여기서는 LoginComponent)를 생성하여 Scope를 Application이 아닌 Activity의 라이프사이클로 한정해야 합니다. 그 뒤 LoginViewModel을 LoginComponent가 관리하도록 합니다.
- LoginComponent가 관리하는 객체인 LoginViewModel은 AppComponent가 관리하는 UserRepository에 의존합니다. 따라서 LoginComponent는 AppComponent가 형성한 그래프에 합류하기 위해 자신을 생성하는 법을 알리는 Factory를 정의해야 합니다.
@Subcomponent
interface LoginComponent {
// Factory that is used to create instances of this subcomponent
@Subcomponent.Factory
interface Factory {
fun create(): LoginComponent
}
fun inject(loginActivity: LoginActivity)
}
- LoginComponent를 AppComponent의 Subcomponent로써 합류시키기 위해 3가지 스텝을 밟아야 합니다.
// 1. LoginComponent를 포함하는 Module을 하나 만든다
@Module(subcomponents = LoginComponent::class)
class SubcomponentsModule {}
// 2. 위에서 만든 Module을 AppComponent에 포함시킨다.
@Singleton
@Component(modules = [NetworkModule::class, SubcomponentsModule::class])
interface ApplicationComponent {
}
// 3. 다음과 같이 App Component에서 LoginComponent 인스턴스를 생성하는 팩토리를 노출한다.
@Singleton
@Component(modules = [NetworkModule::class, SubcomponentsModule::class])
interface ApplicationComponent {
fun loginComponent(): LoginComponent.Factory
}
- 이제 LoginComponent를 LoginActivity의 수명주기에 연결합니다. Application객체가 AppComponent를 참조하고 있는것과 같이 LoginActivity가 LoginComponent를 참조해야 합니다. 단 Dagger에서 LoginComponent를 자동으로 삽입해주지 않으므로 이 전역변수는 @Inject를 설정하지 않고 AppComponent를 통해 create()를 호출하여 생성한 후 inject()합니다. 이 LoginComponent는 Activity가 destroy될 때 암시적으로 폐기됩니다.
class LoginActivity: Activity() {
lateinit var loginComponent: LoginComponent
@Inject lateinit var loginViewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
// 1. subcomponent생성
loginComponent = (applicationContext as MyDaggerApplication)
.appComponent.loginComponent().create()
// 2. 그로부터 삽입
loginComponent.inject(this)
super.onCreate(savedInstanceState)
}
}
-
LoginComponent는 자신이 살아있는동안 항상 같은 LoginViewModel을 제공해야 하므로 커스텀 Scope를 두 클래스에 지정해줘야 합니다. @Singleton은 이미 사용중이라 재활용할 수 없습니다. 새 scope의 이름은 목적이 아닌 Context에 따라 지어주는걸 권장합니다. ex) LoginScope대신 ActivityScope, FragmentScope…
-
아래와 같이 ActivityScope를 생성해봅니다.
@Scope
@Retention(value = AnnotationRetention.RUNTIME)
annotation class ActivityScope
- 그 뒤 LoginComponent와 LoginViewModel에 Scope를 지정합니다.
@ActivityScope
@Subcomponent
interface LoginComponent { ... }
@ActivityScope
class LoginViewModel @Inject constructor(
private val userRepository: UserRepository
) { ... }
-
이제 LoginActivity의 라이프사이클에 LoginComponent를 맞췄습니다. LoginActivity가 포함한 Fragment들은 항상 동일한 LoginViewModel을 제공받으며 이 객체는 LoginActivity가 destroy될 때 함께 폐기됩니다.
-
정리
-
DI 컨테이너를 여기서는 Component와 Module이라고 부른다.
- DI가 필요한 이유
- 의존성 파라메터를 생성자에서 받지 않아도 되므로 보일러 플레이트 코드 제거 가능.
- Interface의 구현체를 쉽게 교체 가능하므로 Mock객체로의 유연한 전환 가능.
- 리팩터링의 편의성
- Dagger의 기본개념
- @Inject : 의존성 주입을 요청함. 예를들어 Activity에서 Inject 어노테이션으로 주입을 요청하면 Component가 Module로부터 객체를 생성하여 넘겨줌, 또한 주입의 대상이 되는 객체의 생성자에 @Inject를 붙이면 주입 된다? 뭔말이지 이
-
@Component : 연결된 Module을 이용해 의존성 객체를 생성, Inject로 요청받은 인스턴스에 생성한 객체를 주입. 주로 Singleton으로 관리되던 객체를 Component로 만들 수 있다?
-
Subcomponent : Component는 하위 계층관계를 만들 수 있음. Inner class 형식의 하위 Component. Dagger의 컨셉인 그래프를 형성하고, Inject로 주입을 요청받으면 Subcmponent에서 먼저 의존성을 검색하고, 없으면 부모로 올라가서 검색한다.
-
@Module : Component에 연결되어 의존성 객체를 생성하고, Scope에 따라 관리한다. @Provides또는 @Bind를 사용하여 주입 될 객체를 생성하거나 주입받아 연결한다. 연결만 하는 경우 @Binds를 이용해 추상 메소드로 만들 수 있다.
-
Scope : 생성된 객체의 생명주기 범위. 안드에서는 주로 화면의 생명주기와 맞춰 사용한다. Module이 Scope를 보고 객체를 관리한다. @Singleton @ActivityScoped @FragmentScoped …
- Dagger의 주요 플로우
- Application, Activity, Fragment등에서 @Inject를 통해 의존성 주입 요청
- Subcomponent에서부터 Application Component까지 계층을 올라가며 의존성 타입을 검색. 컴포넌트의 Module에 @Provides가 붙은 메소드들 중 반환타입이 일치하는 메소드를 찾는다.
- 해당하는 타입을 찾으면 Module에서 생성(Scope에 있으면 바로 반환) 후 Component에 전달
- Component는 @Inject를 요청한 곳에 주입함.
-
@Component가 붙은 interface는 컴파일 타임에 DaggerXXX와 같은 이름이 붙은 클래스로 생성된다. 해당 클래스의 Builder를 통해 모듈을 생성하여 넣어 줌으로써 Application과 Module이 연결된다.
- @Module의 내부에 @ContributesAndroidInjector를 사용하여 Subcomponent를 생성할 수 있다. 뭔 말인지 모르겠다.