본문 바로가기

내일배움캠프 안드로이드 3기

[TIL] 24.05.07 앱개발 심화 주차 개인과제

1. 앱개발 심화 주차 개인과제

 

 저번주는 챌린지반 과제 리팩터링 및 본 커리큘럼 개인과제를 작성하느라 대부분의 시간을 보냈고, 오늘까지도 그랬다. 개인과제는 API를 통해 이미지 검색 결과를 받아와 View에 뿌려주고, 로컬 저장공간을 이용해 북마크 할 수 있는 기능을 구현하는 과제였다.

 실제로 서비스 되는 앱의 기본에 충실한 과제 유형인데, 사실 이런 유형의 과제는 정말 많이 해봤지만, 할 때 마다 코드가 바뀌는게 눈에 보인다. 어떻게 보면 단순한 로직으로 쉽게 작성할 수도 있겠지만, 공부했던 것들을 모두 적용해서 앱의 구조를 작성하기 위해 고민하다보면 어려운 부분이 많다. 한편으로는, 이전에 비슷한 과제를 할 때 신경쓰지 못했던 부분들을 이제는 공부한 상태로 고민하는 스스로를 보면서 매번 저번보다 성장했음을 체감하게 되는 그런 과제 유형이기도 하다.

 

 Presentation-Domain-Data 3계층의 클린 아키텍처에 MVVM을 적용하고, Hilt로 di를 해결하도록 했는데, 기능 요구사항은 모두 달성했지만, 정말 마음에 들지 않는 부분이 있었다. 요구사항 상 Room을 쓸 수 없었고, DataStore를 이용해 로컬 저장소를 구현하게 됐는데, 아무튼 북마크에 대한 정보 변화도 지속적으로 감지하기 위해서 DataStore로부터 Flow형태로 북마크 정보를 받아오게 했다.

 검색 결과 View에는 북마크 여부가 표시되어야 하기 때문에, API를 통해 받아오는 일회성 결과 값과 로컬에 저장된 Flow를 계속해서 병합할 필요가 있었고 나는 이를 UseCase에서 구현했었다. 하지만 여기서 큰 실수가 있었던게, 검색을 위한 UseCase에서 두 데이터를 병합해 Flow로 내려주겠다는 생각이 너무 기이한 구조의 앱을 만들게 했던 것 같다.

 

 그러다보니 ViewModel에서는 아래와 같은 정말 마음에 들지 않는 로직을 수행해야 했다.

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val getSearchImageUseCase: GetSearchImageUseCase,
    private val insertFavoriteItemUseCase: InsertFavoriteItemUseCase
): ViewModel() {

    private val _searchList = MutableStateFlow(SearchListUiState.init())
    val searchList: StateFlow<SearchListUiState> = _searchList.asStateFlow()

    fun getListItem(query: String) {
        viewModelScope.launch(Dispatchers.IO) {
            getSearchImageUseCase(query).collectLatest { result ->
                _searchList.update { prev ->
                    prev.copy(
                        list = (result.contentItems?.map { it.toListItem() }.orEmpty())
                    )
                }
            }
        }

    }
    
    ...

 

 UseCase로부터 Flow를 collect해서 그 값을 바탕으로 View가 collect 중인 StateFlow에 emit 해준다니, 너무 이상한 형태였다. 그래서 튜터님께 찾아가 조언을 구했고, UseCase에서 병합하지 말고, ViewModel에서 로컬의 데이터는 flow 그대로 가져오고, 검색이 발생할 때 결과를 emit 해줄 flow를 만들어서 두 flow를 병합해서 hot stream으로(View가 collect 하는 StateFlow) 만드는 게 좋을 것 같다는 조언을 얻을 수 있었다.

 혹시나 내가 제대로 이해하지 못한 부분이 있을까봐, 내가 제대로 이해했는지 여러 차례 되물어보며 설명해주신 구조를 머리 속에 집어 넣었다. 튜터님께서 알려주신 방향이 누가봐도 훨씬 합리적이었고, 바로 리팩터링했다.

 

 처음에는 대공사가 되지 않을까 하는 생각도 좀 했지만, 그래도 클린 아키텍처를 고민해서 짜놓은 덕에 생각보다 변경하는 품이 크게 들지 않았다. 이럴 때마다 아키텍처나 디자인 패턴이 중요성을 다시금 느끼게 된다..

 

 리팩터링 이후에는 아래와 같이 구현했고, 정상적으로 동작하는 것도 확인했다. 일단 내일 한 번 더 결과를 보여드리고, 옳은 방향으로 구현하는 것이 맞는 지 여쭤 볼 생각이다. 그리고 Flow는 파면 팔수록 생각해야 하는 부분이 많아지는 것 같다. 다양하게 써보려고 노력해야지.

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val getSearchImageUseCase: GetSearchImageUseCase,
    private val getFavoriteItemMapUseCase: GetFavoriteItemMapUseCase,
    private val insertFavoriteItemUseCase: InsertFavoriteItemUseCase
): ViewModel() {

    private val _combinedSearchList = MutableStateFlow(SearchListUiState.init())
    val combinedSearchList: StateFlow<SearchListUiState> = _combinedSearchList.asStateFlow()

    private val searchImageFlow: MutableSharedFlow<SearchImageEntity> = MutableSharedFlow()
    private val favoriteFlow: Flow<HashMap<String, StorageEntity>> = getFavoriteItemMapUseCase()

    init {
        searchImageFlow.combine(favoriteFlow) { searchImageEntity, favoriteMap ->
            val combinedList = searchImageEntity.documents?.map {
                if(favoriteMap.containsKey(it.thumbnailUrl)) {
                    it.toListItem(true)
                }else {
                    it.toListItem(false)
                }
            }.orEmpty()


            _combinedSearchList.update { prev ->
                prev.copy(list = combinedList)
            }

        }.launchIn(viewModelScope)
    }

    fun getListItem(query: String) {
        viewModelScope.launch(Dispatchers.IO) {
            val searchResult = try {
                getSearchImageUseCase(query)
            } catch (e: Exception) {
                //예외처리 임시로
                SearchImageEntity(MetaEntity(null, null, null), emptyList())
            }
            withContext(Dispatchers.Main) {
                searchImageFlow.emit(searchResult)
            }
        }
    }
...

 

 

2. 챌린지반 세션

 저번 주에 이어서 이번 주에도 내 과제를 리뷰하게 되었는데, 동영상 API 추가후 병합, 정렬해서 뿌려주기, 제네릭 이용하기 등이 이번 과제 핵심 내용이었고, 그것들에 대해서 간단하게 리뷰했다. 그 밖에도 튜터님의 조언에 따라 혼자 리팩터링한 부분이 많은 과제였고, 아마 리뷰하면서 그 부분들도 보였을 것이라고 생각한다. 다행히도 튜터님께서 잘 구현한 것 같다고 해주셨고, 대신 API를 받을 때, 특히나 @SerializedName 어노테이션을 사용한다면 데이터 클래스의 프로퍼티들은 nullable하게 해야한다고 조언해주셨다. 결국에는 안전한 앱 동작을 보장하기 위한 목적이었고, nullable이 불편할 때 기본값을 이용하는 방법 등도 같이 설명해주셔서 도움이 많이 되었던 것 같다.

 

 그리고 튜터님의 라이브 코딩을 보면서 혼자 내 코드랑 계속 비교하며 튜터님의 의도를 파악하려고 노력했는데, 이게 도움이 많이 된다. 아마 개인과제가 끝나는 대로, 느긋하게 코드를 비교해보는 시간을 가질 것 같다. 이번 주 과제는 hilt에 대해 공부하고 여유가 된다면 적용까지 해보는 것이라, hilt와 di에 대해 제대로 알지 못했던 부분들을 다시 공부해 봐야 할 것 같다.