- 기존 내 서비스에서 구성되어 있던 API 호출 구조는 매번 Api Task Class를 만들어 Base를 통해 Retrofit 객체를 생성하고 호출하는 과정이었다.
- 각종 초기화 및 고정 Header 값 추가 등의 코드가 난잡하게 구성되어 있어 가독성 및 관리에 좋지 않았다.
- 의존성 자동 주입을 위해 Hilt와 이참에 MVVM 패턴까지 적용해 봤다.
Network Module
- 일단 기존에는 Api Task Class 내부에 매번 API 인터페이스를 다르게 생성했는데, 전반적인 모든 API func을 담을 ApiService를 구성했다.
interface ApiService {
@GET("api/test")
suspend fun getTest(
@Query("zoneId") zoneId: String
): TestResult?
.
.
.
}
- 다음으로 Hilt를 기반으로 Retrofit 및 OkHttpClient를 제공해 줄 Network Module을 선언
@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
...
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(HeaderInterceptor())
.connectTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.authenticator(TokenAuthenticator())
.addInterceptor(RetryInterceptor())
...
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
}.build()
}
@Singleton
@Provides
fun provideRetrofit(
okHttpClient: OkHttpClient
): Retrofit {
return Retrofit.Builder()
.client(okHttpClient)
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
}
- DataBase 모듈 구성 때와 동일하게 싱글톤 컴포넌트 범위로 지정한다.
- 그리고 OkHttpClient에 대해서도 구성을 진행했다. OkHttpClient 생성 함수 내부에 authenticator와 RetryInterceptor 등이 존재하는데, 이 내용이 필요 없는 통신의 경우도 있어 추후 그 분기 처리에 대해서도 작성해 보겠다.
- 그리고 Retrofit 인스턴스를 주입해 줄 provideRetrofit과 provideApiService를 구성한다.
UI State
- Sealed 클래스를 활용하여 UI에서 활용할 상태 클래스를 선언한다. 기본적으로 Loading, Success, Error로 나누어 Flow 활용 시 emit 되는 진행 상태에 따라 UI 구성 처리가 가능하다.
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String = "Error") : UiState<Nothing>()
}
- Loading 상태의 경우 실제 아직 데이터가 없기 때문에 Nothing으로 정의
- Success에서는 데이터 수신에 성공한 경우로서, Response의 대상이 되는 클래스를 제네릭을 통해 매번 다르게 지정 가능하다.
- Error도 결국 Nothing으로 정의했으나 Toast 및 로그 처리 등을 위해 Error Message 또는 code를 남길 수도 있다.
(또는 Error Response 자체를 제네릭으로 받거나 하나의 정형화된 구조를 반환하는 형태로 구성할 수도 있겠습니다.)
Repository
- API에서 브리핑 데이터를 가져오는 역할을 하며, UI에 데이터를 전달하기 위한 상태 처리(UiState)를 Flow로 래핑한 구조
(제 작업 기준에서 역할을 해석한 것이라 다른 관점이 있다면 말씀 부탁드립니다.)
class TestRepository @Inject constructor(
private val apiService: ApiService
) {
fun getTest(): Flow<UiState<TestResult>> = flow {
emit(UiState.Loading)
try {
val result = apiService.getTest(App.id)
if (result != null) {
emit(UiState.Success(result))
} else {
emit(UiState.Error())
}
} catch (e: Exception) {
emit(UiState.Error(e.localizedMessage ?: "Unknown error"))
}
}.flowOn(Dispatchers.IO)
}
- 주입된 ApiService를 통해 Response를 받아오고, 그 과정에서 UiState를 로딩 -> 성공 or 실패의 형태로 Flow를 방출한다.
- 방출되는 Flow를 ViewModel에서 수신받아 UI에 StateFlow로 반환할 수 있도록 collect 한다.
- 네트워크 통신이기 때문에 IO Dispatcher에서 Flow 실행
ViewModel & Presentation
@HiltViewModel
class TestViewModel @Inject constructor(
private val repository: TestRepository
): ViewModel() {
private val _testState = MutableStateFlow<UiState<TestResult>>(UiState.Loading)
val testState: StateFlow<UiState<TestResult>> = _testState
fun fetchTest() {
viewModelScope.launch {
repository.getTest().collect {
_testState.value = it
}
}
}
}
- MutableStateFlow는 내부에서 값을 변경할 수 있는 상태 흐름이다. Private으로 선언하고, collect를 통해 데이터를 수신받으면 갱신하여 UI 레벨에서는 불변형의 StateFlow만 확인 가능하도록 구성 -> 캡슐화
- 상태 흐름을 통해 값이 변경되면 StateFlow는 UI에게 변화를 알려주고 UI에서는 예를 들어 아래와 같이 처리할 수 있다.
activity?.lifecycleScope?.launch {
testViewModel?.testState?.collect { state ->
fun hideSkeleton() {
skeletonLy.visibility = View.GONE
}
when (state) {
is UiState.Success -> {
hideSkeleton()
refreshResult(state.data)
}
is UiState.Error -> {
hideSkeleton()
setErrorMessage(state.message)
}
is UiState.Loading -> {
skeletonLy.visibility = View.VISIBLE
}
}
}
}
- 이런 식으로 점차 MVVM의 형태로 프로젝트 패턴 구조를 잡아가고, 추후 클린 아키텍처로까지의 개선을 바라보고 있다.