조각들

 

개요

data source가 remote인지 local인지 구분할 필요 없게 해준다는 게 어떤 효용인 건지 체감을 잘 못했다.

그런데 어제 책을 통해 캡슐화에 대해 조금 이해하게 되면서 그 의미를 얼핏 알 것만 같다.

 


Presentation Layer에서 분리

클린 아키텍쳐에서는 Layer를 Presentation - Domain - Data 이렇게 3 단계로 나누어야 한다. 

 

 

레트로핏 코드는 Activity나 Fragment와 같은 View에 바로 작성하거나 ViewModel에 작성을 할 수도 있다. 어느쪽이든 Presentation Layer에 포함되는 것이다.

 

그런데 이렇게 할 경우 data는 Data Layer에서 다뤄져야 하는데 Presentation Layer에 포함되므로 올바르게 분리되지 못한 것이다. 이때 repository를 사용하면 data가 Presentation Layer에서 분리시킬 수 있게 된다.

 

data source의 캡슐화

캡슐화는 자율성을 높여 협력 구조를 단순하게 해주는 등의 장점이 있다. local과 remote를 repository 하나로 감싸는 것이 캡슐화고 안드로이드도 객체지향인만큼 이러한 repository의 활용은 유의미하다고 판단한다.


 

적용

리팩토링 전 코드는 아래와 같다. 길어서 일부분만 가지고 왔는데 SearchViewModel이라는 파일 안에 작성돼있는 코드다.

 

	// ... 중략

	fun getSearchList(keywordString: String) {
        viewModelScope.launch {
            runCatching {
                _searchState.value = UiState.Loading
                service.getSearchLocation(keyword = keywordString)
            }.onSuccess {
                if (it.body() != null) {
                    it.body().let { searchResponseSchema ->
                        setData(searchResponseSchema!!.searchPoiInfo.pois)
                    }
                    Timber.tag(ContentValues.TAG).d("Success : getSearchList body is not null")
                } else {
                    dataList.value = null
                    Timber.tag(ContentValues.TAG).d("Success : getSearchList body is null")
                }
                _searchState.value = UiState.Success
            }.onFailure {
                searchError.value = it.message
                _searchState.value = UiState.Failure
            }
        }
    }

    private fun setData(pois: Pois) {
        dataList.value = pois.poi.map {
            SearchResultEntity(
                fullAdress = makeMainAdress(it),
                name = it.name ?: "",
                locationLatLng = LocationLatLngEntity(it.noorLat, it.noorLon)
            )
        }
    }


    private fun makeMainAdress(poi: Poi): String =
        if (poi.secondNo?.trim().isNullOrEmpty()) {
            (poi.upperAddrName?.trim() ?: "") + " " +
                    (poi.middleAddrName?.trim() ?: "") + " " +
                    (poi.lowerAddrName?.trim() ?: "") + " " +
                    (poi.detailAddrName?.trim() ?: "") + " " +
                    poi.firstNo?.trim()
        } else {
            (poi.upperAddrName?.trim() ?: "") + " " +
                    (poi.middleAddrName?.trim() ?: "") + " " +
                    (poi.lowerAddrName?.trim() ?: "") + " " +
                    (poi.detailAddrName?.trim() ?: "") + " " +
                    (poi.firstNo?.trim() ?: "") + " " +
                    poi.secondNo?.trim()
        }

 

아래부터는 리팩토링 과정이다.

 

DataSource 생성

서버통신을 실행시키는 코드만 따로 빼준 것이다.

class DepartureSearchDataSource(private val searchService: KSearchService) {
    suspend fun getSearchList(searchKeyword: String): Response<SearchResponseTmapDto> =
        searchService.getSearchLocation(keyword = searchKeyword)
}

 

Repository 생성

interface DepartureSearchRepository {
    suspend fun getSearchList(keyword: String): List<SearchResultEntity>?
}

 

RepositoryImpl 생성

data 가공 코드를 여기에 작성했다. 이후 ViewModel에서 repository의 func을 호출하면 data가 어디서, 어떤 과정을 거쳐서 오는 건지 몰라도 되고 그냥 받아쓰기만 하면 된다.

 

자동으로 null-check이 이루어졌다. 엘비스 연산자를 사용해서 상황에 따라 직접 null을 return하도록 해주었다. return null을 내가 직접 써주었던 게 조금 낯설었다.

 

class DepartureSearchRepositoryImpl(private val departureSourceDataSource: DepartureSearchDataSource) :
    DepartureSearchRepository {
    override suspend fun getSearchList(keyword: String): List<SearchResultEntity>? {
        return changeData(departureSourceDataSource.getSearchList(keyword)
            .body()?.searchPoiInfo?.pois ?: return null)
    }

    private fun changeData(pois: Pois): List<SearchResultEntity> {
        val changedData = pois.poi.map {
            SearchResultEntity(
                fullAdress = makeMainAdress(it),
                name = it.name ?: "",
                locationLatLng = LatLng(it.noorLat.toDouble(), it.noorLon.toDouble())
            )
        }
        return changedData
    }


    private fun makeMainAdress(poi: Poi): String =
        if (poi.secondNo?.trim().isNullOrEmpty()) {
            (poi.upperAddrName?.trim() ?: "") + " " +
                    (poi.middleAddrName?.trim() ?: "") + " " +
                    (poi.lowerAddrName?.trim() ?: "") + " " +
                    (poi.detailAddrName?.trim() ?: "") + " " +
                    poi.firstNo?.trim()
        } else {
            (poi.upperAddrName?.trim() ?: "") + " " +
                    (poi.middleAddrName?.trim() ?: "") + " " +
                    (poi.lowerAddrName?.trim() ?: "") + " " +
                    (poi.detailAddrName?.trim() ?: "") + " " +
                    (poi.firstNo?.trim() ?: "") + " " +
                    poi.secondNo?.trim()
        }
}

 

Hilt 적용

아직 hilt를 잘 몰라서 어떤 원리로 돌아가는 건지 잘 모르지만 Impl를 쓰기 위한 방법 중 하나는 의존성 주입을 사용하는 것이다. 

    @Singleton
    @Provides
    fun provideDepartureSearchRepository(): DepartureSearchRepository {
        return DepartureSearchRepositoryImpl(
            DepartureSearchDataSource(
                KApiSearch.getRetrofit().create(KSearchService::class.java)
            )
        )
    }

 

 

ViewModel

마찬가지로 나는 아직 hilt를 잘 모른다. 팀원의 코드를 따라 작성하였고 아래처럼 해주니 정상적으로 코드가 돌아갔다. layer 분리를 통해 ViewModel은 자신의 역할에 조금 더 집중할 수 있게 되었다. 가독성도 좋아졌다. 

@HiltViewModel
class SearchViewModel @Inject constructor(private val departureSearchRepository: DepartureSearchRepository) :
    ViewModel() {
    
    //... 중략
    
        fun getSearchList(searchKeyword: String) {
        viewModelScope.launch {
            runCatching {
                _searchState.value = UiState.Loading
                departureSearchRepository.getSearchList(keyword = searchKeyword)
            }.onSuccess {
                if (it != null) {
                    dataList.value = it
                    Timber.tag(ContentValues.TAG).d("SuccessNotNull : getSearchList body is not null")
                } else {
                    dataList.value = null
                    Timber.tag(ContentValues.TAG).d("SuccessButNull : getSearchList body is null")
                }
                _searchState.value = UiState.Success
            }.onFailure {
                searchError.value = it.message
                _searchState.value = UiState.Failure
            }
        }
    }

 


느낀점

data 가공 작업이 필요 없는 간단한 경우 repository 없이 그냥 viewModel에서 data에 바로 접근하는 게 빠를 것 같다. 클린 아키텍쳐의 관점에서는 틀리겠지만 배보다 배꼽이 더 크다고 느껴진다.

 

하지만 자율성을 확보하자는 객체지향의 큰 흐름에 맞추기 위해선 repository를 활용해 캡슐화를 시켜주고 layer를 나눠 관심사 분리를 해주는 것이 적절할 것이다.

 

현재의 내가 생각하는 효용은 Presentation Layer에서 분리, 캡슐화 크게 2가지이다. 구글링을 해보면 Unit Test를 통한 검증이 쉬워진다는 등의 내용이 있는데 지금의 나는 해당 내용을 잘 체감하지 못하겠다. 추후에 경험이 쌓이고 새로 느끼게 되는 점들이 생긴다면 다시 업데이트를 하려고 한다. 

'Android > 공부' 카테고리의 다른 글

retrofit의 비동기 처리  (0) 2024.04.29
Flow  (2) 2024.03.14
[공부] Github Action CI  (0) 2023.03.04
[공부] MVVM, ViewModel, LiveData, DataBinding, Fragment  (1) 2023.01.21
[공부] sopt 31기 안드로이드 세미나 과제  (0) 2022.12.05