[Android] 도메인 레이어, 그리고 ViewModel

cotton·2023년 11월 1일
0

Android

목록 보기
1/1

이 글은 DroidKnights 2023에서 발표된 빈혈(anemic) 도메인 모델과 쓸모없는 유스케이스 그리고 비대한(Bloated) 뷰모델에 대해 생각해보기 내용과 함께 제 생각을 정리한 글입니다.

안드로이드 앱 아키텍처 가이드

MVC, MVP, MVVM … 어느정도 학습하고 숙련된 안드로이드 개발자라면, 어떤 걸 얘기하고자 하는지 바로 알 수 있을 것입니다. 안드로이드 프로젝트를 구성하는 대표적인 디자인 패턴에 대한 내용입니다.

우리는 개발하면서 지속적으로 커져가는 프로젝트를 관리하기 위해서 위와 같은 디자인 패턴을 사용해 왔습니다. 그리고 시간이 지나면서, 구글과 안드로이드는 개발자에게 안드로이드 권장 아키텍처 가이드 문서를 제공했습니다.

구글은 안드로이드 프로젝트가 최소 UI 레이어와 데이터 레이어로 구성되어 있어야 하며, UI 레이어와 데이터 레이어 간 상호작용을 간소화하고 재사용하기 위해서 선택적으로 도메인 레이어를 사용하게 했습니다.

예시

// Domain Model
data class Developer(name: String, age: Int)

data class DeveloperList(val list: List<Developer>)

// Repository
interface DeveloperRepository {
	fun loadDeveloperList(): DeveloperList
}

// Usecase
class LoadDeveloperListUseCase(
 	private val repository: DeveloperRepository
) {
 	operator fun invoke(): DeveloperList {
		return repository.loadDeveloperList()
    }
}

// ViewModel
class DeveloperListViewModel(
	private val loadDeveloperListUseCase: LoadDeveloperListUseCase
): ViewModel() {
	private val developerList = MutableStateFlow(listOf<DeveloperList>())

	private fun load() = viewModelScope.launch {
		developerList.value = loadDeveloperListUseCase()
	}

	fun getYoungDeveloper() = developerList.filter {
		it.age < 30
	}
}

발표 자료에 있던 코드를 그대로 가져왔습니다.

처음 이 코드를 보았을 때, 어떤 생각이 들었나요? 그냥 일반적으로 안드로이드 권장 패턴을 지켰다 라고 생각이 드는지, 아니면 어떤 문제점이 있다고 생각이 들었나요?

뭐가 문제일까?

발표 내용에서는 해당 방식으로 구현하는 패턴은 문제가 있는 패턴이라는 의견을 주셨습니다. 위 코드는 객체 지향의 기본 원리에 부합하지 않기 때문입니다.

캡슐화 (Encapsulation)

객체 지향의 기본 원리 중 하나인 캡슐화는 객체의 상태와 행위를 한 곳에 모아, 외부에서 접근하지 못하게 하는 객체 지향 프로그래밍의 특징입니다.

// Domain Model
data class Developer(name: String, age: Int)

data class DeveloperList(val list: List<Developer>)

// ViewModel
class DeveloperListViewModel(
	private val loadDeveloperListUseCase: LoadDeveloperListUseCase
): ViewModel() {

	fun getYoungDeveloper() = developerList.filter {
		it.age < 30
	}
}

캡슐화의 정의를 보고, 위 로직을 다시 봅시다. DeveloperDeveloperList 는 캡슐화를 위반했습니다. 그 이유는 무엇일까요?

DeveloperDeveloperList 는 비즈니스 로직을 ViewModel, 즉 도메인 모델 밖에 구현되어 있습니다. 이런 방식은 각 객체의 상태를 적절하게 캡슐화하지 못한 상태입니다 이는 해당 도메인 모델을 유지보수 하는데 어려움을 발생시킬 수 있습니다.

빈약한 도메인 모델 (Anemic Domain Model)

The anemic domain model is really just a procedural style design.
빈약한 도메인 모델은 절차지향 스타일 디자인일 뿐입니다. - 마틴 파울러

위와 같이 도메인 모델이 상태만 나타내고, 모델 자체가 getter / setter 역할만 제공하는 도메인 모델을 빈약한 도메인 모델이라고 부르기 시작했습니다. Kotlin의 경우에는 getter과 setter이 둘 다 숨어있는 형태이기 때문에 더더욱 도메인 모델이 아닌 단순 구조체와 같은 형태를 띄고 있습니다.

빈약한 도메인 모델은 모델이 아닌 다른 여러 곳에서(ex. ViewModel) 여러 방식으로 비즈니스 로직이 구현되어 있을 것을 텐데요. 특히 유지보수가 지속적으로 진행되지 않은 프로젝트에서는 같은 값을 반환하는 비즈니스 로직이 다른 방식으로 구현되어 있는 등의 형태가 되어 있을 수도 있습니다.

쓸모 없는 유즈케이스 (Useless Use Case)

// Usecase
class LoadDeveloperListUseCase(
    private val repository: DeveloperRepository
) {
    operator fun invoke(): DeveloperList {
		return repository.loadDeveloperList()
    }
}

그렇다면 이번에는 유즈케이스를 한번 보았을 때, 위 유즈케이스는 좋은 코드일까요? 생각보다 유즈케이스를 사용하고 있는 프로젝트들에서 이런 유즈케이스 코드들이 많이 보입니다.

이런 유즈케이스는 비즈니스 로직을 가지고 있다고 할 수 없습니다. 단순히 Repository를 매핑하고 있는 형태로 구현되어 있는 상태인데요, 왜 이런 유즈케이스 코드들이 많이 보일까요? 아마 프로젝트 내에서 유즈케이스를 사용하고 있는 프로젝트라면 코드 일관성을 위해서 단순히 Repository를 매핑하는 코드들을 많이 작성하기도 합니다.

이런 식의 단순 매핑, 없어도 되는 UseCase를 쓸모 없는 유즈케이스라고 합니다. 이를 안티패턴으로 보는 개발자도 있고, 일관성을 위해서 있어야 하는 코드라고 생각하는 개발자들이 있습니다. 해당 내용은 개발자마다 의견이 많이 갈릴 수 있습니다.

단일 책임 원칙 (Single Responsibility Principle)

단일 책임 원칙이란 객체 지향의 원리인 SOLID 원칙 중 하나로, 객체나 클래스는 하나의 책임만 가져야 한다는 원칙을 나타냅니다. 또한 단일 책임 원칙을 준수하는 객체나 클래스는 명확한 하나의 책임을 수행해야 하고, 해당 역할에 집중해야 합니다.

비대한 뷰모델 (Bloated ViewModel)

단일 책임 원칙에 의해서라면, 모든 객체와 클래스들은 하나의 역할을 해야 합니다. 하지만 우리가 작성하는 ViewModel의 경우에는 하나의 역할을 하고 있나요?

// ViewModel
class DeveloperListViewModel(
	private val loadDeveloperListUseCase: LoadDeveloperListUseCase
): ViewModel() {
	private val developerList = MutableStateFlow(listOf<DeveloperList>())

	private fun load() = viewModelScope.launch {
		developerList.value = loadDeveloperListUseCase()
	}

	fun getYoungDeveloper() = developerList.filter {
		it.age < 30
	}
}

위 코드를 다시 보았을 때, 이 ViewModel은 몇 가지의 역할을 하고 있나요? ViewModel의 근본적인 역할이 무엇인지 다시 생각해 본다면, ViewModel은 UI 관련 로직을 처리하고 해당 데이터를 관리하는 것이 가장 근본적인 목적입니다.

getYoungDeveloper() 가 하고 있는 역할은 비즈니스 로직이 맞을까요? Developer , DeveloperList 객체에서 가져가야 하는 도메인 로직이 아닐까요?

위 코드는 단순 예제이기 때문에 짧게 느껴질 수 있습니다만, 위와 같은 코드들이 계속 늘어난다면 결국 ViewModel 클래스가 과도하게 커질 수 있습니다. 데이터를 기반으로 하는 복잡한 화면에 대해서 ViewModel 클래스를 작성해본 경험이 있다면 어느 정도 공감하실 수 있을 것 같습니다.

이런 문제를 가진 여러 로직들이 ViewModel에 지속적으로 쌓이면서 가독성이 저하되고 코드의 복잡도가 높아진 ViewModel을 비대한 뷰모델(Bloated ViewModel)이라고 합니다. 이러한 비대한 뷰모델은 여러 로직들이 섞여 있는 상태이기 때문에 UI 관련 로직 뿐만 아니라 다른 로직들이 포함되어, 테스트 케이스 작성 및 유지보수가 어려운 상태가 됩니다.

해결법?

이 문제들을 해결할 수 있는 명확한 정답은 없다 입니다. 이 부분에 대해서는 개발자마다의 관점이 다르고, 생각이 다르기 때문에 명확한 정답이 있는 내용은 아닙니다. 하지만 개인 프로젝트이거나, 회사 프로젝트에서, 어떤 경우이던 디자인 패턴을 한 번이라도 고려해 본 적이 있다면, 이와 같은 고민을 한 경험이 있을 겁니다.

그런 고민을 토대로, 개인 프로젝트에서는 자신의 고민을 해결할 수 있는 방법으로 구현하거나, 팀 또는 회사에서는 팀원들과의 논의를 통해 각 레이어에 어떤 처리를 해 줄지를 개발 차원이 아니라 사람 차원에서 고려해서 개발을 진행하는 것이 좋다고 합니다.

카카오스타일의 노력

UI 레이어에 도메인 로직을 구현하지 않습니다.

ViewModel은 UI 레이어에 포함되어 있으며, ViewModel에는 데이터를 뷰에 표현하거나, 사용자의 상호작용을 위한 로직을 구현한다는 기준을 세웠습니다. 즉, ViewModel을 View의 Model 형태로써 해석한 것입니다.

따라서 ViewModel는 UI를 표기하기 위한 State 값을 저장하는 등으로써 사용됩니다.

단일 도메인 모델에 대한 비즈니스 로직은 도메인 모델이 책임진다

도메인 모델은 모델로써, 각자 필요로 하는 로직이 구현할 수 있게 했습니다. ViewModel에서는 도메인 모델에 대한 로직을 알 필요가 없이, 도메인 모델에서 모든 행위를 완료한 채로 데이터를 받을 수 있게 도메인 메소드에 우선 구현하는 방식을 택했습니다.

// Domain Model with behaviors
data class Developer(name: String, age: Int) {
	val isYoung = age < 30
}

data class DeveloperList(val list: List<Developer>) {
	fun getYoungDevelopers() = list.filter { it.isYoung } 
}

여러 도메인 모델에 대한 비즈니스 로직은 유즈케이스를 통해 해결한다

여러 도메인 로직들은 하나의 UseCase에서 모두 처리되어 구현하는 형태를 택했습니다. 여러 도메인 모델이 참조되어 복잡하게 구현이 필요한 비즈니스 로직 같은 경우에 해당됩니다.

그 이외에 카카오스타일은 코드 일관성을 위해서 Repository를 Wrapper하는 UseCase의 경우에는 SAM interface를 이용하여 처리했습니다.

// Repository
interface DeveloperRepository {
    fun loadDeveloperList(): DeveloperList
}

// SAM interface (Repository Wrapper UseCase)
fun interface LoadDeveloperListUseCase: () -> DeveloperList

// dependency injection with Dagger
@Provides
@Singleton
fun provideLoadSpeakerListUseCase(
	developerRepository: DeveloperRepository
): LoadSpeakerListUseCase {
	return LoadSpeakerListUseCase(developerRepository::loadDeveloperList)
}

// dependency injection with Koin
single<LoadSpeakerListUseCase> {
		 LoadSpeakerListUseCase(get<DeveloperRepository>()::loadDeveloperList)
}

// Usecase with business logic for multiple domain models
class LoadDeveloperListWithCompanyNameUseCase(
	private val developerRepository: DeveloperRepository
) {
	operator fun invoke(company: Company): DeveloperList {
		return developerRepository.loadDeveloperList()
			.concatCompanyName(company)
	}
}

참고 자료

https://youtu.be/3mR8_vT7m1U?si=EA3ILH-2vnW0l6t2

https://youtu.be/fzEFgIreHHM?si=4_GuWsz6-kBVLDp7

https://developer.android.com/topic/architecture

https://www.droidcon.com/2022/08/03/bloated-viewmodels-what-next/

https://betterprogramming.pub/how-to-avoid-use-cases-boilerplate-in-android-d0c9aa27ef27

https://mashup-android.vercel.app/mashup-11th/heejin/useCase/useCase/

profile
안드로이드 개발자

0개의 댓글