[Android] 코드 중복을 Hilt를 이용하여 구성(Composition) 방식으로 제거
최근 한성대 공지 어플을 상속 대신 구성을 활용하게 된 경험이 있어 글로 정리하고자 한다.
배경
한성대 공지 어플에는 공지사항을 즐겨찾기 하는 기능이 있다. 이 기능은 홈 화면, 검색 화면 등 다양한 화면에서 작동한다. 해당 기능을 제공하는 곳에서는 즐겨찾기 추가, 삭제, 이 공지사항이 즐겨찾기인지 아닌지 파악하는 작업을 해야한다.
그렇게 필요한 ViewModel들을 만들다보니 아래의 코드가 중복되고 있다는 것을 발견했다.
SomeViewModel.kt
    ...
    val noticeFlow = getNoticeListUseCase().cachedIn(viewModelScope).map {
        it.map(::createNoticeItemUiState)
    }
    fun createNoticeItemUiState(notice: Notice): NoticeItemUiState {
        return NoticeItemUiState(
            notice,
            onClickFavorite = { isFavorite ->
                viewModelScope.launch(dispatcher) {
                    if (isFavorite) {
                        addFavoriteNoticeUseCase(notice)
                    } else {
                        removeFavoriteNoticeUseCase(notice)
                    }
                }
            },
            isFavorite = { isFavoriteNoticeUseCase(notice) }
        )
    }
    ...
이렇게 계속하다가는 더 많은 곳에서 코드가 중복되고 수정할 때 마다 모든 곳을 수정해야 할 것 같았다. 그래서 중복을 제거하기 위해 여러 고민을 했다.
일단은 StateFlow를 Repository로 이동시켜 하나만 두도록 했다. 그리고 NoticeItemUiState를 생성하는 코드를 어떻게 할 지 생각했다.
달콤한 유혹 - 상속(Inheritance)
첫 번째 생각은 코드 재활용을 위해 “상속을 이용해볼까?” 였다. 아래와 같은 ViewModel을 만들고 필요한 곳에서 이를 상속하면 어떨까 잠시 고민했다.
abstract class FavoriteViewModel @Inject constructor(
  ...
) : ViewModel() {
  ...
}
하지만 객체지향 기본 내용인 코드를 재사용하기 위해 상속하면 안된다는 말이 떠올랐다. 위의 사례는 IS-A 관계도 아니었다. 무분별한 상속은 부모 자식간에 결합도가 커지고 확장성이 떨어지는 문제가 있다.
따라서 구성(Composition)을 하기로 결정했다.
구성(Composition)
구성은 상속과 다르게 HAS-A 관계라고 생각하면 된다. 그런데 막상 구성 방식으로 구현하려 해보니 쉽지 않았다. Hilt를 사용하고 있었고 비동기 호출이 있어 viewModelScope를 전달받아야 했다.
어떻게 런타임에 주입할 수 있을지 찾아보니 AssistedFactory라는 어노테이션이 있는 것을 발견했다. 이는 모든 생성자 매개변수를 한 번에 의존성 주입하지 않고 필요한 부분은 따로 런타임에 주입할 수 있게 해준다.
구현된 결과는 아래와 같다. viewModelScope를 런타임에 주입 가능하다. dispatcher는 테스트를 위해 존재한다.
triggerCollection는 Flow가 collect를 하지 않으면 방출이 시작되지 않기 때문에 의도적으로 방출을 시작하기 위해 있다.
@AssistedFactory
interface NoticeItemUiStateCreatorFactory {
    fun create(
        viewModelScope: CoroutineScope,
        dispatcher: CoroutineDispatcher = Dispatchers.Main,
        triggerCollection: Boolean = true
    ): NoticeItemUiStateCreator
}
class NoticeItemUiStateCreator @AssistedInject constructor(
    readFavoriteListUseCase: ReadFavoriteListUseCase,
    private val addFavoriteNoticeUseCase: AddFavoriteNoticeUseCase,
    private val removeFavoriteNoticeUseCase: RemoveFavoriteNoticeUseCase,
    private val isFavoriteNoticeUseCase: IsFavoriteNoticeUseCase,
    @Assisted private val viewModelScope: CoroutineScope,
    @Assisted private val dispatcher: CoroutineDispatcher,
    @Assisted triggerCollection: Boolean
) {
    init {
        if (triggerCollection) {
            viewModelScope.launch(dispatcher) {
                readFavoriteListUseCase().collect()
            }
        }
    }
    /**
     * Favorite에 대한 상태를 가진 [NoticeItemUiState]를 [notice]로부터 생성한다.
     */
    fun create(notice: Notice): NoticeItemUiState {
        return NoticeItemUiState(
            notice,
            onClickFavorite = { isFavorite ->
                viewModelScope.launch(dispatcher) {
                    if (isFavorite) {
                        addFavoriteNoticeUseCase(notice)
                    } else {
                        removeFavoriteNoticeUseCase(notice)
                    }
                }
            },
            isFavorite = { isFavoriteNoticeUseCase(notice) }
        )
    }
}
위의 코드는 아래와 같이 해당 ViewModel들에서 이용하고 있다.
아래를 살펴보면 noticeItemUiStateCreatorFactory를 주입받고 있다. 그리고 create(viewModelScope)를 호출하여 NoticeItemUiStateCreator를 생성하는 모습을 볼 수 있다.
@HiltViewModel
class NoticeViewModel @Inject constructor(
    getNoticeListUseCase: GetNoticeListUseCase,
    noticeItemUiStateCreatorFactory: NoticeItemUiStateCreatorFactory
) : ViewModel() {
    ...
    private val noticeItemUiStateCreator = noticeItemUiStateCreatorFactory.create(viewModelScope)
    init {
        viewModelScope.launch {
            getNoticeListUseCase().cachedIn(viewModelScope).map { pagingData ->
                pagingData.map(noticeItemUiStateCreator::create)
                ...
            }
        }
    }
}
이제 새로운 ViewModel에서 NoticeItemUiState를 생성해야 할 때 간단히 코드를 작성하여 생성할 수 있게 되었다.
      
댓글남기기