[Android] 안드로이드에서 hilt로 의존성 주입 구현하기
의존성 주입은 hilt, koin 등의 라이브러리를 활용해 프로젝트에 적용할 수 있다.
의존성 주입이라는 단어의 뜻을 모른다면 영문 위키백과의 내용을 확인해 본다.
https://en.wikipedia.org/wiki/Dependency_injection
의존성 주입은 객체나 함수가 내부적으로 생성하는 대신 필요한 다른 객체나 함수를 받는 프로그래밍 기법이다. 의존성 주입은 객체 구성과 사용의 문제를 분리해서(관심사 분리) 느슨하게 결합된 프로그램을 만드는 걸 목표로 한다. 이 패턴은 특정 서비스를 사용하려는 객체나 함수가 해당 서비스를 구성하는 함수를 알 필요가 없게 보장한다. 대신 수신하는 클라이언트(객체 or 함수)는 외부 코드(인젝터)에 대해 의존성을 제공받지만 이를 알지 못한다. 의존성 주입은 암시적 의존성을 명시적으로 만들고 아래 문제를 해결하는 데 도움이 된다
- 클래스가 의존하는 객체의 생성으로부터 어떻게 독립적일 수 있는가?
- 앱과 앱이 사용하는 객체가 어떻게 서로 다른 구성을 지원할 수 있는가?
의존성 주입은 의존성 역전 원칙에 따라 코드를 일관되게 유지하는 데 자주 쓰인다. 의존성 주입을 쓰는 정적 타입 언어에선 클라이언트가 사용하는 서비스의 구체적 구현이 아닌 인터페이스만 선언하면 되므로, 런타임에 다시 컴파일하지 않고도 어떤 서비스를 쓸지 쉽게 바꿀 수 있다...(중략)
클래스는 다른 클래스를 참조해야 할 수 있다. 흔히 거론되는 예시로 자동차와 엔진이 있는데, 자동차 클래스가 엔진 클래스를 필요로 할 수 있다. 즉 Car 클래스가 실행되기 위해 Engine 클래스의 인스턴스가 필요한 것이다. 이 때 필요한 클래스(엔진)를 의존성이라고 한다.
클래스가 필요한 객체를 얻는 방법은 3개 정도가 있다.
- 클래스가 필요한 의존성을 구성 (Car는 자체 Engine 인스턴스를 생성해서 초기화함)
- 다른 곳에서 객체를 가져옴
- 객체를 매개변수로 제공받음. 클래스가 구성될 때 이런 의존성을 제공하거나 각 의존성이 필요한 함수에 전달할 수 있음
의존성 주입 없이 코드에서 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 중 하나를 사용하면 되며 자세한 내용은 아래를 참고한다.