최근 클린아키텍쳐 + 멀티 모듈 구조의 안드로이드 프로젝트를 진행하면서, 프로젝트의 규모가 커짐에 따라 클래스와 모듈들 간의 결합이 점점 강해지고 있다는 느낌을 많이 받았다. 결합이 강해지면 강해질수록 유지보수나 기능 개발에 있어서 SOLID 원칙을 위배할 수 밖에 없는 상황들이 왕왕 생겼고 테스트또한 애로사항이 많이 생겼다. 이러한 상황에서 현재 프로젝트가 가지고 있는 구조의 이점을 최대한 취하기 위해서는 이러한 객체 및 모듈간의 강한 결합을 해소할 방법이 필요하였고, 그에 따라 의존성 주입 기법을 도입하여 문제를 해결하기로 결정했다.
의존성 주입 기법은 객체간의 결합을 느슨하게 유지하여 소프트웨어에 유연함(Flexibility)을 증가시키는 디자인 패턴으로서 '의존성의 생성을 객체에서 분리하는 방식' 으로 목적을 달성한다. 의존성 주입 기법은 의존이 필요한 객체의 생성을 생성자 호출을 통해 인스턴트화 하는 방식이 아닌, 외부에서 생성된 객체를 의존성이 필요한 객체에 주입시켜주는 방식을 사용한다. 이를 통해 객체간 결합을 느슨하게 유지해주며 이로 인해 테스트 용이성과 유지보수 용이성, 확장 가능성을 높여주는 이점을 제공한다.
프로젝트 구조가 계속 커지고 테스트와 유지보수 또한 중요한 요소가 된 현재 상황에서 의존성 주입 기법은 단기적으로나 장기적으로나 프로젝트에 큰 이점을 제공할 수 있는 솔루션이라고 판단하여 도입을 결정하게 되었다.
안드로이드 프로젝트에서 의존성 주입을 도입하기 위해 사용할 수 있는 라이브러리는 크게 Dagger2와 Dagger Hilt가 Dagger2는 안드로이드 프로젝트 뿐만 아니라 Java기반의 프로젝트에서 범용적으로 사용이 가능한 라이브러리이며 Dagger Hilt는 기존 Dagger2 기반의 라이브러리에 안드로이드 프로젝트에 특화된 기능들이 추가된 의존성 주입 라이브러리이다. 도입을 결정한 프로젝트가 안드로이드 프로젝트이기에 Dagger Hilt를 사용할 수 있었으나, 의존성 주입 기법에 대해 자세히 공부하고자 하는 학습적 목적 또한 있었기에 Dagger2를 사용한 의존성 주입 기법 도입을 선택했다. 물론, 안드로이드 프로젝트만을 위한 라이브러리가 존재하지만, 실제 Dagger2 라이브러리가 의존성 주입을 처리하는 방식과 그 개념에 대해 깊이 이해하려면 Dagger2를 사용하여 Dagger Hilt가 제공하는 여러 안드로이드 프로젝트에 특화된 기능들을 직접 구현해 보는 경험이 필요하다고 판단하였다.
또한 단순 공식 문서의 예시와 인터넷에 올라온 여러 예제들을 따라서 충분한 이해 없이 프로젝트에 적용하는 것을 지양하고자 프로젝트에 Dagger2를 이용한 의존성 주입 기법을 도입하면서 공부한 내용들을 문서화 하고자 한다.
Dagger는 Annotation을 사용하여 컴파일 타임에 의존성 그래프를 구성하여 정의된 의존성을 지정된 객체에 주입해 주는 방식을 사용한다. 따라서 Dagger가 의존성 그래프를 구성하기 위해 사용되는 요소들은 모두 Annotation을 가지고 있다. Dagger가 미리 정의해 놓은 Annotation 뿐만 아니라 직접 Annotation Class를 정의하여 사용하는 것 또한 가능하다 (이것은 @Scope Annotation에 대해 기술할 때 구체적으로 설명하고자 한다.) 하지만 Dagger를 이용하여 의존성 그래프를 구성하고 의존성을 주입하기 위해 크게 @Component, @Module Annotation을 가진 요소들이 필요하다. 먼저 이 두 요소들에 대해 살펴보려 한다.
Component는 @Component Annotation을 가진 인터페이스 혹은 추상 클래스이다. Component는 미리 정의된 의존성과 의존성이 필요한 지점을 연결해 주는 다리의 역할을 한다. 따라서 Component는 무엇이 어떤 객체에 의존하는지, 의존하는 객체를 어떻게, 어떤 순서로 생성할지를 정의 한다. 또한 Scope를 강제하여 의존성들을 같은 맥락으로 묶을 수 있다. 또한 의존성 주입을 위한 함수를 가지고 있어 의존성이 필요한 곳에서 해당 함수를 호출하여 의존성을 주입할 수 있도록 한다.
Component가 인터페이스 혹은 추상 클래스로 정의되어야 하는 이유는 컴파일 타임에서 Dagger가 의존성 그래프를 구성할 때, @Component annotation을 가진 인터페이스/추상 클래스를 구현한 구현체(DaggerXXXComponent)를 내부적으로 생성하여 의존성 주입이 필요한 곳에서 호출하여 사용할 수 있도록 하기 때문이다.
// AppComponent
@Component
interface AppComponent {
fun inject(app: MainApplication)
}
// MainApplication
class MainApplication(): Application() {
override fun onCreate() {
super.onCreate()
DaggerAppComponent().inject(this)
}
}
의존성의 주입은 @Inject annotation을 붙여서 지정할 수 있으며, Dagger에서는 생성자 주입이 권장된다. 하지만 생성자 주입 뿐만 아니라 맴버 주입 또는 메소드 주입 또한 가능하다.
class SomeClass @Inject constructor(
private val aDependency: DependencyA
) {
// 맴버 주입
@Inject lateinit var bDependency: DependencyB
// 메소드 주입
@Inject
fun someFunction(cDependency: DependencyC) { ... }
}
생성자 주입을 통해 의존성을 주입할 경우, 별도의 조치 없이 Dagger가 의존성 그래프를 바탕으로 필요한 의존성을 식별하여 주입하지만, 다른 주입 방식 특히 맴버 주입 방식을 사용할 경우, Member Injection Function인 inject() 함수를 꼭 호출해 주어야 한다. 안드로이드 프로젝트에서 생성자 주입이 아닌 맴버 주입이 강제되는 상황은 주로 안드로이드 시스템이 정의한 클래스, 또는 이러한 클래스를 상속받은 클래스에서 의존성 주입을 받아야 하는 경우가 있다. 이러한 상황에서는 생성자를 직접 정의할 수 없거나 empty constructor가 강제되기 때문에 맴버 주입을 사용할 수 밖에 없다. 위의 예시에서도 안드로이드 라이브러리가 정의한 Application 클래스를 상속 받는 MainApplication클래스에서 의존성을 주입 받아야 하기 때문에 inject() 함수를 사용하여 맴버 주입 기법으로 의존성을 주입 받게 된다.
또한 Component는 Member Injection Function 뿐만 아니라 미리 정의된 의존성에 직접 접근할 수 있는 함수들도 가질 수 있는데, 이러한 함수를 Provision Function이라고 한다.
@Component
inferface AppComponent {
fun getSomeDependency(): SomeDependency
}
예를 들자면, Component에 SomeDependency객체의 의존성이 미리 정의되어 있다면, 해당 의존성이 필요한 위치에서 DaggerAppComponent().getSomeDependency() 함수를 호출하여 의존성에 직접 접근이 가능하다. 간단히 생각하면 Component는 미리 정의된 의존성에 접근할 수 있는 창구 같은 역할을 한다고 생각하면 된다.
추가로, Subcomponent도 존재하는데, 이는 마치 자식 클래스가 부모 클래스를 상속받는 것 처럼 특정 Component의 의존성을 상속하여 공통적으로 필요한 의존성을 공유함과 동시에 개별적인 의존성 또한 필요한 경우 사용할 수 있다. 주로 Fragment와 같이 중첩된 구조를 가질 경우 사용할 수 있다.
이러한 특성을 바탕으로 안드로이드 프로젝트에 Component를 사용할 때 다음과 같이 구성할 수 있다.
- 앱의 생명주기를 따라가는 객체들의 의존성을 제공하기 위해, 최상단 모듈인
:app모듈에 Component를 정의한다.- 각 스크린 (Scene) 별로 필요로 하는 의존성들을 제공하기 위해 각 Scene마다 Component를 정의한다.
- 독립적인 의존성 그래프를 구성할 경우 Component를, 특정 공통 의존성을 공유하면서 추가적으로 개별적인 의존성 그래프가 필요한 경우 Subcomponent를 사용한다.
Module은 주입에 필요한 의존성들을 정의하는 곳이다. 이러한 Module은 @Component annotation의 Optional Element 중 하나인 modules에 포함시키는 방식으로 특정 Component가 해당 Module에 정의된 의존성에 접근할 수 있도록 한다.
Module에는 객체를 생성하여 제공하는 방법이 포함된 함수들로 구성된다. 또한, 해당 함수들은 객체를 생성하는 방법에 따라 @Provides 와 @Binds의 두가지 annotation을 가지게 된다. @Provides annotation의 경우, 서드 파티 라이브러리에서 제공하는 클래스 혹은 생성자 주입 방식을 사용할 수 없는 클래스의 객체를 제공할 때 사용한다. 이 경우, Dagger는 이러한 클래스의 객체를 생성하는 방식을 알 수 없기에 직접 객체를 생성하는 방식을 정의해 주어야 한다. 따라서 이 경우, Dagger는 미리 정의된 객체 생성 방식을 그대로 사용하여야 하기 때문에 @Provides annotation을 가지는 함수를 포함한 Module은 class 또는 object 등으로 정의되어야 한다. 아래의 코드는 서드 파티 라이브러리에서 제공하는 Retrofit 객체의 의존성을 제공하기 위한 함수이다.
@Module
object NetworkModule {
@Provides
fun providesRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
}
반면, 직접 정의한 클래스와 같이 생성자 주입이 가능한 경우에는 명시적으로 객체 생성 방식을 정의한 함수를 가질 필요가 없다. 이 경우, Dagger가 @Inject annotation을 가진 생성자를 식별해 해당하는 필요로 하는 의존성을 판단해 주입해 준다. 하지만 만약, interface 혹은 abstract class와 같은 추상체의 의존성에 특정 구현체를 주입해 주어야 할 경우, Dagger는 특정 구현체를 알아서 주입해 주지 못한다. 이 경우, Module에 @Binds annotation을 가진 함수를 정의함으로서 의존성을 주입해 줄 수 있다. @Binds annotation은 반드시 추상 함수여야 하며 해당 함수를 포함한 Module 또한 자연스럽게 추상 클래스가 되어야 한다. @Binds annotation을 가진 함수는 특정 구현체를 파라미터로 받아 해당 객체에 필요한 모든 의존성 주입을 수행한 후, 의존성 주입이 완료된 객체를 반환함으로서 추상체의 의존성에 특정 구현체를 제공할 수 있다. 이때, 특정 구현체가 필요로 하는 의존성 또한 접근이 가능하여야 한다. (Module 내부에 정의, 혹은 해당 의존성이 정의된 Module을 Component의 modules에 포함하거나 Module의 includes element에 포함)
interface SomeInterface {}
class SomeImpl @Inject constructor(
private val aDependency: DependencyA
): SomeInterface {
@Inject lateinit var bDependency: DependencyB
@Inject
fun someUniqueFunction(cDependency: DependencyC) {...}
}
@Module(
includes = [DependencyBModule::class]
)
abstract class ExampleBindsModule {
@Binds
fun bindsSomeInterface(impl: SomeImpl): SomeInterface
}
@Component(
modules = [ExmapleBindsModule::class, DependencyCModule::class]
)
interface ExampleComponent { ... }
이러한 함수들은 제공할 객체의 타입을 반환값으로 가지며, 이 반환값을 이용하여 해당 객체의 의존성이 필요한 곳에 의존성이 제공된다. Dagger는 컴파일 타임에 Component의 modules 및 Module의 includes에 나열된 @Module annotation을 가진 Module들에 정의된 의존성들을 Component가 구성하는 의존성 그래프에 포함시키며, Module에 포함된 함수들의 반환값을 이용하여 필요로하는 의존성이 주입될 수 있도록 한다.
만약, 특정 객체에 대한 의존성이 의존성을 주입 받는 곳에 따라 다른 구현체, 혹은 다른 구성으로 제공되어야 할 경우, 혹은 테스트를 위한 객체를 제공 받아야 할 경우, Dagger가 제공하는@Named("name") annotaton, 혹은 사용자가 직접 정의한 annotation을 사용하여 주입 받을 위치에 따라 제공받을 의존성을 특정 할 수 있다.
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class TestServer
@Module
abstract class DataModule {
companion object {
@Provides
@Named("Live")
fun providesRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("www.live_server.com")
.build()
}
@Provides
@TestServer
fun providesTestRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("www.test_server.com")
.build()
}
}
}
class RemoteSource @Inject constructor (
@Named("Live") private val retrofit: Retrofit,
@TestServer private val retrofitTest: Retrofit
) { ... }
이러한 특성을 바탕으로 Module을 안드로이드 프로젝트에 적용할 때 다음과 같이 구성할 수 있다.
- 특정 맥락을 공유하는 의존성들 (e.g. 로그인 관련 Usecase들, 게시판 관련 Usecase들 등)을 한 Module에 정의하여 필요로 하는 의존성 그래프의 modules (Component) 혹은 includes (Module)에 나열하여 공유될 수 있도록 한다.
- 필요시,
@Namedannotation 또는 직접 정의한 annotation을 활용하여 각 business logic 및 테스트 환경등 같은 의존성에 상황별로 적합한 객체를 주입할 수 있도록 한다.
Scope는 Dagger가 구성한 의존성 그래프에서 주입되는 객체의 재사용과 관련된 annotation이다. 간단히 말해, 마치 Component와 주입되는 객체의 인스턴스들간의 계약과 같은데, 해당 계약하에 Component를 통해 주입되는 의존성은 항상 같은 인스턴스를 제공한다. 정의된 의존성 중 Scope annotation을 가지는 함수 또는 통해 주입되는 인스턴스는 같은 Scope annotation을 가지는 Component가 살아있는 한, 같은 인스턴스를 반환한다. 마치 싱글톤 패턴을 따르는 클래스의 객체를 사용하는 것과 매우 유사하다. 심지어 Dagger에서 제공하는 Scope annotation 중 @Singleton이라는 annotation 또한 존재한다. 하지만 @Singleton Scope annotation을 사용함으로서 주입받는 객체가 실제로 싱글톤 패턴을 따른다고는 할 수 없다. 단지 같은 Scope annotation을 사용하는 Component가 살아있는 한 생성된 객체를 재활용할 뿐이다. 만약 주입 받는 객체가 실제로 싱글톤 패턴을 따르기를 원한다면 Module에 객체 생성 방식을 정의할 때 직접 구현해 주어야 한다.
@Module
object ModuleA {
@Provide
@Singleton
fun providesRetrofit(): Retrofit {
...
}
}
@Singleton
@Component(
modules = [ModuleA::class]
)
interface ComponentA {
fun retrofit(): Retrofit // 같은 인스턴스가 재활용되어 주입됨.
...
}
이러한 특성을 바탕으로 Scope annotation을 안드로이드 프로젝트에 적용할 때 다음과 같이 구성할 수 있다.
- 특정 의존성 그래프에서 항상 같은 인스턴스가 주입되어야 할 때, 적절한 Scope annotation을 활용하여 의존성 그래프를 구성함으로서 생성된 인스턴스가 재활용되어 주입될 수 있도록 한다.
- 만약, 특정 의존성 그래프에서가 아닌 모든 의존성 그래프에서 항상 같은 인스턴스가 주입되어야 할 경우, Scope annotation을 사용하는 것이 아닌 Module에 의존성을 정의 할 때 싱글톤 패턴을 따르도록 직접 구현한다.
현재 위에서 서술한 내용을 바탕으로 프로젝트에 의존성 주입 기법을 적용하였으며, 다음과 같은 이점을 취할 수 있었다.
- 객체간 의존성을 느슨하게 유지하여 신규 기능 추가 또는 기존 기능 유지보수 및 수정 간 발생하는 side effect의 전파 범위를 좁힐 수 있었다.
- 여러곳에 필요한 의존성을 한곳에서 관리함으로서 오류 발생시, 원인파악 및 디버그가 용이해 졌다.
- 각 Scene 및 Usecase 별로 필요한 의존성만 취할 수 있도록 하여 불필요한 의존성이 발생하는 것을 방지할 수 있었다.
또한 다음과 같은 이점을 취할 수 있을 것이라고 예상한다.
각 모듈의 유닛 테스트간 테스트 환경에 필요한 객체 또한 테스트 환경에 맞게 mocking/stubbing 된 객체를 주입 받음으로서 테스트 환경을 효율적으로 분리할 수 있을 것이다.
도입간 식별된 단점 또한 다음과 같다.
- 의존성 그래프를 구성하기 위한 Component, Module 등을 구현함에 있어 Boilerplate 코드 및 전반적인 코드베이스가 증가하였다.
- 컴파일 시, 의존성 그래프 구성 자체에 문제가 발생하였을 경우, 문제가 발생한 위치를 찾는 것이 까다로웠던 경우가 있었다.
위에 서술한 내용과 방식에 오류가 있을 수 있고, 더 나은 방법 또한 있을 수 있다고 생각한다. 하지만 단순 구현/도입이 아닌 항상 먼저 공부하고 스스로 이해하며, 그 이해를 바탕으로 한 적용을 실천하기 위해 노력하고자 한다.