
🧑🏻💻오늘의 할 일
오늘은 프로젝트를 하면서 설계한 아키텍처와 의존성 주입에 대해 기록해 보겠습니다. 세상엔 저보다 너무나도 정리를 잘 한 글들이 많기 때문에 이 글에선 클린 아키텍처와 힐트에 관한 기본적인 내용은 아마 다루지 않을 것 같습니다. 대신 Hilt를 사용하면서 알게 된 성능 팁에 관한 내용을 다루겠습니다. 아키텍처를 구성하는 각 구성 요소와 비즈니스 로직을 구현하면서 마주했던 많은 고민들을 기록하면서 좀 더 좋은 아키텍처를 설계하기 위한 발판을 만들어 보겠습니다.
1. 모듈을 나누자

프로젝트를 만들고 가장 먼저 할 일은 클린 아키텍처에 기반하여 모듈을 나누는 일입니다. Presentation과 Data, App은 플랫폼의 의존성을 가져야 하기 때문에 Android Library로, Domain 모듈은 순수 프로그래밍 언어만 존재해야 하기 때문에 Java or Kotlin Library로 만들어 주었습니다.

2. Gradle version Catalog를 만들자
프로젝트를 멀티모듈을 만들게 되면 각 모듈별로 Gradle에 필요한 의존성과 플러그인들을 설정해줘야 합니다. 이때, 하드 코딩을 통해 path나 버전을 작성하면 개발자의 실수로 인해 모듈별로 다른 버전을 추가할 수 도 있고 무엇보다 일일이 확인해 줘야 하는 점이 굉장히 불편했습니다. 그래서 의존성과 플러그인을 한 번에 관리할 수 있는 버전 카탈로그를 사용하게 되었습니다. 버전 카탈로그를 만들기 위해선 gradle Script 경로에 libs.versions.toml 파일을 통해 설정할 수 있습니다.

간단히 두 가지 예시를 살펴보면 다음과 같이 선언 할 수 있습니다. 먼저 Android Gragle Plugin 설정을 위한 플러그인과 Retrofit Library Dependency를 추가하기 위한 코드입니다.
[versions]
agp = "8.2.0"
retrofit = "2.11.0"
[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
📕 [version]
추가할 의존성 또는 플러그인의 버전을 명시해줍니다.
📕 [libraries]
추가할 의존성을 명시해 주며 group 과 name을 나누는 기준은 의존성을 추가할 때 명시하는 : 을 기준으로 선언합니다. 또한 의존성의 버전은 version.ref를 통해 [version] 에서 추가한 의존성의 이름을 참조해 지정할 수 있습니다.

📕[plugins]
id는 추가할 플러그인의 id를 명시해 주며 버전 또한 [librarires]와 동일한 방식으로 참조할 수 있습니다.
3. 의존성과 플러그인을 추가하자

Gradle Version Catalog에 선언한 의존성과 플러그인은 alias 키워드와 libs 키워드를 통해 접근할 수 있습니다.
- alias : Gradle Version Catalog를 사용하여 선언된 플러그인 및 라이브러리를 참조할 때 사용합니다.
- libs : Gradle Version Catalog의 인스턴스를 Gradle 전역에서 접근 가능하게 만드는 키워드입니다.

Gradle Version Catalog에 대한 더 자세한, 세상에 제일 설명을 잘해주신 내용은 이 영상을 참고해 주세요!
4. Data Layer를 설계하자
먼저 Data Layer에서 필요한 것은 무엇일지 생각해 보고 제가 가장 많이 참고한 정말 교과서라고 생각하는 프로젝트를 기준으로 패키지를 나누겠습니다. 해당 프로젝트는 글 마지막에 기재하겠습니다.
가장 넓은 관점에서 봤을 때 다온길 프로젝트에선 Data Srouce의 구성 요소들을 정리해 보겠습니다.
📌 Retrofit
역할 : 서버 API 통신
구성 요소 : Retrofit Service Interface, DTO
📌 Room Database
역할 : 지역 코드, 시군구 코드 저장
구성 요소 : Database, Entitiy, Dao
📌 DataStore
역할 : 사용자 토큰 저장
📌 Hilt Module
역할 : 의존성 주입
Retrofit Module, Database Module, Datasource Module, Retrofit Module
📌 Repository
역할 : DataSource 캡슐화, Domain -> Data Layer로의 의존성 역전

정리한 내용을 바탕으로 Data Layer의 패키지를 나누었습니다. 정리한 내용 외에도 추가된 두 가지 패키지가 있습니다. mapper package는 특정 타입을 매핑하기 위한 확장 함수, Network Handler Class는 네트워크 에러를 처리하기 위해 만들었습니다. 이 내용은 추후 다루며 각 패키지 구현 내용과 클래스에 대해 더 살펴보겠습니다.
service package는 API 요청을 위한 Retrofit Service Interface들을 위한 패키지이며 기본적인 API 요청 플로우를 알아보겠습니다. Retrofit Service Interface는 di package에 위치한 NetworkModule Class를 통해 의존성을 바인딩합니다. 바인딩된 의존성은 @inject을 통해 DataSource 패키지에 위치한 DataSource Class에 주입됩니다. 실제 코드를 살펴보겠습니다.
// Retrofit service interface
internal interface KorWithService {
@GET("areaCode1")
suspend fun getAreaCode(
@QueryMap params: Map<String, String>
): AreaCodeResponse
}
// DI (Hilt)
@Module
@InstallIn(SingletonComponent::class)
internal object NetworkModule {
@Singleton
@Provides
fun provideKorWithService(okHttpClient: OkHttpClient): KorWithService =
Retrofit.Builder()
.baseUrl("https://apis.data.go.kr/B551011/KorWithService1/")
.addConverterFactory(MoshiConverterFactory.create().asLenient())
.client(okHttpClient)
.build()
.create()
@Singleton
@Provides
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor()
.setLevel(HttpLoggingInterceptor.Level.BODY)
).build()
}
@Provides
@Singleton
fun provideMoshi(): Moshi {
return Moshi.Builder()
.add(LocalDateTime::class.java, Rfc3339DateJsonAdapter().nullSafe())
.add(LocalDate::class.java, Rfc3339DateJsonAdapter().nullSafe())
.add(KotlinJsonAdapterFactory())
.build()
}
}
// DataSource
internal class KorWithDataSource @Inject constructor(
private val korWithService: KorWithService
) {
suspend fun getAreaInfoList(code: String = ""): AreaCodeResponse {
return korWithService.getAreaCode(AreaCodeRequest(code).toRequestModel())
}
}
internal 접근 제한자를 사용한 이유는 데이터 모듈의 내용이 다른 모듈에서 참조되질 않길 바라기 때문입니다. 일반적으로 Data 모듈에서 Context 등 현재 앱에서 사용되고 있는 내용들이 필요합니다.
의존성 분리를 위해 모듈을 나누고 힐트를 사용해서 코드는 서로 직접적으로 참조하지 않게 되었지만 의존성 그래프에 내용물들이 다 엮여있을 수 있도록 각 모듈들을 하나로 묶어줄 수 있는 역할이 하나 필요하게 됩니다. 그리고 역할을 App모듈이 수행하고 있습니다. 그러다 보니 App모듈이 Data 모듈의 내용을 알게 됩니다.
이 때문에 App모듈에서 Data모듈의 코드들이 직접 접근되어서 임의로 코드의 내용을 사용할 수 있게 되고 이런 문제를 방지하고자 internal로 적용하게 되었습니다.
또한 Network Module Class를 만들면서 일반 Class로 만들지 object 키워드를 사용해 싱글톤 클래스를 만들지 고민이었습니다. 싱글톤 Class로 만들지 않더라도 클래스에 InstallIn을 통해 싱글톤 컴포넌트에 모듈을 설치했고 Retrofit Service Interface를 주입해 주는 메서드들이 Singleton Annotation을 명시했기 때문에 네트워크 모듈 클래스의 인스턴스가 한 번만 생성되어도 싱글톤처럼 사용이 가능하다는 가설을 새워 자료를 찾아봤지만 명확한 답을 찾지 못해 싱글톤 클래스로 생성해 주었습니다. 이에 대한 답을 아시는 분은 댓글 부탁드립니다.🥺
// Qualifiers.kt
@Qualifier
annotation class Auth
@Module
@InstallIn(SingletonComponent::class)
internal object NetworkModule {
@Singleton
@Provides
fun provideRetrofit(@Auth authClient: OkHttpClient, moshi: Moshi): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(authClient).build()
}
@Auth
@Singleton
@Provides
fun provideAuthClient(tokenDataSource: TokenDataSource): OkHttpClient {
return OkHttpClient.Builder()
//.authenticator(AuthAuthenticator())
.addInterceptor(AuthInterceptor(tokenDataSource))
.addInterceptor(
HttpLoggingInterceptor()
.setLevel(HttpLoggingInterceptor.Level.BODY)
).build()
}
}
다음으로 di package의 qulifiers.kt 파일에 대해 알아보겠습니다. 다온길 프로젝트는 현재 저를 포함한 팀원들 모두 클린 아키텍처에 대한 지식이 부족해 클린 아키텍처를 적용하지 않고 패키지만 나눠 진행하고 있으며 추후 마이그레이션 할 계획입니다. 그래서 아래와 같이 코드를 구성하게 되었습니다.
여기서 주목할 점은 다온길에서 현재 많은 API 사용하고 있기 때문에 각각 다른 OkHttpClient 인스턴스가 필요합니다. 그래서 Hilt를 사용해 의존성을 주입할 때 동일한 OkHttpClient 타입을 주입해줘야 합니다.

하지만Hilt는 의존성을 타입으로 구분하며 기본적으로 중복 바인딩을 허용하지 않습니다.

그렇기 때문에 동일한 타입의 의존성을 컴포넌트에 설치할 경우 Hilt는 어떤 의존성을 주입해야 하는지 알지 못해 Duplicated Binding Error를 일으킵니다.

이 중복 바인딩을 회피하는 방법으로 두 가지 방법이 있습니다. 바로 @Named 어노테이션과 @Qulifier 어노테이션입니다.
@Named 어노테이션은 다음과 같이 @Named() 내부에 이름을 지정해 주면 간편하게 구현할 수 있습니다.
@Module
@InstallIn(SingletonComponent::class)
object TestModule {
@Singleton
@Provides
@Named("CR7")
fun provideCR7(): String = "메시"
@Singleton
@Provides
@Named("Messi")
fun provideMessi(): String = "메시"
@Singleton
@Provides
fun provideBallondor(@Named("CR7") name: String): String {
return "발롱도르 수상자는 $name입니다!"
}
}
@Qualifier 어노테이션은 @Qulifier 어노테이션을 명시한 annotation Class를 만들어주어야 합니다. 그리고 이 어노테이션은 [ @ + 클래스 이름 ]을 통해 사용할 수 있습니다.
🏭 성능 팁
두 가지 방법 중 권장되는 방식은 @Qualifier입니다. @Named 어노테이션은 따로 어노테이션 클래스를 만들 필요가 없어 설정이 간단하고 어디서든 재사용이 가능합니다.
하지만 annotation Class를 만드는 것에 비해 @Named 어노테이션은 그 자체로 의미론적인 약함이 존재합니다. 또한 오타가 나도 컴파일 타임에 오류가 발생하는 것이 아닌 런타임에 오류가 발생하기 때문에 실질적으로, 의미론적으로 의미가 강한 @Qualifier를 사용해야 합니다.
@Qualifier
annotation class CR7
@Qualifier
annotation class Messi
@Module
@InstallIn(SingletonComponent::class)
object TestModule {
@Singleton
@Provides
@CR7
fun provideCR7(): String = "메시"
@Singleton
@Provides
@Messi
fun provideMessi(): String = "메시"
@Singleton
@Provides
fun provideBallondor(@CR7 name: String): String {
return "발롱도르 수상자는 $name 입니다!"
}
}
다음은 Repository 의존성을 바인딩하는 Repository 모듈입니다. 이 부분이 클린 아키텍처를 사용하기 전과 비교 했을 때 힐트의 가장 큰 이점을 느끼게 된 부분입니다.
힐트를 사용하기 전엔 매번 Repository Interface에 다음과 같은 팩토리 함수를 만들어야 했습니다. 이전 프로젝트에 대한 구현 내용은 이글을 참조해주세요! 힐트를 사용함으로써 이런 보일러 플레이트 코드들을 줄일 수 있다는 점이 정말 좋았습니다.
interface PlaceRepository {
companion object{
fun create(): PlaceRepositoryImpl{
return PlaceRepositoryImpl(
PlaceDataSource(
RetrofitInstance.serviceProvider(PlaceService::class.java)
)
)
}
}
}
글 작성 시점에 다른 로직을 작성한 상태에서 작성하다 보니 아래 기재된 코드 외에도 팩토리 함수가 생성되어있으니 이점 양해 부탁드립니다 🙂
@Module
@InstallIn(SingletonComponent::class)
internal abstract class RepositoryModule {
@Binds
abstract fun bindAreaCodeRepository(areaCodeRepositoryImpl: AreaCodeRepositoryImpl): AreaCodeRepository
@Binds
abstract fun bindKorWithRepository(korWithRepositoryImpl: KorWithRepositoryImpl): KorWithRepository
@Binds
abstract fun bindSigunguRepository(sigunguCodeRepositoryImpl: SigunguCodeRepositoryImpl): SigunguCodeRepository
}

🏭 성능 팁
@Provide는 내부에 작성한 메소드 만큼 Hilt에 의해 팩토리 클래스가 생성됩니다. 이 팩토리 클래스는 주입해야 하는 객체를 생성하는 역할을 합니다. 그래서 가능한 @Binds를 사용하는 것이 좋은데요, 그 이유는 @Binds는 새로 팩토리 클래스를 생성하는 것이 아닌 개발자가 작성한 인터페이스의 코드를 그대로 구현체로 교체합니다.
하나의 인터페스만 살펴보면 다음과 같이 구현체로 교체되어 있는 것을 확인할 수 있습니다. 그렇기 인터페이스와 구현체 간의 단순한 바인딩이라면 @Binds를 상용해 팩토리 클래스로 인한 오버헤드를 감소시킬 수 있습니다,.

Domain Model과 DTO 간의 매핑 작업은 단일 책임 원칙과 캡슐화에 의거하여 각각의 클래스가 가져야 할 책임으로 생각해 해당 클래스의 메소드로 선언해 주었습니다.
// DTO
// Request
internal data class AreaCodeRequest(
val areaCode: String,
){
fun toRequestModel(): Map<String, String>{
return mapOf(
"numOfRows" to "31",
"MobileOS" to "AND",
"MobileApp" to "DaOnGil",
"_type" to "json",
"serviceKey" to BuildConfig.KOR_API_KEY,
"areaCode" to areaCode
)
}
}
// Response
@JsonClass(generateAdapter = true)
internal data class AreaCodeResponse(
val response: Response
){
fun toDomainModel(): List<AreaCode> {
return response.body.items.item.map {
AreaCode(it.code, it.name)
}
}
}
다음 내용은 Domain Layer 설계에 관한 글 입니다만, 어떻게 보면 이제부터가 정말 중요한 내용인거 같아 글을 쓰다 보니 개인적인 생각으로 인해 글이 길어져 2편으로 찾아뵙겠습니다. ☺️
글에 기재된 코드들은 모두 여기서 확인하실 수 있습니다
📖 참조
클린 아키텍처를 공부하면서 가장 많은 아이디어와 도움을 받은 프로젝트입니다. 제가 정말 정말 존경하는 분께서 진행한 프로젝트인데 이제 따라 설계할 수 있을 만큼 성장한 것 같아 영광이네요 🙂
이런 좋은 프로젝트 저만 알기 아까워서 세상 사람들 다 알아야 한다 싶어 공유합니다.
https://github.com/mash-up-kr/SulSul-Android
GitHub - mash-up-kr/SulSul-Android: 나는 알콜 프리 근데 취해~ 마신 게 하나도 없는데~
나는 알콜 프리 근데 취해~ 마신 게 하나도 없는데~. Contribute to mash-up-kr/SulSul-Android development by creating an account on GitHub.
github.com
'Android' 카테고리의 다른 글
안드로이드 리사이클러뷰 성능 개선 일지 1편(부제: Recyclerview Deep Dive) (0) | 2024.08.03 |
---|---|
안드로이드 클린아키텍처 에러 핸들링 (부제: 상속을 지양하자) (2) | 2024.07.28 |
버튼 중복 클릭을 막아보자 (Android ThrottleFirst) (1) | 2024.06.24 |
ViewLifeCycleOwner 제대로 알고 사용해보자 (1) | 2024.04.28 |
Room Like + StateFlow debouce와 Throttle (1) | 2024.03.31 |