Skip to content

아키텍쳐 설계: MVVM Clean Architecture RxSwift

Jeon, Yeo Hun edited this page Dec 2, 2021 · 1 revision

아키텍쳐 설계: MVVM + Clean Architecture + RxSwift

MVVM

MVVM은 iOS 개발을 한다면 익숙한 구조일 텐데요, View, ViewModel, Model로 역할을 분리하는 구조입니다. 기존 MVC구조는 Controller에서, 특히 iOS에서는 뷰 컨트롤러에서 너무 많은 일을 하다 보니 역할이 과중되고, 단순한 로직을 테스트하기 위해서 UIkit을 추가해야 한다는 문제가 있었습니다. 

MVVM을 도입하면 아래와 같이 앱의 구조를 나눌 수 있습니다.

각 영역들과 역할을 정리해보면,

1. 뷰와 뷰 컨트롤러: 정말 화면을 그리는 역할을 담당합니다. 

2. 뷰 모델: 화면에서 필요한 정보를 만들기 위한 로직을 실행하고 모델에 데이터를 저장하는 역할을 담당합니다.

3. 모델: 실제 데이터를 저장합니다.

우리 앱에서는?

메이트 러너에서는 아래와 같이 현재 달리기에 대한 정보를 화면에 표시해줍니다.

이 화면에 대해서 앱 구조를 MVVM으로 나누면 아래처럼 나눌 수 있습니다.

뷰 모델은 타이머를 시작하거나, 새로운 칼로리 정보를 갱신하고 거리를 갱신하는 비즈니스 로직을 포함하고, 모델의 데이터가 화면에 보일 수 있도록 형태를 변환하는 역할을 수행합니다.

예를 들어서 경과시간은 뷰 모델에서 타이머를 시작한 뒤, 초단위로 모델에 계속 저장하고 화면에 보일 때는 hh:mm:ss 포맷의 문자열로 변환한 뒤에 뷰 컨트롤러에게 문자열을 넘겨주면 뷰 컨트롤러는 받은 문자열을 표시하기만 합니다.

이렇게 하면 뷰는 정말 화면을 그리는 데에만 집중할 수 있고, 뷰 모델은 비즈니스 로직과 모델의 관리를 담당하면서 실제로 앱을 실행하지 않고도 뷰에 어떤 데이터가 전달되어 그려질지 뷰 모델만 따로 테스트를 할 수 있습니다.

분리는 됐지만 여전히 복잡해..

MVVM 구조를 통해서 뷰와 로직은 분리해낼 수 있었지만, 또 다른 문제가 발생하는데요, 앱이 점점 복잡해지기 시작하면서 뷰 모델이 너무 많은 일을 하게 된다는 것입니다. 

실제로 위 화면에서 칼로리를 계산하려면 Core Motion이라는 네이티브 프레임워크를 통해 사용자의 움직임 정보를 알아야 하고, Core Location 네이티브 프레임워크를 통해 위치 정보도 알아내야 합니다. 더군다나 친구와 실시간 통신 기능이 추가된다면 네트워크 요청과 처리까지 뷰 모델에서 모두 수행하게 됩니다.

이렇게요.. 벌써 엄청 복잡해 보이죠..? 그래서 뷰 모델의 부담을 줄여주기 위해서 클린 아키텍처를 도입하게 되었습니다.

클린 아키텍처

저희 팀에서는 이번 프로젝트에 클린 아키텍처를 적용하기로 했는데요, 클린 아키텍처를 먼저 간단하게 설명할게요.

 iOS-Clean-Architecture-MVVM

저희 팀은 위 레포에서 소개하는 iOS 클린 아키텍처와 부스트캠프 마스터이신 JK님에게 훈련받은 JK-iOS-Clean-Architecture를 기준으로 아키텍처를 도입했습니다. 

클린 아키텍처는 크게 세 레이어(계층)로 나뉘게 됩니다.

위 그림에서 가장 외부에 있는 UI, API, DB 등은 단위 테스트를 할 수 없는 영역들입니다. 우리가 생성하는 뷰가 여기에 해당되고 서버나 데이터베이스도 여기에 해당되죠. 뷰 컨트롤러는 조금 애매하지만 우리 앱에서 뷰 컨트롤러는 뷰를 그리는 역할만을 하는 것이 원칙이기 때문에 외부 레이어로 분류할 수 있습니다.

Presentation은 화면에 보이는 영역을 담당하는 레이어입니다. MVVM에서는 뷰 모델이 여기에 해당합니다. 뷰 모델이 왜 포함되냐고요? 이제부터 기존 뷰 모델에서 비즈니스 로직은 제외하고 화면에 필요한 데이터에만 집중합니다. 이제 우리 앱에서 뷰와 뷰 컨트롤러는 화면을 그리는 역할, 뷰 모델은 뷰에 그려질 데이터를 만드는 역할만 수행하죠.

뷰 모델에서 분리된 비즈니스 로직 유즈 케이스(UseCase)라는 이름으로 Domain 레이어에 위치하게 됩니다. Domain 레이어는 데이터를 표현하는 모델을 갱신하거나 앱에 필요한 주요한 로직들을 수행하는 것을 담당합니다. 그리고 비즈니스 로직에 의해 영향을 받게되는 모델들이 이 레이어에 위치하게됩니다.

마지막으로 Data 레이어는 Presentation 레이어와 같은 수준의 계층으로 데이터를 불러오는 역할을 수행합니다. 네트워크에서 데이터를 가져오거나 Core Data와 같이 앱 내 저장소에 있는 데이터를 가져오는 작업이 이 계층에서 이루어집니다. Data 레이어에서 외부의 데이터를 가져오는 객체를 **레파지토리(Repository)**라고 합니다.

클린 아키텍처의 핵심 

클린 아키텍처의 핵심은 내부에 있는 계층이 외부에 있는 계층을 알지 못하게 하는 것에 있습니다. 즉, Domain 레이어는 Presentaion 레이어나 Data 레이어를 절대 직접 참조하지 않습니다. 그래서 각 계층 간 의존성의 방향은 이렇게 됩니다.

이런 의존성 규칙을 정해둔 이유가 뭘까요? 테스트 관점에서 생각해보면 외부 방향으로 의존성이 생겼을 때 테스트하기가 어려워진다는 것을 알 수 있습니다. 만약 Domain 레이어의 유즈 케이스가 네트워크로부터 어떤 값을 받아 처리해야 한다고 해봅시다.

그럼 유즈 케이스를 테스트할 때는 항상 레파지토리가 있어야 하고, 이 레파지토리는 네트워크 요청을 보내기 때문에 인터넷 연결이 항상 필요합니다. API주소에 문제가 생기거나 네트워크 연결 상태에 문제가 생기면 더 이상 유즈 케이스를 테스트할 수 없게 되죠.

따라서 유즈 케이스는 레파지토리를 직접 소유하지 않고 레파지토리로부터 들어오는 값을 받아야 합니다.

우리 앱에서는?

메이트 러너 앱을 다시 가져와서 클린 아키텍처대로 나누어보겠습니다.

그런데 뭔가 이상합니다.. 의존성이 내부 레이어로는 잘 향하고 있는데 이러면 레파지토리는 누가 호출해야 할까요..? 비즈니스 로직은 유즈 케이스에서 이루어지는데 유즈 케이스는 레파지토리를 소유하지 않습니다.

이런 문제를 해결하기 위해서 Domain 레이어에 레파지토리에 대한 인터페이스를 두어 의존성 역전 원칙을 지키며 레파지토리를 사용할 수 있도록 합니다.

이렇게 하면 유즈 케이스는 같은 레이어에 있는 프로토콜을 소유하고 실제 구현은 Data 레이어에 되어 있어 의존성의 방향을 내부로 향하도록 유지하면서도 레파지토리에 요청을 보낼 수 있게 됩니다.

이제는 테스트 문제도 해결이 되는데요, 유즈 케이스가 호출하는 프로토콜의 구현체를 네트워크를 사용하지 않는 가짜 레파지토리로 정의하고 테스트하고자 하는 결과를 무조건 반환하게 한다면, 네트워크가 연결되어 있지 않아도, API주소가 잘못되어도 유즈 케이스를 단독으로 테스트하는 것이 가능해집니다.

데이터 바인딩

MVVM과 클린 아키텍처에서는 뷰 모델이 절대로 뷰 컨트롤러를 소유하지 않습니다. 애초에 UI영역을 뷰 모델에서 분리하기 위한 목적으로 나누었으니까요. 그럼 UI를 업데이트하려면 어떻게 해야 할까요?

우리는 뷰에 그려야 할 값이 업데이트되었을 때 뷰에 알려서 뷰를 다시 그리도록 해야 합니다. 이렇게 데이터를 뷰에 적절하게 연결 짓는 것을 데이터 바인딩이라고 합니다. 데이터 바인딩을 위해서 몇 가지 방법을 선택할 수 있는데요. 간단하게 소개하도록 하겠습니다. 

1. 콜백 등록하기: 먼저 뷰 컨트롤러에서 작업할 내용을 클로저에 담아 뷰 모델에 넘겨주는 방법을 선택할 수 있습니다.

콜백으로 뷰를 업데이트하는 로직을 뷰 모델에 등록해두고 유즈 케이스에서 값이 경신되면 해당 콜백을 실행하도록 하면 뷰 모델이 직접 뷰를 업데이트하지 않도록 할 수 있습니다.

2. 리액티브 프로그래밍: 콤바인, Rx와 같은 리액티브 프로그래밍을 사용하는 방법도 있습니다.

이번엔 콜백을 사용하지 않고 각 레이어가 자신의 내부 레이어의 값들을 구독한 채로 값이 업데이트되면 지정된 처리 로직을 수행합니다.

위 예시처럼 거리 값의 갱신을 요청하면 유즈 케이스가 거리를 갱신하고, 이 값을 구독하고 있던 뷰 모델이 시간 포맷을 변경합니다. hh:mm:ss 형태로요. 그리고 이 값이 새로운 문자열로 뷰 모델에 저장되면, 이를 구독하고 있던 뷰 컨트롤러가 자신의 레이블을 업데이트합니다.

RxSwift

저희 팀은 데이터 바인딩을 하기 위해서 RxSwift를 사용하기로 결정했습니다. 저희 앱이 가지는 기능 특별한 기능들 때문입니다. 예를 들면 아래와 같이 친구의 정보를 실시간으로 받아와야 하는 화면이 있습니다.

이 화면에서 칼로리, 시간, 나의 달린 거리, 친구의 달린 거리는 모두 한 번의 요청 이후에 비동기적으로 값이 발생합니다.

그중에서도 네트워크를 사용하는 상대방의 실시간 거리는 아래와 같은 흐름으로 데이터를 받아오게 됩니다.

이런 식인데요, Firebase에서 제공하는 프레임워크에 데이터베이스에 대한 옵저버를 걸어두면, 서버에서 값이 갱신되는 대로 새로운 값이 클라이언트로 전달됩니다. 이는 첫 요청 이후에는 FirebaseService부터 레파지토리, 유즈 케이스, 뷰 모델로 이어지는 데이터의 연결통로가 계속 살아있어야 한다는 것을 의미합니다.

만약 뷰 모델 -> 유즈 케이스 -> 레파지토리 -> 네트워크를 모두 escaping closure로 묶어주면 코드의 가독성이 크게 떨어지게 됩니다. 

간단하게 써보면 이런 식으로 되겠죠

// viewModel 
func viewDidLoad(completionHandler: @escaping (String) -> Void) {
	self.usecase.requestMateDistance() { result in
    	switch result {
        case .success(let distance):
        	let distanceString = self.convertDoubleToString(distance)
            completionHandler(distanceString)
        case .failure:
        	// error handling
      
        }
    }
}

// usecase 
func requestMateDistance(completionHandler: @escaping (Result<Double, Error>) -> Void) {
	self.repository.requestListeningServer() { result in
    	switch result {
        case .success(let distance):
			let distance = self.convertToDistance(distanceData)
            completionHandler(.success(distance))
        case .failure:
        	// error handling
            completionHandler(.failure(error))
        }    
    }
}

// repository
func requestListeningServer(completionHandler: @escaping (Result<Data, Error>) -> Void) { 
	self.service.start() { result in
    	switch result {
        case .success(let distanceData):
            completionHandler(.success(distanceData))
        case .failure:
        	// error handling
            completionHandler(.failure(error))
        }    
    }
}

콜백을 인자로 가지는 메서드가 다시 새로운 메서드를 호출하면서 콜백을 정의하기 때문에 콜백에 콜백이 이어지게 됩니다. 만약 여기서 레이어의 깊이가 더 깊어진다면 더 많은 콜백을 작성해야겠죠.

RxSwift는 위와 같은 코드를 아주 간단하고 명료하게 만들어줍니다.

// viewModel 
func viewDidLoad() {
	self.usecase.requestMateDistance()
        .map(self.convertDoubleToString)
    	.subscribe(
            onNext: { distance in self.relay.accept(distance) },
            onError: { errorHandling }
        )
        .disposed(by: disposeBag)
}

// usecase 
func requestMateDistance() -> Observable<Double> {
    return self.repository.requestListeningServer()
    		    	.map(self.convertToDistance)
}

// repository
func requestListeningServer() -> Observable<Data> { 
    return self.service.start()
}

이렇게요. 반환 값에 대한 타입이 너무나 명확하게 제시되어 있어 이 함수 호출을 통해 비동기로 받아야 할 값이 무엇인지 쉽게 알 수 있고, 에러 또한 onError 클로저를 통해 쉽게 제어할 수 있습니다.

Rx에 Input/Output 구조를 곁들이기

저희 팀은 Rx의 사용을 극대화하기 위해서 Input/Ouput 모델링을 적용하기로 결정했습니다. Input/Output 모델링은 화면에서 일어나는 모든 이벤트를 Input으로 정의하고, Ouput은 그 이벤트들로 인해 화면에 보일 데이터들을 정의하게 됩니다.

이번엔 실제로 앱에서 사용된 뷰 모델의 코드 일부를 가져왔습니다. 이 코드는 계속 예시로 사용하고 있는 달리기 화면에 대한 Input과 Output인데요, 이 데이터를 실제 화면이랑 엮어보면 이렇게 됩니다..!

화면에서 일어나는 이벤트들은 뷰 모델에 바인딩되어서 이벤트가 발생할 때마다 뷰 모델이 유즈 케이스에 비즈니스 로직을 요청하고, 그 결과로 갱신되는 여러 값들을 Output에 바인딩해 뷰 컨트롤러는 Ouput을 보고 자신이 어떤 데이터를 화면에 그려야 할지 알게 됩니다.

Output에서는 거리, 칼로리, 시간처럼 계속 값이 갱신되는 데이터들은 Rx로 Output으로 스트림이 흘러가도록 만들어두고 값이 변경될 때마다 계속 스트림에 값을 넣어주면 됩니다.

그럼 Input을 Ouput이랑 연결해주어야 하는데요, 이 뷰모델은 먼저 뷰컨트롤러에서 넘겨준 Input의 이벤트들을 구독해두고 이벤트가 들어올 때마다,

이렇게 필요한 로직들을 유즈케이스를 통해 호출합니다.

그리고 이 로직으로 만들어지는 데이터들을 아래처럼 구독하고 포맷을 변환해서 Ouput에 바인딩 해줍니다.

뷰 컨트롤러는 이 Output에 담겨있는 Observable한 프로퍼티들을 구독해두고 

새로운 값이 스트림에 들어올 때마다 UI를 업데이트 해주게 됩니다.

이런 식의 구조를 만들면 뷰 모델의 함수들을 모두 읽어보지 않아도 뷰에서 어떤 일들이 일어나는지 한눈에 확인할 수 있습니다.

아키텍처 한눈에 보기

전체 아키텍처를 도식화하면 이렇습니다. 클린 아키텍처를 통해 각 모듈들의 역할을 분명하게 분리하고, RxSwift와 RxCocoa를 통해 비동기 데이터의 요청, 그리고 새로운 값을 뷰에 적용하는 것 까지 물 흐르듯이 하나의 흐름으로 만들어낼 수 있었습니다.

Clone this wiki locally