[Design Pattern] MVVM
Updated:
들어가며
지난번에는 MVC 패턴에 대해서 공부를 했었는데, 이때 MVC 패턴에는 다음과 같은 단점들이 있었다.
👉🏻 MVC 패턴 포스팅 보러가기
실제로 MVC 패턴을 적용해서 프로젝트를 진행하다보면 View와 Controller는 매우 밀접해있으며, View와 Model의 역할들에 충실하게 코드를 작성하게 되면 (View는 화면을 그리는 것, Model은 App 데이터) ViewController가 점점 무거워지게 되어 Massive ViewController가 되었다.
게다가 Model을 제외하고는 View와 Controller를 테스트하기 위해서는 해당 인스턴스들을 생성하는 하며 각종 제약들이 존재하게 되어서 단위 테스트를 진행하기가 어려워졌다. 그리고 이를 테스트하기 위해서는 View와 비지니스 로직이 분리될 필요성을 느끼고 찾아본 결과 바로 MVVM 패턴을 찾게 되었다.
MVVM
구조
MVVM 패턴은 다음과 같은 구조를 가진다.
이 그림은 MVVM 구조를 잘 나타내고 있는 것 같아서 raywenderlich MVVM Design Pattern Tuturial에서 가져왔다.
구조를 보면 MVC 패턴과 비슷하게 Model과 View, 그리고 ViewController가 있지만 ViewModel
이 새롭게 생겼다. 기존의 MVC에서는 다양한 로직들을 Controller에서 처리하다보니 Controller가 커지게 되었고, 이를 해결하기 위해서 MVVM은 View에 업데이트할 데이터를 ViewModel에서 처리함으로써 기존에 Controller가 처리는 하는 것을 막아 Controller가 커지는 것을 방지해주는 것이다.
역할
MVVM 패턴은 M(Model), V(View), VM(ViewModel)로 이루어져 있다.
M(Model)
- 데이터 구조를 정의
- ViewModel이 Model을 소유하고 가공하여 View에 갱신
V(View)
- UIView, UIViewController가 MVVM의 View에 속함
- 말 그대로 보여주는 작업과 유저의 인터랙션을 받는 역할
- 유저의 인터랙션을 ViewModel에게 명령하고, ViewModel이 업데이트 요청한 데이터를 보여준다
VM(ViewModel)
- View에 실제로 보여질 데이터로 Model의 데이터를 가공하는 역할
- View가 유저 인터렉션을 보내주면 이에 알맞는 작업을 처하고 View를 변경해준다
실제로 Model의 모든 데이터가 View에 그대로 보여지지 않는 경우들이 있다. 그렇기 때문에 View에서는 필요한 데이터만 보여주기 위해서 이를 가공해서 보여주게 되는데, 기존의 MVC 패턴에서는 가공하는 역할을 Controller에서 진행하였기 때문에 바로 Massive한 Controller가 된 것이다. 그리고 이 역할을 바로 ViewModel
에서 대신 해줘서 Controller의 부담을 줄여준다.
MVVM 패턴 적용
간단한 프로젝트를 통해서 MVVM 패턴을 적용해보자.
다음과 같이 사진, 이름, 나이, 급여를 보여주는 간단한 App이 있다고 할 때 이를 MVVM 패턴을 적용해서 구현하면 다음과 같다.
Model
import UIKit
enum Position {
case striker
case midfielder
case defenser
case goalkeeper
}
struct Player {
let name: String
let birthday: Date
let position: Position
let image: UIImage
init(name: String, birthday: Date, position: Position, image: UIImage) {
self.name = name
self.birthday = birthday
self.position = position
self.image = image
}
}
모델은 다음과 같이 선수의 이미지, 이름, 생일, 포지션 데이터를 가지게 된다.
View
import UIKit
final class PlayerView: UIView {
let imageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
let nameLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = .preferredFont(forTextStyle: .body)
label.textColor = .black
return label
}()
let ageLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = .preferredFont(forTextStyle: .body)
label.textColor = .black
return label
}()
let transferFeeLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = .preferredFont(forTextStyle: .body)
label.textColor = .black
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
addSubview(imageView)
addSubview(nameLabel)
addSubview(ageLabel)
addSubview(transferFeeLabel)
NSLayoutConstraint.activate([
imageView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
imageView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.804),
imageView.heightAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.804),
nameLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 30),
nameLabel.leadingAnchor.constraint(equalTo: imageView.leadingAnchor, constant: 10),
ageLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 20),
ageLabel.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor, constant: 0),
transferFeeLabel.topAnchor.constraint(equalTo: ageLabel.bottomAnchor, constant: 20),
transferFeeLabel.leadingAnchor.constraint(equalTo: ageLabel.leadingAnchor, constant: 0)
])
}
required init?(coder: NSCoder) {
fatalError()
}
}
View는 사용자에게 보여질 화면의 요소들의 생성과 layout을 구성해준다.
ViewModel
import UIKit
final class PlayerViewModel {
private let player: Player
private let calender: Calendar
init(player: Player) {
self.player = player
self.calender = Calendar(identifier: .gregorian)
}
var name: String {
return "선수 이름: \(player.name)"
}
var image: UIImage {
return player.image
}
var ageText: String {
let today = calender.startOfDay(for: Date())
let birthday = calender.startOfDay(for: player.birthday)
let components = calender.dateComponents([.year], from: birthday, to: today)
let age = components.year!
return "나이: \(age)살"
}
var transferFeeText: String {
var string = "주급: "
switch player.position {
case .striker:
string += "5억"
case .midfielder:
string += "3억"
case .defenser:
string += "2억"
case .goalkeeper:
string += "5000만원"
}
return string
}
}
extension PlayerViewModel {
func configure(_ view: PlayerView) {
view.nameLabel.text = name
view.imageView.image = image
view.ageLabel.text = ageText
view.transferFeeLabel.text = transferFeeText
}
}
ViewModel은 Model에서 실제로 View에 보여지기 위해서 데이터를 가공해준다. 이떄 이미지는 Model의 이미지를 그대로 사용하지만, 이름, 나이, 급여의 경우에는 ViewModel에서 가공된다. 이때 제일 중요한
ViewController
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let birthday = Date(timeIntervalSinceNow: (-2 * 86400 * 6000))
let image = UIImage(named: "Son")!
let son = Player(name: "Son",
birthday: birthday,
position: .striker,
image: image)
let playerView = PlayerView()
let viewModel = PlayerViewModel(player: son)
viewModel.configure(playerView) // ⭐️⭐️ 중요
self.view.addSubview(playerView)
playerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
playerView.topAnchor.constraint(equalTo: view.topAnchor),
playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
이제 ViewController의 코드인데, PlayerView와 PlayerViewModel을 둘 다 가지며, 이때 뷰에 가공한 데이터를 제공하는 코드는 바로 아래부분이다.
let playerView = PlayerView()
let viewModel = PlayerViewModel(player: son)
viewModel.configure(playerView) // ⭐️ 이부분!!
그리고 실제로 viewModel.configure()
를 확인해보면 아래와 같다.
extension PlayerViewModel {
func configure(_ view: PlayerView) {
view.nameLabel.text = name
view.imageView.image = image
view.ageLabel.text = ageText
view.transferFeeLabel.text = transferFeeText
}
}
View를 매개변수로 받아서 View에 ViewModel에서 가공한 데이터를 할당해준다.
이 프로젝트의 경우에는 비지니스 로직만 분리한 아주 간단한 예제로, 실제로 MVVM에서의 발생하게 되면 뷰와 뷰, 뷰와 모델간의 바인딩은 나오지 않는다.
데이터 바인딩이란
그렇다면 MVVM에서의 데이터 바인딩(binding)은 무엇일까?
쉽게 설명하면 Model과 UI요소 간의 싱크를 맞춰주는 것이다. 위에서의 프로젝트에서는 바인딩이 사용되지 않았는데, 그 이유는 바로 View가 변할 일이 없었기 때문이다. 그러나 우리가 사용하는 대부분의 App은 화면이 사용자와의 인터렉션이나, 네트워킹을 통해 데이터를 받아와서 보여주는 등의 이유로 View가 변하게 된다. 그렇기 때문에 바로 바인딩이 필요한 것이며, 이를 통해 View와 로직이 분리되어 있어도 한 쪽이 바뀌면, 다른 쪽도 업데이트가 이루어져 데이터의 일관성을 유지하도록 해주는 것이다.
데이터 바인딩은 View가 자신이 변화하기 위해서 감지해야할 필요가 있는 ViewModel의 요소를 감지대상으로 설정하고, 요소에 변화가 생기면 스스로 변화하게 되는 것이다. 따라서 이를 구현할 수 있는 방법은 다음과 같다.
- KVO(Key Value Observing)
- Delegation
- Property Observer 이러한 방법들을 사용해서 구현할 수 있다.
또한 RxSwift, Combine Framework를 통해서도 좀 더 반응적(Reactive)으로 사용자와의 인터렉션에 대응할 수 있다. 하지만 이러한 부분에 대해서는 이번 포스팅에서 다루지는 않겠다.
데이터 바인딩 (Property Observer) 적용
Property Observer를 사용해서 바인딩을 구현하는 예제는 iOS Academy Swift5 MVVM을 참고하였으며 다음과 같다.
다음과 같이 tableview에 URL로부터 이름을 받아와서 화면에 보여주는 간단한 예시이다.
Observable
class Observable<T> {
typealias Listener = ((T?) -> Void)
// value가 바뀌었을 때, listner가 이것을 알게 하기 위해서 didSet 설정
var value: T? { // 💡3️⃣ View에서 value가 바뀌므로 didSet에서 클로저를 실행한다.
didSet {
listener?(value)
}
}
init(_ value: T?) {
self.value = value
}
// 처음에는 listener이 없기 때문에 Optional
private var listener: Listener?
// Listener는 value가 바뀔 때 마다 불리게 되고 업데이트 value를 return
// 이때 T가 optional인 이유는 viewModel의 value가 종종 nil인 경우가 있기 때문
// 예를 들면, API로부터 데이터를 받는 경우
// 그리고 클로저를 외부에 저장하기 위해서는 @escaping 키워드 필요
func bind(_ listener: @escaping Listener) {
listener(value) // 가장 최근의 value로 bind()하는 즉시, 현재의 값이 무엇이든간 한 번의 호출이 이뤄짐
self.listener = listener
}
}
Observable 클래스는 말 그대로 관찰 가능한, 즉 관찰 대상이 되는 것(감지 되는 값)에 대해 설정하면된다. 이때 제너릭 타입을 사용해서 모든 타입에 대해서 적용할 수 있도록 한다.
Observable 클래스는 ((T?) -> Void)
클로저와 value, listener 프토퍼티 그리고 bind() 메서드를 가지고 있다.
listener
: value가 설정, 변경됨에 따라서 호출되는 클로저 (View의 변화 내용을 담는 부분)value
: 감지할 대상bind()
: View에서 바인딩 하기 위한 부분(메소드 내에선 내부 listener 설정)
실제 Observable을 사용하는 것은 ViewController 부분에서 사용 코드와 다시 설명할 것이다.
Model
struct User: Codable {
let name: String
}
Model은 간단하게 이름만 가지는 구조체이다. 그리고 Codable을 채택하였다.
ViewModel
struct UserListViewModel {
// 💡2️⃣ 이떄 viewModel의 타입인 UserListViewModel의 user 프로퍼티는
// Obseravable 타입이고, 매개변수로 UserTableViewCellViewModel로 초기화 해주었다.
var users: Observable<[UserTableViewCellViewModel]> = Observable([])
}
struct UserTableViewCellViewModel {
let name: String
}
ViewModel은 하나의 TableViewCell의 ViewModel인 UserTableViewCellViewModel
과 TableView전체의 ViewModel인 UserListViewModel
이 있다. 그리고 우리는 각각의 tableViewCell 내부의 값의 변화에 대해서 관찰해야하므로 UserListViewModel
은 UserTableViewCellViewModel의 배열을 매개변수로 Observable 타입을 초기화해준다.
View
class ViewController: UIViewController, UITableViewDataSource {
private let tableView: UITableView = {
let tableView = UITableView()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
return tableView
}()
private var viewModel = UserListViewModel()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
tableView.frame = view.bounds
tableView.dataSource = self
// 💡4️⃣ 실행되는 클로저는 ViewController에서 View와 ViewModel을 바인딩한 부분이며
// 지금 여기서는 tableview의 데이터를 reload() 해주게 된다.
viewModel.users.bind { [weak self] _ in
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
fetchData()
}
func fetchData() {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/users") else { return }
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else {
return
}
do {
let userModels = try JSONDecoder().decode([User].self, from: data)
// 💡1️⃣ 네트워크로 받아온 데이터를 viewModel에다가 넣어줌
self.viewModel.users.value = userModels.compactMap({
UserTableViewCellViewModel(name: $0.name)
})
} catch {
}
}
task.resume()
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.users.value?.count ?? 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = viewModel.users.value?[indexPath.row].name
return cell
}
}
MVVM에서 View인 ViewController는 TableView와, ViewModel을 가지게 된다. 그리고 URL로부터 데이터를 받아와서 viewModel에 값을 저장해주는 fetchData()
라는 메소드를 가진다.
기존의 프로젝트와 다르게 viewDidLoad()
를 보면 View와 ViewModel을 바인딩 해주기위해서 bind()
메서드를 호출하고 있는 것을 볼 수 있다. 왜냐하면 기존의 프로젝트에서는 View가 변할 일이 없었지만, 지금 이 프로젝트에서는 처음에는 비어있는 tableView에 URL로부터 네트워킹을 통해서 데이터를 받아오면 데이터를 넣어주기 때문에 View가 변하게 되기 때문이다.
이때 코드에 번호와 함께 주석을 달아놓아서 어떠한 순서로 바인딩이 되는 지 확인해 볼 수 있다. 그리고 이번 예시를 통해서 Property Observer를 통해서 MVVM 패턴에서 바인딩을 구현하는 방법을 볼 수 있었다.
데이터 바인딩 (Delegation) 적용
이번에는 Delegation을 사용해서 바인딩을 해보자. 프로젝트는 iOS Academy MVVM Design Pattern 영상을 참고하였다.
TableView에 페이스북처럼 간단하게 사진, 이름, 닉네임, 그리고 버튼이 있는 App이다. 그리고 이때 사용자가 Follow버튼을 누르면 Unfollow로 버튼이 바뀌게 되고, 반대로 Unfollow에서 누르면 Follow로 바뀌게 된다. 즉, 사용자와의 인터렉션에 Model과 UI를 일치시켜주기 위해서 바인딩을 Delegation을 사용해서 구현하게 된다.
Model
enum Gender {
case male, female, unspecified
}
struct Person {
let name: String
let birthdate: Date?
let middleName: String?
let address: String?
let gender: Gender
var username = "KnayWest"
init(name: String, birthdate: Date? = nil, middleName: String? = nil, address: String? = nil, gender: Gender = .unspecified) {
self.name = name
self.birthdate = birthdate
self.middleName = middleName
self.address = address
self.gender = gender
}
}
Model은 사용자의 이름만 필수로 받고, 나머지는 값이 들어올 수도 있고 그렇지 않을 수 있으므로 다음과 같이 작성해준다.
View
import UIKit
protocol PersonFollowingTableViewCellDelegate: AnyObject {
func personFollowingTableViewCellDelegate(_ cell: PersonFollowingTableViewCell,
didTapWith viewModel: PersonFollowingTableViewCellViewModel)
}
class PersonFollowingTableViewCell: UITableViewCell {
static let identifier = "PersonFollowingTableViewCell"
weak var delegate: PersonFollowingTableViewCellDelegate?
private var viewModel: PersonFollowingTableViewCellViewModel?
private let userImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
return imageView
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .label
return label
}()
private let usernameLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .secondaryLabel
return label
}()
private let button: UIButton = {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(userImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(usernameLabel)
contentView.addSubview(button)
NSLayoutConstraint.activate([
userImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
userImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
userImageView.heightAnchor.constraint(equalToConstant: 30),
userImageView.widthAnchor.constraint(equalTo: userImageView.heightAnchor),
nameLabel.leadingAnchor.constraint(equalTo: userImageView.trailingAnchor, constant: 10),
nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
usernameLabel.topAnchor.constraint(equalTo: nameLabel.topAnchor, constant: 20),
usernameLabel.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
usernameLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
button.widthAnchor.constraint(equalToConstant: 100),
button.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
button.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10)
])
// 버튼이 터치됨을 감지하기 위해서 타깃 설정
button.addTarget(self, action: #selector(didTapWithButton), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError()
}
@objc private func didTapWithButton() {
guard let viewModel = viewModel else {
return
}
// ⭐️ 이때 ViewModel은 struct라서 값 타입임으로 복사하여 사용
var newViewModel = viewModel
newViewModel.currentlyFollowing = !viewModel.currentlyFollowing
delegate?.personFollowingTableViewCellDelegate(self,
didTapWith: newViewModel)
prepareForReuse()
configure(with: newViewModel)
}
func configure(with viewModel: PersonFollowingTableViewCellViewModel) {
self.viewModel = viewModel
nameLabel.text = viewModel.name
usernameLabel.text = viewModel.username
userImageView.image = viewModel.image
// Follow -> Unfollow
if viewModel.currentlyFollowing {
button.setTitle("Unfollow", for: .normal)
button.setTitleColor(.blue, for: .normal)
button.layer.borderWidth = 1
button.layer.borderColor = UIColor.black.cgColor
} else { // Unfollow -> Follow
button.setTitle("Follow", for: .normal)
button.setTitleColor(.white, for: .normal)
button.backgroundColor = .link
}
}
override func prepareForReuse() {
super.prepareForReuse()
nameLabel.text = nil
usernameLabel.text = nil
userImageView.image = nil
button.backgroundColor = nil
button.layer.borderWidth = 0
button.setTitle(nil, for: .normal)
}
}
ViewModel
import UIKit
struct PersonFollowingTableViewCellViewModel {
let name: String
let username: String
var currentlyFollowing: Bool
let image: UIImage?
init(with model: Person) {
name = model.name
username = model.username
currentlyFollowing = false
image = UIImage(systemName: "person")
}
}
Model의 데이터 중에서 PersonFollowingTableViewCellView에 보여질 데이터만 가공하는 ViewModel이다.
ViewController
import UIKit
class ViewController: UIViewController, UITableViewDataSource {
private var models = [Person]()
private let tableView: UITableView = {
let tableView = UITableView()
tableView.register(PersonFollowingTableViewCell.self,
forCellReuseIdentifier: PersonFollowingTableViewCell.identifier)
return tableView
}()
override func viewDidLoad() {
super.viewDidLoad()
configueModels()
view.addSubview(tableView)
tableView.dataSource = self
tableView.frame = view.bounds
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return models.count
}
private func configueModels() {
let names = [
"Joe", "Dan", "Jeff", "Jenny", "Emily"
]
for name in names {
models.append(Person(name: name))
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let model = models[indexPath.row]
guard let cell = tableView.dequeueReusableCell(withIdentifier: PersonFollowingTableViewCell.identifier, for: indexPath) as? PersonFollowingTableViewCell else {
return UITableViewCell()
}
cell.configure(with: PersonFollowingTableViewCellViewModel(with: model))
// cell의 delegate를 설정
cell.delegate = self
return cell
}
}
extension ViewController: PersonFollowingTableViewCellDelegate {
func personFollowingTableViewCellDelegate(_ cell: PersonFollowingTableViewCell, didTapWith viewModel: PersonFollowingTableViewCellViewModel) {
/**
if viewModel.currentlyFollowing {
여기서 내부를 구현하여
ViewModel의 currentlyFollowing value의 값을 변경하는 방법도 있고
지금 구현한 것처럼 view에서 didTapWithButton() 내부에서 변경할 수도 있다
}
**/
}
}
MVVM의 장단점
MVVM 패턴의 장단점은 다음과 같다.
장점
- MVC 패턴의 View와 Model 사이의 독립성을 가질 수 있도록함
- View에 관한 로직과 비지니스 로직을 철저히 구분하여 단위 테스트 가능
단점
- 데이터 바인딩이 필수적으로 요구됨
- 데이터 바인딩을 위해서 Boilerplate code를 작성해야하는데, View가 간단한 로직이라면 배보다 배꼽이 더 큰 경우가 발생하게 됨
💡 Boilerplate code
: 작지만 대체할 수 없고, 여러 곳에 포함되어야 하는 코드 섹션. 프로그래머가 매우 작은 일을 하기 위해서 많은 코드를 작성해야하는 경우를 의미한다.
마치며
MVVM 디자인 패턴은 iOS에서 개발할 때 많이 사용한다고 듣기는 했었지만, 실제로 공부를 해보니까 왜 기존의 MVC 패턴을 사용하지 않고 MVVM을 사용하는지 느낄 수 있던 것 같다. 프로젝트의 규모가 커질수록 View들이 많아지고, 이에 로직들도 많아질텐데 View와 비지니스 로직을 따로 분리하는 것 자체가 코드의 재활용이나, 테스트 측면, 그리고 역할의 구분에서도 확실히 이점이 많은 것 같다고 생각한다.
추후에는 MVVM의 단점인 Boilerplate code를 보완하고 사용자의 인터렉션에 더 반응적인 RxSwift나 Combine도 공부해보고 싶다. :)
참고 문서
Raywenderlich MVVM Design Pattern
iOS Academy Swift MVVM Bindings Pattern
iOS Academy MVVM Swfit 5
lena MVVM Design Pattern
Leave a comment