이 글은 번역된 글로 원문 출처는 아래와 같습니다. 위 글로 인해 Koin에 대한 깊은 이해와 DSL로 간단한 Koin을 만드는 법을 배울 수 있었습니다. 다시 한번 감사드립니다. 👏
Koin은 Kotlin, Android 프로젝트에 의존성 주입(DI)을 도와주는 프레임워크입니다. Koin은 다른 프레임워크(Dagger2, Hilt)에 비해 정말 간단하게 사용할 수 있다는게 큰 장점입니다. 하지만 Koin을 DI로 볼 것인지에 대한 많은 사람들의 의문이 있고 실제로도 Service Locator 패턴으로 보는 시각이 많지만 여기서는 다룰 내용은 아니므로 넘어가겠습니다. (아직 다른 프레임워크에 대해 잘 알지 못해요..ㅠ 😹)
이 글은 목표는 DSL를 활용한 Koin에 대해 이해를 해보고 의존성 관리의 핵심 로직을 직접 구현해보는데 의의를 두겠습니다. 여기서 만들 커스텀 프레임워크 이름을 LiteKoin이라고 하겠습니다.
Koin은 DSL로 모든 코드를 작성한다고 하였으니 Kotlin에 대한 기본 지식들이 필요합니다. 아래에 개념들에 대해 알지 못하면 이해하는데 조금 시간이 걸릴 수 있으니 참고하세요
LiteKoin을 Service Locator라는 디자인 패턴을 사용하려고 하고 위키백과의 내용을 읽으면 쉽게 이해할 것 같습니다.
" 서비스 로케이터 패턴은 강력한 추상화 계층으로 서비스를 얻는 것과 관련된 프로세스를 캡슐화하기 위해 소프트웨어 개발에 사용되는 디자인 패턴입니다. 이 패턴은 요청 시 특정 작업을 수행하는데 필요한 정보를 반환하는 중앙 레지스트리 역할을 하게 됩니다"
서비스 로케이터를 구현하는 방식은 다양하지만 LiteKoin에서는 type으로 서비스를 가져오는 방식으로 하겠습니다.
interface Service {
val type: KClass<*>
val instance: Any
}
Service 인터페이스를 먼저 정의하고 멤버변수로 type과 instance 가 있습니다.
class DefaultService(
override val type: KClass<*>,
override val instance: Any
): Service {
companion object{
fun createService(instance: Any) = DefaultService(instance::class, instance)
}
}
이후 Service 인터페이스를 구현하는 DefaultService 클래스를 생성하였습니다. 해당 클래스에서는 DefaultService를 반환하는 factory 메소드인 createService를 추가하였습니다.
class ServiceLocator {
private val serviceMap: MutableMap<KClass<*>, Service> = ConcurrentHashMap()
fun <T : Any> getService(clz: KClass<T>): Service {
return serviceMap[clz] ?: error("Unable to find definition of $clz")
}
private fun addService(service: Service) {
serviceMap[service.type] = service
}
}
의존성을 한 곳에 유지하도록 ServiceLocator 클래스를 정의하였고 getService 메소드와 addService 메소드를 정의하였습니다.
Koin 공식문서를 확인하면 Koin 모듈은 Koin Definition들을 모으는 공간으로 아래와 같이 함수를 통해 선언합니다.
val myModule = module {
// Declare dependencies here...
}
모듈을 빌드하는데 도움을 주는 DSL로 의존성 제공하고 싶은 것들을 모듈 개념으로 선언해야 합니다. 모듈 클래스를 설명하기 전에 Declaration에 대해 먼저 설명하도록 하겠습니다.
typealias Declaration<T> = () -> T
factory{ Repository() } // Declaration example
Declaration은 typealias을 사용하여 선언된 간단한 람다 함수로 LiteKoin 프레임워크에서 중요한 개념으로서 주입시킬 의존성을 나타낸다고 볼 수 있습니다. 위 예시를 보면 Repository()가 아닌 { Repository() }가 Declaration입니다.
이제 아래와 같이 모듈 클래스를 정의할 수 있습니다.
class Module {
val declarationRegistry: MutableMap<KClass<*>, Declaration<Any>> = ConcurrentHashMap()
inline fun <reified T : Any> factory(noinline declaration: Declaration<T>) {
declarationRegistry[T::class] = declaration
}
operator fun plus(module: Module) = listOf(module, this)
}
operator fun List<Module>.plus(module: Module) = this + listOf(module)
모듈 클래스는 모든 Declaration들을 확인할 수 있도록 map을 통해 관리합니다. 위 코드에서는 2가지의 유틸 함수로 plus 메소드를 추가하였습니다. 처음 메소드는 단순히 module끼리의 덧셈하여 리스트를 반환하는 매소드이고 두 번째 메소드는 모듈 리스트에 모듈을 포함시켜 리스트를 반환하는 메소드입니다.
위 클래스에서 가장 중요한 함수는 factory 메소드로 Declartaion을 매개변수로 받아 map에 추가해주는 함수입니다. reified 키워드를 통해 Generic이라도 T 클래스의 타입을 지정할 수 있고 타입을 key로 하여 Declaration을 추가할 수 있습니다. 전에 factory{ Repository() } 예시를 확인하시면 Kotlin의 고차함수 특징을 이용하여 factory 함수를 사용하고 있는 것을 확인할 수 있습니다. 결국은 모든 Declaration들이 declarationRegistry라는 곳에 저장되어 있습니다.
fun module(block: Module.() -> Unit) = Module().apply(block)
val myModule = module{
factory { UseCase() }
}
Module 클래스를 생성하였으므로 builder 메소드를 DSL로 생성할 수 있습니다. module 메소드가 Module 클래스의 builder로서 매개변수에는 리시버 람다를 받게 됩니다.
Koin에서 가장 흥미로운 메소드는 get() 함수일 것입니다. get() 함수는 ServiceLocator에 등록된 의존성을 확인하고 언제든지 가져올 수 있는 함수입니다. get 메소드가 사용될 때는 크게 2가지로 나뉩니다.
factory{ UseCase(get()) }
val viewModel: ViewModel = get()
이전에 생성했던 Module 클래스에 get() 메소드를 추가해보겠습니다.
class Module {
val declarationRegistry: MutableMap<KClass<*>, Declaration<Any>> = ConcurrentHashMap()
inline fun <reified T : Any> factory(noinline declaration: Declaration<T>) {
declarationRegistry[T::class] = declaration
}
inline fun <reified T : Any> get(): T {
val declaration = declarationRegistry[T::class]
var instance = declaration?.invoke()
if (instance == null) {
val liteKoin = LiteKoinContext.getLiteKoin()
instance = liteKoin.declarations[T::class]?.invoke()
?: error("Unable to find declaration of type ${T::class.qualifiedName}")
}
return instance as T
}
operator fun plus(module: Module) = listOf(module, this)
}
여기에서는 reified 타입을 이용하였고 위의 get 메소드는 첫 번째 경우에 사용되는 메소드입니다. get 메소드에 대해서 추가적으로 알아보기 전에 LiteKoin 컴포넌트를 살펴보겠습니다.
LiteKoin 클래스는 프레임워크에서 중요한 컴포넌트로 메인 ServiceLocator에 접근할 수 있게 해주는 역할을 합니다. 여기서는 module을 추가하여 ServiceLocator에 제공해줍니다.
class LiteKoin {
private val registry = ServiceLocator()
lateinit var declarations: Map<KClass<*>, Declaration<Any>>
fun loadModules(modules: List<Module>){
declarations = modules.declarationRegistry
registry.loadModules(modules)
}
fun resolveInstance(type: KClass<*>) = registry.getService(type)
}
val List<Module>.declarationRegistry: Map<KClass<*>, Declaration<Any>>
get() = this.fold(this[0].declarationRegistry) { acc, module ->
(acc + module.declarationRegistry) as MutableMap<KClass<*>, Declaration<Any>>
}
modules.declarationRegistry는 List< Modules >의 확장함수로 각 모듈의 declarationRegistry에 추가된 Declartion들을 하나로 합친 map을 반환해주는 함수입니다.
LiteKoin 컴포넌트 설정을 했으니 두 번째 get 메소드에 대해서 알아보겠습니다.
fun getLiteKoin() = LiteKoinContext.getLiteKoin()
inline fun <reified T : Any> get(): T {
val service = getLiteKoin().resolveInstance(T::class)
return service.instance as T
}
inline fun <reified T : Any> inject(): Lazy<T> = lazy { get() }
위의 코드에서 두 번째의 경우인 get 메소드와 inject 메소드를 선언하고 있습니다. inject 메소드는 단순히 get 메소드를 lazy 하게 사용하는 것입니다. get 메소드는 LiteKoin 인스턴스를 가져와 resolveInstance 메소드를 호출하여 service 를 가져옵니다. 이후 service의 instance를 Generic하게 형변환하여 반환하고 있습니다.
object LiteKoinContext {
private val liteKoin = LiteKoin()
fun modules(modules: List<Module>) {
liteKoin.loadModules(modules)
}
fun getLiteKoin() = liteKoin
}
fun startLiteKoin(block: LiteKoinContext.() -> Unit) = LiteKoinContext.apply(block)
위의 코드를 보시면 LiteKoinContext로부터 LiteKoin을 가져오는 것을 볼 수 있을 것입니다. LiteKoinContext는 안드로이드나 코틀린 프로젝트에서 LiteKoin을 사용하기 위한 진입점 역할을 합니다. 내부적으로 LiteKoin을 생성하고 모듈을 로드하는 초기화 작업을 합니다. startLiteKoin 함수를 보면 위에서 module builder와 비슷하게 DSL을 활용하여 편리하게 초기화 작업을 할 수 있도록 합니다.
startLiteKoin{
modules(mo1 + mo2)
}
이렇게 startLiteKoin을 사용하여 modules 메소드를 호출할 수 있습니다.
get 메소드나 inject 메소드를 살펴보면 의존성 제공을 Service 형태로 하지만 LiteKoin에서 의존성 관리는 람다함수인 Declaration로만 저장합니다. ServiceLoactor에서 모듈이 로드되기 전까지는 Declartion 형태로 관리하다가 의존성 제공 시에 Service 클래스로 변환시켜 제공합니다.
fun <T:Any> Declaration<T>.toService(): Service {
val instance = this()
return DefaultService.createService(instance)
}
Kotlin에서 확장함수를 사용하면 손쉽게 Declaration을 Service로 변환해주는 함수를 정의할 수 있습니다. 여기서 this()를 this.invoke()로 바꿔서 사용해도 됩니다.
class ServiceLocator {
private val serviceMap: MutableMap<KClass<*>, Service> = ConcurrentHashMap()
fun <T : Any> getService(clz: KClass<T>): Service {
return serviceMap[clz] ?: error("Unable to find definition of $clz")
}
private fun addService(service: Service) {
serviceMap[service.type] = service
}
fun loadModules(module: List<Module>) {
module.forEach { registerModule(it) }
}
private fun registerModule(module: Module) {
module.declarationRegistry.forEach {
addService(it.value.toService())
}
}
}
이제 ServiceLocator 클래스에 loadMoudles와 registerModule 메소드를 추가하여 완성시킬 수 있습니다. loadModules 함수를 호출하면 각 Module의 declarationRegistry에 저장되어 있는 Declaration을 Service로 변환하여 map에 추가시킵니다. 모든 것이 완성되었습니다!! 😎
class UseCase(private val repo: Repository) {
fun execute() = repo.getText()
}
class Repository {
fun getText() = "Text from repository"
}
class ViewModel(private val useCase: UseCase) {
fun showText() = useCase.execute()
}
val mod1 = module {
factory { ViewModel(get()) }
factory { Repository() }
}
val mod2 = module {
factory { UseCase(get()) }
}
의존성 주입시킬 것들을 모듈로 선언하게 되며 Module 클래스의 get 메소드를 이용하여 Registry에 추가된 instance가 있는지 확인하고 없으면 싱글톤으로 선언된 LiteKoinContext에서 확인합니다.
class KoinApplication : Application() {
override fun onCreate() {
super.onCreate()
startLiteKoin {
modules(mod1 + mod2)
}
}
}
Koin과 유사하게 Application 클래스에 startLiteKoin 메소드를 호출하여 LiteKoin을 생성하고 모듈들을 초기화 시킵니다.
class MainActivity : AppCompatActivity() {
val viewModel: ViewModel by inject()
val repository: Repository = get()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<TextView>(R.id.textView).text = viewModel.showText()
}
}
이제 마지막으로 MainActivity에서 inject와 get 메소드를 사용하여 의존성을 주입을 받을 수 있게 됩니다.
Koin 프레임워크가 다른 DI에 비해서 쉬운것은 사실이나, 의존성들이 어떻게 제공되는지는 몰랐었습니다. 원본 글의 저자에게 다시 한번 감사의 말을 전하며, Koin을 직접 만들어 보고 Kotlin의 위대함을 같이 한번 느껴보면 좋겠습니다. 감사합니다~ 😘