개인 프로젝트/안드로이드

[메이플 캘린더] Hilt와 Clean Architecture

보단잉 2024. 5. 3. 17:10

사용 의도

  • 의존성 주입을 위해 Hilt를 사용하기로 하였고, 그에 따라 Clean Architecture에서 지향하는 관심사의 분리를 하기로 하였다.

 

학습 내용

The Clean Architecture

Clean Architecture에서는 구성 요소를 Entity, UseCase, Interface, Framework로 계층적으로 분리한다.

  • Entity : 핵심 비즈니스 로직을 캡슐화하며, 가장 변하지 않고 외부의 어떤 요소로부터 영향을 받지 않는다.
  • UseCase : 애플리케이션을 사용하는 사용자들의 행위를 정의하는 요소이다.
    • UseCase는 Entity로 들어오고 나가는 데이터의 흐름을 조정하고, 비즈니스 로직을 바탕으로 UseCase의 목적을 달성하도록 한다.
    • UseCase는 Entity에 영향을 끼쳐서는 안 된다.

 

  • Interface Adapter : MVVM과 같은 Architecture가 여기에 속하며, UseCase와 Framework의 중간다리 역할을 한다.
  • Framework : 애플리케이션의 핵심 업무와는 관련이 없는 세부 사항이며, 일반적인 애플리케이션의 개발자는 이 영역에서 작성할 코드는 거의 없다.
    • 구글이 새로운 Android Framework를 배포할 때 변경사항이 발생할 수 있기 때문에 가장 외부에 배치하여 변경사항에 따른 피해를 최소화하여야만 한다.

 

  • 그림을 기준으로 원 바깥의 요소가 원 안쪽에 의존하는 형태이며, 원 안쪽에 있는 요소는 원 바깥쪽에 있는 요소를 몰라야 한다.
    • 따라서 UseCase는 Entity에 의존하며, Entity는 어떤 UseCase가 존재하는지 모르고, View나 ViewModel은 UseCase에 의존하고, UseCase는 어떤 View나 ViewModel이 있는지 모른다.
    • 지금까지는 ViewModel은 직접적으로 Repository를 필드 주입을 함으로써 Repository에 의존하도록 하였는데, 이제부터는 ViewModel은 UseCase에 의존하고, UseCase는 Repository에 의존하게 함으로써 Clean Architecture에 부합하도록 한다.

 

  • Android에서는 Clean Architecture를 지향한 코딩을 하기 위해서는 모듈 단위로 계층을 분리하고 의존 관계를 지정하여야 한다. 계층은 Data, Domain, UI로 이루어져 있다.
    • UI Layer
      • View : UI 렌더링과 UX 구현을 담당하는, 플랫폼에 직접적으로 의존하는 영역이며, Presenter에 의존한다.
      • Presenter : UX에 따른 반응을 판단하며, 어떤 UI를 그려야하는지에 대한 데이터를 갖고 있다. MVVM 패턴에서는 ViewModel이 그 역할을 한다. Presenter는 View를 알지는 못 하고 Presenter에 의존하는 View가 해야 할 UI/UX 구성에 대한 동작을 정의한다.

 

  • Domain Layer
    • Entity : 애플리케이션에서 사용하는 실질적인 데이터이며, 앞서 서술했듯이 Clean Architecture 내에서 어떤 영역에도 의존하지 않고 영향도 받지 않기 때문에 변경이 되어서는 안 된다.
    • UseCase : 서비스를 사용하고 있는 User가 해당 서비스로 무언가를 하는 것을 UseCase라고 하며, 사용자가 애플리케이션을 수행하면서 하는 동작인 비즈니스 로직을 정의한다.
      • 예를 들면 어떤 앱을 켜서 로그인을 하거나, 어떤 상품 정보를 보거나 하는 등 서비스에서 수행하고자 하는 모든 것들이 UseCase가 될 수 있다.
      • Screaming Architecture : 어떤 서비스를 제공하는지 직관적으로 파악이 가능하도록 하는 Architecture이며, Android에서는 ViewModel이 어떤 UseCase에 의존하도록 구현함으로써 해당 ViewModel이 어떤 기능을 제공하는지를 직관적으로 알 수 있도록 한다.
      • 또한, UseCase에 의존하기 때문에 Repository에 직접 의존하지 않게 된다. UseCase에서 직접 Repository에 의존하여 비즈니스 로직을 갖게 되고, ViewModel은 의존하고 있는 UseCase 내부에 작성된 비즈니스 로직을 그대로 사용하기만 하면 되는 것이며, 이렇게 의존성을 줄임으로써 특정 로직에 대한 수정을 최소화할 수 있게 된다.
      • UseCase는 이름만 봐도 어떤 역할을 수행하는지를 알 수 있도록 명시하여야 한다.(예를 들면 로그인을 한다는 UseCase라면 LoginUseCase)

 

  • Repository Interface : UseCase가 원하는 데이터를 가져올 수 있도록 정의한다.
  • Data Layer
    • DataSource : API, DB를 통해 데이터를 CRUD하는 역할이다.
      • RemoteDataSource : API로 가져온 데이터를 CRUD
      • LocalDataSource : Room 등 DB에서 가져온 데이터를 CRUD

 

  • Repository : Domain Layer에서 Interface 형태로 선언한 Repository를 구현한다. DataSource의 형태로 데이터를 return하며, UseCase가 바로 이 Repository에 의존하여 데이터를 가져오게 된다.
    • 이 때 Mapper 클래스를 따로 선언하여 데이터를 가공할 수 있다.

 

  • Module : 이게 중요한데 Singleton으로 DataSource, Repository, API 등을 애플리케이션 내에서 하나의 객체로 생성되도록 하고 생성자 주입을 통해 의존성 주입이 이루어지도록 하는 것이 바로 Hilt이다. Module은 바로 이러한 요소들을 Singleton으로 제공하도록 하는 역할을 한다.

의존성 주입

  • 프로그래밍을 하다 보면, 의존이 순환되는 경우가 존재하는데 이를 순환 참조라고 하며, 순환 참조가 발생하면 프로그램의 유지 및 보수가 어렵게 된다.
  • 이 순환 참조를 방지하기 위한 SOLID 원칙이 의존 역전 원칙이고, 이것을 바탕으로 의존성 주입을 직접 코드로 작성한다.
  • Kotlin에서는 여러 라이브러리를 통해 의존성 주입을 코드로 실현하고 있다.
    • 그 중에서도 Android에 최적화되어있는 Hilt를 사용하기로 하였다.

Hilt

  • Hilt는 의존성 주입을 도와주는 라이브러리로, Dagger보다도 사용하기 쉽게 간소화되어있으며 Android 클래스에 최적화되어있다는 특징이 있다.
  • Hilt는 다음과 같은 방식으로 작동한다.
    • 가장 먼저 MainApplication에 @HiltAndroidApp 어노테이션을 지정한다. 해당 App은 의존성을 제공하는 Component의 역할을 하게 된다.
    • @Installin(SingletonComponent::class) 어노테이션을 지정함으로써 App 전역에서 하나의 인스턴스 형태로 사용할 것임을 나타내고, @Module 어노테이션을 지정함으로써 모듈을 생성하여 Component 역할을 하는 Android App에 저장할 수 있게 되고, Android App에 저장한 만큼 Android의 생명주기 동안 사용할 수 있게 된다.
    • @Provides 어노테이션을 지정한 모듈 내 메소드는 종속성을 제공하고 Hilt에 의해 주입될 수 있도록 한다.
    • Android의 Component에는 @AndroidEntryPoint 어노테이션을 지정하여 Hilt가 해당 클래스의 종속성을 주입할 수 있도록 한다.
    • @Inject 어노테이션을 지정한 생성자에 대한 인스턴스를 Hilt가 자동으로 주입하게 된다.

 

 

적용 방법

먼저 Clean Architecture에 따른 관심사 분리를 해야하는데, 아직 애플리케이션의 규모가 크지는 않기 때문에 패키지 단위로 분리한다.

  • 다음과 같이 data, domain, presentation 패키지로 분리한다.

 

패키지 단위로 계층 분리

  • data : Data Layer의 역할을 하는 패키지이다.
    • api : API 통신을 위한 Interface가 존재한다.
    • model : API 통신에서 사용되는 DTO의 역할을 하는 Entity를 정의해두었다.
    • repository : DataSource와 Domain Layer에서 선언한 Repository를 정의한다.
    • di : API로 받아온 DataSource 객체를 제공하는 RemoteDataModule, Repository 객체를 제공하는 RepositoryModule, 그리고 네트워크와 관련된 Retrofit이나 OkHttpClient 등을 객체로 제공하는 NetworkModule로 구성하였다.

 

  • domain : Domain Layer의 역할을 하는 패키지이다.
    • entity : 애플리케이션에서 실제로 사용할 데이터 클래스이다.
    • repository : Repository를 Interface 형태로 선언한다.
    • usecase : 애플리케이션의 Presenter(MVVM 패턴을 사용하는 메이플 캘린더에서는 ViewModel)가 사용할 UseCase들을 정의한다.

 

  • presentation : UI Layer의 역할을 하는 패키지이다.

 

계층 분리를 진행했으면 Hilt를 사용하여 의존성 주입을 구현한다. Hilt를 사용하기 위해서는 다음과 같은 의존성 주입 작업이 필요하다.

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("kotlin-android")
    id("kotlin-kapt")
    id("kotlin-parcelize")
    id("com.google.dagger.hilt.android")
    kotlin("plugin.serialization") version "1.5.0"
}

...

dependencies {

    // Retrofit2
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-scalars:2.9.0")
    implementation("com.squareup.retrofit2:converter-moshi:2.9.0")

    // OkHttp3
    implementation("com.squareup.okhttp3:okhttp:4.10.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.9.3")

    // moshi
    implementation("com.squareup.moshi:moshi-kotlin:1.11.0")

    // Hilt
    implementation("com.google.dagger:hilt-android:2.48")
    kapt("com.google.dagger:hilt-android-compiler:2.48")
}

kapt {
    correctErrorTypes = true
}
  • 패키지 단위로 계층 분리를 하였기 때문에, 그냥 하나의 모듈에 대한 gradle에 다음과 같이 Hilt 라이브러리를 추가하면 된다.
  • kapt가 에러 타입을 판단할 수 있도록 correctErrorTypes = true을 추가한다.

 

plugins {
    id("com.google.dagger.hilt.android") version "2.48" apply false
}
  • 프로젝트 단위의 gradle에는 다음과 같이 2.48 버전의 Hilt를 추가한다.

 

아까 app 패키지에 대해서 서술하지 않았는데, app 패키지에는 Application 클래스를 하나 선언하였다.

@HiltAndroidApp
class MainApplication : Application() {

    ...
}
  • @HiltAndroidApp 어노테이션을 지정함으로써 애플리케이션은 하나의 Singleton Component가 되는 것이다.

 

다음으로 할 것은 Module 클래스를 선언하는 것이다.

@InstallIn(SingletonComponent::class)
@Module
object NetworkModule {

    private const val BASE_URL = "https://open.api.nexon.com/maplestory/"

    @Provides
    @Singleton
    fun provideMoshiConverterFactory(): MoshiConverterFactory {
        val moshi = Moshi.Builder()
            .add(KotlinJsonAdapterFactory())
            .build()

        return MoshiConverterFactory.create(moshi)
    }

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder().apply {
            this.addInterceptor(NexonApiKeyInterceptor)
        }.build()
    }

    @Provides
    @Singleton
    @Named("Maplestory")
    fun provideMaplestoryRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(provideOkHttpClient())
            .addConverterFactory(provideMoshiConverterFactory())
            .build()
    }

    @Provides
    @Singleton
    fun provideMaplestoryApiService(@Named("Maplestory") retrofit: Retrofit): MaplestoryApi {
        return retrofit.create(MaplestoryApi::class.java)
    }
}
  • 네트워크에 필요한 Retrofit이나 OkHttpClient를 제공하는 Module이다.
  • 이 Module은 애플리케이션 내부에서 Singleton으로, 하나의 객체로써 사용할 것이기 때문에 @InstallIn(SingletonComponent::class) 어노테이션을 지정하였고, Module임을 나타내기 위해 @Module 어노테이션을 지정해주었다.
  • 네트워크와 관련된 Retrofit, OkHttpClient, API 및 데이터 직렬/역직렬화와 관련된 moshi를 하나의 객체로 제공하도록 하기 위해 @Provides와 @Singleton 어노테이션을 지정하였다.
  • RemoteDataModule, RepositoryModule도 마찬가지로 Module로 지정하고 필요한 객체를 제공하도록 한다.

 

애플리케이션을 Hilt를 이용한 의존성 주입이 가능하도록 하였고, 네트워크, Repository, DataSource를 하나의 객체로 제공하여 생성자 주입이 이루어지도록 하기 위한 작업이 끝났으니 실제로 의존성 주입을 구현하면 된다.

class MaplestoryRemoteDataSourceImpl @Inject constructor(
    private val maplestoryApi: MaplestoryApi
) : MaplestoryRemoteDataSource {
	
    ...
}
class MaplestoryRepositoryImpl @Inject constructor(
    private val maplestoryRemoteDataSource: MaplestoryRemoteDataSource
) : MaplestoryRepository {

    ...
}
  • DataSource에서 API 통신으로 받아온 데이터를 제공하고, Repository에서 DataSource에서 return하는 데이터를 Mapper 클래스로 가공해서 UseCase가 데이터를 사용하도록 하기 위해서는, DataSource는 API에 의존하고 Repository는 DataSource에 의존하게끔 해야 한다.
  • 이것을 @Inject 어노테이션을 지정하여, Hilt가 의존해야 하는 요소를 생성자 주입을 통해서 주입할 수 있도록 한다.

 

class GetCharacterOcidUseCase @Inject constructor(
    private val maplestoryRepository: MaplestoryRepository
) {

    suspend fun getCharacterOcid(characterName: String): Result<CharacterOcid> =
        maplestoryRepository.getCharacterOcid(characterName)
}
  • UseCase는 Repository에 의존하여 데이터를 가져오는 역할을 하기 때문에 마찬가지로 Repository에 대한 생성자 주입이 가능하도록 구현한다.
  • 또한, 해당 UseCase가 어떤 역할을 하는지를 용이하게 파악할 수 있도록 UseCase명을 상세하게 작성한다.

 

@HiltViewModel
class MainViewModel @Inject constructor(
    private val getCharacterOcidUseCase: GetCharacterOcidUseCase
) : ViewModel() {

    ...
}
  • ViewModel은 UseCase에서 정의한 동작에 대한 결과를 그대로 사용하기만 하면 된다. 따라서 UseCase에 의존하도록 구현한다.
  • @HiltViewModel 어노테이션을 지정함으로써 의존성 주입이 필요한 ViewModel임을 선언해준다.

 

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()
    ...
}
@AndroidEntryPoint
class LobbyFragment : BaseFragment<FragmentLobbyBinding>(R.layout.fragment_lobby) {

    private val viewModel: MainViewModel by activityViewModels()
    ...
}
  • @AndroidEntryPoint 어노테이션을 지정하여 View에 ViewModel에 대한 의존성을 주입해준다.

 

코드

 

GitHub - littlesam95/MapleCalendar: 🍄 메붕이들을 위한 이벤트 일정 알리미

🍄 메붕이들을 위한 이벤트 일정 알리미. Contribute to littlesam95/MapleCalendar development by creating an account on GitHub.

github.com