개발/안드로이드

[Hilt] Hilt의 바인딩

스몰스테핑 2024. 8. 5. 17:02

Qualifier 활용

Hilt는 타입으로 의존성을 구분한다.
타입이 명시되어있기 때문에 타입으로 구분하여 의존성을 바인딩하고 주입하는 것이 가능하다.
그런데 동일한 타입이 두 번 바인딩 된다면? 

Hilt 입장에선 Client에서 의존성을 요청할때 어떤 바인딩을 주입해야할지 애매해질 것이다

 

 

그렇기에 컴파일 타임에 중복 바인딩 에러를 띄우게 된다.
이러한 양상의 오류가 발생할 경우 중복된 바인딩 요소를 제거하면 간단히 해결된다.

그러나 중복바인딩을 하고 싶을 수도 있다.
이 해결방법의 핵심이 @Qualifier 애노테이션이다.

 

1. 커스텀 Qualifier 선언

Qualifier를 애노테이션으로 갖는 새로운 애노테이션을 선언해야한다.
실제 프로젝트 진행 시 필요하다면 상황에 맞게 Qualifier를 선어하여 사용하여야한다.

예를 들어 다음과 같이 사용 가능하다.

@Qualifier
annotation class CustomQualifier
@InstallIn(SingletonComponent::class)
object AppMoudle {
    @CustomQualifier
    @Provides
    fun provideFoo1(): Foo {
        return Foo()
    }

    @Provides
    fun provideFoo2(): Foo {
        return Foo()
    }
}

 

Foo를 리턴하는 메서드가 2개임에도 불구하고 provideFoo1에는 @CustomQualifier 애노테이션이 있기 때문에 컴파일 타임에 오브젝트 그래프 생성 시 둘을 구분지을 수 있게 된다.
의존성을 요청해도 에러를 띄우지 않게 된다.

마찬가지로 Qualifier로 바인딩을 했다면, 요청할때도 해당 Qualifier를 붙이면 된다.

@AndroidEntryPoint
class MainActivity: ComponentActivity() {
    @CustomQualifier
    @Inject
    lateinit var foo: Foo
}

 

그러면 저 Qualifier의 유무로 하여금, 해당 의존성을 명확하게 주입할 수 있게 된다.

 

2. @Named 활용하기

@InstallIn(SingletonComponent::class)
object AppMoudle {
    @Named("Foo1")
    @Provides
    fun provideFoo1(): Foo {
        return Foo()
    }
}

 

다른 방법으로는 @Named 애노테이션을 사용하는 것이다. 이 애노테이션은 이미 선언되어있으며, 문자열을 속성으로 갖는다.
이 문자열로 하여금, 유니크한 키값으로 바인딩을 구분지을 수 있게 된다.

 

3. 요약

  • @Qualifier 애노테이션을 통해 의존성 구분이 가능하다
  • @Qualifier 애노테이션 여러개의 속성을 가질 수 있다
  • @Named를 활용하여 간단하게 의존성을 구분할 수 있다

 

Lazy와 Provider

Lazy 주입은 일반적인 주입과 다르다. 늦은 인스턴스 생성을 의미한다.
Lazy 인스턴스 자체는 주입 시기 자체에 주입이 되지만, 이 Lazy의 Generic 타입은 의존성을 가져오기 위해 Lazy에 get이라는 메서드를 호출해야한다.

@AndroidEntryPoint
class MainActivity: ComponentActivity() {
    @Inject
    lateinit var fooLazy: Lazy<Foo>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val foo1 = fooLazy.get()
    }
}

 

주의점으로는 Kotlin에도 Lazy가 있기 때문에 import할 때, Kotlin의 Lazy가 아닌 Dagger의 Lazy를 주입해야 get() 메서드를 사용할 수 있다.


예를 들어, 메인 액티비티에 fooLazy 주입을 만들었을때, onCreate 당시가 아니라 그 이후 fooLazy.get()을 사용했을때 인스턴스화가 이루어진다. 초기화를 조금 늦출 수 있다. 

 

특정 시점에 인스턴스를 생성하고 싶을 때 사용이 가능하다.

 

1. Lazy 주입 특징

  • Lazy<T>의 get() 메서드를 호출할 때 T를 반환한다. get() 호출 시점에 T가 인스턴스화 한다.
  • Lazy<T>의 get() 호출 이후 다시 get()을 호출하면 캐시 된 (동일한) T 인스턴스를 얻는다.
  • T 바인딩에 Scope가 지정되어 있다면, 각 Lazy<T> 요청에 대한 동일한 Lazy<T> 인스턴스가 주입된다.
  • 특정시점에 바인딩 인스턴스화 할 때 사용하면 좋다.
  • 인스턴스 생성에 비용이 큰 경우 사용하면 좋다.

2. Provider 주입

Lazy와 마찬가지로 주입받고자 하는 의존성을 Generic 타입으로 갖는 래퍼 프로바이더를 주입받는 방식이다.

@AndroidEntryPoint
class MainActivity: ComponentActivity() {
    @Inject
    lateinit var fooProvider: Provider<Foo>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val foo1 = fooProvider.get()
    }
}

 

Lazy와 다른 점은 매 get() 메서드 호출 시 마다 새로운 인스턴스를 생성한다는 것이다.

 

3. Provider 주입 특징

  • Provider<T>의 get() 메서드를 호출할 때마다 새로운 인스턴스 T를 반환한다.
  • T 바인딩에 Scope가 지정되어 있다면, Provider<T>의 get() 메서드를 호출할 때 동일한 인스턴스 T를 반환한다.
  • T 바인딩에 Scope가 지정되어 있다면, 각 Provider<T> 요청에 대한 동일한 Provider<T> 인스턴스가 주입된다.
  • 하나의 Provider<T>로 여러 T 인스턴스를 생성하기 원할 때 사용할 수 있다. (Builder, Factory 패턴과 유사)

 

4. 요약

  • Lazy를 통해 인스턴스화 시점을 늦출 수 있다.
  • Lazy 인스턴스는 매 get() 호출에 동일한 인스턴스를 반환한다.
  • Provider는 매 get() 호출에 새로운 인스턴스를 반환한다.

 

바인딩 기법

바인딩이란 의존성을 컴포넌트에 추가하는 행위, 그 의존성을 일컫는 용어이다.

 

1. 바인딩의 종류

  • @Inject를 활용한 생성자 바인딩
  • @Provides를 활용한 바인딩
  • @Binds를 활용한 바인딩
  • @BindsOptionalOf를 활용한 바인딩
  • @BindsInstance를 활용한 바인딩

 

1-1. 생성자 바인딩 with @Inject

class Foo @Inject constructor()

 

1-2. @Provides 바인딩

@AppMoudle
@InstallIn(...)
object MyModule {
    @Provides
    fun provideFoo(...): Foo {
        ...
    }
}

 

1-3. @Binds 바인딩

바인딩 된 의존성을 효율적으로 활용하는 방법

 

interface Engine

class GasolineEngine @Inject constructor(): Engine

 

위와 같은 예제가 존재할 때, 현재 컴포넌트에 바인딩된 의존성은 GasolineEngine이다.
그리고 Kotlin이나 Java는 문법상으로 GasolineEngine을 Engine으로 캐스팅하는게 가능하다.

두 전제를 토대로 컴파일 타임에 오브젝트 그래프를 체크한다.
예를 들어 Client는 Engine을 요청한다. 근데 컴포넌트에 바인딩 된 것은 GasolineEngine이다.
그렇기에 Hilt는 컴파일 타임에 해당 의존성을 찾을 수 없다면서 Missing Binding 오류를 띄운다.

이럴 경우, 모듈에 GasolineEngine을 Engine으로 다시 제공하는 Provides 메서드를 사용할 수도 있지만, @Binds를 사용하는 것이다.

 

@Module
@InstallIn(...)
abstract class EngineModule {
    @Binds
    abstract fun bindEngine(engine: GasolineEngine): Engine
}

 

위 코드처럼 @Binds 애노테이션을 선언할 수 있고, abstract 메소드이기에 클래스 또한 abstract여야한다.
abstract이기 때문에 당연히 구현체도 없다. 메서드 파라미터에는 이미 바인딩 되었던 의존성을 명시한다.
반환 타입이 Engine이다. 캐스팅 가능한 상위 타입을 선언해주는 것이다. GasolineEngine은 Engine으로 캐스팅이 가능하기 때문

 

1-4. @Binds 애노테이션 활용

Client는 Car이고, 컴포넌트에 바인딩 된 것들은 GasolineModule, DiselModule, TestEngineModule과 같은 식으로 활용할 수 있을 것이다. 모두 설치하면 중복바인딩 에러가 나겠지만, 선택적으로 설치한다면 가솔린 자동차, 디젤 자동차, 테스트 엔진 자동차 등으로 활용이 가능해진다.

개발목적을 위해 빌드 타입을 나누는 경우가 있는데 그럴 경우에도 활용할 수도 있을 것이다.

 

1-4-1. @Binds 제약 조건

  • @Binds는 반드시 모듈 내에 abstract 메서드에 추가해야 한다.
  • @Binds 메서드는 반드시 파라미터 1개만을 가진다.
  • 파라미터 타입이 반환타입의 서브타입이어야 한다.
  • Scope 및 Qualifier 애노테이션과 함께 사용할 수 있다.

 

1-4-2. @Binds, @Provides 함께 사용 시 주의 사항

@Provides 메서드의 경우 접근하고 호출하기 위해 객체가 있어야한다. 그렇기에 반드시 인스턴스가 가능한 콘크리트 클래스 내에서 선언이 되어야 한다.

즉 모듈 클래스가 콘크리트 클래스여야 한다.

 

하지만, @Binds 메서드는 abstract 클래스내 abstract메서드로, 이 abstract는 미완성이기에 인스턴스로 생성할 수가 없다.
그렇기 때문에 될 수 있다면 @Provides와 @Binds는 각각 독립된 모듈에서 사용하여야 한다.

 

꼭 두 애노테이션이 하나의 모듈에 공존해야 한다면 다음과 같이 접근가능한 함수로 바꿔주어야 한다.

 

@Module
@InstallIn(SingletonComponent::class)
abstract class MyModule {
    Companion object {
        @Provides
        fun proviedeFoo(): Foo {
            ...
        }
    }

    @Binds
    abstract fun bindsEngine(engine: GasolineEngine): Engine
}

 

Companion object 키워드를 사용해서 외부에서 접근 가능하도록 만드는 것이다. 그렇다면 MyModule을 굳이 인스턴스화 하지 않고도 접근이 가능하다.

 

1-5. @BindsOptionalOf 바인딩

바인딩 되어 있지 않을 가능성이 있는 의존성을 요청할 때 사용한다. (=옵셔널 바인딩)

평범하게 Client가 Foo를 요청했을때 컴포넌트에 Foo가 바인딩 되어 있다면 에러가 뜨지 않는다.
그런데 Client가 Foo를 요쳥했을때 컴포넌트에 Foo가 바인딩 되어 있지 않다면 에러가 뜰 것이다.
이 때, Client가 Optional<Foo>를 요쳥한다면 컴포넌트에 Foo의 바인딩 여부와 관계없이 Hilt의 컴파일을 통과할 수 있다.

 

@Module
@InstallIn(SingletonComponent::class)
abstractclass FooModule {
    @BindsOptionalOf
    abstract fun optionalFoo(): Foo
}

 

위와 같이 쓸 수 있다. @Binds와 마찬가지로 abstract여야 한다.

 

1-5-1. Optional<T> 요청

@AndroidEntryPoint
class MainActivity: ComponentActivity() {
    @Inject lateinit var optionalFoo: Optional<Foo>
}

class Bar @Inject constructor(optionalFoo: Optional<Foo>)

@Provides
fun provideString(optionalFoo: Optional<Foo>): String {
    ...
}

 

해당 바인딩을 요청하는 방법은 위와 같다.
안드로이드 클래스의 필드 뿐만 아니라 메서드의 파라미터, 생성자 바인딩처럼 사용할 수 있다.
바인딩된 의존성들은 이렇게 다 요청하고 주입할 수 있다.

 

1-5-2. Optional<T> 주요 메서드

  • isPresent(): 바인딩 된 경우 true를 반환한다.
  • get(): 바인딩 된 의존성 T를 반환하고, 바인딩 되지 않았을 경우 예외를 던진다. orElse 류의 메서드 호출로 안전하게 접근할 수도 있다.

예) if (isPresent) get() else //something...

 

1-5-3. @BindsOptionalOf 제약 조건

  • @BindsOptionalOf는 반드시 모듈 내의 abstract 메서드에 추가해야 한다.
  • @BindsOptionalOf 메서드는 void 타입을 반환하면 안된다. (= 반환타입이 반드시 있어야 한다.)
  • @BindsOptionalOf 메서드는 파라미터를 가질 수 없다.
  • 생성자 바인딩 된 의존성은 항상 present 상태이므로, 이 경우 해당 의존성은 옵셔널 바인딩이 불가능하다.
  • Optional<Provider<T>>, Optional<Lazy<T>>, Optional<Provider<Lazy<T>>> 형태로 주입도 가능하다.

 

1-6. @BindsInstance 바인딩

컴포넌트 생성과 동시에 바인딩된다.

Hilt는 안드로이드 시스템에서 생성하는 인스턴스들을 기본 바인딩으로 제공하고 있고 표준컴포넌트도 제공한다.
그렇기에 Hilt 가볍게 사용하는 일반적인 개발자들은 @BindsInstance의 사용이 거의 필요 없을 수 있다.

그러나 사용할 수도 있기 때문에 가볍게 다룰 것이다.
커스텀 컴포넌트를 직접 정의해서 그 컴포넌트에 대한 인스턴스 또한 직접 생성해서 사용할때 사용하기 때문이다.

 

@DefineComponent.Builder
interface MyComponentBuilder {
    MyComponentBuilder setFoo(@BindsInstance Foo foo) 
}

 

컴포넌트를 만들기 위한 빌더 인터페이스에서 setter 메서드의 파라미터로 @BindsInstance를 사용할 수 있다.

 

2. 요약

  • 다양한 바인딩 기법을 활용하여 상황에 맞게 컴포넌트에 의존성을 바인딩할 수 있다.
  • @Provides와 @Binds의 차이와 각각의 특징을 이해하고 사용해야 한다.
  • 바인딩 여부가 확실하지 않은 경우 옵셔널 바인딩 기법을 활용한다.
  • 컴포넌트와 생성과 동시에 바인딩을 하는 경우 @BindsInstance를 활용할 수 있다.

 

Hilt 멀티바인딩 기법

멀티바인딩이라는 것은 여러 의존성을 하나의 컬렉션으로 관리하는 것이다.

멀티바인딩은 컴포넌트에 컬렉션 자체를 바인딩 하는 것이다.
Hilt는 set과 map을 지원하고 있다.

 

1. set 멀티 바인딩

set 멀티 바인딩은 동일한 타입의 의존성들을 set 형태로 관리하는 것이다.

 

@IntoSet
@Module
@InstallIn(SingletonComponent::class)
object MyModule {
    @Provides
    @IntoSet
    fun provideOneString(): String {
        return "ABC"
    }
}

 

@IntoSet을 추가하면, 컴파일 타임에 컴포넌트 내 set을 하나 추가하고, set에 Generic 타입인 의존성들을 추가할 수 있는 환경을 만들어준다.


멀티바인딩 하기 전에 @Provides를 단독으로 사용하는 경우 해당 의존성은 String 문자열이 단독으로 바인딩되어야 한다.
근데 @IntoSet을 추가하면 멀티바인딩을 명시하는 것이기 때문에 단독바인딩이 되지 않는다.

 

String을 요청하면 missingBinding 에러가 발생하게 된다.

 

1-1. @ElementsIntoSet

@Module
@InstallIn(SingletonComponent::class)
object MyModule {
    @Provides
    @ElementsIntoSet
    fun provideSomeStrings(): Set<String> {
        return listOf("DEF", "GHI").toSet()
    }
}

 

@ElementsIntoSet은 어떤 요소들을 통째로 멀티바인딩하는 방법이다.

 

1-2. 멀티 바인딩 된 Set 주입

class Bar @Inject constructor(
    val strings: Set<String>
) {
    init {
        assert(strings.contains("ABC"))
        assert(strings.contains("DEF"))
        assert(strings.contains("GHI"))
    }
}

 

위 코드는 테스트용 코드이다.
이전에 멀티바인딩하는 @IntoSet과 @ElementsIntoSet을 사용하여 추가한 의존성들이 존재하는지 확인하는 코드이다.

 

2. map 멀티 바인딩

동일한 타입의 의존성들을 map형태로 관리한다.
map 멀티 바인딩 시에는 의존성과 관계를 가질 Key가 반드시 필요하다.

 

2-1. Map 멀티 바인딩을 위한 기본 키

  • @StringKey
  • @IntKey
  • @LongKey
  • @ClassKey

 

2-2. @IntoMap

@InstallIn(SingletonComponent::class)
ojbect MyModule {
    @Provides
    @IntoMap @StringKey("foo")
    fun provideFooValue(): Long {
        return 100L
    }

    @Provides
    @IntoMap @ClassKey(Bar::class)
    fun provideBarValue(): String {
        return "value for Bar"
    }
}
@AndroidEntryPoint
class MainActivity: ComponentActivity() {
    @Inject lateinit var map1: Map<String, Long>
    @Inject lateinit var map2: Map<Class<*>, String>

    override fun onCreate(savedInstanceState: Bundle>) {
        super.onCreate(savedInstanceState)
        map1["foo"].toString() // 100
        map2[Bar::class.java] // value for Bar
    }
}

 

map 타입으로 선언하는데 map의 Generic타입을 보고 주입이 이뤄지기 때문에 이 타입을 신경써서 작성하여야 한다.

 

2-3. @MapKey

enum class MyEnum {
    ABC,
    DEF
}
@MapKey
annotation class MyEnumKey(val value: MyEnum)
@Module
@InstallIn(SingletonComponent::class)
object MyModule {
    @Provides
    @IntoMap
    @MyEnumKey(MyEnum.ABC)
    fun provideABCValue(): String {
        return "value for ABC"
    }
}
@AndroidEntryPoint
class MainActivity: ComponentActivity() {
    @Inject lateinit var map: Map<MyEnum, String>

    override fun onCreate(savedInstanceState: Bundle>) {
        super.onCreate(savedInstanceState)
        map[MyEnum.ABC] // value for ABC
    }
}

 

@MapKey는 enum class를 통해 Key를 만들어 사용하는 것이다.

 

3. @Multibinds

의존성을 꼭 하나 이상 멀티바인딩 해야만 Map이나 Set이 생성되었는데, 추상적으로 비어있는 Map이나 Set을 생성하기 위해 @Multibinds를 사용할 수 있다.

 

@Module
@InstallIn(SingletonComponent::class)
abstract class MyModuleA {
    @Multibinds
    abstract fun fooSet(): Set<Foo>

    @Multibinds
    abstractfun fooMap(): Map<String, Foo>
}

 

멀티바인딩된 컬렉션이 기존에 존재하지 않는 경우 Client가 요청하면 존재하지 않기 때문에 에러가 발생한다.
그런데 이 Multibinds 애노테이션을 미리 선언해두었다면 에러없이 작업을 수행할 수 있다.

 

4. 요약

  • 멀티 바인딩 기법을 활용하여 Map 또는 Set으로 의존성을 관리할 수 있다.
  • @MapKey를 이용하여 Map 멀티 바인딩을 위한 커스텀 키를 만들 수 있다.
  • 바인딩 여부가 확실하지 않을 경우, @Multibinds를 활용하여 컴파일 타임에 빈 컬렉션을 제공할 수 있다.