[연재] 코틀린 프로젝트 - 01 오픈소스 라이브러리의 이해: 의존성 주입

의존성 주입 - Dagger 2

의존성 주입으로 많이 사용되는 Dagger 는 Square에서 개발되고 현재 Dagger 2가 구글에 의해 지원 개발 되었습니다. 다음 사이트에서 정보를 확인할 수 있습니다.

https://Dagger.dev
https://github.com/google/dagger

안드로이드에서의 문제점

안드로이드에서 Dagger는 프로가드(ProGuard)라는 후처리 컴파일러에 의존하고 있습니다. 대거를 안드로이드 앱에서 구현하기 위해서는 약간 어려운 점이 있는데, 안드로이드 프레임워크의 클래스들은 액티비티(Activity)나 프로그먼트(Fragment)처럼 OS자체로 인스턴스를 만들어 냅니다. 대거는 주입된 개체들을 생성할 수 있는데 안드로이드에서는 라이프 사이클 매수드에 멤버주입이 필요하게 됩니다.

dagger.android

위와 같은 문제점을 해결하기 위해서 몇 가지 추가적인 API 를 제공합니다. 좀 더 손쉽게 사용할 수 있도록 Dagger기반으로 만들어진 Hilt라는 의존성 주입 라이브러리도 있습니다.

일반적인 대거를 사용하는 방법은 아래에 잘 기술되어 있습니다.

사실 작은 사이즈의 프로젝트에서는 대거가 오히려 개발 속도를 늦출 수 있습니다. 작은 사이즈의 프로젝트에서는 직접 의존성 주입을 만드는 것도 방법입니다. 프로젝트의 사이즈에 따라서 대거를 선택할 수 있습니다. 다음을 참조해 주세요.

 

그래들 의존성 라이브러리

안드로이드에 이 라이브러리를 적용하기 위해 build.gradle에 다음을 추가 합니다. 

apply plugin: 'kotlin-kapt'
...
dependencies {
    implementation 'com.google.dagger:dagger:2.x'
    kapt 'com.google.dagger:dagger-compiler:2.x'
    compileOnly 'org.glassfish:javax.annotation:10.0-b28'
...

의존성 주입이란?

Dagger는 의존성 주입 혹은 DI(Dependency Injection)이라는 프레임워크 라이브러리 입니다. 의존성 주입이란 구성요소간의 의존 관계가 소스코드 내부가 아닌 외부의 설정파일 등을 통해 정의되게 하는 디자인 패턴 중의 하나입니다. 다음 그림을 통해 확인해 봅시다.


기존 방식과 의존성 주입 방식

전통적인 방법에서는 Main이 여러 코드에 의존되어 있어 하나가 변경되면 차례로 변경을 해야 합니다. 의존성 주입은 Main에서의 의존은 최대한 줄이되 필요할 때마다 의존성 주입을 통해 동적으로 구조를 적용하는데 있습니다. 

필요한 이유를 먼저 생각해 봅시다. 먼저 모듈간의 의존성이 낮아져 프로그램이 대형화 되면 유지보수나 재사용성이 늘어나는데 있습니다. 하지만 규모가 작은 프로그램에서는 오히려 코드량이 많아져 읽기 어려워질 수 있습니다. 안드로이드는 특히 context의 영향을 많이 받게 되는데 만일 Activity나 Fragment의 인스턴스를 만들고 그 생명주기에 진입하면 당연히 Activity나 Fragment의 context에 영향을 받을 수 밖에 없습니다.

하지만 역으로 생각해 인스턴스를 Activity나 Fragment 밖에서 만들고 받아서 사용하면 범용적으로 재사용이 가능하게 됩니다. 이것을 '제어의 반전'이라는 용어로 Inversion of Control이라는 디자인 개념으로 설명하고 있습니다. 소프트웨어 공학을 위한 용어로 꽤 어려운 개념으로 정의되어 있는데 간단히 말하면 '다른 누군가가 대신 해주는' 개념으로 생각 하면 좋을 것 같습니다. 자바 EE 기반의 개발을 위한 애플리케이션 프레임워크인 Spring 같은 것은 이것을 철저하게 따르고 있습니다. 

Spring 프레임워크와 코틀린의 사용이 궁금한 경우 다음 내용을 참조해 볼 수 있습니다. 

hyper-cube.io: http://hyper-cube.io/2017/11/27/spring5-with-kotlin/
spring.io: https://spring.io/guides/tutorials/spring-boot-kotlin/
kotlinlang.org: https://kotlinlang.org/docs/tutorials/spring-boot-restful.html

의존성 주입의 방법


Activity에 인스턴스를 주입하는 컴포넌트

의존성을 주입하기 위해서는 일단 3가지 방식의 주입을 이해해야 합니다.

  • 생성자 주입: 필요한 의존성을 포함하는 클래스의 생성자를 만들고 생성자를 통해 의존성 주입
  • 세터를 통한 주입: 의존성을 입력 받는 세터 메서드를 만들고 이것을 통해 의존성 주입
  • 인터페이스를 통한 주입: 의존성을 주입하는 메서드를 포함한 인터페이스를 작성하고 이 인터페이스를 구현하도록 해 실행 시에 이를 통해 의존성 주입

Dagger 2는 어노테이션 표기법을 이용해 다음과 같이 표현됩니다. 

  • @Inject : Dagger 의존성 그래프에 타입을 추가
  • @Bind :  어떤 인터페이스가 구현돼야 하는지 대거에게 알려준다
  • @Provide : 프로젝트가 소유하지 않은 클래스를 어떻게 제공할 것인지 대거에게 알려준다
  • 하나의 컴포넌트(@Component )에서만 모듈(@Module )들을 정의해야 한다
  • 생명주기에 의존된 범위(scope)를 위해 @ApplicationScope , @LoggedUserScope , @ActivityScope 와 같은 어노테이션을 사용할 수 있다

 


Dagger 2의 어노테이션 표기법

먼저 @Module, @Provides의 공급자와 이것을 @Inject주입해 소비하는 소비자클래스들, 연결을 담당하는 @Component 인터페이스로 이루어져 있습니다. @Provide 어노테이션은 특정 의존성을 제공하는 메서드에 사용됩니다. 아래의 코드에서처럼 @Provides 가 붙은 provideContext()는 Application의 의존성 객체인 컨텍스트를 제공하게 됩니다. 

@Module
class AppModule(private val app: Application) {
  @Provides
  @Singleton
  fun provideContext(): Context = app
}

가장 상위 개념인 앱의 Application을 공급자로 설정합니다. 앱 전반의 의존성을 제공할 수 있게 됩니다. 주 생성자에 private로 선언된 프로퍼티인 app 객체와 이 객체를 반환하는 provideContext()를 통해 설정될 수 있습니다. Application 클래스는 보통 context객체를 통해 안드로이드의 여러 요소 간 사용할 공통의 내용을 접근할 수 있습니다. Application 클래스의 이름은 AndrodiManifest.xml에 <application> 태그에 정의되어 있습니다. 따라서 @Provides 어노테이션은 의존성의 특정 자료형, 여기서는 Context 객체를 제공할 수 있다는 것을 나타냅니다. 

@Singleton은 Dagger API는 아니고 javax.annotation 패키지에 포함된 어노테이션으로 인스턴스가 오로지 하나 이어야만 한다는 것을 나타냅니다. 이것을 다시 @Module 어노테이션을 통해 의존성을 주입할 수 있는 클래스로 @Component에 제공할 수 있는 Dagger 모듈이 만들어 졌습니다. 

의존성 주입의 연결과 빌드

이제 이것을 사용하기 위해서는 @Component 어노테이션을 사용하는 연결 매개체가 필요합니다. 

@Singleton
@Component(modules = [AppModule::class])
interface AppComponent

@Component은 다수의 모듈을 가질 수 있습니다. [AppModule::class] 표현법은 배열 표현법이 됩니다. 이 컴포넌트는 의존된 두 객체간의 연결을 담당합니다. 보통 inject() 메서드를 사용해 주입하고자 하는 대상을 지정합니다. Application의 하위 클래스에서 이것을 할 수 있습니다. 예를 들어 myApplication이 있다면 그 안의 프로퍼티와 초기 메서드를 다음과 같이 작성할 수 있습니다. 

...
lateinit var myComponent: AppComponent
...
private fun initDagger(app: myApplication): AppComponent =
      DaggerAppComponent.builder()
          .appModule(AppModule(app))
          .build()
...

빌드 전까지는 DaggerAppComponent에서 에러를 발생하는데 빌드 후 DaggerAppComponent.builder()에 의해DaggerAppComponent.java가 생성되면서 에러는 없어집니다. 모든 의존성이 제대로 만족하는지 컴파일 타임에 검사하게 되는 것입니다. 이것은 초기 Dagger 1이나 기존의 DI 프레임워크는 컴파일 타임에 검사하지 못하고 런타임에 의존성 적합 여부를 알기 때문에 잘못된 의존성이 있을 경우 런타임에서 에러가 날 수 있었습니다. Dagger 2는 이것을 방지합니다. 

 초기화와 주입

이제 myApplication에서 onCreate()를 다음과 같이 초기화 함수를 호출 시킵니다. 

override fun onCreate() {
    super.onCreate()
    wikiComponent = initDagger(this)
  }

이제 주입을 위해 AppComponent 인터페이스에 다음을 선언 합니다. 

fun inject(target: HomepageActivity)

이제 HomepageActivity는 AppComponent로부터 주입이 필요한 클래스가 됩니다. 공급자 모듈을 하나 더 만들어 봅시다.

@Module
class PresenterModule {
  @Provides
  @Singleton
  fun provideHomepagePresenter(): HomepagePresenter = HomepagePresenterImpl()
}

이 모듈은 메서드를 통해 HomepagePresenterImpl()을 반환 하게 됩니다. 그러면 다음과 같이 @Component에 모듈을 더 추가할 수 있게 됩니다. 

@Component(modules = [AppModule::class, PresenterModule::class])

이제 HomepageActivity 에는 다음과 같은 프로퍼티를 선언합니다. 

...
@Inject lateinit var presenter: HomepagePresenter // 주입 되어야 함을 나타냄
...

여기에 있는 @Inject도 사실 Dagger의 어노테이션이 아닌 javax의 어노테이션으로 주입 되어야 함을 나타냅니다. 실제로는 onCreate()에서 inject()를 호출하며 주입 합니다. 

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_homepage)

    (application as myApplication).myComponent.inject(this)
    ...
  }

이제 Dagger는 HomepagePresenter 객체를 HomepageActivity 에 주입할 것입니다. 

커피 제조기 예제

또 다른 예제로, 좀 더 이해해 보려면 다음 사이트를 참조합니다. 앞서 우리가 코틀린 코드로 만들었던 커피 제조기 코드가 Dagger 2가 적용된 예제로 다음 위치에서 볼 수 있습니다.

Java 예제: https://github.com/google/dagger/tree/master/examples/simple/src/main/java/coffee
Kotlin 예제: https://github.com/JetBrains/kotlin-examples/tree/master/maven/dagger-maven-example/src/main/kotlin

 

CoffeeMaker의 연관 관계 

소스를 연관 관계에 맞춰 그림으로 구성했습니다. @Module, @Provides에서 제공자가 있고, 이것을 통해 연결 역할을 하는 Coffee 인터페이스가 있습니다. 이 인터페이스는 어노테이션 컴파일러 kapt에 의해 Dagger가 붙은 클래스를 생성하며 여기에 Builder 클래스와 build() 메서드를 통해 의존성에 맞는 인스턴스를 생성해 @Inject가 있는 생성자에 주입 합니다. 

결론과 DI 프레임워크

사실, 이 프로그램은 그다지 크지 않기 때문에 지나치게 모듈화 하면 코드 읽기가 나빠집니다. 단, 만일 확장성과 프로그램이 더 커져 많은 모듈을 추가해야 할 경우에는 기존의 코드를 수정하지 않고도 새로운 모듈을 주입할 수 있고, 테스트를 위해 Mock 코드를 주입해 손쉽게 테스트 할 수도 있습니다. 따라서, 향후 확장성을 고려해 적절하게 도입하는 것이 중요합니다.

개념에 대한 것은 이 정도로 마무리 하겠습니다. DI프레임워크를 완전하게 이해하는 것은 상당히 어렵습니다. 자세한 것은 관련 서적이나 Square사의 개발자인 Jake Wharton의 비디오와 슬라이드를 살펴볼 것을 추천합니다. 

Youtube: https://youtu.be/plK0zyRLIP8
Speakerdeck: https://speakerdeck.com/jakewharton/dependency-injection-with-dagger-2-devoxx-2014

 

의존성 주입 - Koin

Dagger에 비해 좀 더 가볍게 사용할 수 있는 순수 코틀린DSL로 만들어진 의존성 주입 라이브러리입니다. Dagger와 달리 어노테이션 프로세스나 리플렉션을 사용하지 않습니다. 따라서 컴파일과정에서 DI를 사용하지 않으므로 컴파일 과정이 빨라질 수 있지만 런타임에서 의존성을 주입하게 되므로 실행 과정에서 오류를 발생할 수 있고 Dagger보다 약간 성능이 떨어질 수 있습니다. 단, 사용하기 더 쉽다는 장점이 있죠. 다음 사이트에서 확인할 수 있습니다. 

https://insert-koin.io/

안드로이드에서 사용하는 경우 다음과 같이AndroidX를 위한 라이브러리를 포함할 수 있습니다. 

...
// Stable Koin Version
koin_version = "3.5.0"
...
dependencies {
    // Koin for Android
    compile "org.koin:koin-android:$koin_version"
    // or Koin for Lifecycle scoping
    compile "org.koin:koin-androidx-scope:$koin_version"
    // or Koin for Android Architecture ViewModel
    compile "org.koin:koin-androidx-viewmodel:$koin_version"
    // or Koin for Android Fragment Factory (unstable version)
    compile "org.koin:koin-androidx-fragment:$koin_version"
}

그러면 먼저Koin에서 사용하는 DSL로 정의된 내용을 정리해 보겠습니다. 

  • applicationContext: Context의 주입
  • module: 모듈의 생성하고 정의
  • factory: inject할 때마다 새로운 인스턴스(객체)를 생성
  • single: 싱글톤 개념으로 앱 전역에 사용 가능한 인스턴스 생성
  • bind: 생성할 객체를 종속시켜 다른 형식으로 바인딩할 때 사용
  • get: 필요한 곳에 의존성 주입

Koin 간단히 사용해보기

공식사이트에서 제공하는 간단한 예를 통해 이해해 보겠습니다. 먼저 데이터를 제공하는 HelloRepository 인터페이스와 클래스를 만들어 보겠습니다. 

interface HelloRepository {
    fun giveHello(): String
}

class HelloRepositoryImpl() : HelloRepository {
    override fun giveHello() = "Hello Koin"
}

이제 이 데이터는 프레젠터 클래스에서 사용하게 될 것입니다.

class MySimplePresenter(val repo: HelloRepository) {

    fun sayHello() = "${repo.giveHello()} from $this"
}

이제 DSL의 module을 이용해 모듈을 선언합니다. 이것은 첫번째 컴포넌트가 될 것입니다.

val appModule = module {

    // single instance of HelloRepository
    single<HelloRepository> { HelloRepositoryImpl() }

    // Simple Presenter Factory
    factory { MySimplePresenter(get()) }
}

factory로 정의된 MySimplePresenter는 액티비티가 요구할 때마다 매번 새로운 인스턴스를 생성합니다. 반면에 single은 앱이 살아있는동안 유지되는 싱글톤으로 정의됩니다.

이제 이 모듈을 시작하기 위해 애플리케이션 클래스에 startKoin()함수를 사용할 것입니다. 

class MyApplication : Application(){
    override fun onCreate() {
        super.onCreate()
        // Start Koin
        startKoin {
            androidLogger()
            androidContext(this@MyApplication)
            modules(appModule)
        }
    }
}

 이때 AndroidManifest.xml에도 정의되어야 합니다.

...
   <application
        android:name=".MyApplication"
...

이렇게 해서 MySimplePresenter 컴포넌트는 HelloRepository 인스턴스를 생성할 것입니다. 이것을 액티비티의 by inject()에 의해서 의존성이 주입됩니다. 

class MySimpleActivity : AppCompatActivity() {

    // Lazy injected MySimplePresenter
    val firstPresenter: MySimplePresenter by inject()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        ...
        tv_message.text = firstPresenter.sayHello()
        ...
    }
}

by inject()는 액티비티나 프레그먼트, 서비스등을 실행시간에 인스턴스를 생성해 주입하게 됩니다. 앞서 사용된 get()은 lazy형태가 아닌 직접 그 즉시 인스턴스를 생성하게 됩니다. 

의존성 주입을 위한 어떤 라이브러리를 사용할지 선택할 수 있습니다. 학습곡선이 다소 높은 Dagger 대신에 Koin을 먼저 사용해보고 익숙해지면 Dagger를 도전해도 되겠지요? 다음 은 Android Jetpack에 대해서 소개해볼까 합니다. 

 
 
 
 
 
youngdeok's picture

Language

Get in touch with us

"If you would thoroughly know anything, teach it to other."
- Tryon Edwards -