본문 바로가기
Android/Koin

[Koin] Koin 정리

by 태크민 2024. 11. 13.

Koin은 무엇인가?

Koin은 Dagger, Hilt처럼 안드로이드에서 사용되는 대표적인 DI 프레임워크 중 하나로, 순수 코틀린으로 작성되었으며 다른 DI 프레임워크보다 러닝커브가 낮고 경량화되었다.

Kotlin DSL로 만들어진 DI Library

 

여기서 DSL란,

Domain Specific Language 의 약어로 특정 분야에 최적화 된 프로그래밍 언어를 뜻한다. (아래에서 더 정리)

 

즉,

Koin은 코틀린 언어에 최적화된 DI 라이브러리 라고 볼 수 있다.

 

따라서, Koin을 사용했을 때의 장점은 다음과 같다.

 

  • Kotlin 개발 환경에 도입이 쉽다
  • 다른 DI 라이브러리에 비하여(Dagger, Hilt) 러닝 커브가 낮다.
  • Annotation 을 사용하지 않아 Complie 시간이 단축된다.

여기서 러닝 커브가 낮다. 라는 면에서는 확실하게 DI 라이브러리를 공부했던 사람이라면 확실하게 느낄 수 있을 것이다.

 

Kotlin 기반으로 프로젝트를 많이 진행하고 있기 때문에 Kotlin DSL로 만들어진 Koin을 프로젝트에 도입하기 쉬울 뿐 아니라 러닝 커브가 다른 라이브러리에 비해 낮아 사용하는데 크게 불편함이 있지 않다.

사실 이것만으로도 DI 라이브러리로 Koin을 선택하기에 큰 영향을 준다고 생각한다.

 

그렇다면,

Koin의 단점은 무엇일까?

 

  • 런타임 중 에러가 발생할 가능성이 있다.
  • 런타임 시 의존성 주입을 해주다 보니, 앱의 퍼포먼스가 떨어질 가능성이 있다.

Dagger를 사용했을 때는 방지가 가능한 런타임 중 에러가 Koin에서는 발생할 수 있다는 단점이 있다.

또한 런타임 시에 DI가 이루어지다보니 앱의 퍼포먼스가 떨어진다고 하는데, 이는 프로젝트의 크기가 커지면 커질수록 부하가 늘어난다고 한다.

따라서, 큰 단위의 프로젝트를 들어가게 되면 어떠한 DI를 사용할 것인지 꼼꼼히 확인 한 후에 채택을 해야할 것으로 보인다.

 

Koin DSL

Koin은 Hilt처럼 Annotation을 사용하지 않고, Kotlin DSL을 사용해 개발자가 좀 더 편리하게 의존성을 주입할 수 있도록 API를 제공한다.

 Hilt 에서의 모듈 명세

@Module
@InstallIn(ViewModelComponent::class)
internal object RepoModule {
    @Provides
    @ViewModelScoped
    fun provideRepo() : Repository = Repository()
}

 Koin 에서의 모듈 명세

val dbModule = module {
	single {
        Repository())
    }
}

val viewModelModule = module {
	viewModel {
    	MainViewModel(get())
    }
}

💡 Kotlin DSL ?

DSL은 Domain Specific Language의 약자로, 직역하면 특정 분야에 최적화된 프로그래밍 언어다. DSL은 기존의 명령형 코드 대신 선언적 코드 형식을 따른다. Kotlin DSL은 가독성 좋고 간략한 코드를 사용하는 코틀린의 언어적인 특징을 사용하여 Gradle 스크립팅을 하는 것을 목적으로 하는 DSL이다.

 


 

이제 Koin을 예제를 통해 배워보자

Module 정의

Koin에서 Module은 모든 구성요소를 선언하는 공간, 즉 Koin으로 제공할 객체를 명세하는 곳

module 전역함수를 사용해서 Koin 모듈을 선언

val myModule = module {
    // Dependencies 작성 (제공할 객체)
}

여러 Module 사용

Koin은 구성요소를 반드시 동일한 모듈안에 모두 선언할 필요가 없습니다

Koin은 기본적으로 지연초기화 방식으로 인스턴스 요청 시점에 Module을 통해 인스턴스를 생성하고 의존성을 주입하기 때문에, 서로다른 Module을 여러개로 나누어서 Koin에 사용등록을 하면 요청 시점에 여러 Moudle을 둘러보면서 인스턴스를 생성할 때만 해결하면 됩니다

// ComponentB <- ComponentA
class ComponentA()
class ComponentB(val componentA : ComponentA)

val moduleA = module {
    // Singleton ComponentA
    single { ComponentA() }
}

val moduleB = module {
    // Singleton ComponentB with linked instance ComponentA
    single { ComponentB(get()) }
}

2개의 클래스가 존재하며, ComponentB는 생성자 인수로 ComponentA 인스턴스가 필요합니다

moduleA는 ComponentA 객체를 생성하고 ModuleB는 ComponentB 객체를 생성하는데 ComponentA 객체가 필요

// Start Koin with moduleA & moduleB
startKoin{
    modules(moduleA,moduleB)
}

2개의 Module을 모두 사용등록을 하면 ComponentB 객체 요쳥 시 Koin은 ModuleB에게 ModuleA를 통해 ComponentA 객체를 주입해주고 ComponentB 객체를 생성하여 의존성 주입을 완료합니다

이렇게 가능한 이유는 Koin의 지연초기화 방식으로 Module 등록 시 인스턴스가 즉시 생성되는게 아닌, 요청 시 생성하므로 여러 Module들을 순회하며 서로 상호운용이 가능합니다 

Component 정의

Single

single 컴포넌트는 전체 컨테이너에 영속적인 객체를 생성합니다

즉 해당 객체를 싱글톤으로 제공(App 수명주기 동안 단일 인스턴스)

class AA()

val myModule = module {
    single { AA() }    // AA 클래스 인스턴스를 싱글톤으로 제공
}

AA클래스 인스턴스를 by inject() 또는 get()으로 요청 시 싱글톤의 AA 인스턴스를 제공 

Factory

factory 컴포넌트는 요청할 때마다 매번 새 인스턴스를 생성해서 제공합니다

Dagger의 Provider와 같은 개념으로 생각하면 됩니다

factory 컴포넌트로 제공되는 객체는 컨테이너에 저장하지 않으므로 다시 참조할 수 없습니다

class AA()

val myModule = module {
    factory { AA() }    // AA 인스턴스 요청 시 매번 새로 생성해서 제공
}

Scoped

Scoped 컴포넌트는 명시된 Scope 생명주기에 영속적인 객체를 생성해서 제공합니다

Dagger의 Scope를 생각하면 이해가 쉽습니다

scoped 컴포넌트를 사용하기 위해서는 먼저 필수적으로 scope() 함수를 통해 범위를 선언해야합니다

범위(scope)의 이름을 지정하려면 Qulifiernamed (한정자)가 필요합니다

Qulifiernamed(한정자)는 두 가지 종류로 StringQulifier(문자열한정자) 또는 TypeQulifier(타입한정자)로 구분해서 사용

StringQulifier - 문자열 한정자

class A

val myModule = module {
    // String 문자열 한정자
    scope(named("my_scope")) {
        scoped { A() }
}

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

// Scope 인스턴스 생성
val myScope = getKoin().getOrCreateScope("id", named("my_scope"))

// Scope 통해 의존성 주입
val a = myScope.get<A>()

먼저 StringQulifier 문자열 한정자 사용방법입니다

Scope 클래스는 ScopeIdname 속성을 생성자 인수로 받습니다

ScopeId는 고유 식별자 / name은 Scope 타입 유형으로 name을 통해 사용할 Scope을 정의합니다

생성된 Scope 인스턴스를 통해 scope 범위에 포함된 scoped 컴포넌트를 통해 의존성 주입을 요청합니다

TypeQulifier - 타입 한정자

class A
class B
class C

val myModule = module {
    factory { A() }

    scope(named<A>()) {		// TypeQulifier 타입한정자 1️⃣
        scoped { B() }
        scoped { C() }
    }

    scope<A> {			// TypeQulifier 타입한정자 2️⃣
        scoped { B() }
        scoped { C() }
    }
}

TypeQulifier 타입한정자 선언은 위 처럼 같은 결과를 다르게 선언 가능합니다. 두개 모두 동일한 의미입니다

B와 C 클래스 인스턴스는 A 인스턴스에 범위가 잡혀있습니다. A 인스턴스 존재 범위내에만 의존성 주입이 가능을 의미

// Scope 범위 대상인 A 인스턴스 주입
val a = get<A>()

// A 인스턴스 기반 Scope 생성
val scopeForA = a.getOrCreateScope()

// Scope로 인스턴스 주입
val b = scopeForA.get<B>()
val c = scopeForA.get<C>()

Scope 인스턴스인 socpeForA는 A 인스턴스에 의존적입니다. A인스턴스의 존재 범위에서만 scope를 통해 의존성주입이 가능하게 됩니다

+ scope 속성 사용

// Scope 범위 대상인 A 인스턴스 주입
val a = get<A>()

// Scope 인스턴스 생성 없이 바로 의존성 주입
val b = a.scope.get<B>()
val c = a.scope.get<C>()

Scope 인스턴스를 별도 생성없이, A인스턴스의 scope 속성을 통해 바로 의존성 주입을 할 수 있습니다

Scope 범위 및 연결된 인스턴스 삭제

// `a` 스코프 삭제 & `b`,`c` 인스턴스 해제
a.closeScope()

closeScope() 함수로 Scope 범위인스턴스와 연결된 인스턴스를 모두 해제하고 삭제합니다

의존성 해결 및 주입

각 Component들로 제공할 인스턴스(객체) 생성에 Component 내에서 추가적으로 의존성 해결과 주입이 필요한 경우

Koin 컨테이너로 의존성을 주입하려면 Activity와 다르게 생성자 주입함수를 사용해야합니다 -> get() 함수

get() 함수는 일반적으로 생성자 값을 주입하기 위해 생성자에 사용합니다

// Presenter <- Service
class AA()
class BB(val aa : AA)

val myModule = module {
    // AA 인스턴스 제공 Component
    single { AA() }
    // BB 인스턴스 제공 Component
    single { BB(get()) }	// 생성자 인자 aa 의존성 해결
}

BB인스턴스 생성부분을 보면 생성자 인자 aa 인스턴스를 get() 함수를 통해 해결합니다

추가 기능 

동일 Type 중복? - Qulifier Named  

만약 동일 Type이 서로 다른 의존성으로 주입이 필요하다면? - Qulifier Named

Dagger와 마찬가지로 다수의 동일 Type을 가질 경우, Koin도 의존성 주입을 위한 Type 구분불가 (예외발생)

Dagger에서는 @Named 어노테이션을 사용해서 Type 구분을 했지만, Koin은 named 속성으로 구분
(Dagger - @Named Annotation / Koin - named 속성)

// Drink 인터페이스
interface Drink {}

// Drink 구현 Coffee 클래스
class Coffee(var name:String) : Drink {}

val myModule = module {

    // Drink 타입 바인딩 + named 속성 설정
    factory<Drink>(named("Espresso") { Coffee("Espresso") }

    // Drink 타입 바인딩 + named 속성 설정
    factory<Drink>(named("Americano") { Coffee("Americano") }
}

Coffee 객체를 Drink 타입에 의존성 주입하는 2개의 factory 컴포넌트를 선언합니다

val test : Coffee by inject()	// ❌ 동일 Type 컴포넌트 중복으로 구분 불가 (예외 발생)

val espresso : Coffee by inject(name = named("Espresso"))	// ✔️ Espresso named 설정된 Component로 제공

val americano : Coffee by inject(name = named("Americano"))	// ✔️ Americano named 설정된 Component로 제공

첫번째 의존성 요청에서 예외를 발생합니다. 단순히 Coffee 타입으로만 요청할 경우 Coffee 타입의 Component가 중복되므로 Koin은 구분하지 못하게 되어 예외를 발생

시작시 인스턴스 생성 - createAtStart

인스턴스(객체)가 지연초기화 아닌, 모듈 선언과 동시에 즉시 생성이 필요한 경우엔 ? createAtStart 속성

Koin은 기본적으로 지연초기화(lazy init)가 기본값으로 모든 객체 생성은 요청 시점에 생성 (single + factory 모두)

하지만 모듈 선언과 동시에 생성이 필요할 경우엔?

createdAtStart 속성을 true로 설정 (Single에서만 의미가 있는 효과)

single(createdAtStart = true) { AA() }

생성자 인수 설정 - parametersOf()

Module에 선언한 객체를 생성할 때 그때그때 다른 인자를 넘겨줘야하는 경우엔 Injection parameter 사용

// Coffee 클래스 - item(제품명), price(가격) 프로퍼티 포함
class Coffee(val item: String, var price: Int) {}

val myModule = module {
    // 생성자 인수로 받을 Injection parameter 선언
    factory { (item: String, price: Int) -> Coffee(item, price) }
}

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
// 사용방법

var espresso = get<Coffee>{ parametersOf("Espresso", 4300) }	// Parameter 전달

이렇게 Coffee 객체의 의존성 주입 Component Factory에 injection parameter를 선언하여서 객체 생성마다 다른 인자를 받아 설정할 수 있습니다

SingleTon 타입인 single에서는 여러번 호출 시 적용은 아래와 같습니다

val myModule = module {
     single { (item: String, price: Int) -> Coffee(item, price) }
}

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
// single 컴포넌트 3번 할당예시

var espresso = get<Coffee>{ parameterOf("Espresso", 4300) }	// ✔️ single은 맨 처음 선언만 적용

var americano = get<Coffee>{ parameterOf("Americano", 4100) }	// ❌ 적용X, 이미 설정된 Espresso, 4300원의 Coffee인스턴스 반환

var closeCoffee = get<Coffee>()			// ❌ 적용X, 이미 설정된 Espresso, 4300원의 Coffee인스턴스 반환

Module에서 item과 price 인자를 받아 싱글톤의 Coffee 객체를 생성하도록 선언합니다

3개의 get() 호출은 모두 처음 호출한 4300원의 Espresso 객체가 반환됩니다.

싱글톤 single은 첫 인스턴스를 생성한 다음부터는 의존성 요청 시 인자 전달을 생략해도 정상적으로 주입이 가능
하지만 최초 인스턴스 호출 시에는 반드시 인자 전달이 필수적  

속성 주입 - Property Injection

생성자 파라미터 전달은 parameterOf()로 하고, 해당 클래스의 속성주입이 필요한 경우는 아래와 같이 합니다

class B
class C

class A {
    lateinit var b: B	// lateinit, 지연초기화 가능
    lateinit var c: C	// lateinit, 지연초기화 가능
}

// 모듈 정의
val myModule = module {

    single<A> { A() }	// A 인스턴스 제공

    single<B> { B() }	// B 인스턴스 제공

    single<c> { C() }	// C 인스턴스 제공
}

A 클래스는 속성으로 b와 c의 인스턴스를 포함하고 있는 구조입니다

val a : A by inject()

// 속성 주입 inject Properties
a::b.inject()
a::c.inject()

A클래스의 lateinit으로 비어있는 b, c 속성을 주입합니다

이 방법은 리플렉션 API를 사용하지 않습니다

2개의 속성을 한번에 주입하기 위해선 아래 방법이 존재합니다

val a : A by inject()

// 속성 주입 inject Properties
a.inject(a::b, a::c)

이렇게 한번에 속성 주입도 가능합니다. 이 방법은 리플렉션 API를 사용해서 속성 Type을 추측합니다

Type 지정 + as, bind() 바인딩

Module에서 객체 명세 작성 시 별도의 타입을 지정하지 않을 경우 가장 구체적인 Type으로 자동 지정

myModule.kt
interface Drink {}		// Drink 인터페이스

class Coffee() : Drink {}	// Drink 인터페이스 구현체 Coffee

val myModule = module {
    factory { Coffee() } 	// Coffee 인스턴스 생성 : Coffee Type 의존성 인식
}

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

var espresso = get<Coffee>() 	// ✔️ OK! 정상적으로 factory의 Coffee() 객체 주입

var espresso = get<Drink>() 	// ❌ Error! 모듈에 Drink 타입의 의존성을 찾지못함 

Drink 인터페이스 구현체인 Coffee 클래스를 선언하고 Module의 객체 명세는 Coffee()로 선언합니다
이렇게 별도로 Type을 지정하지 않을 경우엔, 선언한 Coffee() 생성자의 클래스 Type을 자동 지정합니다 

따라서 get() 으로 Coffee 인스턴스를 요청하면 정상적으로 주입이 가능하고, Drink 인스턴스를 요청하면 찾을 수 없음

Drink 인터페이스의 구현체인 Coffee 인스턴스를 Drink 타입으로 바인딩하고 싶다면? 

myModule.kt
val myModule = module {

    factory<Drink> { Coffee() }			// 1️⃣ Drink 타입만 주입 가능

    factory { Coffee() as Drink }		// 2️⃣ Drink 타입만 주입 가능

    factory { Coffee() } bind Drink::class	// 3️⃣ Coffee + Drink 타입 모두 주입 가능
}

1️⃣ factroy에 Type parameter 명시
    : 해당 Factory 컴포넌트는 Drink Type으로 강제 설정

2️⃣ Coffee 인스턴스를 Drink 타입으로 캐스팅(as) 
    : Coffee()로 생성되는 객체를 as 캐스팅 연산자로 Drink 타입으로 캐스팅
      ( 캐스팅을 하기위해선 Coffee와 Drink가 상속관계로 되어있어야 함 )

3️⃣ 복수 타입 연결 Bind
    : Bind는 복수 타입의 연결 구현, 즉 CoffeeDrink 모두에 연결해서 두 타입으로 의존성 주입이 가능

제네릭 다루기

Koin에서는 제네릭 형식 인수를 구분하지 않기 때문에, 제네릭 형식을 사용할 경우엔 named 속성으로 구분해야합니다

예를 들어, 두 개의 다른 Type인 List를 선언합니다

val myModule = module {

    factory { ArrayList<Int>() }	// 1️⃣ Int 타입 List

    factory { ArrayList<String>() }	// 2️⃣ String 타입 List
}

Int 타입과 String 타입의 서로다른 List를 선언합니다. 분명 서로다른 타입의 List지만 Koin은 동일한 List 타입으로 인식하기 때문에 예외를 발생합니다

val myModule = module {

    factory(named("Ints")) { ArrayList<Int>() }	// 1️⃣ Int 타입 List

    factory(named("Strings")) { ArrayList<String>() }	// 2️⃣ String 타입 List
}

이렇게 named 속성을 설정함으로 Koin이 서로 다른 Type List로 인식하게 만들어줘야 사용이 가능합니다

 

 

Activity, Fragment 외부에서 koin을 사용해 객체 주입하기

Koin을 사용할 시 컨테이너는 안드로이드 컴포넌트의 생명주기에 맞추어 생성과 파괴가 되도록 만들어져야 한다. 

 

  • Hilt는 안드로이드의 생명주기와 통합되어 있어서 Activity, Fragment, ViewModel 등이 파괴될 때 의존성 객체도 자동으로 소멸됩니다. 이는 Hilt가 @Singleton이나 @ActivityScoped, @FragmentScoped와 같은 어노테이션을 통해 의존성 객체의 생명주기를 관리하기 때문입니다. 따라서, 안드로이드 각 컴포넌트 생명주기에 따라 관리가 가능하기 때문에 안드로이드에 최적화되어 있습니다.
    즉, 생명주기에 맞춰 의존성 객체를 생성하고 소멸시킬 수 있습니다. 예를 들어, ActivityFragment의 의존성 객체는 해당 ActivityFragment가 생성될 때 주입되고, 컴포넌트가 파괴될 때 의존성 객체도 자동으로 소멸됩니다. 어노테이션을 사용함으로써 Hilt가 이 과정을 자동으로 처리하기 때문에 개발자가 명시적으로 객체의 생명주기를 관리할 필요가 없습니다.
  • Koin은 객체의 생명주기를 명시적으로 관리해야 합니다. Koin은 기본적으로 생명주기 관리를 자동으로 해주지 않습니다. 대신, Koin에서는 의존성 객체의 생명주기를 수동으로 관리하는 방법을 제공합니다. Koin은 scope를 사용하여 의존성 객체의 생명주기를 관리하며, 이 스코프는 객체가 어떤 범위에서 살아있을지를 정의합니다.
    예를 들어, ActivityFragment에서 의존성 객체가 Activity/Fragment의 생명주기에 맞게 소멸되도록 설정할 수 있지만, 자동으로 관리되지 않으며, 개발자가 스코프를 수동으로 설정해야 합니다.

예시

Koin의 객체 주입

Koin에서는 의존성 주입 시 scope를 명시적으로 정의하고, clear() 메서드를 호출하여 객체를 관리해야 합니다.

val activityModule = module {
    scope(named("activityScope")) {
        scoped { MyRepository() }  // Activity 생명주기 동안 객체 유지
    }
}

// Koin에서 스코프를 사용하여 의존성 주입
val scope = getKoin().createScope("activityScope", named("activityScope"))
val myRepository = scope.get<MyRepository>()
 

이와 같이, Koin에서는 명시적으로 scope를 생성하고 종료 시 clear() 메서드를 호출해 주어야 하므로 자동으로 객체가 파괴되지 않습니다. 반면 Hilt는 이를 자동으로 처리합니다.

따라서, Hilt는 생명주기 관리가 자동이고, Koin은 수동 관리가 필요하다는 점에서 차이가 있습니다.

 

 


참고자료

https://heegs.tistory.com/80

 

[Koin] Koin을 사용하여 의존성 주입을 해보자.

처음 학습하면서 작성한 글입니다. 필요시 추후 내용을 수정할 예정입니다. 틀린 부분이 있으면 언제든 지적해주면 감사하겠습니다 :) 지난번에 작성한 Dagger2에 이어서, 많이 사용되는 DI인 Koin

heegs.tistory.com

https://jaejong.tistory.com/154

 

[Android][Kotlin] Koin #2 - 자세히 알아보기

Koin #2 - Definitions Definitions Module 정의 및 사용을 위한 등록 정의를 설명 [Koin Document 사이트] 기반으로 작성 Module 정의 Koin에서 Module은 모든 구성요소를 선언하는 공간, 즉 Koin으로 제공할 객체를 명

jaejong.tistory.com

https://velog.io/@jeongminji4490/DI-Koin

 

[DI] Koin

이전에 작성했던 dependency injection 포스팅에서 Koin에 대해 짤막하게 소개했었다. 그런데 이번에 Hilt에 대해 공부하고 글을 올리면서 Koin에 대해서도 아직 지식이 부족함을 느꼈고, 따라서 좀 더 깊

velog.io

https://android-lab.tistory.com/28

 

[여기가 DI 설명 제일 잘함] Koin vs Hilt

지난 번 의존성 주입 첫번째 포스터에서 의존성 주입(DI)이 뭔지 알아보았고 의존성 주입 두번째 포스터에서 안드로이드 의존성 주입(DI)라이브러리 2가지 Dagger와 Hilt를 알아보았습니다. 그렇다

android-lab.tistory.com