이번 글은 DroidKnights 2023 박종혁 님의 빈혈(anemic) 도메인 모델과 쓸모없는 유스케이스 그리고 비대한(Bloated) 뷰모델에 대해 생각해보기 에 대해서 정리와 나의 생각을 작성해보는 글이다.
이렇게 생긴 코드, 보신 적 있으신가요?
우리는 구글 아키텍쳐 UI Layer -> Domain Layer -> Data Layer 로 구성되어 왔다.
그런데 우리는 관습처럼 작성하는 아키텍쳐가 과연 객체지행에 대해서 잘 따르고 있는지 의문이다.
아래 예시를 보자.
// 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
}
}
우리는 객체지향적으로 구성하고 있는걸까?
- 데이터 추상화(Data abstraction)
- 캡슐화 (Encapsulation)
- 데이터 은닉 (Data hiding)
- 다형성 (Polymorphism)
- Composition, inheritance, and delegation.....
- etc....
캡슐화를 지키고 있나?
객체지향의 기본 원리 중 캡슐화라는 것이 있다.
캡슐화는 객체의 상태와 행위를 한 곳에 모아, 외부에서 접근하지 못하게 하는 객체지향 프로그래밍 기본 특징이다.
그런데 도메인에 정의한 객체는 객체의 상태와 행위를 한 곳에 모아 외부에 접든하지 못하게 해야 하는데
Develop과 DevelopList의 비지니스로직이 ViewModel 밖에 구현되어 있다.
// 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
}
}
빈약한 도메인 모델(Anemic Doman Model) 과 쓸모 없는 UseCase
// Usecase
class LoadDeveloperListUseCase(
private val repository: DeveloperRepository
) {
operator fun invoke(): DeveloperList {
return repository.loadDeveloperList()
}
}
The anemic domain model is really just a procedural style design.
빈약한 도메인 모델은 절차지향 스타일 디자인일 뿐입니다. - 마틴 파울러 (블로그)
위와 같이 도메인 모델이 상태만 나타내고, 모델 자체가 getter / setter 역할만 제공하는 도메인 모델을 빈약한 도메인 모델이라고 부르기 시작했다. Kotlin의 경우에는 getter과 setter이 둘 다 숨어있는 형태이기 때문에 더더욱 도메인 모델이 아닌 단순 구조체와 같은 형태를 띄고 있다.
빈약한 도메인 모델은 모델이 아닌 다른 여러 곳에서(ex. ViewModel) 여러 방식으로 비즈니스 로직이 구현되어 있을 것을 텐데
특히 유지보수가 지속적으로 진행되지 않은 프로젝트에서는 같은 값을 반환하는 비즈니스 로직이 다른 방식으로 구현되어 있는 등의 형태가 되어 있을 수도 있다.
또한 단순히 repository의 메소드를 호출하기만 하는 Usecase는 안티패턴으로 여겨지기도 하는데 그렇달고 해서 크드 일관성 및 미래의 수정에 대비해야 할 것 같아 필요할 것 같기도 하다.
단일 책임 원칙과 비대한 뷰모델(Bloated 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의 근본적인 목적은 UI로직을 처리하고 UI의 데이터를 관리하는데 역할이 있다.
그런데 getYoungDeveloper()은 UI를 위한 로직은 아닌 것 처럼 보이고 오히려 도메인 로직처럼 보인다.
뿐만 아니라, ViewModel 클래스가 과도하게 커지거나 복잡해지는 경우 너무 많은 코드와 로직이 한 ViewModel 클래스에 모이면 가독성이 저하되고 코드가 복잡해진다.
이처럼 ViewModel은 UI 관련 로직을 처리하고 데이터를 관리하는 것이 주요 역할이지만, Bloated ViewModel에서는 이외의 다양한 역할까지 포함합니다.Bloated ViewModel 내에 다양한 로직이 섞여 있으면 테스트 케이스 작성 및 유지보수가 어려울 수 있습니다.
정답은 없습니다만...
이 문제들에 대해 정답은 없다. 하지만 각자의 제품과 상황에 맞는 가이드라인을 정할 수 있다.
카카오 스타일의 가이드 라인
- UI 레이어에 도메인 로직을 구현하지 않습니다.
- 단일 도메인 모델에 대한 비지니스 로직은, 도메인 모델이 책임지도록 합니다.
- 여러 도메인 모델에 대한 비지니스 로직은, 유스케이스가 해결할 수 있습니다.
UI레이어에 도메인 로직을 구현하지 않습니다.
ViewModel은 presentation layer (UI Layer)이다.
- 데이터를 뷰에 표현한다.
- 사용자 상호작용을 위한 로직이 구현되어야 한다.
- 뷰에 표현되어야 하는 UI 상태를 기억할 수 있다.
단일 도메인 모델에 대한 비지니스 로직은, 도메인 모델이 책임지도록 합니다.
Domain Model은 스스로 필요한 로직을 구현해야 한다.
- ViewModel에서 DomainModel을 직접 다루는 로직을 구현해서는 안된다.
- UI 로직이 아니라면 Domain Method에 구현하는 것을 우선 고려해야 합니다.
// 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 }
}
여러 도메인 모델에 대한 비지니스 로직은 유즈케이스를 통해 해결합니다.
Domain 로직은 다음과 같이 UseCase로 구현되어야 한다.
- 여러 도메인 모델이 참조되는 복잡한 비지니스 로직
- UI레이어에서 직접 Data Layer를 참조하지 않도록 하는 Wrapper Layer 로직
- 특별한 비지니스 로직이 추가되어 있지 않다면 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)
}
}
나의 생각 정리
이번 글은 요즘 고민을 많이 했던 부분을 때마침 강의가 나와 어느정도 궁금증이 풀려 기분좋은 강의가 되었다.
강의를 보며 내가 가진 Domain과 ViewModel에 대한 정립을 시켜보려 한다.
abstract class ResultUseCase<in Params, R> {
operator fun invoke(params: Params): Flow<ResultStatus<R>> = flow {
emit(ResultStatus.Loading)
emit(doWork(params))
}.catch { throwable ->
emit(ResultStatus.Error(throwable))
}
protected abstract suspend fun doWork(params: Params): ResultStatus<R>
}
fun <T> Flow<ResultStatus<T>>.doWork(
scope: CoroutineScope,
isLoading: () -> Unit,
isSuccess: (T) -> Unit,
isError: (Throwable) -> Unit,
) {
scope.launch {
collect { status ->
when (status) {
is ResultStatus.Loading -> {
isLoading()
}
is ResultStatus.Success<T> -> {
isSuccess(status.data)
}
is ResultStatus.Error -> {
isError(status.throwable)
}
}
}
}
}
// Domain Model
data class Student(accessKey: String, name: String, age: Int, university: String)
data class StudentList(val students: List<Student>)
data class University(val title: String, val image: String)
// SAM interface
fun interface getStudentListUseCase: () -> StudentList
// Usecase with business logic for multiple domain models
class GetUniversityUseCase(
private val studentRepository: StudentRepository
private val universityRepository: UniversityRepository
) : ResultUseCase<String, University>{
override suspend fun doWork(accessKey: String): ResultStatus<University> {
return try {
val students = studentRepository.getStudent()
val university = universityRepository.getUniversity(students.university)
return ResultStatus.Success(university)
} catch (throwable: Throwable) {
ResultStatus.Error(throwable)
}
}
}
출처
- 드로이드 나이츠 2023 빈혈(anemic) 도메인 모델과 쓸모없는 유스케이스 그리고 비대한(Bloated) 뷰모델에 대해 생각해보기