서론
이전 글에 이어서 이번엔 Domain Layer와 Presentation Layer를 설계해 보겠습니다. 클린 아키텍처를 처음 적용하면서 Domain Layer를 설계하는 것이 정말 많은 고민을 하게 해 주었습니다. 도메인 레이어가 의미 있으려면, 정말 다른 플랫폼에서도 재사용 가능할 정도로 치밀하게 비즈니스 로직이 잘 작성되어있어야 하며 그 어떤 외부 참조 없이 순수하고 클린 해야 합니다. 게다가 개인 프로젝트면 모르겠지만, 팀이 있다면 기획자, 서버개발자, 타 플랫폼 개발자가 보기에도 명확하게 짜여있어야 하며 궁극적으로는 도메인 레이어만 보고도 마치 공식문서처럼 해당 서비스의 스펙을 모두 파악할 수 있어야 합니다.
하지만 그렇게 도메인 레이어를 구축하는 건 사실상 불가능에 가깝습니다. 너무 이상적인 이야기고요. 그럼 도메인 레이어가 하는 역할은 결국 프레젠테이션 레이어와 데이터 레이어의 중간에서 의존성을 한번 순단하듯 끊어주는 것뿐입니다. 그러다 보니 클린아키텍처라는 구조에 갇혀서 극단적으로 두 레이어를 완전히 끊어내는 역할만 하게 되는 경우가 많습니다. 꼭 도메인 레이어가 없더라도 Data 레이어가 interface-impl 구조로 분리되어 있고, 의존성 역전 원칙을 적용하면 이미 두 레이어 간 의존성은 느슨해집니다. 게다가 interface 모듈과 impl 모듈로 분리해서 모듈 단위로 의존성 역전 원칙을 적용하면 도메인 모듈을 분리한 것과 완전히 동일하게 UI와 Data를 분리할 수 있고 그럼 결과적으로, 도메인 레이어는 필요가 없습니다.
또한 실제로 모든 이론과 정설들을 확립한 클린 아키텍처로 만든 프로젝트라면 프레젠테이션 모듈만 똑 때서 Swift로 만든다고 해도 잘 동작해야하죠. 하지만 실제론 어떨까요? 당장에 Data Layer의 DataStore만 보더라도 라이브러리 구현 자체에 Context를 요구하고 있습니다. 이는 Presentation Layer와 마찬가지로 Data Layer 또한 플랫폼에 종속성을 가질 수밖에 없다는 뜻이고 이 때문에 클린 아키텍처에 대한 반발이 존재하는 것 같습니다.
그럼에도 불구하고 클린 아키텍처가 인기있는 이유는 개인적인 생각으론 유지보수성이라고 생각합니다. 각 모듈별로 역할을 명확히 나누고 의존성을 분리함으로써 추후 프로젝트의 덩치가 커질 때, 또는 작은 변경 사항이 일어나도 큰 반향을 일으킬 수 있는 사이드 이펙트를 최소화할 수 있기 때문에 의미가 있다고 생각합니다. 당연한 말이지만, 완벽한 아키텍처란 존재하지 않는다고 생각합니다. 아무리 모듈을 나눠 책임을 분리하고, DI를 사용해 의존성을 외부에서 주입하고 인터페이스와 구현체를 나눠 의존성을 역전시킨다고 해도 눈에는 보이지 않는 숨은 의존성이 존재할 수밖에 없습니다. 이런 것들을 잘 나누고 분리해서 좋은 아키텍처를 설계하는 게 우리가 해야 할 일이 아닌가 싶습니다.
5. Domain Layer를 설계하자
먼저 기존 프로젝트에서 하나의 비즈니스 로직을 가지고 클린 아키텍처를 적용하기 전에 코드를 살펴보겠습니다. 기존 Domain Layer는 Model, Repository Interface, UseCase로 분리되어 있습니다. 클린 아키텍처로 마이그레이션 하면서 가장 고민되었던 부분은 UseCase의 존재였습니다. UseCase를 사용하는 이유엔 어떤 이유가 있을까요?
5-1. UseCase의 책임에 대해 생각해 보자
일관성
어떤 요구사항은 Repository를 직접 호출하고 어떤 요구사항은 UseCase를 호출하다 보면 이는 지속적으로 볼 때 코드의 일관성을 저하시켜 요지보수성을 저하시킵니다.
미래 지향성
클린 아키텍처는 요구사항이 변경되었을 때 이로 인해 전파되는 예측 불가능한 문제사항들을 최소화할 수 있도록 코드 베이스를 제공함에 목적이 있습니다.
Screaming Architecture
제가 처음 UseCase를 접할 때 UseCase를 사용 이유에 있어 가장 큰 동의를 얻은 내용입니다. Screaming Architecture란 밥 아저씨(Robert C. Martin)가 제안한 구조로 마치 소프트웨어가 "안녕? 나는 감자야? " , "반가워! 나는 호박 고구마야!"라고 소리치는 것처럼 소프트웨어의 구조만 봐도 비즈니스 로직이나 프로젝트의 도메인을 파악할 수 있어야 하는 아키텍처입니다.
서론에도 언급했듯이 클린 아키텍처에는 도메인 레이어만 보고 서비스의 스펙을 모두 파악할 수 있어야 합니다. 이 관점에서 볼 때 Screaming Architecture 아키텍처는 너무나도 매력적입니다. 존재 자체가 도메인의 역할을 담고 있으니 말이죠. 그래서 저는 이 관점을 통해 UseCase 존재 의의를 이해했습니다.
5-2. UseCase의 문제에 대해 생각해 보자
가장 직관적으로 직면한 문제는 클래스의 수가 너무 많아지는 문제였습니다. 해당 사진엔 잘려있지만 실제론 더 많은 UseCase가 존재합니다. 그리고 이 UseCase 중 대다수는 단순히 Repository를 Call 하는 책임을 가지고 있고 그 이유는 저희 프로젝트에서는 대다수의 비즈니스 로직이 ServerDriven으로 이루어져 있기 때문에 (벡엔드 분들의 피와 땀 눈물이 담긴...) 이런 현상이 발생했습니다.
또한 이런 UseCase들은 서버에서 제공하는 API에 비례해서 UseCase Class들이 점점 늘어나고 있습니다. 그래서 저는 단순히 클래스의 수가 많아지는 것과 이런 UseCase들이 올바른 비즈니스 로직을 가지고 있는지, 이 자체로 보일러 플레이트가 아닌지에 대한 의문을 갖기 시작했습니다.
이 부분에 대해선 굉장히 많은 논쟁과 의견이 있는 문제라고 생각합니다. 다만, 저도 아래와 같이 여러 비즈니스 로직이 합쳐 저 하나의 UseCase로 이어진다면 당연히 UseCase 가 필요하다고 생각합니다. 아키텍처란 것은 회사나 조직의 상황, 비즈니스 로직과 수많은 요구 사항, 개발 조직의 프로세스 등등 많은 환경에 의해 언제든 변할 수 있기 때문에 저의 프로젝트도 이런 상황에서 최선의 방향을 찾기 위해 정말 많은 고민을 했습니다.
또한 이 글을 보면서 아키텍처에 정통하신 분들도, 앞으로 저처럼 아키텍처를 설계하면서 공부해 나가는 분들도 저와 같이 요구사항에 대해 Repository에 구현할지 ViewModel에 구현할지 도메인 모델에 구현할지, 또 UseCase를 사용할지 말지 정말 많이 고민하고 또 괴롭힘 당하셨을 거라고 생각합니다. 그래서 저도 그 일련의 과정으로 나름의 규칙을 정하고 아키텍처를 설계해보려 합니다.
5-3. 빈약한 도메인 모델과 쓸모없는 유즈케이스 그리고 비대한 뷰모델
2023 DroidKnigts에서 발표된 "빈혈(anemic) 도메인 모델과 쓸모없는 유스케이스 그리고 비대한(Bloated) 뷰모델에 대해 생각해 보기" 란 영상을 보게 되었습니다. 정말 신기할 정도로 제가 고민했던 문제들을 그대로, 마치 제가 1 대 1로 질문을 한 것처럼 발표된 영상을 보고 큰 감명과 아이디어를 얻게 되었습니다. 그래서 연사자분의 발표 내용을 기반해서 도메인 레이어를 설계하게 되었습니다. 제 프로젝트의 하나의 비즈니스 로직에 대해 기존 코드를 리팩토링 하면서 이 영상에 발표된 이론들에 대해 이해해 보겠습니다.
소개할 비즈니스 로직은 검색 화면에서 API를 호출해 대한민국 지역(Ex. 서울, 경기도)의 이름에 해당하는 코드를 List에서 추출해 검색 옵션 상태를 관리하는 프로퍼티에 담는 로직입니다.
첫 번째, 빈약한 도메인 모델은 은 마틴 파울러가 주창한 이론입니다. 핵심은 도메인 모델이 DTO를 도메인 레이어에서 사용했을 때 도메인에서 데이터 레이어 방향으로 생기는 의존성을 끊거나 다른 객체들과 풍부한 릴레이션을 갖고 있는 것처럼 보이지만 가장 중요한 행위가 없고 기껏해야 게터 세터 정도만 가지고 있다고 합니다.
// Domain Model
data class AreaCode (
val code: String,
val name: String
)
두 번째, 쓸모 없는 유즈 케이스는 앞서 말한 것처럼 Repository를 Call 하기만 하는 유즈 케이스들을 말합니다. 먼저 도메인 레이어의 근본적인 역할이 무엇일까에 대해서 더 생각을 해 볼까요? 도메인엔 프로젝트나 서비스의 핵심 비즈니스 로직이나 규칙 등이 들어갑니다. 그렇다면 뭐가 비즈니스 로직이고 뭐가 비즈니스 로직이 아닐까에 대해서 더 생각해 볼 수 있겠죠.
아래 코드처럼 Repository를 그저 Call 하기만 하는 것이 비즈니스 로직일까요? 저는 이 관점에서 바라봤을 때 유즈케이스들은 비즈니스 로직이 없다는 것을 의미합니다. 이러한 패턴은 저희 프로젝트처럼 주로 서버에 많은 로직을 위임하는 경우, 즉 serverDriven일 때 많이 발생합니다. 이러한 패턴은 Useless UseCase 라고 불리며 안티 패턴으로 여겨지기도 합니다.
// UseCase
class GetAllAreaCodeUseCase(
private val areaCodeRepository: AreaCodeRepository,
) {
suspend operator fun invoke(): List<AreaCode>{
return areaCodeRepository.getAllAreaCodes()
}
}
세번째, 비대한 뷰모델입니다. 앞서 말한 빈약한 도메인 모델이 원래는 가지고 있어야 할 행위를 뷰모델이 가지게 되면서 점점 뷰모델이 많은 책임을 가지게 되고 이로 인해 코드가 복잡해지고 유지보수가 안좋아집니다. 그렇다면 이 코드에서 도메인 모델이 가져야 할 책임은 무엇일까요? 현재 onSelectedArea 메서드는 지역 정보 리스트에서 인자로 전달받은 지역 이름(areaName)의 해당하는 지역 코드를 가져옵니다. 바로 이 로직이 바로 뷰모델이 가져야 할 행위인 거죠.
// ViewModel
class SearchMainViewModel(
private val getAllAreaCodeUseCase: GetAllAreaCodeUseCase,
) : ViewModel() {
private val _areaCode = MutableStateFlow<List<AreaCode>>(emptyList())
val areaCode get() = _areaCode.asStateFlow()
private val _listSearchOption = MutableStateFlow(ListSearchOption())
val searchOption get() = _listSearchOption.asStateFlow()
fun onSelectedArea(areaName: String) {
val areaCode = areaCode.value.find { it.name == areaName }?.code
_listSearchOption.value = _listSearchOption.value.copy(areaCode = areaCode)
}
}
5-4. 도메인 모델의 규칙을 만들자
이제까지 알아본 내용들을 기반으로 이제 몇 가지 도메인 레이어에 대한 규칙을 만들었습니다.
👆 하나의 도메인 모델에 대한 행위는 도메인 모델에 만들자
이는 지금까지 살펴본 빈약한 도메인 모델을 회피하고 뷰모델의 책임을 나누기 위함입니다. 당연한 얘기지만 이렇게 책임을 나누면 가독성이 좋아지고 유지보수가 좋아지겠죠 ? 또한 단일 책임 원칙(SRP) 관점에서도 좋은 사례가 된다고 생각합니다. 단일 책임 원칙은 하나의 책임만 가져야 한다. 즉, 하나의 클래스는 최소 하나의 책임을 가져야 합니다. 그렇기 때문에 도메인 모델의 행위를 도메인이 가진다면 단일 책임 원칙을 준수할 수 있고 또 객체지향의 캡슐화 관점에서 올바른 코드가 될 수 있습니다.
✌️ 복수의 비즈니스 로직인 경우에만 유즈케이스를 만들자
Repository를 Call 하기만 하는, 비즈니스 로직이 없는 Useless UseCase는 만들지 않으며 뷰모델에 직접 Repository를 주입합니다. 여러 비즈니스 로직이 엮여 하나의 비즈니스 로직이 된다면 이 때는 유즈 케이스를 사용합니다. 이부분에서 UseCase의 장점으로 꼽을 수 있는 일관성에 어긋나는게 아닌가 생각할 수 도 있습니다. 하지만 저는 일관적이다라는 말은 하나의 규칙이 존재한다는 것을 뜻한다고 생각합니다. 그래서 프로젝트 내에서 일정한 원칙을 만들고 이 원칙을 잘 준수한다면 이 또한 일관성이 있다고 표현할 수 있다고 생각합니다.
5-5. 기존 코드를 리팩토링 해보자
data class AreaCode (
val code: String,
val name: String
)
data class AreaCodeList(
val areaList: List<AreaCode>
){
fun findAreaCode(areaName: String): String?{
return areaList.find { it.name == areaName }?.code
}
}
기존에 List 를 통해 관리하던 도메인 모델을 데이터 클래스로 한번 캡슐화해주었습니다. 물론 List<AreaCode>.XX 처럼 도메인 모델 컬렉션의 확장함수로 만들 수 도 있었지만 그것보단 데이터 클래스로 한번 캡슐화하는 것이 좀 더 가독성 측면이나 의미적으로도 명확한 의미를 가진다고 생각했습니다. 그리고 앞서 ViewModel에 있던 비즈니스 로직을 도메인 모델로 옮겨 이 도메인 모델이 행위를 갖도록 변경했습니다.
class SearchMainViewModel @Inject constructor(
private val areaCodeRepository: AreaCodeRepository
) : BaseViewModel() {
private val _areaCode = MutableStateFlow(AreaCodeList(emptyList()))
val areaCode get() = _areaCode.asStateFlow()
fun onSelectedArea(areaName: String) = viewModelScope.launch{
val areaCode = _areaCode.value.findAreaCode(areaName) ?: ""
_option.value = _option.value.copy(areaCode = areaCode)
}
}
ViewModel에선 앞서 정한 규칙으로 인해 하나의 비즈니스 로직을 구현하기 위해 Repository를 직접 주입받았습니다. 가장 큰 변경점은 이제 onSelectedArea 메소드에서 Domain Model의 메서드를 호출해주기만 하면 된다는 점입니다. 비록 지금은 간단한 로직이라 크게 달라진 점이 없다고 생각할 수 도 있습니다. 하지만 비즈니스 로직이 엄청 복잡해지고 이런 로직이 뷰모델에 다수가 존재한다면 그 땐 정말 차이를 뼈저리게 느낄 수 있을 것 같습니다.
6. 마무리
이렇게 해서 저의 첫 클린 아키텍처 도메인 설계가 끝이 났습니다. 부끄럽지만 좋은 설계가 되었는지는 잘 모르겠네요😉 이 외에도 에러처리 등등 많은 고민들이 있었는데요, 저는 개인적으로 이런 아키텍처를 설계하면서 계속해서 생각하고 고민하는 것이 정말 재미있는 것 같습니다.
참조
'PROJECT' 카테고리의 다른 글
[PROJECT] MulterError: Unexpected field (1) | 2024.02.06 |
---|---|
[PROJECT] Fused Location Provider (0) | 2024.01.28 |
[PROJECT] 프로젝트에 DataBinding & @BindingAdapter 사용해보기 (2) | 2024.01.24 |
[PROJECT]프로젝트 리팩토링 (0) | 2024.01.16 |