시작하기에 앞서
개발자로 직장에서 일한지는 좀 오래됐지만, 일 할때 알게된 진리중 하나는 “문서를 있는그대로 100% 신뢰하지 마라”였다. 심지어 MS API문서도 직접 사용해보면 다르게 동작하는 경우가 있기도 했음. 안타깝지만, 개발자는 결국 코드로 읽어야 하는 존재란걸 또 깨닫는 경험을 하게됐다.
Koin 4.0이 나왔음에도 공식 사이트의 문서들은 3.5에 기반한 내용들이다. 버전별 API문서도 없고, 심지어 가장 간단한 Kotlin tutorial도 완전하지 않은채로 유지되고 있다. 사용자층도 그렇게 많지 않아서 관련 내용찾기가 쉽지않다. 사람들이 사용이 쉽다고 하지만, 이게 쉬운건지 잘 모르겠다. 코드를 뜯어볼거 아니면 좀 모르겠네. 그럼에도 KMP를 쓸거면 이걸 쓰는게 맞는거 같기도 하고. 믿을건 github에 있는 코드뿐임을 알고 사용하자.
DI (Dependency Injection)
Dependency Injection은 객체를 직접 관리하지 않아도 필요한 경우에 객체를 자동으로 생성해주는 방법이다. 이것은 “separation of concerns” 원칙을 위한 것인데, 한 부분의 구현에 있어서 다른 부분들의 세세한 정보들을 알 필요 없게 만들어주는걸 말한다. 모듈러 프로그래밍이나, 인캡슐레이팅(encapsulating)처럼 정보를 숨기는 것들이 다 이에 해당한다. Dependency Injection은 말 그대로 의존성에 대한 것인데, 인자로 받아오는 객체나 멤버로 갖고 있는 객체의 생성을 내부적으로 구현해줘서 이러한 객체들을 어디에서 인스턴스를 만들어야 하는지 같은 복잡한 문제들을 없애준다. 추가적으로 객체 생성에 대한걸 외부에 맡겨 의존성이 분리되므로 디버깅시에 목업등의 사용이 간편해진다. 개발자가 인스턴스들을 직접 관리하지 않으니 암튼 편하다. 말로만 써서는 혼란스러울 수 있으니 다음을 보자.
안드로이드 개발자 공홈에서는 차와 엔진의 예를들어 보다 친철하고 상세하게 이에대해 설명한다.
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
위 예제의 경우, Car에서 Engine을 생성하고 있기 때문에 Car와 Engine은 강하게 커플링된 의존성을 갖고 있다. 엔진을 바꾸려면, Car 클래스를 수정해야 한다. 이것은 테스트도 힘들게 된다. 만약에 Engine을 인자로 받아오도록 구현한다면,
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}
Engine과 Car의 강하게 커플링된 의존성이 사라지면서 유연한 작업이 가능해진다. 외부에서 인스턴스를 생성해서 넣어주고 있으므로 이런 형태를 DI(Dependency Injection)이라고 한다. 위와 같은 경우를 Constructor Injection이라고 부르고, 이런 형태가 불가능할 때 다음과 같이 구현한다.
class Car {
lateinit var engine: Engine
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.engine = Engine()
car.start()
}
Car 클래스 생성시 Engine이 생성되지 않고 외부에서 생성해서 멤버로 넣어주게 되는데, 이런걸 Field Injection 이라고 한다.
위에서 보듯, Dependency Injection은 객체를 외부에서 생성해서 주입해 주는 것인데, 이걸 main()에서 작업해주고 있다. 이걸 사용자가 신경쓰지 않아도 되도록, 코드상 필요할 때 객체를 생성하고 관리하는 부분을 자동화 할 수 있다. 이걸 해주는 라이브러리가 존재하는데, 대표적으로 Dagger 가 있다. 이 Dagger를 이용하여 Android에서 편하게 사용하도록 만든 라이브러리가 Hilt 이다. 안드로이드 공홈에선 Hilt를 사용하도록 권장하고 있다. Hilt는 Java 베이스로 구현되어 있으며, 컴파일 타임에 코드를 분석하고 객체의 생성 관련 코드들을 생성해준다. 컴파일은 길어지겠지만, 런타임에 영향이 없다.
Kotlin이 나날이 발전해 나가면서, Java 의존성도 낮아지고 많은 부분이 Kotlin native로 대체되고 있다. 이런 와중에 Kotlin만을 위한 Kotlin native DI가 나왔는데, 바로 Koin 이다. Koin은 Kotlin으로 구현되어 있어 Kotlin native나 KMP(Kotlin Multi-Platform)에 어울린다. 또한 가장 큰 차이점이 있는데, Koin은 Hilt와 다르게 컴파일 타임에 객체를 생성하지 않고 런타임에 작동한다.
Dagger-Hilt는 매우 메이저한 놈들로 자료도 많고 안드로이드 공홈도 이걸 기준으로 설명한다. 여기서는 Koin에 대해 기본사용법을 간단히 알아보려고 한다.
Koin Setup : https://insert-koin.io/docs/setup/koin
Koin은 그리 오래되지 않은 라이브러리로 버전이 올라가면서 변화가 많다. 여기서는 최신 버전을 사용하고, Kotlin에서의 사용에 대해 설명하겠다. IntelliJ IDEA community 버전으로 샘플 코드를 만들었다. 안드로이드는 별도 포스트로.
Koin 라이브러리 리스트를 보면, 수많은 라이브러리가 포함되어 있는 것을 알 수 있다. 3.5.0부터 BOM이 적용되었다고 하는데, 이를 사용하면 각 라이브러리 개개별로 생각하지 않아도 된다. BOM은 각 라이브러리가 다른 버전들을 가질 때, 서로 맞는 버전을 만들어줘서 호환성과 라이브러리 버전관리를 쉽게 만들어준다. BOM에 대한 설명은 다른분의 블로그 포스팅을 참고하자.
build.gradle.kts 에 다음을 추가한다.
val koinVersion = "4.0.0"
...
dependencies {
...
implementation(platform("io.insert-koin:koin-bom:$koinVersion"))
implementation("io.insert-koin:koin-core")
}
BOM을 이용해 기본적인 core 라이브러리를 추가해줬다. 필요한경우 위의 core와 같이 더 추가해주면 된다.
Koin Usage : https://insert-koin.io/docs/quickstart/kotlin
먼저, Koin은 DSL(Domain Specific Language)이다. 자체적으로 하나의 간단한 언어로 보이도록 가공되어 있다. 이걸 알고 시작해보자.
DI는 injection될 인스턴스들을 따로 관리해준다. inject되야 할 때 관리하는 컴포넌트중에 필요한 인스턴스를 생성해 돌려주거나, 싱글톤처럼 단일 인스턴스의 경우 그 레퍼런스를 돌려준다. 대략적인 형태를 그려보면 다음과 같다.

이와같이 가장 먼저 해줄 부분은 Koin module을 만들고 필요한 컴포넌트들을 정의 하는 것이다. User 리스트를 관리하는 경우를 생각해보자. 안드로이드의 MVVM구조처럼 데이터를 관리하는 Repository를 다음과 같이 만든다.
data class User(val name: String)
interface UserRepository {
fun findUser(name: String): User?
fun addUsers(users: List<User>)
}
class UserRepositoryImpl : UserRepository {
private val _users = arrayListOf<User>()
override fun findUser(name: String) : User? {
return _users.firstOrNull { it.name == name }
}
override fun addUsers(users: List<User>) {
_users.addAll(users)
}
}
인터페이스를 사용했는데, 이는 인터페이스의 사용시 예제를 한 번에 보여줄 수 있기 때문이다. 이제 이걸 Koin module을 만들어 정의해보자.
val appModule = module {
// classic
// single<UserRepository> { UserRepositoryImpl() }
// constructor DSL
singleOf(::UserRepositoryImpl) { bind<UserRepository>() }
}
위 코드에서 주석처리한 부분과 처리가 안된 라인이 동일한 기능을 한다. 다만, 주석처리 한 부분이 예전 사용방식이고, 새로운 constructor DSL형태로 아래처럼 사용할 수 있다. 인터페이스와 구현체가 각각 어디에 위치하는지 확인해보면 어떻게 사용하는지 알 수 있다. single, singleof를 쓰고 있는데, 이는 싱글톤 객체의 경우 이렇게 정의해서 사용한다.
싱글톤이 아니고 매번 객체를 생성하는 경우에 factoryOf()를 사용할 수 있다. 다만, 아직 적절한 예를 찾지 못했다. factoryOf()는 매번 객체를 생성해 돌려주기 때문에, 굳이 이걸 사용할 필요가 있나 싶은데, 모듈 내에서 파라미터로 객체를 생성해 넣어야 할 때 사용할 수 있을거같다.
다시 위의 예제를 이어가보면, Repository를 생성자 인자로 받는 UserService를 다음과 같이 만들고자한다.
object DefaultData {
val DEFAULT_USER = User("Koin")
val DEFAULT_USERS = listOf(DEFAULT_USER)
}
class UserService(private val userRepository: UserRepository) {
fun getDefaultUser() : User = userRepository.findUser(DefaultData.DEFAULT_USER.name) ?: error("Can't find default user")
fun saveDefaultUsers() {
userRepository.addUsers(DefaultData.DEFAULT_USERS)
}
}
그런데, 이 UserService도 싱글톤으로 module에 넣고 싶다. 이경우, module에 있는 Repository를 또한 module에 있는 UserService의 인자로 넘겨줘야 한다. 이는 다음과 같이 할 수 있다.
val appModule = module {
// classic
// single<UserRepository> { UserRepositoryImpl() }
// single { UserService(get())}
// constructor DSL
singleOf(::UserRepositoryImpl) { bind<UserRepository>() }
singleOf(::UserService)
}
고전적인 방식으로는 get()을 이용하면, module에서 해당 인스턴스를 꺼내올 수 있다. 이렇게 인자로 넘겨준다. 새로운 constructor DSL에서는 이 get()을 사용하지 않아도 자동으로 처리해준다. (뒤에서 get()을 추가해주는 것으로 알고 있다. )
이렇게 module에 넣어주긴 했는데, 내 앱에서 어떻게 사용이 가능할까? 우선, Koin의 초기화 작업이 필요하다. 바로 startKoin 함수가 그 역할이며, 이걸 어딘가에서 불러줘야 한다. 여기선 단순한 main함수를 갖는 Kotlin 앱이므로, 여기서 불러주면 된다.
fun main() {
startKoin {
modules(appModule)
}
}
사실, Koin module은 여러개를 만들 수도 있으며, 이 startKoin에서 그 module들을 로딩해준다. 그렇게 로딩된 모듈들이 사용 가능해지는 것이다.
이제 준비는 다 끝났다. 마지막으로 이렇게 로딩된 모듈에서 Injection을 어떻게 시킬지 하는 부분이다. 편의상 UserApplication 클래스를 추가하자. 이렇게하면, main함수를 사용하는 Kotlin 외에도 적용할만한 구조가 만들어진다.
class UserApplication : KoinComponent {
private val userService : UserService by inject()
init {
userService.saveDefaultUsers()
}
fun sayHello() {
val user = userService.getDefaultUser()
val message = "Hello '$user'!"
println(message)
}
}
코드를 보면, userService를 by inject()로 injection해주고 있다. 이는 UserApplication이 상속받는 인터페이스인 KoinComponent가 제공하는 것으로 by inject()는 lazy하게 작동한다. 바로 작동하도록 하려면 get()을 사용하면 된다. 최종적으로 메인함수는 다음과 같이 쓸 수 있다.
fun main() {
startKoin {
modules(appModule)
}
UserApplication().sayHello()
}
----
// console result
// Hello 'User(name=Koin)'!
이 외에도 인자를 전달하기위한 parameterOf()를 사용할 수도 있으며, 더 많은 내용은 공식 문서를 참조하기 바란다. 제일 앞에서 말했지만, 코드로 크로스 체크하는 것도 잊지말고 ㅋ
1 thought on “Koin 사용하기”