본문 바로가기

Android

[Android] Dagger와 Hilt로 자동 의존성 주입 이해하기

 

이전 포스팅에서 의존성 주입의 의미와 왜 필요한지, 그리고 수동 의존성 주입에 대해서 알아보았는데요

그때 이야기 했던 수동 의존성 주입의 단점들을 보완하여 편하게 의존성을 주입하기 위해 DI 라이브러리인 Dagger와 Hilt에 대해 알아보려고 합니다

 

 

 

☄️ DI 라이브러리

의존성을 자동으로 주입해주는 라이브러리는 대표적으로 Dagger, Koin, Hilt가 있는데요

그 중에서도 Dagger와 Hilt가 많이 사용되고, 특히 안드로이드에서는 Hilt를 가장 많이 사용하고 있습니다

 

그래서 저도 프로젝트를 진행하며 Hilt를 사용했었고, 처음엔 Hilt만 사용할거니까 Hilt만 알면 되지 않아? 라고 생각했는데 Hilt가 Dagger를 기반으로 만들어진 라이브러리라 Hilt를 잘 사용하기 위해서는 Dagger의 대략적인 흐름을 이해하는 것이 도움이 될 것 같더라구요

 

그렇기 때문에 Dagger와 Hilt를 차례로 알아보도록 하겠습니다

 

 

 

 

🌟 Dagger

Dagger는 안드로이드 뿐만 아니라 자바에서도 사용이 가능한 DI 라이브러리입니다

코드 생성과 컴파일 타임에 의존성 주입을 설정해주어 문제가 있을 경우 컴파일 시점에서 에러를 발생시킵니다

 

Dagger는 아래 5가지의 개념을 가지고 의존성을 주입합니다

 

  1. Component
  2. Module
  3. Scope
  4. Inject
  5. SubComponent

 

1. Component

Component는 의존성 그래프를 관리하는 중심 역할을 합니다

즉, Dagger에서 가장 중요한 역할을 합니다

 

@Component 어노테이션을 사용해 어떤 의존성을 제공할지 정의하고, 이를 통해 필요한 곳에 의존성을 전달해줍니다

보통 Interface나 추상 클래스에 붙어서 사용하고, 사용될 Module, Scope Level, 주입받을 대상을 설정 가능합니다

 

Component는 Module을 통해 필요한 의존성을 제공받고 필요한 객체를 주입하는데 사용됩니다

 

@Component(modules = [NetworkModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
}

 

위 코드에서 AppComponent는 의존성 주입을 관리하는 중앙 허브 역할을 하고,

@Component(modules = [NetworkModule::class]) 라고 설정하면 AppComponent가 NetworkModule에 정의된 의존성들을 가져와 사용할 수 있게 됩니다

 

 

 

2. Module

Module은 의존성을 생성하고 제공하는 역할을 하며 필요한 객체를 생성하고 제공하는 함수들을 모아 놓은 클래스입니다

 

Provides 또는 Binds 어노테이션을 사용해 함수에 제공할 의존성을 명시하고, 이 함수들이 Component에 의해 필요할 때 호출되어 의존성을 생성합니다

 

@Module
class NetworkModule {

    @Provides
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
    }
}

 

위 코드에서 @Module을 통해 Dagger에게 이 클래스가 의존성을 제공하는 클래스라는 것을 알립니다

그럼 Dagger는 NetworkModule을 통해 @Provides가 붙은 함수를 찾아 의존성을 제공하게 됩니다

 

@Provides는 특정 타입의 객체를 반환하는 함수에 붙여 그 객체를 의존성으로 제공하겠다는 의미로

provideRetrofit 함수는 Retrofit 타입을 반환하고 Dagger는 이 함수를 통해 Retrofit 객체를 생성할 수 있습니다

 

이제 provideRetrofit 함수는 Retrofit.Builder()를 사용해 새로운 Retrofit 인스턴스를 생성하고, 이 인스턴스를 다른 곳에서 주입할 때 제공하게 됩니다

 

정리하자면, 이제 다른 클래스가 Retrofit 인스턴스를 필요로 할 때 Dagger는 NetworkModule의 provideRetrofit을 호출하여 이 Retrofit 인스턴스를 반환하고 주입합니다

그럼 다른 클래스에서 Retrofit 객체가 필요할 때 직접 생성할 필요없이 자동으로 주입받아 사용할 수 있게 되겠죠 !

 

 

 

3. Scope

Scope는 의존성의 생명주기(Lifecycle)를 관리하는 개념으로, 객체가 언제 생성되고 언제 파괴되는지를 정의합니다

Module과 Component에 @Singleton으로 Scope를 설정한다면 처음 요청 시에만 객체를 생성하고 그 다음부터는 처음에 제공한 인스턴스를 제공받을 수 있게 됩니다

 

@Singleton
@Component(modules = [NetworkModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
}

 

위 코드에서 @Singleton 어노테이션은 AppComponent 전체 와 그에 속하는 의존성들에 대한 싱글턴 스코프를 지정해줍니다

즉, AppComponent에서 관리하는 의존성들 (ex. NetworkModule에서 제공하는 Retrofit 객체)은 앱 전체에서 단일 인스턴스를 공유하게 됩니다

 

특정 Scope를 정의하는 데 사용되는 어노테이션은 @Singleton 이외에도 @ActivityScope, @FragmentScope 등이 있습니다

 

 

 

4. Inject

@Inject 어노테이션은 Dagger에서 의존성을 주입받을 때 사용되는 어노테이션입니다

이를 사용하면 Component로부터 의존성 객체를 주입해 달라고 요청이 가능하고, component는 요청을 받으면 Module로부터 객체를 생성하고 넘겨주거나 직접 생성해서 넘겨줍니다

 

@Inject 어노테이션을 필드, 생성자, 또는 메서드에 붙여서 Dagger가 이 필드 또는 생성자에 필요한 의존성을 자동으로 제공하게 만듭니다

 

class Repository @Inject constructor(
    private val apiService: ApiService
) {
    // 의존성 주입된 Repository 클래스
}

 

위 코드에서 @Inject는 Repository 클래스의 생성자에 붙어 있습니다

이 경우, Dagger에서 Repository를 생성할 때 자동으로 의존성인 ApiService를 주입해 줍니다

 

 

 

5. SubComponent

SubComponent는 부모 Component에 의존성을 추가하는 역할을 합니다

이는 컴포넌트 계층 구조를 만들기 위해 사용되며 부모 컴포넌트와 자식 컴포넌트 사이의 관계를 정의합니다

이 구조는 의존성을 효율적으로 관리하고, Scope와 모듈화를 더 유연하게 관리할 수 있도록 해줍니다

 

부모 컴포넌트로부터 의존성을 상속받아 사용할 수 있으며 특정 하위 기능이나 모듈에서만 사용할 추가 의존성을 정의할 때 유용합니다

 

Inject로 의존성 주입을 요청 받으면 SubComponent부터 의존성을 검색하게 됩니다

 

// 부모 컴포넌트
@Component(modules = [AppModule::class])
@Singleton
interface AppComponent {
    fun activityComponent(): ActivityComponent
}

// 서브컴포넌트
@Subcomponent
interface ActivityComponent {
    fun inject(activity: MainActivity)
}

 

@Component 어노테이션을 사용해 AppComponent를 정의합니다

이 컴포넌트는 애플리케이션 전반에 걸쳐 공유되는 의존성을 제공합니다

 

activityComponent() 메서드는 ActivityComponent 서브컴포넌트를 생성하는 메서드입니다

이를 통해 ActivityComponent에서 필요한 의존성을 제공할 수 있습니다

 

@SubComponent 어노테이션을 사용하여 ActivityComponent를 정의합니다

이 컴포넌트는 MainActivity와 같은 특정 Activity에 필요한 의존성을 주입합니다

 

ActivityComponent는 AppComponent에서 제공하는 의존성을 상속하여 사용하기 때문에 AppComponent의 의존성을 재사용 할 수 있습니다

 

class MainActivity : AppCompatActivity() {

    @Inject lateinit var repository: Repository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // AppComponent에서 ActivityComponent를 생성
        val activityComponent = (application as MyApplication)
            .appComponent
            .activityComponent()

        // MainActivity에 의존성 주입
        activityComponent.inject(this)

        // 이제 repository는 주입된 상태
    }
}

 

이제 MainActivity에서 ActivityComponent를 사용하여 의존성을 주입할 수 있습니다

 

SubComponent를 사용하면 특정 UI 컴포넌트(Activity, Fragment 등)에 필요한 의존성만을 주입할 수 있습니다

 

 

 

 

🌟 Hilt

Dagger에서는 컴포넌트, 모듈, 서브 컴포넌트 등을 수동으로 정의해야 했기 때문에 설정이 번거로웠는데요,

Hilt는 대부분의 설정을 자동화 하여 제공해줍니다

 

안드로이드의 모든 클래스에 컨테이너를 제공하고, 수명 주기를 자동으로 관리함으로써 애플리케이션에서 의존성 주입을 사용하는 표준 방법을 제공해줍니다

 

 

1. @HiltAndroidApp

Hilt를 사용하는 모든 앱은 @HiltAndroidApp 어노테이션으로 주석이 지정된 Application 클래스를 포함해야 합니다

 

@HiltAndroidApp 어노테이션을 붙이게 되면

 

  1. @HiltAndroidApp을 붙이는 순간, Hilt는 이 앱에서 의존성 주입이 필요하다는 것을 인식
  2. Hilt는 앱에 필요한 의존성 컨테이너 (객체들을 관리하는 공간)를 자동으로 생성하고, 앱의 각 컴포넌트 (Activity, Fragment 등)에서 의존성을 주입할 수 있도록 준비

즉, @HiltAndroidApp은 Hilt가 의존성 주입을 위한 준비 작업을 시작하도록 신호를 보내는 역할을 하기 때문에 Application class에 해당 어노테이션을 붙여주어야 합니다

 

@HiltAndroidApp
class MyApplication : Application()

 

Hilt는 아래와 같은 Component들을 계층 구조로 제공해줍니다

 

 

 

2. @AndroidEntryPoint

@HiltAndroidApp을 통해 Hilt를 설정해 구성 요소를 사용할 수 있게 되면 @AndroidEntryPoint를 통해 다른 클래스에 의존성을 제공할 수 있습니다

 

아래 클래스들에 @AndroidEntryPoint 어노테이션을 붙여주면 Hilt가 해당 클래스에 의존성을 주입해줄 수 있는 Component를 생성해줍니다

 

 

 

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var myService: MyService

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        // myService는 이제 Hilt에 의해 자동으로 주입됨
        myService.doSomething()
    }
}

 

MainActivity에 @AndroidEntryPoint를 추가함으로써, Hilt는 이 액티비티에 자동으로 의존성을 주입해줍니다

즉, MainActivity가 생성될 때 myService 필드에 MyService 인스턴스를 Hilt가 제공해주기 때문에 MainActivity에서 따로 myService를 수동으로 생성하거나 관리할 필요가 없습니다

 

(@Inject는 Hilt에게 의존성이 필요함을 알려 @AndroidEntryPoint가 생성된 클래스는 Hilt가 해당 의존성을 자동으로 주입하도록 합니다)

 

 

 

3. @Inject

@Inject는 생성자, 필드, 메소드에 의존성을 주입하는 데 사용됩니다

이 어노테이션은 Hilt에게 해당 필드나 생성자에 의존성을 주입할 준비가 되어 있음을 알려줍니다

 

이러한 의존성이 필요하다고 발하는 방법은 @Inject 어노테이션을 사용하여

1. 필드 주입을 하는 방법과

2. 생성자 주입을 하는 방법이 있습니다

 

 

필드 주입 (Field Inject)

 

필드 주입은 의존성을 클래스의 필드에 직접 주입하는 방식입니다

직관적이고 간단하지만, 주입할 필드가 클래스 외부에서 수정 가능할 수 있기 때문에 의존성의 수명 관리에 있어 문제가 발생할 수 있습니다

 

class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var myService: MyService  // 필드 주입

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Hilt가 자동으로 myService를 주입해줌
        myService.doSomething()
    }
}

 

 

생성자 주입 (Constructor Injection)

 

생성자 주입은 의존성을 클래스의 생성자에서 주입하는 방식입니다

이 방식은 불변성을 보장하며 의존성을 외부에서 강제로 전달해야 하므로 코드가 더 안전합니다

 

class MainActivity @Inject constructor(
    private val myService: MyService  // 생성자 주입
) : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // myService는 이제 생성자를 통해 주입됨
        myService.doSomething()
    }
}

 

 

+ 생성자 주입을 할 수 없는 경우

객체가 없는 인터페이스 같은 경우에는 생성자 주입을 할 수 없습니다

또한 외부 라이브러리의 클래스와 같이 가지고 있지 않는 유형도 생성자 주입을 할 수가 없는데요, 이럴 때엔 Hilt 모듈 (@Module)을 사용해 Hilt에 결합 정보를 제공합니다

 

 

 

4. @Module과 @InstallIn

Dagger에서와 마찬가지로 Hilt에서도 필요한 의존성 객체를 제공해주기 위해 Module을 정의해야 합니다

 

Dagger에서는 Module을 생성한 다음 정의한 Component에 Module을 직접 정의해줬지만, Hilt에서는 기본적으로 생성된 Component들이 존재합니다

 

그래서 Hilt에서는 Component에서 Module을 정의하는 것이 아닌 Module에서 해당 모듈이 정의될 Component를 정의해줍니다

그렇기 때문에 Module을 정의할 때 반드시 @InstallIn 어노테이션을 사용해 어떤 Component에 정의할 것인지 정해주어야 합니다

 

 

@Module

Hilt에서 의존성을 제공하기 위해 사용하는 클래스에 붙이는 어노테이션입니다

@Binds나 @Provides를 통해 필요한 객체를 제공합니다

 

 

@InstallIn

해당 모듈이 어떤 컴포넌트에서 사용할 수 있는지를 정의합니다

컴포넌트는 의존성의 생성 및 수명 주기를 관리하며, 예를 들어 ActivcityComponent는 Activity의 수명 주기와 동일합니다

 

@Module
@InstallIn(SingletonComponent::class) // 의존성은 애플리케이션 수명 주기를 가짐
class AppModule {
    @Provides
    fun provideString(): String {
        return "Hello, Hilt!"
    }
}

 

위 코드에서 @Module을 붙였으므로 AppModule은 의존성을 제공하는 역할을 하는 클래스임을 나타내어 AppModule에서 정의된 의존성들을 다른 컴포넌트에 주입할 수 있게 됩니다

 

그리고 @InstallIn(SingletonComponent::class)를 통해 AppModule이 SingletonComponent에 의존성을 제공할 것임을 지정합니다

이를 통해, AppModule의 의존성들은 SingletonComponent의 수명 주기 내에서 사용되고, 애플리케이션 전반에 걸쳐 하나의 인스턴스로 주입됩니다

 

 

그럼 이제 직접 의존성을 주입해줘야 합니다

의존성을 제공하는 방법@Binds@Provides 2가지가 있습니다

 

 

@Binds

@Binds 어노테이션은 인터페이스와 구현체를 연결할 때 주로 사용합니다

간단한 의존성 주입을 처리하며, Hilt가 생성자를 통해 구현체를 생성할 수 있어야 합니다

 

interface AnalyticsService {
    fun logEvent(event: String)
}

class AnalyticsServiceImpl @Inject constructor() : AnalyticsService {
    override fun logEvent(event: String) {
        println("Event logged: $event")
    }
}

@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {
    @Binds
    abstract fun bindAnalyticsService(
        impl: AnalyticsServiceImpl
    ): AnalyticsService
}

 

위 코드에서 @Binds는 인터페이스인 AnalyticsService와 이를 구현한 AnalyticsServiceImpl 클래스 간의 관계를 설정해 줍니다

 

bindAnalyticsService 함수는 반환 타입으로 AnalyticsService 인터페이스를 지정하고, AnalyticsServiceImpl을 매개변수로 받음으로써, AnalyticsServiceImpl이 AnalyticsService로 주입될 수 있도록 합니다.

 

이제 Hilt가 ActivityComponent에서 AnalyticsService 타입을 주입해야 하는 상황이 오면

이 AnalyticsModule의 bindAnalyticsService를 참조하여 AnalyticsServiceImpl 인스턴스를 제공합니다

 

 

@Provides

@Binds가 간단한 인터페이스 - 구현체 관계를 연결해줬다면, @Provides는 생성자가 복잡하거나 빌더 패턴 등을 사용해야 하는 경우, 클래스가 외부 라이브러리에서 제공되는 경우 (Retrofit, Room 등)에 사용됩니다

 

@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
    @Provides
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
    }
}

 

위 코드에서 @Provides는 Hilt가 의존성 그래프에 특정 타입의 객체 (Retrofit)를 제공하도록 정의합니다

 

provideRetrofit() 함수가 Retrofit 객체를 생성하고 반환합니다

이 함수에 @Provides를 붙임으로써 Hilt는 이 함수가 반환하는 객체를 의존성 그래프에 등록하게 됩니다

 

이제 SingletonComponent에서 Retrofit 객체가 필요한 경우, NetworkModule의 provideRetrofit()을 호출하고

provideRetrofit()dms Retrofit.Builder를 사용해 새로운 Retrofit 객체를 생성하여 반환합니다

 

이 함수에서 반환된 Retrofit 객체는 SingletonComponent에 등록됩니다

즉, 앱 전반에서 Retrofit 객체가 필요할 때 동일한 인스턴스를 주입받게 됩니다

 

 

@Binds와 @Provides의 차이점

  • @Binds
    • 주로 인터페이스와 구현체 간의 의존성을 *바인딩하는 데 사용
    • 이미 생성된 객체나 단순한 바인딩에 적합
    • 생성 로직을 포함하지 않으며, 이미 생성된 인스턴스를 주입
    • 외부 라이브러리에서 제공하는 객체는 주입할 수 없음. @Binds는 객체를 "생성"하는 역할이 아니기 때문 (외부 라이브러리 클래스는 보통 생성자를 통해 객체를 생성해야 함)
      즉, 이미 생성된 객체를 어떤 인터페이스와 연결할 때 사용됨

* 바인딩(Binding) : 객체를 연결하거나 연관시키는 것을 의미

ex) @Binds는 인터페이스와 그 구현체를 연결하는 역할을 함

 

  • @Provides
    • 객체를 직접 생성하는 데 사용
    • 외부 라이브러리 객체나 복잡한 객체를 주입할 때 유용
    • @Provides는 객체 생성 로직을 포함하여, 의존성을 직접 코드로 생성하고 반환

 

 

 

🤔 이때 저는 @Inject도 의존성 주입이고.. @Binds와 @Provides도 의존성 주입이고..?라는 점이 헷갈렸는데요..!

 

 

 

Hilt에서 의존성을 주입할 때 @Inject 어노테이션 하나만으로도 객체를 주입받을 수 있습니다

 

class MyRepository @Inject constructor(private val apiService: ApiService) {
    // Hilt는 MyRepository 클래스가 생성될 때, 자동으로 apiService를 주입
}

 

위 코드에서 @Inject가 붙은 생성자가 있으면 Hilt는 ApiService 인스턴스를 자동으로 주입합니다

이처럼 @Inject는 객체를 자동으로 생성하고 주입하는 간단한 방법입니다

(@Inject는 주입받는 쪽에서 사용)

 

하지만 특정한 작업을 해야 할 경우, 즉 객체를 단순히 생성하는 것이 아니라 좀 더 복잡한 로직을 수행해야 할 경우에는 @Binds나 @Provides와 같은 어노테이션을 사용하는 모듈이 필요합니다

(@Binds와 @Provides는 주입하는 쪽에서 사용)

 

 

정리해보자면

  • @Inject : @Inject 어노테이션을 통해 의존성을 자동으로 주입 가능. 이는 클래스가 단순히 생성되면 되며, 복잡한 작업 없이 자동으로 객체를 생성하고 주입해줌
  • @Binds : 인터페이스와 그 구현체를 바인딩하는 데 사용. 주로 구현체가 이미 존재하는 경우에 사용됨
  • @Provides : 객체를 단순히 생성하는 것이 아니라, 복잡한 생성 과정을 포함하거나 외부 라이브러리의 객체를 사용하는 경우에 사용

따라서 @Inject만으로 해결할 수 없는 의존성 주입을 해야 할 경우에 @Provides나 @Binds를 사용하여 어떤 방식으로 의존성을 제공할지 명시하는 것입니다 !

 

 

 

5. @Singleton과 같은 Scope 어노테이션

@Singleton과 같은 어노테이션은 의존성의 수명 주기(scope)를 관리하는 데 사용됩니다

Hilt는 기본적으로 @Singleton을 사용하여 싱글톤 객체를 생성하지만, 다른 커스텀 스코프를 사용할 수도 있습니다

 

@Singleton
@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
    @Provides
    fun provideNetworkService(): NetworkService {
        return NetworkServiceImpl()
    }
}

 

위 코드에서 @Singleton 어노테이션을 사용하여 NetworkService를 제공하는 객체가 애플리케이션에서 한 번만 생성되도록 지정합니다

이 인스턴스는 애플리케이션이 실행되는 동안 계속해서 재사용됩니다

 

이를 통해 메모리 관리와 성능에 도움을 주고, 불필요한 객체 생성을 방지해줄 수 있습니다

 

 

 

🍀 마무리

Dagger의 기본 개념부터 Hilt의 어노테이션들까지 알아보았는데요 Hilt를 처음 접했을 땐 너무 막막하고 이해가 어려웠는데 저번 포스팅인 의존성 주입의 개념부터, 왜 필요한지, 그리고 Dagger의 기본 개념까지 차근차근 훑어보니 조금씩 이해가 되는 것 같습니다

그리고 예시 코드를 같이 공부하니 이해하는 데 도움이 많이 된 것 같습니다

 

다온길 이라는 프로젝트에서 클린 아키텍처 개념만 가지고 코드를 구현했다가 이를 Hilt를 도입하여 마이그레이션을 해보았는데요

이 과정에서 어떻게 의존성 주입을 편하게 바꾸었는지도 다음 포스팅에 작성해보겠습니다

 

Hilt를 쉽게 쓰는 그날까지.. 🍀