프로젝트 중 MVC
로 되어있던 레거시 코드들을 클린아키텍처와 MVVM
디자인 패턴을 도입하여 리팩토링 했었던 것에 대해 나의 의도와 생각을 말해보고자 한다.(클린 아키텍처에 대해서는 다소 간략하게 소개를 하고 리팩토링 과정에 대해 포커싱을 둘 것이다.)
이번 편에서는 클린아키텍처로의 리팩토링에 대한 나의 생각을 적을 것이고 이후에 2편에서 MVVM
에 대한 생각도 적어볼 예정이다.
안드로이드 진영에서 이제는 정석이 되었다고 해도 무방한 클린아키텍처에 대해 말해보고자 한다.
아키텍처는 소프트웨어의 구성 요소들의 유기적 관계를 표현하고 요구사항을 해결하려는 계획 과정등의 원칙을 말한다.
안드로이드의 정책이 바뀌고, 요구사항이 변경되어 지속적인 유지보수가 필요한 상황들이 반드시 오게 된다.
이때 애플리케이션은 당연하게도 점점 거대해지고 유지보수비가 늘어나는 것이 필연적이다.
아키텍처가 잘 설계된 애플리케이션이라면 유지보수 비용이 점차 줄어들 것이지만 그렇지 않은 애플리케이션이라면 급속도로 유지보수 비용이 늘어날 것이다.
추상적으로 말하자면 유연하게 변경하며 견고한 구조를 만들기 위한 소프트웨어 철학이자 원칙이다.
이 원칙에 대해 좀 더 구체적으로 말하자면 아래와 같다.
소프트웨어의 관심사를 계층별로 분리하여 코드 종속성이 외부로부터 내부로 의존하도록 한다. 즉 구성 요소를 계층적으로 분리하고 코드 변동성은 원 바깥으로 향할수록 크고 의존성은 원 바깥쪽에서 안쪽으로 향하도록 한다.
관심사 분리가 핵심이라고 생각한다.
엔티티는 어떤 서비스를 제공해야 하는지에 대한 중요 로직을 포함한다. 즉 비즈니스 로직의 핵심 구성요소이다.
유즈케이스는 사용자와 시스템 간 주요 활동을 기술하고 엔티티 간 데이터 흐름을 조정한다. 또한 비즈니스 로직을 캡슐화하는 역할을 한다.
도메인 지식은 서비스에 대한 전문적인 지식을 뜻한다. 즉, 도메인 레이어의 역할은 애플리케이션이 어떤 서비스를 제공해야 하는 지에 대한 핵심 비즈니스 로직을 포함하고 있는 계층이다.
리포지터리는 데이터를 생성, 저장, 변경하는 방식을 결정하고 데이터 소스에서 생성한 모델을 엔티티로 변환하여 전달한다.
프레젠터는 뷰의 상태를 생성하는 역할을 한다.
데이터 소스는 네트워크 통신 등을 통해 데이터를 생성, 저장, 변경하는 역할을 한다.
유저 인터페이스는 사용자와 상호작용 및 데이터를 화면에 표시한다.
실제 데이터를 갖고 뷰상태를 생성하고 화면에 표시하는 역할을 한다.
플랫폼과 가장 의존성이 높은 UI 레벨의 레이어로서 MVP
, MVVM
등과 같은 패턴을 사용할 수 있다.
Presentation Logic
은 Domain Layer
를 대상으로 의존성을 가지고 우리는 MVVM 패턴을 사용중이었기에 패턴도 그대로 적용하였다.
Domain Layer
는 애플리케이션이 어떤 서비스를 갖고 있는 지를 담고 있고, 비즈니스 로직을 포함하고 있다. UseCase
를 통해 넘겨줄 데이터를 담을 Model
과 Presentation Layer
와 Domain Layer
를 이어줄 UseCase
, 그리고 Data Layer
의 데이터 소스를 통해 데이터를 가져올 Repository
가 있다.
데이터를 생성, 저장, 변경하는 역할을 한다.
데이터 레이어에서의 모델은 오로지 데이터 전송을 위한 클래스이며 별도의 로직을 포함하지 않는다.
위의 전체 구조를 보면 Data Layer
의 리포지터리 구현체와 Domain Layer
에 리포지터리 인터페이스가 있을 것이다.
그렇다면 이 리포지터리는 왜 필요한 것일까?
리포지터리가 없는 상태를 보자.
리포지터리가 없는 경우이므로 도메인 레이어
는 잠시 생략해두고 생각해보자.
Presentation Layer
의 뷰모델은 데이터 레이어
에 의존성을 두고있다.
뷰모델에서 동일한 뷰 상태를 생성하더라도 모델의 타입이 다른 경우 별도의 로직을 추가해야 한다.
즉, 모델의 타입이 늘어날수록 뷰모델이 복잡해질 것이다. 이러한 문제를 해결하기 위해 데이터 레이어
에 리포지터리를 추가해보자.
뷰모델에서 공통된 뷰 상태를 생성할 때 각 모델의 출처 및 타입을 알지 못하게 하기 위해 리포지터리를 추가하고 엔티티를 추가하였다.
리포지터리는 데이터 소스에서 생성한 모델을 갖고 통일된 데이터인 엔티티를 전달한다. 그래서 모델 타입이 늘어나더라도 뷰모델 로직은 변경되지 않는다.
데이터는 Data Layer
의 데이터 소스를 통해 Domain Layer
로 흐르게 되어있다.
그래서 Domain Layer
는 Data Layer
를 참조해서 데이터를 조회해야 하기 때문에 Data Layer
에 의존성을 갖는다.
이러한 구조는 클린아키텍처의 의존성 규칙에 위반된다.
문제가 뭐냐하면, Data Layer
의 인프라가 변경이 되면 비즈니스 로직을 담당하는 Domain Layer
가 변경이 된다.
Retrofit
을 사용하다가 Volley
로 변경했다고(혹은 로컬 DB로) 해서 비즈니스 로직이 변경이 된다는 것이다.
그저 동일한 역할을 하는 라이브러리로 교체를 한 것 뿐인데...
(의류 판매 업체의 비즈니스가 스파오에서 유니클로로 바뀐다고 해서 의류 판매가 아닌 음식 판매 업체가 되지 않는 것이 당연한 것처럼?)
클린아키텍처는의 원칙은 코드 종속성이 외부로부터 내부로 의존하여야 하는데, Domain Layer
는 Data Layer
보다 내부에 있기 때문에 이 문제를 해결하기 위해 리포지터리 인터페이스가 필요하다.
그래서 Domain Layer
에 추상적인 리포지터리 인터페이스를 두고 Data Layer
에 구체적인 리포지터리 인터페이스 구현체를 둠으로써 의존성 관계를 역전시킬 수 있다.
프로젝트 내에 코틀린
, 클린아키텍처
, MVVM
으로 되어있는 부분들도 꽤 있었지만 자바와 MVC 패턴으로 된 부분도 상당했다. 이 상황을 보고 오히려 기뻤다. 주니어 개발자로서 레거시 코드를 리팩토링 할 수 있는 기회가 있다는 것은 내가 알고있는 지식을 직접 적용해볼 수 있는 좋은 경험과 동시에 트렌디한 기술, 아키텍처 등이 왜 나오게 되었고, 왜 인기가 많은 지에 대해 레거시와 비교하며 체감할 수 있기 때문이다.
개선했던 코드 중 검색 결과 화면에 대한 일부분을 예시로 들겠다.
검색 결과 화면에서의 수많은 기능 중 검색 키워드를 입력하여 해당 키워드에 맞는 상품들을 보여주는 기능이 있다. 정리하면 아래와 같다.
우선 코드자체가 Activity/Fragment
에 몰려있었으며 Controller
또한 많은 양의 코드가 몰려있었다. 또한 서로 굉장히 깊은 커플링이 있었고 이는 하나만 수정해도 연쇄적으로 수정해야 하는 어려움이 있었고 일단 파악하는데 코드 보기가 너무 불편했다..
또한, 커플링이 심했고 API
호출하는 부분이 Activity/Fragment
와 Controller
에 있었기 때문에 API 모델이 조금만 바뀌어도 수정할 부분이 굉장히 많았고 수정 또한 쉽지 않았다.
우선 어떤 서비스를 담고있는지(검색)를 나타내는 Domain Layer
부터 작업을 했다.
검색 결과에 대한 정보들을 담고있는 SearchItemEntity
, 이 데이터를 뷰상태로 만들기 위해 뷰모델에서 참조할 GetSearchItemsUseCase
그리고 검색 결과 API를 호출하기 위한, 의존성 역전 원칙을 적용한 Search Repository Interface
를 만들었다.
그 후 Data Layer
에서 검색 API를 통해 데이터를 가져올 모델과 SearchApiDataSource
작업을 진행했고, 이를 SearchRepository
의 구현체인 SearchRepositoryImpl
작업을 했다.
그 후 Presentation Layer
에서는 SearchResultViewModel
을 생성하여 UseCase
를 통해 데이터를 전달 받아 뷰 상태를 관리하였다.
리팩토링을 진행하며 UI Model
을 도입하였다. 기존의 클린아키텍처가 적용되어있는 부분들은 Presentation Layer
에서 별다른 모델을 두지 않고 Domain Layer
의 Entity
를 사용 중이었다.
얼핏보면 Data Layer
에서 변경이 되어도 뷰모델에서 수정사항이 생기지 않도록 하기 위해 Domain Layer
의 리포지터리와 Entity
가 존재하는 것이므로 그대로 Presentation Layer
에서 사용해도 되는 것 아닌가? 라는 생각이 들 수도 있다.
하지만 여기에는 문제가 있다.
내 생각에 UI Model
이 필요한 이유는 기본적으로 클린아키텍처의 주요 원칙인 관심사 분리에 어긋나지 않기 위함이다.
UI Layer
에서 Entity
를 직접 사용하면 UI와 비즈니스 로직을 담당하는 Domain Layer
와의 아주 강한 커플링이 생기게 된다.
이는 관심사 분리를 위배한다는 것이 되고, 아래에서 좀 더 설명을 하겠다.
우리는 다양한 상호작용이 있는 앱을 제공하고 있고,
코드 변동성은 원 바깥으로 향할수록 커야 한다.
다채로운 UX가 많이 개발 되면서 요구사항 또한 많아졌고 강조 표시를 할 상품과 그렇지 않은 상품, 이벤트 전달 방식 등 여러 형태로 개발자들이 개발/유지보수에 대해 고려해야 할 부분이 많아졌다.
다채로운 UX가 많이 개발 되면서 요구사항 또한 많아졌고 강조 표시를 할 상품과 그렇지 않은 상품, 이벤트 전달 방식 등 여러 형태로 개발자들이 개발/유지보수에 대해 고려해야 할 부분이 많아졌다.
위의 화면은 상품 검색 시 브랜드 필터 화면이다.
브랜드 리스트 중 사용자가 선택한 브랜드들이 무엇인지 판단 후 해당 브랜드들의 Idx
를 검색 필터에 넣어줘야 한다.
선택 여부를 판단하기 위해 브랜드 Model
에는 isSelected
와 같은 Boolean
타입이 새롭게 필요하다.
이 때 Domain Layer
의 Entity
를 Presentation Layer
에서 사용한다면 UI 상태 변화를 위해 Entity
가 수정되어야 한다.
즉, 원 안쪽에 있는 Domain Layer
가 변경이 잦은 원 바깥쪽의 Presentation Layer
에 의존하게 된다.
그러므로 클린아키텍처의 원칙을 위반하게 된다.
또한, 이와 함께 오는 문제가 Domain Layer
는 우리의 서비스에 대한 정보 등을 담고 있다. 그래서 한 화면 혹은 기능에서만 의존하고 있는 것이 아니라 여러 군데에서 의존하고 있을 가능성이 크다.
결국 위에서 언급했던 내용이기는 하지만 브랜드 필터를 위해 다른 브랜드 관련 기능에서 필요없는 프로퍼티 등이 추가되어 불필요한 작업이 수행될 수 있다는 점이다.
UI Model
을 생성하고 Mapper
를 이용하여 Entity
를 변환하면 된다.
data class ItemBrandDataUiModel(
var brandIdx: String = "",
var korean: String = "",
var english: String = "",
val count: Int = 0,
var isSelected: Boolean = false
) : Parcelable
/**
* Mapper
* [ItemBrandDataEntity] to [ItemBrandDataUiModel]
*/
fun ItemBrandDataEntity.toUiModel(): ItemBrandDataUiModel {
return ItemBrandDataUiModel(
brandIdx = this.brandIdx.orEmpty(),
korean = this.korean.orEmpty(),
english = this.english.orEmpty(),
isHighlighting = false.orFalse(),
)
}
Mapping
에 대한 수행은 ViewModel
에서 하면 된다.
ViewModel
의 역할은 리포지터리에서 데이터를 가져와 뷰 상태를 생성하는 것이므로 데이터를 사용하는 과정에서 Mapping
하여 뷰 상태를 만들어내는 것이라고 생각하면 된다.
/**
* 상품 브랜드 목록 가져오기
*/
fun getBrands(query: String?) {
if (query.isNullOrEmpty()) {
refreshItem()
}
getItemBrandUseCase(ItemBrandRequest(q = query), PageRequest())
.map { pagingData -> pagingData.map { it.toUiModel() } }
.onEach { _brands.value = it }
.launchIn(viewModelScope)
}
Domain Layer
는 제일 안쪽에 있는 계층이므로 바깥쪽에 있는 계층에 의존적이지 말아야 한다. 즉, 안드로이드 플랫폼에도 종속되지 말아야 하며 순수 자바/코틀린만이 존재해야 한다는 것이 나의 생각이다.
하지만 로그를 남기기 위해서 안드로이드 플랫폼에 의존적인 android.util.Log
가 사용되었다.
이를 해결하기 위해서는 Logger
를 추상화 시키고 Log Module
이나 Util
, Common Module
등을 따로 두고 거기에서 구현을 하면 될 것이다.
그럼에도 불구하고 고민이 되었던 점은 '위치'였다. 현재 우리 프로젝트는 Presentation
, Domain
, Data
, Common
, Design
으로 총 5개 모듈로 나뉘어져 있었다.
Logger Module
을 만들거나 Common Module
에 넣으면 될 것 아닌가? 라고 생각 할 수도 있겠지만 이것도 나름의 사정이 있었다.
우리의 개발문화는 굉장히 개방적이고 변화에 자연스러운 조직 문화였지만 함께 작업하는 프로젝트이니만큼 최소한의 논의는 필요했다.
하지만 우리는 시간이 촉박했고 이것 말고도 의논해야 할 것, 각자 할 일, 스프린트 등이 기다리고 있었다.
android.util.Log
는 안드로이드 플랫폼에 의존적이기는 하지만 변동가능성이 적어 의존을 한다고 해도 Domain Layer
에 미치는 영향이 아주 적을 것이라고 생각이 들었기에 우선순위에서 밀려난 후 아직 내 고민 리스트에 추가만 되어있는 상태이다.
UI Model
을 도입하면서 Entity -> UI Model
에 대한 Mapper
코드의 보일러 플레이트 코드가 많아졌다.
Data Layer -> Domain Layer
에서의 Mapper
는 Entity
자체를 공통으로 사용하는 경우가 많고 한 번 작성해 놓으면 API가 변경되지 않는 이상 수정할 일이 크게 없기 때문에 괜찮았지만 UI Model
같은 경우 여러 화면에서 다양한 형태로 사용되기 때문에 중복 코드가 많아졌다.
이는 코드를 작성할 때의 피곤함과 더불어 코드 생산성에도 귀결되는 내용이다.
물론 해결책은 있었다. 어노테이션을 사용하여 Mapping
을 쉽게 도와주는 라이브러리도 있었고, 확장함수를 만들어 사용하는 방법도 있었다.
하지만 이를 채택하지 않고 고민을 했던 이유는 '성능' 때문이다. 라이브러리 내부를 뜯어봐도 그렇고, 확장함수를 사용하는 방법 또한 리플렉션을 사용해야 했기 때문이다.
(리플렉션을 사용할 경우 런타임에 많은 연산을 요구하고 또한 컴파일러의 최적화가 제한될 수 있기 때문)
성능 체크를 직접 해보고 비교하여 트레이드 오프(trade-off)를 감안하고 팀원들과 함께 결정할 수도 있었겠지만 이것 또한.. 시간이 문제였다. 그래서 우선순위에서 밀려나 아직 내 고민 리스트에 남아있다.
하지만 명확한 것은 이러한 고민 때문에 UI Model을 도입하지 않는 것은 다소 이해가 가지 않는 입장이었기에 UI Model은 도입하였다.
기존 코드를 리팩토링 하기로 마음 먹고 코드를 살펴보자마자 기존 코드는 모두 제거하고 새롭게 만들어야 겠다는 생각을 했다.
일단 우리는 멀티모듈(Presentation, Domain, Data, Common, Design)의 형태로 클린아키텍처를 적용중이었는데, 커플링이 너무 심하여 기존 코드에서 일부분씩 떼어내서 작업을 하는 것은 거의 불가능했고, 가능해도 시간이 몇배는 더 걸릴 것 같았기 때문이다.
하지만 새로 뜯어내는 것도 쉽지 않았다. 검색 결과 화면에는 굉장히 많은 코드들이 있었고 MVC 형태로 되어있었기에 기존 동작들에 대한 명세를 파악하기 힘들었다.
리팩토링을 했을 때 가장 중요한 점은 기존과 기능이 동일해야 하는 것이기에 파악이 중요했는데.. 모델, 뷰, 컨트롤러가 하는 역할이 분리되어있기는 했지만 그 경계가 모호한 것들도 많았고, 역할들이 몰려있어서 파악이 더 힘들었다.
뷰와 상호작용하는 코드들과 호출하는 API 그리고 트래킹 등을 문서에 정리했고, 안드로이드 프로젝트 두 개를 켜놓은 후 한 개는 레거시 코드를, 한 개는 리팩토링 할 프로젝트를 화면에 띄워놓고 한 부분씩 위의 클린아키텍처를 적용해 나갔다.