본문 바로가기

Android/DI

[Hilt] Koin을 Hilt로 Migration 해보자.

728x90

필자는 지금까지 예제를 만들면서 DI가 필요하면 Koin을 사용해왔다.

단순히 사용하기가 상당히 쉽고, 간단한 예제를 만드는 것에는 Koin을 사용하는 것이 손이 많이 가지 않기 때문이다.

 

하지만, 최근 들어 확인해보니 DI로 Hilt를 사용하는 회사가 많아지는 것 같다.

Koin에서 Hilt로, Dagger에서 Hilt로 변경하면서 마이그레이션 작업을 진행하는데,

필자는 기존에 사용했던 예제를 사용하여 Koin에서 Hilt로 마이그레이션 작업을 진행해 보았다.

 

Dagger와 다르게 Koin은 Hilt와 동시에 사용이 가능하기 때문에 비교적 변경하기가 쉬웠던 것 같지만,

그래도 많이 사용하지 않았던 부분이라 예제의 DI를 변경하는 것에도 많은 오류를 발견할 수 있었다.

 

따라서, Hilt를 적용하는 방법과, 필자가 맞닥뜨린 문제를 해결하는 방법에 대하여 작성해보고자 한다.

 

글을 시작하기 앞서, Hilt를 사용하기 위한 Gradle 설정과, 기본적인 사용 방법은 다음 글을 참고하길 바란다.

2022.03.05 - [Android/DI] - [Hilt] Hilt를 사용하여 의존성 주입을 해보자.


기본적인 Gradle 설정은 되어있다고 가정하고, 바로 Hilt를 적용시켜보도록 하자.

 

Koin은 Application단에 

 

startKoin {
    androidContext(this@DIApplication)
                    
    modules(apiModule)
        ...
}

 

위와 같이 startKoin을 사용하고, 미리 선언해둔 Module을 적용시켜줘서 사용한다.

 

반면, Hilt의 경우에는 @HiltAndroidApp 어노테이션을 Application단에 선언해주기만 하면 된다.

 

@HiltAndroidApp
class DIApplication: Application() {
    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidContext(this@DIApplication)

//            modules(apiModule)
//            modules(localDataModule)
//            modules(networkModule)
//            modules(remoteDataModule)
//            modules(repositoryModule)
//            modules(viewModelModule)
//            modules(useCaseModule)
        }
    }
}

 

필자는 Koin을 완전히 제거하지 않고 남겨둔 상태로 Hilt를 사용할 예정이기 때문에 다음과 같이 사용하였다.

기존 예제에서 KoinApplication이던 클래스를 DIApplication으로 변경하였다.

 

다음으로는, 의존성을 주입받을 부분에 @AndroidEntryPoint 어노테이션을 추가하도록 하자.

보통 해당 어노테이션을 추가하는 부분은 Activity와 Fragment 부분이고, 필자는 Fragment가 없는 관계로 의존성을 주입받아야 하는 Activity에 선언해 주었다.

 

@AndroidEntryPoint
class WebViewActivity : BaseActivity<ActivityWebBinding>(R.layout.activity_web) {
    ...
}
@AndroidEntryPoint
class MovieSearchActivity :
    BaseActivity<ActivityMovieSearchBinding>(R.layout.activity_movie_search) {
    ...
}

 

이렇게 말이다.

 

필자가 만든 예제에서는 위의 각 Activity에서 객체를 주입받는 것은 없기 때문에 Activity 단에서 작업해 줄 부분은 viewModel에 대한 의존성을 주입하는 부분만 작업하면 된다.

 

// Koin
// import org.koin.androidx.viewmodel.ext.android.viewModel
private val viewModel: MovieSearchViewModel by viewModel()
// Hilt
// import androidx.activity.viewModels
private val viewModel: MovieSearchViewModel by viewModels()

 

기존 Koin을 사용할 때와 Hilt를 사용할 때를 비교해보면, by viewModel()과 by viewModels()로 s 한 글자 차이가 난다.

물론, 정말로 차이가 s 한 글자가 아니기 때문에 오해 방지를 위하여 import 되는 부분도 함께 작성해 두었다.

 

우선 이렇게 viewModel까지 선언하였으면 Activity에서 Hilt 관련하여 수정해야 할 부분은 없다.

 

다음으로는 ViewModel을 확인해 보자.

전에 작성한 글을 확인해보면 알 수 있겠지만, viewModel에서는 2가지만 선언해주면 된다.

@HiltViewModel 어노테이션생성자 주입 부분이다.

 

@HiltViewModel
class WebViewModel @Inject constructor() : BaseViewModel(), JavaScriptRepository, DummyRepository {
    ...
}
@HiltViewModel
class MovieSearchViewModel @Inject constructor(
    private val getMoviesUseCase: GetMoviesUseCase,
    private val getPagingMoviesUseCase: GetPagingMoviesUseCase,
    private val getLocalMoviesUseCase: GetLocalMoviesUseCase,
    private val networkManager: NetworkManager,
) : BaseViewModel() {

 

생성자에 인자가 있고 없고의 차이를 보여주기 위하여 두 가지 종류를 모두 가져와 보았다.

@HiltViewModel 어노테이션을 사용하기 전에는, @ViewModelInject constructor()를 사용하였는데, ViewModelInject 어노테이션이 Deprecated 되고 @HiltViewModel 어노테이션이 새롭게 나왔다.

 

따라서, ViewModel에서도 HiltViewModel 어노테이션만 선언하면 다른 부분의 생성자 주입과 동일하게 @Inject constructor()를 통해 생성자를 주입해 줄 수 있다.

 

Koin에서 Hilt로 넘어갈 때는, 별도로 제거되는 코드는 존재하지 않고 위의 설명한 두 가지 종류가 추가만 되면 된다.


자, 필자가 위에서 생성자에 인자가 있는 것과 없는 것 두 가지 종류를 가져왔는데, 이것에는 이유가 있다.

Clean Architecture 구조를 사용하는 경우, ViewModel에서 UseCase를 인자로 받아서 사용하는 경우가 대부분이고, 그것에 따른 사용 방법을 알지 못하면 상당히 헷갈릴 수 있기 때문이다.

 

생성자 주입을 통해 3가지 종류의 UseCase와 NetworkManager라는 클래스를 주입받아서 사용하고 있다.

UseCase를 설명하기 앞서 보다 간단한 NetworkManager에 대한 작업을 진행해주도록 하자.

 

class NetworkManager @Inject constructor(@ApplicationContext private val context: Context) {
    ...
}

 

기본적으로 생성자를 주입해 줄 때, 인자로 사용되는 클래스가 있다면 해당 클래스도 동일하게 의존성 주입을 통해 생성자를 주입해 주어야 한다.

 

간단하게 생각하면, A를 사용하는 B라는 클래스를 사용하는 C라는 클래스가 있다고 가정해보자.
C에 대한 의존성 주입을 하게 되면 B라는 클래스를 생성자 주입을 통해 넣어주게 되는데, 그 B라는 클래스를 주입하기 위해서는 A가 어떤 것인지 알아야 한다.

 

따라서, MovieSearchViewModel에 NetworkManager를 주입하기 위해서는 NetworkManager 또한 의존성 주입을 통해 생성자를 주입해 주어야 하고, 해당 생성자에 필요한 인자 또한 주입을 해주어야 한다.

 

여기서는 context를 주입받아서 사용해야 하는데, context를 별도로 생성할 수는 없으므로 ApplicationContextModule이 제공하는 Context를 주입받기 위하여 @ApplicationContext 어노테이션을 사용하도록 한다.

해당 어노테이션을 선언하면 이후에 인자로 들어가는 context를 주입시켜주는 것이다.

 

어떠한 말인지 이해가 안 된다면, 일단은 Context는 일반적인 방법으로 주입받기 어렵기 때문에 @ApplicationContext 어노테이션을 사용하여 주입받는다.라고 기억하고 넘어가면 될 것이다.

 

다시 돌아와서, 위와 같이 선언하게 되면 NetworkManager에 대한 의존성은 주입이 완료가 된다.

 

다음으로 UseCase를 확인해 보자.

Clean Architecture의 정석적인 구조를 갖고 있기 때문에, UseCase와 연관된 부분은 조금 복잡하다.

 

우선, 간단하게 UseCase부터 흘러가는 순서에 따라서 정리하면 다음과 같다.

 

UseCase(repository) >

repositoryImpl(dataSource1, dataSource2) : repository >

dataSource1Impl(subRepository) : dataSource1,

dataSource2Impl(subRepository) : dataSource2

 

위의 흐름을 하나씩 확인해 보자.

 

1. Usecase에서는 상속을 받지 않고, interface인 repository를 인자로 사용한다.

2. repository는 interface이기 때문에 구현부가 필요하며, 그 구현부는 repositoryImpl이다.

3. repositoryImpl은 interface인 repository를 상속받고, 인자로 한 개 이상의 interface인 dataSource를 인자로 사용한다.

4. dataSource 또한 interface이기 때문에 구현부가 필요하며, 그 구현부는 dataSourceXImpl이다.

5. dataSourceXImpl은 interface인 dataSourceX를 상속받고, interface인 subRepository를 인자로 사용한다.

6. subRepository가 apiInterface나 Dao라면 순서의 마지막이고, 아니라면 ApiInterface나 Dao를 사용할 때 까지 반복한다.

 

6번의 항목에서 순서의 마지막인 이유는, 별도의 HiltModule에서 생성자를 주입해주기 때문에 별도로 해당 클래스에서 작업할 필요가 없기 때문이다.

반대로 말하면, Module에서 주입해주지 않는 부분에 대해서만 작성하면 된다.

 

위의 흐름을 보고 UseCase부터 생성자를 주입해보도록 하자.

 

class GetMoviesUseCase @Inject constructor(private val repository: MovieRepository) {
    ...
}

 

생성자에 사용되는 인자인 MovieRepository는 interface이기 때문에 구현부를 찾는다.

 

class MovieRepositoryImpl @Inject constructor(
    private val movieRemoteDataSource: MovieRemoteDataSource,
    private val movieLocalDataSource: MovieLocalDataSource,
) : MovieRepository {
    ...
}

 

구현부의 생성자에 사용되는 인자인 2개의 DataSrouce는 interface이기 때문에, 다시 구현부를 갖는다.

동일한 패턴이기 때문에 하나의 예시만 들도록 하겠다.

 

class MovieRemoteDataSourceImpl @Inject constructor(private val apiInterface: ApiInterface) :
    MovieRemoteDataSource {
    ...
}

 

DataSource 구현부의 생성자에 사용되는 인자는 ApiInterface로, 해당 인자는 Hilt Module에서 주입받기 때문에 이 Class에서 생성자를 주입하는 것이 마지막이 되게 된다.

 

이렇게 꼬리를 물고 생성자를 주입시켜야 하는 이유는, 위의 A,B,C를 사용해서 예로 들었던 것을 다시 읽어보면 이해가 될 것이다.

생성자로 사용되는 인자를 주입하기 위해 계속해서 생성자를 만들어 나가는 과정이라는 것이다.

 


마지막으로,

Module을 만들어 의존성 주입을 할 클래스를 Hilt에 알려주도록 하자.

 

@Module
@InstallIn(SingletonComponent::class)
object ApiModule {
    ...
}

 

우선, 모듈을 만드는 방법은 @Module 어노테이션을 달아주기만 하면 된다.

 

@InstallIn 어노테이션을 사용하여 Hilt에서 표준적으로 제공하는 Component를 해당 모듈에 intall 해준다.

간단하게 말하면, 해당 모듈이 사용되는 범위를 넣어준다고 생각하면 된다.

 

@InstallIn 어노테이션에서 사용할 수 있는 Component의 종류는 안드로이드 공식 페이지에 나와있다.

 

 

여기서, ApplicationComponent는 SingletoneComponent로 변경 되었다.

또한, 범위에 대한 자세한 내용은 해당 페이지의 아래에 나와있다.

 

 

해당 이미지를 확인하고, Module에 선언할 클래스들이 어디에서 사용되는지 확인 후에 알맞는 범위에 맞춰서 InstallIn 어노테이션을 사용하여 선언해주면 된다.

 

여기서 동시에 여러개의 Component를 InstallIn 어노테이션에 선언할 수 있는데,

같은 스코프에 존재해야 하며, 부모 자식 관계에 있어선 안되고, 서로의 요소에 접근이 가능한 경우에만 동시에 여러개의 Component를 선언할 수 있다.

 

@InstallIn(ViewComponent::class, ViewWithFragmentComponent::class)

 

위의 이미지에서 확인해보면, ViewComponent와 ViewWithFragmentComponent가 같은 Scope에 존재하므로 이처럼 사용이 가능하다.

 

우선 필자가 수정 중인 예제에서는 SingletoneComponent만 사용하여 모듈을 선언하였다.

모듈 안에 선언되는 내용을 살펴보자.

 

@Singleton
@Provides
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
    return Retrofit.Builder()
        .baseUrl(ApiClient.BASE_URL)
        .client(okHttpClient)
        .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
        .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
        .build()
}

 

 

@Singleton 어노테이션을 사용하여 싱글톤으로 선언하고,

@Provides 어노테이션을 사용하여 외부 라이브러리인 Retrofit에 대한 내용을 Hilt에 알려주었다.

 

 

Provides 어노테이션에 대한 안드로이드 공식 페이지의 설명이다.

위의 예시로 설명하자면, OkHttpClient에 종속되어 있는 Retrofit 타입을 제공하는 인스턴스를 Hilt에게 알려준다는 의미가 된다.

이것도 간단하게 생각하면 @Inject를 통해서 생성자 주입을 수행할 때, 결과적으로 코드에는 없지만 사용해야 하는 클래스들을 이곳에 선언하여 쓴다고 생각하면 된다.

즉, Hilt에 해당 클래스를 사용할테니 미리 알아둬. 라고 정보를 제공(Provide) 하는 것이다.

 

다시 코드로 돌아와서, 해당 코드는 Koin에서 선언한 Module과 비교하면 거의 비슷한 것을 알 수 있다.

 

single<Retrofit> {
    Retrofit.Builder()
        .baseUrl(ApiClient.BASE_URL)
        .client(get())
        .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
        .addConverterFactory(get<GsonConverterFactory>())
        .build()
}

 

<Retrofit>을 통해 Retrofit 타입을 반환한다는 것을 알 수 있고,

get() 부분을 통해 client에 들어가는 항목에 종속된다는 것을 알 수 있다..

여기서 koin은 get()으로 선언하면 되기 때문에 해당 코드에서 어느 클래스가 사용되는 지 알 필요는 없지만, Hilt에서는 매개변수로 타입을 선언해주어야 하기 때문에 어떠한 클래스가 사용되는지 알아야 한다는 불편함이 있는 것 같다.

 

외부 라이브러리의 경우는 Koin과 동일하니 제외하고, 내부 클래스에서 의존성 주입이 필요한 부분을 확인해보자.

 

@Inject 어노테이션을 추가한 부분을 확인하면 되는데, 위에 Usecase를 사용하여 흐름을 설명한 부분을 확인하면 알 수 있다.

Usecase부터 @Inject constructor를 통하여 생성자를 주입받았는데, 그 인자로 사용된 부분부터 모듈에 선언해주면 된다.

즉, Usecae에서 주입받은 Repository, Repository의 구현부에서 주입받은 DataSource에 대하여 주입을 하면 된다.

 

순서대로 생각해보자.

Usecase에서 주입받은 Repository를 모듈에 선언하면 다음과 같이 작성된다.

 

@Singleton
@Provides
fun provideMovieRepository(
    movieRemoteDataSource: MovieRemoteDataSource,
    movieLocalDataSource: MovieLocalDataSource,
): MovieRepository {
    return MovieRepositoryImpl(movieRemoteDataSource, movieLocalDataSource)
}

 

Repository는 interface이기 때문에 구현부(Impl)로 반환된다.

여기서 사용되는 2개의 DataSource에 종속되어 있는데, 이 2개의 DataSource는 어떤 것인가 생각해보자.

Repository와 동일하게 interface이며, dataSource의 구현부에 대한 정보가 존재하지 않는다.

따라서, 각 2개의 DataSource에 대해서도 어떠한 인스턴스인지 Hilt에 알려주어야 하는 것이다.

 

여기서, 다시 위에 공식 페이지에 나와있는 설명을 확인해 보자.

반환 유형은 Hilt에게 알려줄 인스턴스의 유형, 매개변수는 종속 항목을 알려준다.라고 나와있기는 하지만

 

class MovieRepositoryImpl @Inject constructor(
    private val movieRemoteDataSource: MovieRemoteDataSource,
    private val movieLocalDataSource: MovieLocalDataSource,
) : MovieRepository {
    ...
}

 

이처럼 return 되는 유형 그대로 선언하여 사용하기 때문에 내부 클래스에 관련된 의존성 주입이라면 어떻게 선언해야 할지 고민하지 않고 기존 클래스 형태를 그대로 사용해도 된다. 

 

@Singleton
@Provides
fun provideRemoteDataSource(apiInterface: ApiInterface): MovieRemoteDataSource {
    return MovieRemoteDataSourceImpl(apiInterface)
}

@Singleton
@Provides
fun provideLocalDataSource(movieDao: MovieDao): MovieLocalDataSource {
    return MovieLocalDataSourceImpl(movieDao)
}

 

Repository와 동일하게, 각 DataSource로 선언된 interface의 구현부를 반환하고 매개변수에 종속되어 있다는 것을 Hilt에 알려준다.

 

물론, 여기서도 ApiInterface, MovieDao에 대한 항목도 Hilt는 알 수 없기 때문에 해당 항목에 대한 것도 따로 Provides로 선언하여 Hilt에 제공해주어야 한다.

 

@Singleton
@Provides
fun provideMovieDao(movieDatabase: MovieDatabase): MovieDao {
    return movieDatabase.movieDao()
}

@Singleton
@Provides
fun provideRoom(@ApplicationContext context: Context): MovieDatabase {
    return Room.databaseBuilder(
        context,
        MovieDatabase::class.java,
        "Movie.db"
    ).build()
}

 

이렇게 선언을 해주게 되면, 위의 LocalDataSource에 대하여 선언이 끝나게 된다.

결과적으로, 매개변수가 없거나, @ApplicationContext로 선언된 context가 될 때까지 꼬리물기 하면서 생성하여 Hilt에 알려주는 작업이 필요하다.


이런 식으로 필자가 Hilt로 마이그레이션 한 순서대로 글을 작성해 보았다.

필자는 Module에 Provides로 선언하는 부분에 대하여 처음에 이해하지 못하고, 어떠한 것들은 선언하고 어떠한 것들은 선언하지 않아서 많은 시행착오를 겪었다.

 

A, B, C로 든 예시나, UseCase를 통해 흐름을 설명한 것 또한 이렇게 생각하니까 좀 이해가 됐다.라는 경험을 토대로 말한 것이지 정확한 개념이나 설명이 아닐 수 있다.

필자도 아직 Hilt에 대하여, DI에 대하여 공부하는 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.

 

DI에 대한 개념은 어느 정도 알 것 같으면서도 헷갈리는 부분이 많기도 하고, 아직 필자가 명확하게 이해한 부분이 아니라 그런지 글로 작성하려고 하니 상당히 어려운 것 같다.

공부를 계속 진행하면서 잘못 생각했던 부분이 있다면 바로바로 수정을 진행할 예정이다.

 

작성하다 보니 글이 길어져서 진행하면서 발생했던 오류에 대해서는 다음 글에 한 번에 정리해서 올리고자 한다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/CleanArchitectureSample

 

GitHub - HeeGyeong/CleanArchitectureSample

Contribute to HeeGyeong/CleanArchitectureSample development by creating an account on GitHub.

github.com

 

 

728x90