관리 메뉴

나만을 위한 블로그

[Android] 안드로이드에서 hilt로 의존성 주입 구현하기 본문

Android

[Android] 안드로이드에서 hilt로 의존성 주입 구현하기

참깨빵위에참깨빵_ 2024. 6. 16. 23:11
728x90
반응형

의존성 주입은 hilt, koin 등의 라이브러리를 활용해 프로젝트에 적용할 수 있다.

의존성 주입이라는 단어의 뜻을 모른다면 영문 위키백과의 내용을 확인해 본다.

 

https://en.wikipedia.org/wiki/Dependency_injection

 

Dependency injection - Wikipedia

From Wikipedia, the free encyclopedia Software programming technique Dependency injection is often used alongside specialized frameworks, known as 'containers', to facilitate program composition. In software engineering, dependency injection is a programmi

en.wikipedia.org

의존성 주입은 객체나 함수가 내부적으로 생성하는 대신 필요한 다른 객체나 함수를 받는 프로그래밍 기법이다. 의존성 주입은 객체 구성과 사용의 문제를 분리해서(관심사 분리) 느슨하게 결합된 프로그램을 만드는 걸 목표로 한다. 이 패턴은 특정 서비스를 사용하려는 객체나 함수가 해당 서비스를 구성하는 함수를 알 필요가 없게 보장한다. 대신 수신하는 클라이언트(객체 or 함수)는 외부 코드(인젝터)에 대해 의존성을 제공받지만 이를 알지 못한다. 의존성 주입은 암시적 의존성을 명시적으로 만들고 아래 문제를 해결하는 데 도움이 된다

- 클래스가 의존하는 객체의 생성으로부터 어떻게 독립적일 수 있는가?
- 앱과 앱이 사용하는 객체가 어떻게 서로 다른 구성을 지원할 수 있는가?

의존성 주입은 의존성 역전 원칙에 따라 코드를 일관되게 유지하는 데 자주 쓰인다. 의존성 주입을 쓰는 정적 타입 언어에선 클라이언트가 사용하는 서비스의 구체적 구현이 아닌 인터페이스만 선언하면 되므로, 런타임에 다시 컴파일하지 않고도 어떤 서비스를 쓸지 쉽게 바꿀 수 있다...(중략)

 

클래스는 다른 클래스를 참조해야 할 수 있다. 흔히 거론되는 예시로 자동차와 엔진이 있는데, 자동차 클래스가 엔진 클래스를 필요로 할 수 있다. 즉 Car 클래스가 실행되기 위해 Engine 클래스의 인스턴스가 필요한 것이다. 이 때 필요한 클래스(엔진)를 의존성이라고 한다.

클래스가 필요한 객체를 얻는 방법은 3개 정도가 있다.

 

  1. 클래스가 필요한 의존성을 구성 (Car는 자체 Engine 인스턴스를 생성해서 초기화함)
  2. 다른 곳에서 객체를 가져옴
  3. 객체를 매개변수로 제공받음. 클래스가 구성될 때 이런 의존성을 제공하거나 각 의존성이 필요한 함수에 전달할 수 있음

 

의존성 주입 없이 코드에서 Engine 의존성을 만드는 Car의 구현은 아래와 같을 수 있다.

 

class Car {

    private val engine = Engine()

    fun start() {
        engine.start()
    }
}

fun main() {
    // ....
}

 

이 코드는 Car 클래스가 자체적으로 Engine을 구성해서 의존성 주입의 예시는 아니다. 위 코드는 아래 이유로 문제가 될 수 있다.

 

  • Car, Engine이 밀접하게 연결돼 있다. 한 가지 유형의 Engine을 사용해서 서브 클래스, 대체 구현을 쉽게 사용할 수 없다. 다른 엔진에 같은 Car를 재사용하는 대신 각 엔진에 맞는 Car를 생성해야 할 수 있다
  • Engine의 종속성이 높으면 테스트가 어렵다

 

이 의존성 주입을 안드로이드에서 수행하는 주요 방법은 2가지 있다.

 

  • 생성자 삽입 : 클래스의 의존성을 생성자로 전달
  • 필드 삽입(setter 삽입) : 액티비티, 프래그먼트 같은 특정 안드로이드 프레임워크 클래스는 시스템에서 인스턴스화하므로 생성자 삽입을 할 수 없다. 이 때 필드 삽입을 쓰면 의존성은 클래스가 생성된 후 인스턴스화된다

 

그리고 위 방법을 통해 의존성 주입을 수행하려면 사용할 수 있는 라이브러리가 hilt, koin이 있다.

디벨로퍼에선 hilt의 사용을 권장하기 때문에 예시도 hilt를 사용한다.

 


 

우선 앱 gradle, 프로젝트 gradle에 hilt를 사용하기 위한 의존성들을 추가해야 한다.

프로젝트 gradle에 아래 의존성을 추가한다.

 

dependencies {
    classpath 'com.google.dagger:hilt-android-gradle-plugin:2.42'
}

 

그리고 앱 gradle에 아래 의존성들을 추가한다. 여러 의존성을 추가할 수 있지만 디벨로퍼에선 아래 의존성을 제시하기 때문에 이것만 추가한다.

추가로 버전이 다르면 싱크 도중 에러가 발생할 수 있기 때문에 버전은 가급적 동일한 버전을 사용한다.

 

plugins {
    id 'kotlin-kapt'
    id 'dagger.hilt.android.plugin'
}
...
dependencies {
    implementation "com.google.dagger:hilt-android:2.42"
    kapt "com.google.dagger:hilt-compiler:2.42"
}

 

그리고 최신 안드로이드 스튜디오를 사용하면 라이브러리 버전을 버전 카탈로그 형태로 관리하게 된다.

그래서 기존의 앱 gradle 형태에 익숙하던 사람들은 당황할 수 있는데, 전혀 당황할 것 없이 위의 앱 gradle에 추가해야 하는 코드들을 그대로 붙여넣으면 된다. 아래는 그 예시다.

 

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.jetbrains.kotlin.android)
    id("kotlin-kapt")
    id("com.google.dagger.hilt.android")
}

android {
    namespace 'com.example.testapp'
    compileSdk 34

    defaultConfig {
        applicationId "com.example.testapp"
        minSdk 24
        targetSdk 34
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {

    implementation libs.androidx.core.ktx
    implementation libs.androidx.appcompat
    implementation libs.material
    implementation libs.androidx.activity
    implementation libs.androidx.constraintlayout
    testImplementation libs.junit
    androidTestImplementation libs.androidx.junit
    androidTestImplementation libs.androidx.espresso.core

    implementation("com.google.dagger:hilt-android:2.42")
    kapt("com.google.dagger:hilt-android-compiler:2.42")
}

 

이제 Application을 상속하는 클래스를 만들고 매니페스트에 등록해야 한다.

 

@HiltAndroidApp
class App: Application() {
}

 

@HiltAndroidApp은 의존성을 주입하고 적절한 생명주기에서 hilt의 컴포넌트를 인스턴스화하기 위해 필요한 어노테이션이다.

hilt를 사용하다 보면 프로젝트에 generated가 붙은 폴더가 있고, 이 폴더 안에는 앞에 "Hilt_" 접두어가 붙은 클래스가 있는 걸 볼 수 있다. 이 클래스가 @HiltAndroidApp에 의해 자동으로 생기는 것이라고 생각하면 된다.

 

이제 의존성 주입을 시작할 준비가 됐다. hilt를 통한 의존성 주입의 형태는 크게 3가지 있다.

 

  • 생성자 주입
  • 필드 주입
  • 메서드 주입

 

먼저 생성자 주입의 예시다. 예시로 레트로핏을 사용하는 경우를 가정한다.

레트로핏을 사용하면 흔히 인터페이스와 레포지토리(또는 유즈케이스), 뷰모델을 구현하는데, 이 때 인터페이스의 의존성을 레포지토리(또는 유즈케이스)의 생성자로, 레포지토리(또는 유즈케이스)를 뷰모델의 생성자로 전달해서 필요한 함수를 호출하도록 구현할 수 있다.

아래는 예시 코드다.

 

interface GithubApiService {
    // 여러 함수들
}

 

위와 같이 인터페이스를 만들면 추상 클래스 또는 object class에 인터페이스의 인스턴스화 로직을 넣은 다음 @Module과 @InstallIn을 추가한다.

왜냐면 인터페이스를 주입받을 방법을 hilt에 알려줘야 하는데 인터페이스는 인스턴스화할 수가 없기 때문에, 내가 코드로 인터페이스의 인스턴스를 어떻게 만들어서 주입하라고 명시해야 한다.

그리고 @Module을 추가해서 hilt한테 의존성 제공하는 방법은 여길 참고하라고 알려야 한다.

앞서 말했듯 추상 클래스나 object class 중 하나를 사용할 수 있는데, 여기선 object class를 사용한다.

 

import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object ApiModule {
    @Provides
    @Singleton
    fun provideGson(): Gson = GsonBuilder().setLenient().create()

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient = {
        // ...
    }

    @Provides
    @Singleton
    fun provideGithubClient(client: OkHttpClient): GithubApiService = Retrofit.Builder()
        .baseUrl(BuildConfig.GITHUB_BASE_URL)
        .addConverterFactory(ScalarsConverterFactory.create())
        .addConverterFactory(GsonConverterFactory.create(provideGson()))
        .client(client)
        .build()
        .create(GithubApiService::class.java)
}

 

GithubApiService 인터페이스의 인스턴스를 레트로핏의 빌더를 통해 생성해서 제공하라는 provide 함수가 핵심이다.

@InstallIn의 매개변수로는 이 모듈을 설치할 컴포넌트를 넣어준다. hilt의 표준 컴포넌트 종류는 아래를 참고한다.

 

 

SingletonComponent가 최상위 컴포넌트고, 하위 컴포넌트는 상위 컴포넌트의 바인딩에 접근 가능하지만 그 반대는 불가능하다. 이 예시에선 SingletonComponent를 사용한다.

 

이제 레포지토리(또는 유즈케이스)에서 이 인터페이스의 의존성을 참고할 수 있다. 아래와 같이 생성자로 인터페이스를 넘긴다.

 

import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class GithubRepository @Inject constructor(
    private val githubApiService: GithubApiService
)

 

@Inject는 의존성 주입을 위해 필요한 어노테이션이다. 그 뒤의 constructor에는 받고자 하는 의존성을 넣는다.

뷰모델도 위와 비슷한 형태로 정의한다. 이후 액티비티에선 아래와 같이 사용한다.

 

@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {

    private val githubViewModel: GithubViewModel by viewModels()

 

@AndroidEntryPoint는 안드로이드 컴포넌트를 주입하기 위해 필요한 어노테이션이다.

액티비티, 프래그먼트, 뷰, 서비스, 브로드캐스트 리시버를 지원하며 이 클래스에서 hilt 어노테이션이 붙은 의존성을 주입받으려면 반드시 @AndroidEntryPoint를 선언해야 한다.

 

이후 프로젝트를 다시 빌드하거나 앱을 실행해 보면 generated 안에 아래와 같은 파일들이 생성된다.

 

@DaggerGenerated
@SuppressWarnings({
    "unchecked",
    "rawtypes"
})
public final class ApiModule_ProvideGithubClientFactory implements Factory<GithubApiService> {
  private final Provider<OkHttpClient> clientProvider;

  public ApiModule_ProvideGithubClientFactory(Provider<OkHttpClient> clientProvider) {
    this.clientProvider = clientProvider;
  }

  @Override
  public GithubApiService get() {
    return provideGithubClient(clientProvider.get());
  }

  public static ApiModule_ProvideGithubClientFactory create(Provider<OkHttpClient> clientProvider) {
    return new ApiModule_ProvideGithubClientFactory(clientProvider);
  }

  public static GithubApiService provideGithubClient(OkHttpClient client) {
    return Preconditions.checkNotNullFromProvides(ApiModule.INSTANCE.provideGithubClient(client));
  }
}
@DaggerGenerated
@SuppressWarnings({
    "unchecked",
    "rawtypes"
})
public final class GithubRepository_Factory implements Factory<GithubRepository> {
  private final Provider<GithubApiService> githubApiServiceProvider;

  public GithubRepository_Factory(Provider<GithubApiService> githubApiServiceProvider) {
    this.githubApiServiceProvider = githubApiServiceProvider;
  }

  @Override
  public GithubRepository get() {
    return newInstance(githubApiServiceProvider.get());
  }

  public static GithubRepository_Factory create(
      Provider<GithubApiService> githubApiServiceProvider) {
    return new GithubRepository_Factory(githubApiServiceProvider);
  }

  public static GithubRepository newInstance(GithubApiService githubApiService) {
    return new GithubRepository(githubApiService);
  }
}

 

실제론 더 많이 생성되지만 이 포스팅에 쓴 코드들은 hilt가 이런 형태로 코드를 생성한다는 걸 보여주기 위해 2개만 가져왔다.

필드 주입은 아래와 같이 할 수 있다.

 

import android.util.Log
import javax.inject.Inject

class Something @Inject constructor() {
    fun doSomething() = Log.e("Foo", "doSomething")
}

 

클래스를 사용한 경우 액티비티에서 아래와 같이 사용할 수 있다.

 

import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val TAG = this::class.simpleName

    @Inject
    lateinit var something: Something

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

        Log.e(TAG, "Something 클래스 초기화 여부 : ${this::something.isInitialized}")
        something.doSomething()
    }

}

 

@Inject를 사용한 후 지연 초기화 변수를 전역으로 선언한다.

이후 실행하면 로그캣에 아래와 같이 출력된다.

 

 

마지막으로 메서드 주입은 아래와 같이 수행할 수 있다.

 

import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val TAG = this::class.simpleName

    lateinit var something: Something

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

        Log.e(TAG, "Something 클래스 초기화 여부 : ${this::something.isInitialized}")
        something.doSomething()
    }

    @Inject
    fun injectSomething(something: Something) {
        this.something = something
        Log.e(TAG, "injectSomething() called")
    }

}

 

이후 앱을 실행하면 아래와 같이 로그가 표시된다.

 

 

injectSomething()의 영향으로 Something의 인스턴스가 액티비티의 lateinit var에 들어가고, 이후 초기화 여부와 함수 호출 모두 성공적으로 되는 걸 볼 수 있다.

 

추가로 모듈에서 리턴타입이 같은 provide 함수를 정의하면 DuplicateBindings 에러가 발생한다.

@Qualifier, @Named 중 하나를 사용하면 되며 자세한 내용은 아래를 참고한다.

 

https://onlyfor-me-blog.tistory.com/entry/Android-hilt-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%EB%8F%99%EC%9D%BC-%EA%B0%9D%EC%B2%B4%EC%9D%98-%EC%A4%91%EB%B3%B5-%EB%B0%94%EC%9D%B8%EB%94%A9%EC%9D%84-%ED%97%88%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95

 

[Android] hilt 사용 시 동일 객체의 중복 바인딩을 허용하는 방법

같은 타입인 객체의 중복 바인딩을 유지하면서 사용해야 할 경우가 있다. 예를 들어 base url이 서로 다른 레트로핏 객체를 생성하고 싶을 수 있다.그러나 hilt는 같은 타입의 객체를 바인딩하려고

onlyfor-me-blog.tistory.com

 

반응형
Comments