koin 2.0 맛보기

in #kr-dev6 years ago

Kotlin용 의존성 주입 프레임워크인 koin의 2.0 버전의 기능 특징을 간단히 정리해본다. 공식 참조 문서도 꽤 잘 만들어져 있기 때문에 궁금하신 분들은 이 글을 빠르게 읽고 공식 문서를 참고해보시면 더 효율적으로 koin 2.0을 익힐 수 있을 듯. koin 2.0 은 이 글을 작성하는 4월 중순, rc-2 까지 나온 상태이다.

모든 내용은 android 개발 환경을 기준으로 작성했다.

gradle 설정

repositories {
    jcenter()
}
dependencies {
    //코어
    implementation 'org.koin:koin-core:2.0.0-rc-2'
    //안드로이드 context 주입, 안드로이드 log 지원 기능을 제공
    implementation 'org.koin:koin-android:2.0.0-rc-2'
    //LifecycleOwner 들에서 scope 생성-삭제를 자동화하는 기능을 제공
    implementation 'org.koin:koin-androidx-scope:2.0.0-rc-2'
    //AAC의 ViewModel 을 Actiity나 Fragment에서 손쉽게 주입받을 수 있는 기능을 제공
    implementation 'org.koin:koin-androidx-viewmodel:2.0.0-rc-2'

    //테스트용 유틸리티
    testImplementation 'org.koin:koin-test:2.0.0-rc-2'
}

Module / KoinApplication / GlobalContext

Module은 제공할 객체의 명세이다. KoinApplication은 제공된 모듈 명세를 이용해 객체 인스턴스를 관리한다. 따라서 모듈에 명시한 객체를 주입받고 싶다면 KoinApplication 인스턴스에 요청을 해야 한다. 하나의 애플리케이션은 여러개의 KoinApplication을 가질 수 있다. GlobalContext는 전역 KoinApplication 을 담고 있다. 만약 애플리케이션이 단 하나의 KoinApplication만을 사용한다면 GlobalContext에서 KoinApplication을 가져오면 되므로 굳이 KoinApplication의 인스턴스를 관리할 필요가 없다.

KoinApplication 생성

다음은 KoinApplication을 생성하는 두 가지 방법이다.


//KoinApplication을 생성한 후, GlobalContext에 등록. 애플리케이션에서 하나의 KoinApplication만 사용할 경우 편리함
startApplication {
    logger()
    modules( moduleA, moduleB, ... )
}

//KoinApplication만을 생성함. 생성한 KoinApplication 인스턴스는 알아서 잘 관리해야 함
val koinApplicaiton = startKoin {
    //들어가는 내용은 startApplication과 동일함
}

startApplication이 GlobalContext에 자신을 등록한다는 점을 빼면 둘 사이에 차이점은 없다. dsl에 들어가는 상세 옵션은 참조 문서를 보면 된다. modules, logger, androidApplication 정도만 알아두면 충분하다.

Module 생성

factory, single, scoped

모듈은 객체를 제공하는 명세를 담는다. 모듈 생성 문법을 살펴보자.

val module = {
   factory { Foo() }
   single { Bar() }
   scoped { FooFoo() }
}

factory는 요청 시 마다 새로운 인스턴스를 만든다. single은 단일 인스턴스를 반환한다. scoped는 scope 내에서 단일 인스턴스를 반환한다. 기본적으로 모든 객체 생성은 요청할 때 이뤄진다. 하지만 모듈 선언과 동시에 생성이 필요할 경우엔 createdAtStart 속성을 true로 주면 된다. 이는 single에서만 의미 있다고 생각한다.

single(createdAtStart = true) { Bar() }

동일 타입의 객체는 한번만 선언할 수 있다. 여럿 선언하면 모듈 로드 과정에서 예외가 발생한다. 만약 동일 타입의 객체가 여럿 필요하다면 qualifier를 지정하면 된다.

single(named("kim")) { Human("kim") }
single(named("lee")) { Human("lee") }

scope는 closed scope와 open scope이 있다. 이름을 한정한 scope를 closed scope, 이름을 한정하지 않은 scope를 open scope라 보면 된다. scope 인스턴스는 id와 name을 가지는데, name은 scope의 유형, id는 scope의 고유 식별자로 보면 된다. open scope 로 선언한 객체는 어느 scope에서나 가져다 쓸 수 있지만, closed scope로 선언한 객체는 해당 name을 가진 scope에서만 가져다 쓸 수 있다.

//open scope
scoped { Foo() }
//closed scope
scope(named("my_scope")) {
    scoped { Bar() }
 }

위와 같이 모듈을 선언한다면, 가져다 쓸 때 아래와 같이 동작한다.

//id 는 id, name 은 your_scope 인 scope 생성
val myScope = koinApplication.koin.getOrCreateScope( "id", named("your_scope")) 

//문제 없음
val foo = koinApplication.koin.get<Foo>( scope = myScope)  

//에러 : Bar 는 my_scope라는 name을 가진 scope에서만 가져올 수 있음
val bar = koinApplication.koin.get<Bar>( scope = myScope)  

scoped 선언 시 주의할 점이 있다. 다음은 문제가 있을까, 없을까?

scope(named("lee")) { 
    scoped{ Human("lee") }
}

scope(named("kim")) { 
    scoped{ Human("kim") }
}

문제가 없어보이기도 한다. lee 라는 이름의 scope에서 Human을 찾으면 lee 라는 Human이 나오고, kim 이라는 이름의 scope에서 Human을 찾으면 kim 이라는 Human이 나오면 되는거 아닐까? dagger는 이런 식으로 동작한다. 하지만 koin에선 아무리 closed scope bean 선언이더라도 모듈 내 전역적으로 영향을 비친다. 따라서 위 선언은 Human이라는 동일 타입의 bean 선언이 두 번 나온 것으로 간주되어 module load 시 예외가 발생한다. 이 문제를 막기 위해선 qualifier를 지정하는 수 밖에 없는데, 개선되었으면 하는 부분이다.

또 한 가지는 scope 안에 factory를 선언하는 경우이다.

scope(named("my_scope")) {
  scoped{ Foo("lee") }
  factory { Bar( get<Foo>())}
}

얼핏 보면 저 factory는 my_scope 라는 이름의 scope 안에서만 의미가 있을 것 같지만, 위 선언은 아래와 동일하다.

scope(named("my_scope")) {
  scoped{ Foo("lee") }
}
factory { Bar( get<Foo>())}

한번 돌려서 생각하면 결국 주입받으려는 Foo 타입이 my_scope 라는 scope 에서만 선언되어 있으므로, Bar를 가져오려면 다음과 같이 해야 하므로 scope 안에 factory 가 선언된 것과 동일하다고 볼 수 있다.

val scope = koinApp.koin.getOrCreateScope("id", named("my_scope"))
val bar = koinApp.koin.get<Bar>(scope = scope)
assertNotNull(modelB)

하지만 Bar 타입 선언 자체는 scope에 국한되지 않는다. 개발자에게 문의하니, scope 안에는 scoped 만 집어넣는게 맞을것 같다고 한다. (https://github.com/InsertKoinIO/koin/issues/414#issuecomment-478471215)

따라서 scope 안에는 scoped 유형만 선언하자.

injection parameter

module에 객체를 생성할 때 생성자에 인자를 넘겨야 하는 경우가 있다. 이런 경우 injection paramter를 쓸 수 있다.

val myModule = module {
  factory { (name:String)  -> Human(name) }
}

val humanNamedLee = koin.get<Human>{ paramtersOf("Lee")}

위와 같이 원하는 객체의 생성자 인자를 넘길 수 있다. single에선 어떨까?


val myModule = module {
  single { (name:String)  -> Human(name) }
}

val humanNamedLee = koin.get<Human>{ paramtersOf("Lee")}

val humanNamedKim = koin.get<Human>{ paramtersOf("Kim")}

val humanWithNoName = koin.get<Human>()

위 새개의 get 호출 모두 처음에 만든 Lee 이름을 가진 Human 인스턴스가 반환된다. 따라서 매우 헷갈린다

게다가 humanWithNoName 같은 경우도 문제다. 인자를 가지는 single의 경우, 누군가 먼저 인스턴스를 생성했다면 그 다음부터는 인자를 넘기지 않아도 된다. 어차피 넘겨봤자 의미도 없고. 하지만 최초 호출한 경우라면 당연히 인자를 넘겨야 한다. 내가 이 싱글턴 객체를 처음 호출하는 지, 아닌지를 어떻게 판단할 수 있는가! 따라서 안전하게 사용하고 싶다면 애시당초 single 이나 scoped 선언은 injection parameter를 갖지 않는 편이 안전하다. 이와 달리 factory에선 매우 편하고 유용하게 사용할 수 있다.

type 지정

모듈에서 객체 명세를 작성할 때 별도로 타입을 지정하지 않을 경우 가장 구체적인 타입으로 지정된다. 따라서 다음 경우엔 제대로 동작하지 않는다.

open class Foo 
class Bar: Foo 


val myModule = module {
  //Bar에 대한 선언  
  factory { Bar() }
}

//못찾음!
val foo = koin.get<Foo>() 

Foo로 선언하고 싶다면 앞쪽에 type parameter를 명시하던지, as로 캐스팅한다. 복수의 타입에 연결하고 싶다면 bind 를 쓴다.

factory<Foo> { Bar() }
or
factory { Bar() as Foo}

//Foo, Bar 모두에 연결하기
factory { Bar() } bind Foo::class

koin component

지금까지 제공하는 방법을 살펴봤다면 이제 주입받는 방법을 살펴보자.

주입받는 방법은 직접 KoinApplication 인스턴스에 대고 get() 메서드를 호출하던지, KoinComponent 인터페이스를 구현한 객체에서 get() 이나 by inject()를 호출하면 된다.

//직접 가져오기 
val foo = koinApplication.koin.get<Foo>()

//KoinComponent에서 가져오기
class MyKoinComponent: KoinComponent {
    val foo by inject<Foo>()
}

val foo = MyKoinComponent().foo

KoinComponent의 내부 구현을 보면 GlobalContext에서 KoinApplication을 가져오도록 되어있다. 만약 별도의 KoinApplication을 사용한다면 getKoin() 메서드를 override 해야 한다.

class MyKoinComponent: KoinComponent {
    val foo by inject<Foo>()

    override fun getKoin(): Koin = SomeWhere.getMyCustomKoin()
}

모듈에서 injection parameter를 받도록 bean을 선언한 경우, parametersOf 펑션을 이용해 인자를 건네준다. 당연히 bean선언에서 정의한 매개변수의 타입과 순서를 맞춰줘야 하는데, 어떠한 compile time의 검증도 없기 때문에 맞춰서 넣어줘야 한다는 문제가 있다. 자동화 테스트가 없다면 꽤 위험하다.

scope 생성은 모듈 선언 쪽에서 살짝 다뤘으므로 건너뛴다.

테스트

Dagger는 빌드가 힘든 반면, 한번 빌드되면 거의 제대로 동작한다. 하지만 Koin은 일단 돌려봐야 제대로 의존 관계를 명시했는지 확인이 된다. 잘못하면 수십번 run을 하면서 조금씩 고쳐나가야 할 수 도 있다. 다행히 테스트 유틸 모듈이 제공하는 checkModules 펑션을 이용하면 제대로 injection이 되는지, 즉 모듈 선언에서 내가 inject 받고자 하는 객체가 제대로 제공되는 지 확인할 수 있다. 하지만 checkModules는 모듈 선언 수준의 정의만을 확인할 뿐, 여기저기서 호출하는 by inject(),get() 펑션이 제대로 동작하기 위해 injection parameter를 순서대로 잘 넘겼는지, scope을 필요로 하는 부분에서 scope을 제대로 넘겼는지는 별도로 확인해야 한다. 안그러면 runtime에서 예외가 발생하니 안정성에 문제가 생길 것이다.

koinApplication{
  modules(
    module {
      single { Foo() }
      single { Bar(get()) }
      factory { (name:String) -> Human(name) }
  })
}.checkModules {
    create<Human> { parametersOf("Lee")}
}

위 코드와 같이, inection parameter를 필요로 하는 부분에선 create 펑션을 이용해 적절한 인자를 넘겨줘야 한다. 만약 인자로 넘겨야 하는 객체가 생성하기 매우 복잡한 경우엔 그냥 mock(ComplexClass::class.java)와 같이 mock 객체를 넘겨주자. 어차피 객체 값이 중요한게 아니라 모듈 선언이 제대로 되어있는지가 중요하기 때문이다.

한 가지 더 문제가 있는데, 만약 factory 에서 scoped bean을 get() 으로 주입받을 경우 checkModules가 실패한다. 왜냐면 현재 checkModules 는 scoped 객체만 임시 scope을 만들어 확인하고 있기 때문에 factory나 single이 scoped bean을 get()을 이용해 주입받으려고 한다면 scoped bean을 global scope에서 찾지 못하기 때문에 실패한다.

즉, 아래 테스트는 실패한다.

    @Test(expected = ScopeNotCreatedException::class)
    fun factory_depends_on_scoped_bean() {
        koinApplication {
            modules(
                module {
                    scoped {
                        ModelA("haha")
                    }

                    factory { ModelB(get()) }
                }
            )
        }.checkModules {  }
    }

문제를 회피하려면 현재로선 bean 선언을 override 하는 수 밖에 없어보인다. (https://github.com/InsertKoinIO/koin/issues/414)

checkModules 테스트는 koin을 쓴다면 반드시 수행해야 한다고 생각한다. 안그러면 runtime에 injection을 확인하고, 문제가 있을 경우 일일이 고쳐야 하는데, 너무 비효율적이면서 위험하기 때문이다.

안드로이드 local jvm test에서 checkModules 실행하기

안드로이드 개발자라면 아무래도 checkModules는 local jvm test에서 돌리는게 빠른데, module에서 생성한 객체가 android에 의존성을 갖게된다면 생성 과정에서 exception이 나는 문제 등등 local test를 하기가 어려울 수 있다. 이럴 땐 선언을 과감히 override해 버리는 선택을 할 수도 있겠다. 물론 이러면 점점 제대로 된 확인에서 멀어지기는 하지만, 어디까지나 개략적인 모듈 선언을 확인하는 용도라면 이 정도의 타협은 괜찮지 않나 생각한다.

koinApplication{
  modules(
    //진짜 모듈
    module {
      single { Foo() },
      single { 안드로이드시스템에_의존하는_객체() }, //local test에서 생성하다 문제가 생김
  } ,
    //테스트를 위한 모듈
    module {
        //mocking을 해서 생성에 문제가 없도록 함
        single(override=true) { mock(안드로이드시스템에_의존하는_객체::class.java) }
    }
  )
}.checkModules {}

android 특화된 기능

지금까지 살펴본 부분은 모든 kotlin 애플리케이션에 해당했다. 이제 별도의 모듈로 제공되는 안드로이드 특화 기능을 살펴보자.

context 지원

모듈에서 객체를 생성할 때, 생성자에 안드로이드의 context를 넣어주어야 하는 경우가 많다. 이를 위해 KoinApplication 생성 시 context를 건네주고, module 에서 이 context를 생성자에 넣어줄 수 있다.

koinApplication {
   //context 인스턴스를 koin application에 전달
   androidContext(context)

   modules( 
       module {
           //Foo 생성자에 context를 전달
           single { Foo(androidContext())}
       }
   )
}

자매품으로 androidApplication 도 있다.

안드로이드 logger

androidLogger 로 koin 로그를 android log로 남길 수 있다.

koinApplication {
    //디버그 레벨로 koin 로그를 남긴다
    logger(AndroidLogger(Level.DEBUG))
}

ComponentCallbacks 익스텐션 펑션

get(), by inject() 를 쓰려면 KoinComponent 인터페이스를 구현해야 한다. 하지만 ComponentCallbacks 인터페이스에 대한 익스텐션 펑션이 지원된다. 즉, Acitivity, Fragment, Service, Application 등 대부분의 injection이 필요한 안드로이드 컴포넌트에선 KoinComponent를 구현할 필요가 없다. 다만 이는 GlobalContext를 사용할 경우에만 해당한다. GlobalContext가 아닌 커스텀한 KoinApplication을 사용한다면 여전히 KoinComponent를 구현하고, getKoin() 펑션을 오버라이드해야 한다.

LifeCycle - Scope 연결

MVP 패턴을 쓴다면 Presenter는 Activity가 생성될 때 마다 생성되어야 한다. 이 경우 factory를 써도 되고, scoped 를 써도 된다. factory를 쓸 경우 get 할 때 마다 새 instance가 만들어지므로 주의해야 하니 scoped를 쓰는 편이 더 안전하다고 할 수 있다. (참고로 single을 쓰면 Presenter 인스턴스가 계속 남아있으며, 여러개의 동일 타입 Activity가 똑같은 Prenseter 인스턴스를 주입받으므로 절대 쓰면 안된다!)

그런데 일일이 scope 생성하기가 귀찮고, 해당 scope의 lifecycle이 LifeCycleOwner의 lifecycle과 동일하다면 extension property로 제공되는 currentScope 를 쓰면 간단히 scope 생성/ 종료 처리를 할 수 있다. 이 경우 scope의 id 는 LifeCycleOwner의 toString() 값, name은 LifeCycleOwner의 타입 이름이 된다. 따라서 거의 그럴 일은 없지만 Activity의 toString() 을 override할 경우 scope id가 겹쳐버려 이상하게 동작할 수 있으니 주의해야 한다.

AAC ViewModel 지원

AAC의 ViewModel은 ViewModelProvider를 통해 가져와야 한다. 이 때 koin의 viewmodel 지원 기능을 쓴다면 생성자 injection이 된 viewmodel을 간단히 koin application 을 통해 가져올 수 있다.

koinApplication {
    viewModel { MyViewModel( get(), get())}
}

쓰는 쪽에서 고려할 점이 있는데, 해당 ViewModel이 내가 생성한 것인지 (activity), 남이 이미 생성한 걸 내가 갖다 쓰는 입장인지 ( fragment에서 activity가 생성한 ViewModel을 가져다 쓰는 경우) 에 따라 명령이 달라진다. 만약 실수로 fragment 에서도 생성하는 명령을 쓸 경우, activity가 만든 ViewModel과 다른 instance를 주입받게 된다.

class DetailActivity : AppCompatActivity() {

    //내가 만든거
    val detailViewModel: DetailViewModel by viewModel()
}

class MyFragment : Fragment() {

    //내가 만든거
    val fragmentViewModel: FragmentViewModel by viewModel()

    //남이 만든거
    val activityViewModel: ActivityViewModel by sharedViewModel()
}

정리

koin 2.0의 경우 dsl 검증이 너무 느슨하다는 점이 아쉽지만, 이를 감안해도 매우 쓸만한 도구라고 생각한다. 난 dagger가 정말 손에 익지를 않는데, koin은 아주 맘에 든다. dagger에 비해 컴파일 타임 검증이나 기능의 부족함이 있지만 이는 설정에서 타협을 한다거나 하는 방식으로 충분히 극복할 수 있다고 본다.