본문 바로가기

iOS

TCA(리덕스패턴)과 MVVM, 그리고 MVC [2 - 完]

이전 글을 꼭 읽고 와주세요

 

  • Combine과 RxSwift를 이용한 MVVM 패턴도 데이터흐름을 이벤트 스트림으로 단방향으로 보내는 패턴인데...?

이전에 필자가 쓴 TCA 입문기를 읽으면 마치 TCA(리덕스패턴)이 유일무이하고 아주 참신한 단방향 아키텍쳐 패턴인양 느껴질 것이다.

하지만 위와 같은 의문에 대한 답을 찾아보니 TCA와 RxSwift 혹은 Combine을 통한 MVVM(Model-View-ViewModel)은 모두 모델과 뷰 간의 효율적인 데이터 흐름을 단방향으로 유지하면서 UI의 복잡성을 줄이는 아키텍처 패턴이 맞다고 한다!!!

 

그렇다면 같은 단방향 아키텍쳐를 기반으로 두 아키텍쳐가 어떻게 다른 방식을 취하고 있고, 어떠한 부분이 좋은지에 대한 필자 개인적인 생각을 정리하고 공유해보고자 한다.

 

이 종합적인 비교를 위해선 SwiftUI를 위한 가장 알맞은 아키텍쳐가 무엇인가에 대한 담론의 시작부터 다루는게 좋을 것 같다.

 

사실, MVVM이 SwiftUI에 맞지 않는다 라는 의견을 가진 담론은 아키텍쳐 패턴을 공부하다 보면 한 번 쯤 들어봤을 것이다.

실제로 "Is MVVM good for swiftui?" 라고 구글에 검색한다면 MVVM이 SwiftUI에 좋지 않다는 아주 많은 양의 자료를 찾을 수 있다.

 

다음은 내가 읽어본 자료들의 목록이다.

 

- 일본의 개발자님께서 쓴 글, TCA에 대한 이야기도 있다.(댓글에 전쟁터가 열려있다ㅋㅋㅋ)

- 위 글을 영어로 작성하여 미디엄에 올린 글(일본어로 작성하신 글이 더 자세하며, 속편도 있기 때문에 이쪽을 추천하는 편이지만, 일본어를 이해해야할 것이다. 읽어보는 것을 강력하게 추천!!!!!)

- Udemy에서 여러 인기 강의를 만드신 Mohammed Azam 개발자님께서 쓰신 글(이것 또한 진짜 명글....!!! 살짝 이해가 어려울 수 있지만 이것도 읽어보시길 강력하게 추천!!!!)

- 윗 글을 가지고 HackingWithSwift에서 나눈 의견

- 필자가 자주 보는 iOS 개발 유튜브 중의 하나

- 애플의 개발자 포럼

사실 애플에서 예전부터 iOS와 macOS개발을 위해 권장하는 아키텍쳐 패턴은 MVC 였다. 하지만 이는 문제점이 있다는 것을 TCA입문기에서 이미 다뤘기 때문에 모두 알 것이다. 그렇게, 의존성의 문제, 유지보수의 문제 등의 이유로 MVC를 지나서 모바일 앱 개발(특히 안드로이드)에서 널리 채택되던 MVVM 패턴으로 넘어오게 되었다.

하지만 MVVM에서 ViewModel이라는 것이 사실 선언형 UI 를 구축하기 위한 프레임워크인 SwiftUI에는 필요가 없다! 라는 것이 위 회의론의 중심내용이다.

 

필자가 개인적으로 위 글들을 읽고 '간단하고 직관적이게'(모든 내용을 요약하기엔 의견이 너무나도 많고, 세세합니다) 정리해보자면, 위와같은 주장을 하는 이유에는 크게 3가지의 이유가 있었다.

 

1. 선언형UI에서는 View 자체가 ViewModel의 역할을 하기 때문

 

선언적UI에 대해 아직 정확한 개념이 없는 분들을 위한 짧은 설명을 하겠다

 

import SwiftUI

struct ContentView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Text("Count: \(count)")
            	.font(.largeTitle)
            
            Button {
                count += 1
            } label: {
                Text("Increment")
            }
        }
    }
}

 

여기 SwiftUI로 작성한 코드가 있다. 우리가 아무런 의심을 하지 않고 써내려가던 간단한 코드이겠지만, 이 안에 선언형(declaritive) UI의 개념이 숨겨져 있다.

 

여기서 UI 를 작성하는 '방식''선언적'이라고 하는 것인데, 즉, 위 코드에서 body 속성은 해당 View의 본문을 기술하고, 뷰의 계층 구조를 만들기 위해 다양한 뷰 컨테이너(VStack, HStack ...)를 사용한다. 각 뷰는 또 메서드 체이닝으로 위 코드에서 예를들면, .font(.largeTitle) 과 같이 속성과 스타일을 설정할 수 있다.

 

그리고 선언적 UI의 핵심은 State이다. 선언적 UI에 도입된 State라는 개념은 값의 상태를 담고 이 상태가 바뀌면 새로운 화면을 생성하여 업데이트하는데 이것의 역할을 SwiftUI에서는 @State 상태프로퍼티(속성)을 사용해 나타낸다.

 

또, SwiftUI에서는 @Binding이라는 프로퍼티  래퍼를 제공하는 것을 다들 알 것이다. 이는 어떠한 값이 다른 곳에서 와야하고 두 곳에 모두 공유되어야할 때 쓰게되는 프로퍼티 래퍼인데, 두 뷰 사이에서 모달을 열고 닫을 때 쓰는 .sheet()을 이용할 때 본 적이 있을 것이다.

 

이러한 @State와 @Binding 기능으로 SwiftUI에서는 굳이 ViewModel이라는 추상화 레이어(layer of abstraction)을 거쳐서 데이터 바인딩을 할 필요가 없어졌다는 것이다!

 

예를 들어, 기존 UIKit + RxMVVM과 같은 경우 위 코드는 이렇게 되었을 것이다.

 

import UIKit
import RxSwift
import RxCocoa

class ContentViewModel {
    private let disposeBag = DisposeBag()
    private let count = BehaviorRelay<Int>(value: 0)
    
    // Input
    let incrementButtonTapped = PublishSubject<Void>()
    
    // Output
    let countText: Driver<String>
    
    init() {
        incrementButtonTapped
            .subscribe(onNext: { [weak self] in
                self?.count.accept(self?.count.value ?? 0 + 1)
            })
            .disposed(by: disposeBag)
        
        countText = count.asObservable()
            .map { "Count: \($0)" }
            .asDriver(onErrorJustReturn: "")
    }
}

class ContentViewController: UIViewController {
    private let disposeBag = DisposeBag()
    private var countLabel: UILabel!
    private var incrementButton: UIButton!
    
    private let viewModel = ContentViewModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        bindViewModel()
    }
    
    private func setupViews() {
        //UI 그리는 부분
    }
    
    private func bindViewModel() {
        incrementButton.rx.tap
            .bind(to: viewModel.incrementButtonTapped)
            .disposed(by: disposeBag)
        
        viewModel.countText
            .drive(countLabel.rx.text)
            .disposed(by: disposeBag)
    }
}

 

코드는 생략되었지만 countLabel 이라는 레이블이 뷰에 있을 것이고, 뷰 컨트롤러에 연결되어 있다. 그리고 ViewModel을 정의하고 이 타입을 뷰 컨트롤러에서 인스턴스로 생성해주었다. 그리고 count가 변하게 되면 그 변경사항을 countLabel에 반영해줄 것이다. 이렇듯, MVVM에서 ViewModel이 가지고 있는 근본적인 역할은 뷰와 뷰모델을 분리한 후 변경할 데이터를 바인딩해주고 반영하는 것이다.

 

애플 공식문서
출처: https://azamsharp.com/2022/07/17/2022-swiftui-and-mvvm.html (세번째 링크)

 

하지만 우리는 공식문서에서도 볼 수 있듯이 SwiftUI에서는 대부분의 동기화(데이터 바인딩)을 지원하기 때문에, 이러한 데이터 바인딩을 위한 하나의 계층이 필요하지 않다.

 

그리고 위의 링크 걸어두었던 유투브영상을 보면 이를 실제로 적용하시면서 설명을 해주시는데, 위 내용을 읽고 영상을 보면 어떠한 논리에서 'SwiftUI와 MVVM은 어울리지 않는다' 라는 주장을 하게 되는지 충분히 느낄 수 있을 것이다.

 

근데 맨위에 필자가 올려놓은 링크 중 첫번째 링크에 이러한 부분에 많은 사람들이 신랄한 비판을 해주셨다ㅋㅋㅋ. 열자마자 웃음이 나온 3개의 댓글을 보자. (글쓴이가 일본어를 영어로 직역하는 과정에서 생긴 소통의 오류가 이러한 비판의 큰 이유이기도 한 것을 감안하고 보자..)

 

 

 

스스로 혼란스러워하는 중이라 글도 혼란스러움

 

 

 

 

 

 

 

 

 

 

 

 

이딴 글 때문에 미디엄 구독 취소하고 싶어짐

 

 

 

 

 

너 테스트코드 안쓰지?(개인적으로 이거보고 뿜었다)

 

 

 

 

 

다들 sarcastic하게 비판해주셨지만 솔직히 얘기해서, 대부분이 결국 '비즈니스 로직 분리, 그리고 테스팅때문에 MVVM을 사용한다'라는 느낌을 지울 수가 없었다. 근데 비즈니스 로직과 테스팅이라는 이유 또한 위 주장을 하시는 분들은 이렇게 반박을 한다.

 

먼저 비즈니스 로직에 대한 반박을 알아보자.

 

2. 뷰모델을 굳이 안 만들어도 이미 도메인 레이어에 접근 안하고 있다

출처:&nbsp;https://azamsharp.com/2022/07/17/2022-swiftui-and-mvvm.html

세번째 링크에서 Azam은 대부분의 클라이언트 앱(Swift UI)에는 도메인 모델이 없기 때문에, 도메인 로직은 서버에 있고, 따라서 이처럼 서버 컴포넌트(클라이언트 요청을 수행하는 측의 기능)가 없는 앱은 그 모델을 그대로 앱의 도메인 모델로도 사용할 수 있다고 이야기한다.  Azam의 말을 다시 한 번 빌리자면 클라이언트 앱은 그냥 서버의 엔드포인트를 사용하여 CRUD 작업을 수행하기만 되고, 서버 컴포넌트가 없는 앱의 경우엔 비즈니스 로직이 클라이언트 쪽에서 처리될 것이라고 한다.

 

즉, (필자가 이해한 바로는) 이유 없이(클라이언트 요청을 수행하는 기능이 없는데도) 도메인 모델의 복사본을 만들어 하나의 뷰모델이라는 층을 더 만들었다는 것이다.

 

또한, 이러한 이유를 근거로 테스팅때문에 MVVM을 사용한다는 의견을 반박한다.

 

 

3.  로직이 없으면 테스트를 작성할 이유가 없으며, 작성했다고 하더라도 사용자 인터페이스를 검증하기 위해 작성하는 테스트 코드도 아니다.

 

- 본 질문에 대한 답은 원래의 글과, 이 글을 보고 필자가 개인적으로 정리하고, 느낀 점입니다. 자세하고 정확한 답변은 링크를 확인해주세요. -

 

테스트를 작성할 때엔, 결국 앱의 도메인을 테스트하는 데에 중점을 두어야한다는 것인데, 클라이언트/서버 앱에서 도메인 레이어는 대부분 서버에 있다는 것이다. 그래서 서버에서  받은 데이터를 저장하는 유닛 테스트를 작성하게 될 것이라는 거다. 이는 대부분이 비즈니스 로직에 대한 테스트도 아니며, 사용자 인터페이스를 검증하는 UI 테스트도 아니라는 것이다. 이는 뷰에 대한 뷰모델이 필요 없는데 뷰모델을 작성하게 되는 것 처럼, 필요없는 테스트를 위해 테스트를 작성하는 꼴이 된다는 것이다.

 

정리

 

모든 글을 다 읽어본 결과, MVVM을 쓰지 말라고 해서 View에서 모든걸 다 하라는 주장은 아니다. 하지만 MVVM이 그냥 SwiftUI에 끼워맞춰졌기 때문에 어색하다는 것이다. 필자의 생각엔, MVVM을 SwiftUI에 무리하게 끼워맞춘 것이 오히려 위에서 필자가 비유한 도메인 모델의 복사본(ViewModel이라는 중간 레이어)과 같이 작용하며 양방향 데이터 플로우 구조를 갖게 되는 경우가 있고, 원래의 단방향 데이터 플로우를 지향하는 MVVM의 목적과 상반되는 결과를 초래할 수 있다는 것을 경고하는 것이라고 결론지었다.(어디까지나 저의 개인적인 의견입니다. 댓글로 부족한 부분은 알려주세요!)

 

이에 대해 Azam과 일본의 개발자는 TCA를 그 대안으로 제시하고 있었다.

 

실제로 필자가 개발하면서 느낀 점은, TCA의 공식 문서, 그리고 영상들을 보며 코드를 작성하다보면 자연스럽게 데이터플로우를 생각하면서 개발을 하게 되는데, 아주 철저하게 단방향성을 확인하면서 만들게 된다. 

그리고 MVVM이 뷰와 모델, 그리고 뷰모델을 분리한다고 쳐도, (필자가 부족해서 일 수도 있지만) 결과적으로 @Environment、@EnvironmentObject、@StateObject, @ObservedObject 등의 프로퍼티 래퍼들을 떡칠 남용하게 되는 것은 운명인 것 같았다.

 

하지만 TCA는 UI의 부품화(컴포넌트화)율(率)이 훨씬 높다. Composable이라는 단어 자체가 '구성 가능한', 즉 부품(컴포넌트)들을 '조립할 수 있는' 아키텍쳐로서의 성격을 띄는 것을 말해주듯 말이다. 물론 지금까지도 커밋이 계속 이어지면서 빠르게 변화하기 때문에 예시를 찾기도 어렵고 찾는다고 해도 이미 구버전일 확률이 높다. 그러니 계속 공부하여 지식을 늘리고 프로젝트에 적용시키며 체득, 체감을 하는 것이 중요하다고 생각한다!!

 

그리고 비즈니스 로직과 테스트에 대한 부분은 실력이 부족해서 많이 체감하진 않았지만, 느낀점을 말하자면...

비즈니스 로직같은 경우엔 MVVM처럼 비대해지는 경우가 거의 없다고 느꼈다. 실제로 도메인 별로 기능이 다 분할되다 보니 한 눈 에 알아보기 쉽고 분리가 잘 되어있는 것은 정말 사실이다.

위의 2번에 대한 Azam의 반박에 대한 부분은 사실 필자는 무언가 나아진 것을 체감하지는 못했다. 하지만 테스트에 용이하다는 점은 인정을 해야할 것 같다. reducer에 있는 함수의 비즈니스 로직과 UI를 '필요한 경우에', '나누어서' 테스트할 수 있기 때문이다!

 

느낀점

 

사실 맨 처음의 질문에 대한 내용을 chatGPT나 구글링을 해서 물어보면 굉장히 단편적인 내용만 나온다. 예를 들자면, TCA는 더 큰 앱에서 유용합니다~ 라고 하거나 MVVM은 비즈니스로직을 뷰모델이 다 담당하기 때문에 뷰모델이 비대해질 수 있습니다~ 등등. 몇몇 글들은 심지어 MVVM과 TCA의 장단점을 거의 같게 적어놓은 경우도 보았다.

이렇게 아키텍쳐라는 부분은 본인이 직접 경험하면서 느끼지 않는 한, 겉도는 이야기밖에 할 수 없게 되는 것 같았다. 그래서 앞으로도 개발을 계속하면서 이러한 부분에 대해서 계속 생각하고 느끼려고 해야겠다고 생각했다.