🍎 iOS/아키텍처&디자인 패턴

[iOS/Swift] MVVM-C + Clean Architecture 리팩토링

dev_zoe 2023. 9. 1. 02:57
반응형

MVVM-C(Coordinator)로 되어있는 프로젝트를 진행하던 중,

ViewModel이 API 통신 + 비즈니스 로직 등 모든 로직을 처리하면서 한 클래스가 너무 많은 역할을 가져가고 있다는 생각이 들었고,

이에 따라 유지보수에 많은 시간이 소요된다고 생각했다.

--> 객체지향 SOLID의 S(RP, 단일 책임 원칙)에 거리가 멀어 응집도가 낮고 결합도가 높음

--> 유지보수에 많은 시간 소요 + 단위 테스트의 어려움

 

이미 MVVM-C인 패턴에서 ViewModel의 데이터 로직 - 비즈니스 로직을 분리해줄 아키텍처가 있을까 해서 VIPER나 RIBs 등 여러가지 아키텍처에 대해 조사해본 결과, 현재 아키텍처에서 Clean Architecture의 원리를 도입하는 것이 가장 의도와 적절한 아키텍처라는 생각이 들어 해당 내용을 공부하고 적용한 내용을 정리해보고자 한다.

 

MVVM + Clean Architecture의 레이어

 

GitHub - kudoleh/iOS-Clean-Architecture-MVVM: Template iOS app using Clean Architecture and MVVM. Includes DIContainer, FlowCoor

Template iOS app using Clean Architecture and MVVM. Includes DIContainer, FlowCoordinator, DTO, Response Caching and one of the views in SwiftUI - GitHub - kudoleh/iOS-Clean-Architecture-MVVM: Tem...

github.com

Clean Architecture에서 원 바깥쪽에서 안쪽으로 의존 관계를 가진다. 또한, 안쪽의 원들은 바깥 원들에 대해 알지 못한다. 

 

Domain Layer (비즈니스 로직)

1) Entity

- 일반 데이터 모델 (주로 API 통신할 때 사용하는 모델 등 데이터를 추상화한 모델)

2) Use Cases

- Data Layer에 데이터 요청

- 비즈니스 로직 수행

Presentation Layer (UI 로직)

- UI를 담당하는 레이어로, ViewController / View / ViewModel 포함 (MVVM-C에서는 Coordinator도 포함)

- MVVM + Clean Architecture에서의 ViewModel은 기존에 모든 비즈니스 로직을 처리하던 것과 달리, 화면에 필요한 정보를 만들기 위한 로직만을 실행함 (View Input과 Output 시에 View 바인딩 위한 적절한 로직 처리, View bindig 변수 소유)

Data Layer (데이터 로직)

1) Repository

- 데이터를 데이터 제공자에 요청해서 가져오는 역할

2) API Service, DB(CoreData, Keychain...) --> 데이터 제공자

- API 통신 혹은 영구 DB를 통해 데이터를 가져와서, Repository에 제공함

 

현재 프로젝트의 구조도

ViewModel이 비즈니스 로직, 네트워크 요청, Keychain 요청, Coordinator로부터 이벤트 결과를 전달받는 등 매우 많은 역할을 하고있음을 알 수 있다. 이 역할을 단일한 작업을 하는 클래스들로 분리하고자 하는것이 목표다.

 

리팩토링 진행 방향

1) ViewModel의 데이터 요청부 --> UseCase 클래스를 만들어 분리하고, 해당 UseCase는 Repository에게 데이터를 요청함

Repository는 API Service에 네트워크를 요청하고, 데이터를 UseCase에 전달

2) ViewModel의 비즈니스 로직 실행 부 --> UseCase 클래스로 분리

3) UseCase는 Repository로부터 데이터를 전달받으면, ViewModel로 전달하며 ViewController는 ViewModel에 바인딩되어있는 뷰 업데이트

 

또한, 아래의 의존성 방향과 그림을 참고하여 리팩토링을 진행했다.

의존 방향이 원 밖에서 안으로 향해야하며, 안쪽으로 갈수록 바깥쪽을 몰라야한다.
Domain UseCase와 Dat Repository 사이에 의존성 주입을 통한 의존성 역전 발생

 

1️⃣ Presentation Layer, Domain Layer, Data Layer로 그룹화

 

Before

Common --> 공통적으로 쓰이는 클래스들 모음. Service, Model, View가 모두 포함

MainTabbar > Feature  별로 그룹화하였고, 마찬가지로 그룹 별로 Service, Model, View 포함

 

After

 

1) Presentation

기존의 Feature - ViewController, ViewModel, Coordinator를 Feature 별로 그룹화하여 Presentation 그룹에 이동

Feature와 무관하게 여러 Feature에서 사용하는 공통적인 Component와 Custom View들 Common으로 그룹화 및 이동

 

2) Domain

기존의 Data Model 모두 Feature 별로 분리하여 Entities 폴더로 이동

비즈니스로직에 해당하는 Service들 Service 폴더로 그룹화하여 이동

 

3) Data

Data 레이어에 해당하는 Firebase Storage 관련 로직, Network 로직, Keychain 로직 그룹화하여 Data 폴더로 이동

 

2️⃣ 데이터 제공자 (Network, Firebase, Keychain) 로부터 데이터를 얻어올 Data Repository 그룹화

 

본래 UseCase와 실질적으로 네트워크 통신, DB로부터 데이터를 얻어오는 데이터 제공자 사이에 Data Repository가 존재하나,

현재 프로젝트에서 Basic(도메인)APIService 클래스와 이 클래스가 의존하는 (의존성 역전) APIService로 나뉘어져있으므로,

여기서 Repository 클래스를 각 도메인 별로 생성하지 않아도 클린 아키텍처의 의존성이 향하는 화살표가 어긋나지 않으며 SOLID의 D(의존성 역전의 원칙)이 구현되어있으므로 굳이 기존 코드를 변경하지 않고 위 도표에 맞게 그룹화했다.

 

❓UseCase와 Data Repository 간 의존성 역전 원칙(DIP)을 적용해야하는 이유?

Clean Architecture는 위에도 언급했던것처럼 원의 가장 바깥쪽에서 안쪽으로 의존 관계를 가진다. 여기서 UseCase는 Repository를 활용하여 데이터 제공자로부터 데이터를 얻어와서 비즈니스 로직을 수행하는데, 그렇게 되면 의존관계가 UseCase --> DB, 즉 밖으로 향하게 된다.

그래서 Domain Layer에 Repository의 로직을 모듈화한 Protocol을 위치하여 의존성을 주입하게 되면, Repository가 해당 Protocol에 의존하고 UseCase가 Protocol에 의존하게되면, 의존성 역전이 발생하여 의존성 방향이 원 안쪽으로 향하도록 할 수 있다.

 

이를 그림으로 그려보면 다음과 같다.

✅ 리팩토링 후 폴더 구조

1) Presentation

변화 X

 

2) Domain

데이터 제공자 역할을 하던 도메인별 BasicAPI/KeyChain/FirebaseService를 모듈화한 프로토콜을 Protocol + Repository 폴더로 그룹화 (이후 의존성 주입을 통해 UseCase가 해당 프로토콜에 의존하도록 함)

 

3) Data

API의 EndPoint를 추상화한 클래스인 Moya API 클래스를 그룹화하여 Data Layer에 위치시켰고, 데이터 제공자 역할을 하는BasicAPI/KeyChain/FirebaseService를 Repositories 폴더로 그룹화하여 위치시킴

(위에도 언급했던 것처럼 Repository가 네트워크 통신, Firebase, Keychain에게 데이터 요청 등을 수행하나 지금 현상황에서 의존성이 향하는 방향이 올바르게 가고있기도 하고, 과공수인것 같아서 따로 생성하지 않고 이대로 그룹화 진행)

 

3️⃣ ⭐️ ViewModel에서 데이터 요청 등 비즈니스 로직을 UseCase로 분리하여 ViewModel은 뷰와 관련한 모델 소유 + 바인딩

 

여기서 고민을 좀 많이했다.

ViewModel에서 예를들어 네트워크 로직을 진행할 때 (이제 Data Layer로 이전) 토스트메시지를 띄워준다든지 등의 로직은 UseCase와 ViewModel 간 어떻게 분리할 수 있을까?

즉, ViewModel이 뷰에 띄워주기 위한 로직을 아얘 UseCase로 분리할 수 있는지, 혹은 UseCase의 비즈니스 로직이란 정확히 어떤 로직을 뜻하는건지가 헷갈렸다.

 

❓ ViewModel에서 모든 비즈니스 로직을 완전히 분리할 수 있는지? UseCase의 비즈니스 로직이란 어떤 것을 하는 로직인지

현재 참고하고 있는 MVVM + Clean Architecture 템플릿과 구글링을 통해 내린 결론으로 다음과같이 생각했다.

 

Entity를 직접적으로 가져오기 위한 비즈니스 로직은 UseCase (위 원의 의존 관계 방향성을 보면 ViewModel은 Entity와 소통할 수 없고, UseCase가 Repository를 통해 소통하며 데이터를 얻어옴)

UseCase에서 completionHandler(혹은 RxSwift, Combine 등)를 통해 VIewModel에 데이터를 넘겨주게 되면 ViewModel은 UI 관련한 로직만을 담당하게 됨

 

따라서 UseCase가 데이터를 불러와서 가공을 해서 이를 ViewModel에 넘겨주면, ViewModel은 UI 관련한 데이터들을 View에게 넘겨주는 식으로 리팩토링을 진행했다.

 

ViewModel로부터 책임은 어느정도 분리가 되었으나 처음에 고민했던 Data와 UI 로직이 ViewModel에 같이 섞여있는 부분은 완전한 해결이 되지 않았다고 생각했기에

extension을 활용하여 UseCase에 데이터를 요청하여 받아오는 부분 / UI 관련 비즈니스 로직을 진행하는 부분을 분리했다.

extension MessageViewModel {
	// MARK: - UseCase에 데이터 요청

    func requestDataToUseCase() {
        
        routeInputs.needUpdate
            .flatMap { _ in
                self.messageUseCase.getMessageRoomList()  // usecase를 통해 데이터 획득
            }
            ...// 후략
            
	// MARK: - UI 관련 비즈니스 로직

    func uiBusinessLogic() {
        
        inputs.messageRoomId
            .compactMap { $0 }
            .subscribe(onNext: { [weak self] roomId in
                self?.messageRoomId = roomId
                self?.routes.goMessageRoom.onNext(roomId)
            })
            .disposed(by: disposeBag)
    }
}

 

이러한 방향으로 리팩토링하고 난 뒤, 프로젝트 구조도를 그려보면 다음과 같다.

회고

1) ViewModel에서 데이터를 요청하는 로직이 UseCase로 분리됨으로써 ViewModel의 책임 부담을 던 것이 체감되었다.

 

2) Layer간, 그리고 Layer의 class 간 책임 분리가 더해져서 유지보수가 용이하게 될 것으로 기대된다.

 

3) 그리고 크게 느꼈던 장점은, 예를들어 LoginAPIService와 Login 관련 비즈니스 로직을 수행하는 LoginService 등 Login 관련해서 데이터 요청이나 비즈니스 로직을 하나의 LoginUseCase 클래스로 묶음으로써 ViewModel이 이를 소유하니,
ViewModel의 UseCase들을 보고 "아 이 Feature는 어떠한 기능을 가지고 있구나"를 짐작할 수 있다는 점이 장점으로 느껴졌다.

--> 이는 곧 유지보수의 용이성이 올라갈 것으로 기대된다.

 

4) 가장 아쉬웠던 부분은 최대한 역할을 분리함에도 ViewModel은 여전히 UseCase로 가져온 데이터를 뷰에 바인딩하기 위한 로직을 추가적으로 수행한다는 점에서

처음에 의도했던 "ViewModel 안에서의 데이터 요청 및 데이터 응답, View 바인딩, 비즈니스 로직"이 완전히 분리되지는 않았다는 생각이 들었다.

가독성을 높이기 위해 extension을 활용하여 UseCase에 데이터를 요청하는 메소드와 UI 관련 비즈니스 로직을 수행하는 메소드로 분리하기는 했으나 이게 최선인지는 확신이 들지 않는다.

Clean Architecture에 관해 관심있게 공부한 iOS 동료 개발자가 있다면 같이 논의해보고 싶다.

 

처음 공부하고 적용한 것이라서 물론 내가 리팩토링한 내용이 좋은 코드라고 확신할 수는 없기에 꾸준히 자료를 보면서 리팩토링을 진행하고 깨달은 점들을 포스팅하고자 한다.

 

Reference

https://github.com/kudoleh/iOS-Clean-Architecture-MVVM

https://eunjin3786.tistory.com/207

https://jeonyeohun.tistory.com/305

https://codekodo.tistory.com/211

https://cau-meng2.tistory.com/137

반응형