MVVM 패턴

Dophi·2023년 8월 21일

개발 기술

목록 보기
8/12

소개글

요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!

MVVM 패턴이란

MVVM 패턴에 대해 설명하기 앞서 MVC 패턴에 대해 간단하게만 얘기해볼까 합니다. 참고로 "iOS"에서의 MVC 패턴에 관한 것이며, 일반적인 MVC 패턴과는 다릅니다.

MVC 패턴은 Model, View, Controller 로 나눠져 있습니다. View에서 사용자 입력을 받으면 Controller는 이를 Model에게 전달하고 Model을 업데이트 합니다. 이후 Model에서 변한 데이터가 다시 Controller를 통해 전달되어 View를 업데이트합니다.

이처럼 View와 Model이 소통하기 위해서는 반드시 Controller를 거쳐야하며, 덕분에 View와 Model 사이 결합도는 낮아지고 View를 쉽게 재사용할 수 있습니다. (UITableView 처럼)

이렇듯 View의 재사용을 위해 애플에서는 MVC 패턴을 권장한다고 알고있는데, 그럼에도 불구하고 많은 개발자들이 MVVM 패턴을 사용하고 있습니다.

그 이유는 바로 MVC 패턴에서는 View와 Controller 사이의 결합도가 너무 높기 때문입니다. UIKit 코드를 보면 ViewController가 View의 LifeCycle 및 관련 로직에도 관여하고, Model의 데이터를 View에 어떻게 보여줄지 결정하는 프레젠테이션 로직도 가지고 있다 보니 코드가 너무 많아지게 됩니다. 이렇게 되면 결합도가 높아서 단위 테스팅도 어렵고 방대한 코드는 관리도 어렵습니다.

반면 MVVM은 Model, View, ViewModel로 나눠져 있는데, MVVM의 목표는 비즈니스 로직과 프레젠테이션 로직을 UI로부터 분리하는 것입니다. 즉 View에서는 UI를, ViewModel에서는 프레젠테이션 로직을, Model에서는 비즈니스 로직을 다루게 됩니다.

이를 위해 ViewController도 View로 취급하며 기존에 MVC에서 ViewController가 하던 역할 중 일부(프레젠테이션 로직)를 ViewModel이 하게 됩니다. 또한, 데이터 흐름은 전체적으로 MVC와 비슷하지만 ViewModel로 전달된 Model의 데이터를 Data Binding을 통해 View를 업데이트해준다는 점이 다릅니다.

이러한 구조로 각각의 역할이 깔끔하게 분리가 되었고, Data Binding 덕분에 View와 ViewModel 사이 결합도도 낮아지게 되어 코드 관리 및 단위 테스팅이 훨씬 수월해지게 됩니다.

간단하게 정리하자면 아래와 같습니다. 왼쪽은 디자인 패턴의 요소이고 오른쪽은 실제 코드에서 어떤 것들이 들어가는지 입니다.

MVC

  • View+Controller = UI + View 관련 로직 (LifeCycle 등) + 프레젠테이션 로직
  • Model = 데이터 + 비즈니스 로직

MVVM

  • View = UI + View 관련 로직 (LifeCycle 등)
  • ViewModel = 프레젠테이션 로직
  • Model = 데이터 + 비즈니스 로직

코드

SwiftUI와 UIKit 환경에서 각각 어떻게 구현했는지 코드와 함께 설명드리겠습니다. 사실 개념이 좀 어려울 수 있는거고 코드 자체는 어렵지 않습니다.

SwiftUI

SwiftUI에서는 Property Wrapper만 쓰면 되기 때문에 MVVM을 구현하기가 굉장히 간편합니다. Property Wrapper들이 Data Binding이 되도록 해주는데 이 포스팅에서는 자세히 설명드리지는 않겠습니다.

  1. viewModel이 ObservableObject 프로토콜을 따르게 하기
  2. viewModel 안에서 관찰하고 싶은 값을 @Published로 정의하기
  3. viewModel 객체를 @StateObject 또는 @ObservedObject로 정의하기
  4. view에서 viewModel 값들을 가져다 쓰기
import SwiftUI

// ViewModel
public class ProblemListViewModel: ObservableObject {
	// Data Binding을 위해 Published 써주기
    @Published var problemCellList: [DefaultProblemCellVO] = []
    
    // Model의 데이터를 View에 어떻게 보여줄지 결정하는 프레젠테이션 로직 중 하나
    func updateProblems() {
        ...
    }
}

// View
public struct ProblemListView: View {
	// Data Binding을 위해 StateObject 써주기
    @StateObject private var viewModel: ProblemListViewModel
    
    public init(viewModel: ProblemListViewModel) {
        self._viewModel = StateObject(wrappedValue: viewModel)
    }
    
    public var body: some View {
        VStack() {
        	// viewModel의 변수가 변하면 자동으로 뷰가 업데이트됨
            ForEach($viewModel.problemCellList, id: \.self) { problemCellVO in
            	...
        	}
        }
        .onAppear {
        	// 외부 입력?에 따른 viewModel의 프레젠테이션 로직 실행
            viewModel.updateProblems()
        }
    }
}

UIKit

UIKit에서 MVVM을 구현하기 위해서는 여러 방법이 있겠지만, 저같은 경우는 Combine을 사용했었습니다. (조금 예전 프로젝트이긴 합니다.)

코드는 살짝 복잡해보이긴 하는데 원리는 간단합니다.
1. viewModel에 input(view->viewModel)과 output(viewModel->view)를 enum으로 정의
2. view와 viewModel의 publisher를 서로 연결 (아래 코드에서는 bind 함수)
3. view에서는 상호작용이 생기면 viewModel에게 신호 보내기 (send)
4. viewModel에서는 들어온 신호에 따라 프레젠테이션 로직을 실행하고 달라진 데이터 반환해주기 (send)

사실 이 코드에 대해 이해하려면 Combine에 대한 지식이 필요해서, 혹시 잘 이해가 안되는 분들은 이런 방법도 있구나라고 생각하고만 넘어가도 괜찮을 것 같습니다. 해당 코드가 무조건 정답도 아니고 Combine을 쓰지 않는 다른 방법이 존재할 수 있습니다.

import Combine
import UIKit

// ViewModel
final class PaperTemplateSelectViewModel {
    private let output: PassthroughSubject<Output, Never> = .init()

	// View로부터 받아올 신호 (유저의 상호작용, 외부 입력 등)
    enum Input {
        case viewDidAppear
        ...
    }
    // View로 보내줄 신호 (Model의 업데이트로 인해 변한 데이터)
    enum Output {
        case getRecentTemplateSuccess(templates: [TemplateEnum])
        ...
    }
    
    // 어떤 입력이 Input으로 들어오면 프레젠테이션 로직을 처리하고 변한 데이터를 output으로 반환해주기
    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
        input
            .receive(on: DispatchQueue.global(qos: .background))
            .sink(receiveValue: { [weak self] event in
                guard let self = self else {return}
                switch event {
                case.viewDidAppear:
                    output.send(.getRecentTemplateSuccess(templates: recentTemplates))
                ...
                }
            })
            .store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }
}

// View
class PaperTemplateSelectViewController: UIViewController {
    private let viewModel = PaperTemplateSelectViewModel()
    private let input: PassthroughSubject<PaperTemplateSelectViewModel.Input, Never> = .init()
    
    override func viewDidLoad() {
    	// view와 viewModel 사이에서 신호 주고 받을 수 있도록 연결해주기
    	bind()
        ...
    }
    
    // viewModel로 신호 보내기
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        input.send(.viewDidAppear)
    }
    
    // input이 설정될때마다 자동으로 transform 함수가 실행되고 그 결과값으로 output이 반환되면 어떤 행동을 할지 정하기
    private func bind() {
        let output = viewModel.transform(input: input.eraseToAnyPublisher())
        output
            .receive(on: DispatchQueue.main)
            .sink(receiveValue: { [weak self] event in
                guard let self = self else {return}
                switch event {
                case .getRecentTemplateSuccess(let templates):
                    self.recentTemplates = templates
                    ...
                }
            })
            .store(in: &cancellables)
    }
}

마무리

이상으로 제가 구현해본 MVVM을 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊

profile
개발을 하며 경험한 것들을 이것저것 작성해보고 있습니다!

0개의 댓글