Search

MVVM 패턴 - 양방향 바인딩 with Vanilla Swift

 들어가며

이전에 MVVM 패턴에 대한 포스팅을 작성했습니다.
이전 포스팅을 토대로 여러 방식의 바인딩을 구현해보려고 합니다. 이번 포스팅에서는 라이브러리를 사용하지 않고 순수하게 Swift만을 사용해서 예제를 만들어보려고 합니다.
그럼, 바인딩이 뭔지 한 번 더 짚고 넘어가 봅시다.

 Two-way Binding

View와 ViewModel 사이에서 유저 액션이나 데이터를 주고 받기 위한 장치로 바인딩을 선택했습니다.
바인딩을 통해서 View에서는 유저 액션을 보내 Model에서 변경이 일어날 수있도록 하고, ViewModel에서는 업데이트된 Model 데이터를 보내서 변경된 값이 화면에 표시될 수 있도록 하죠.
하지만, 이러한 바인딩을 위해서는 바인딩 매커니즘을 제공해주는 Utility가 필요합니다.
여러분이 잘 아는 RxSwift, PromiseKit, Combine이 바인딩을 쉽게 수행 가능하도록 도와주는 역할을 하죠. 하지만, 그만큼 진입장벽이 높습니다.
그렇기에 간단하게 사용할 수 있는 방법들을 먼저 알아보고자 합니다.

Example)

이번 예시에서는 3가지 버튼을 눌러서 하단 라벨 텍스트를 변경할 겁니다. 각각의 버튼은 다른 바인딩 방식을 사용해서 구현할 겁니다.
UI는 스토리보드를 사용해서 만들었습니다. 자세한 내용은 생략하겠습니다.

  Purple Button with Delegate

먼저, 가장 위에 있는 보라색 버튼부터 ViewModel과 연결해줬습니다.
보라색 버튼을 눌렀을 때, ViewModel로 유저 액션이 연결되어야 합니다.
ViewController는 ViewModel를 프로퍼티로 소유하고 있기 때문에 버튼을 눌렀을 시에 ViewModel의 메서드를 호출하도록 해줬습니다.
@IBAction func didTapPurpleButton(_ sender: Any) { self.viewModel.updateText() }
Swift
복사
ViewModel로 유저 액션이 전달이 되었지만, 반대로 ViewModel로부터 View로 데이터 전달이 완료되지 않았습니다. 어떻게 ViewModel로부터 View가 데이터를 전달받을 수 있을까요?
ViewModel은 View와는 다르게 View를 프로퍼티로 소유할 수 없고, View를 알아서도 안됩니다.
따라서, ViewModel은 View가 선택한 방식과는 조금 다른 방식으로 데이터를 전달해주어야 합니다.
저는 Delegate Pattern를 사용했습니다.
먼저, PurpleButtonDelegate라는 프로토콜을 만들어 줄게요.
protocol PurpleButtonDelegate: AnyObject { func updateText(_ text: String) }
Swift
복사
그리고 ViewController가 해당 프로토콜을 채택하도록 했습니다. ViewModel에서 updateText 메서드를 호출하면 ViewController에서는 descriptionLabel 에 텍스트가 들어가는 모습을 볼 수 있을 겁니다.
extension ViewController: PurpleButtonDelegate { func updateText(_ text: String) { self.descriptionLabel.text = text } }
Swift
복사
ViewModel에는 ViewController로부터 호출될 updateText 라는 메서드를 만들었습니다. 해당 메서드가 호출되면 ViewController에 있는 updateText 메서드가 호출됩니다. 호출될 때, ViewModel에서 보낸 Purple Button is Tapped 텍스트가 라벨에 표시될 겁니다.
final class ViewModel { weak var delegate: PurpleButtonDelegate? func updateText() { self.delegate?.updateText("Purple Button is Tapped") } }
Swift
복사
하지만, 위의 코드까지만 짜고 실행하면 실행이 안됩니다. 위임을 안해줬거든요.
self.viewModel.delegate = self
Swift
복사
위의 코드를 실행한 모습입니다. ♡ ٩(´▽`)۶ ♡

++) Purple Button with Closure

코드를 살짝 바꿔서 Closure 로 같은 기능을 구현해볼게요.
버튼을 탭해서 ViewModel에 있는 updateText 메서드를 부르는 부분은 그대로 놔두고 delegate 관련된 코드만 주석 또는 지워주시면 됩니다.
@IBAction func didTapPurpleButton(_ sender: Any) { self.viewModel.updateText() }
Swift
복사
ViewModel 내부 코드를 수정해볼게요. 이전에 있던 delegate 를 지우고 프로퍼티를 하나 만들어서 해당 프로퍼티의 값이 바뀌면 프로퍼티 옵져버에 의해서 didSetText 라는 클로져가 실행되도록 했습니다.
final class ViewModel { private var text: String = "" { willSet { self.didSetText?(newValue) } } var didSetText: ((String) -> Void)? func updateText() { self.text = "Purple Button is Tapped" } }
Swift
복사
해당 클로져를 통해서 바뀐 text 값이 전달될겁니다. 그럼 전달된 값은 어디로 받아야 할까요?
bindViewModel 라는 메서드를 만들어서 didSetText 를 통해서 받은 text를 라벨에 넣어주도록 할게요.
override func viewDidLoad() { super.viewDidLoad() self.bindViewModel() } // MARK: - func private func bindViewModel() { self.viewModel.didSetText = { text in self.descriptionLabel.text = text } }
Swift
복사
이러면 위의 예시와 동일한 기능이 클로져로 완성됩니다!

  Red Button with Observable

이번엔 빨간색 버튼을 ViewModel과 연결해볼게요.
이번에는 Observable 이라는 클래스를 하나 만들어서 해당 클래스를 사용해서 value 에 값을 저장하고, listener 를 통해서 값을 방출할 수 있도록 할 겁니다. 값을 방출하기 위해서는 bind 메서드를 사용해서 listener 와 연결 시켜주어야 합니다.
import Foundation final class Observable<T> { typealias Listener = (T) -> Void var value: T { didSet { self.listener?(value) } } var listener: Listener? init(value: T) { self.value = value } func bind(_ listener: Listener?) { self.listener = listener } }
Swift
복사
ViewModel를 만들어서 그 안에 Observable 프로퍼티를 만들었습니다. 해당 프로퍼티는 String 타입의 값을 가질 수 있습니다. 이전처럼 ViewController에서 updateText 를 호출해주면 Observable이 가지고 있는 값을 변경해서 listener 클로져가 실행됩니다.
final class RedViewModel { let text: Observable<String> = Observable(value: "") func updateText() { self.text.value = "Red Button is Tapped" } }
Swift
복사
그러면, 실행된 클로져에서 방출되는 값을 어떻게 받을 수 있을까요?
listener로부터 방출된 값을 descriptionLabel의 텍스트로 넣어서 화면에 표시될 수 있게끔 할 수 있습니다.
private func bindViewModel() { self.redViewModel.text.bind { text in self.descriptionLabel.text = text } }
Swift
복사
빨간색 버튼을 누르니깐 라벨이 잘 바뀌는 군요. ٩( ᐛ )و

  Grey Button with KeyPath

마지막으로 회색 버튼을 보겠습니다.
회색 버튼은 Bindable 이라는 클래스를 사용할 겁니다. 해당 클래스는 KeyPath를 사용해서 bind 메서드를 구현했습니다.
KeyPath를 잘 아시나요?
저도 KeyPath를 많이 사용해보지 못해서 잘 모릅니다. 하지만, KVO에서 KeyPath를 사용하고 있더라구요.
Key-Value Observing을 아시나요?
KVO에 대해서 자세하게 알아보기 위해서 Cocoa Binding부터 알아볼게요.
이전에 Cocoa MVC 패턴을 사용하던 때에 Cocoa Binding를 MVC의 중요한 기능 중 하나로 사용했습니다. Model과 View를 동기화된 상태로 유지시키는 Glue code 없이도 Model, View 내부 속성을 동기화할 수 있는 수단으로 사용했죠.
덕분에 Controller에서 유저 인터페이스와 데이터를 연결할 수 있었습니다.
Cocoa Binding의 주요한 기술 중 하나가 바로, Key-Value Observing 입니다.
KVO는 다른 Object의 변경 사항을 Object에 알릴 수 있는 매커니즘입니다. 그렇기 때문에, Controller에서 Model의 변화를 Observing하기 위해 사용하기 좋았던 것이죠.
변경사항이 있으면 알림이 Observer Object로 직접 전송되기 때문에 알림을 보내기 위한 별 다른 구현을 해주지 않아도 된다는 장점이 있습니다.
그럼, KVO는 어떻게 구현하는가?
Observed Object가 KVO를 준수하도록 하면 됩니다. 준수하는 방식은 NSObject를 상속받도록 하면 됩니다.
그리고 관찰(Observation)할 값에 @objc attribute와 dynamic modifier를 추가해줍니다.
class Person: NSObject { @objc dynamic var name: String init(name: String) { self.name = name } }
Swift
복사
그리고 name 속성을 observe 메서드를 사용해서 observing하도록 하겠습니다.
let duna = Person(name: "Yoonah") duna.observe(\.name, options: [.old, .new]) { object, change in print(change.oldValue, change.newValue) }
Swift
복사
만약, 이후에 name 속성이 바뀐다면 콘솔창에 출력이 될 겁니다.
duna.name = "Duna"
Swift
복사
observe 메서드에 보면 첫번째 매개변수에 KeyPath를 넣을 수 있습니다. 그리고 \.name 이라는 값이 들어간 것이 보이네요.
KeyPath는 특정 Key String를 사용해서 간접적으로 속성에 접근을 하는 방법입니다. KeyPath를 사용해서 간접적으로 Value를 가져올 수 있죠. 그리고 \<Type Name>.<path> 로 작성합니다.
위에 \.name\Person.name 를 나타낸 겁니다. Person 클래스에 있는 name 속성에 간접적으로 접근한 거죠.
KeyPath에는 총 5가지가 있으며 각각의 특징이 다 다릅니다.
KeyPath<Source, Target>
PartialKeyPath
AnyKeyPath
WritableKeyPath<Source, Target>
ReferenceWritableKeyPath<Source, Target>
위의 3가지 KeyPath는 read-only access이고, 아래 KeyPath는 write, read 모두 가능합니다. 마지막 ReferenceWritableKeyPath는 이름 그대로 Source가 Reference Type일 때 사용합니다.
KeyPath에서 <Source, Target>이라는 부분이 보일텐데요. 해당 부분은 Source 타입에서의 Target 타입인 속성만 받겠다는 뜻이 됩니다. 따라서, KeyPath<Person, String> 은 Person 타입에서 String 타입인 속성만 받는다는 뜻이 되겠죠.
KeyPath는 값을 참조하고 있는걸까요?
KeyPath는 프로퍼티를 참조하고 있습니다. 어떤 타입의 값에 대한 경로를 표현하고 있는거죠.
그렇기 때문에, KeyPath만 출력하게 되면 어떠한 값도 나오지 않습니다. 참조하고 있는 속성만 출력됩니다.
하지만, 해당 KeyPath를 적절한 곳에 Key로 넣어주게 된다면 그에 맞는 Value를 받을 수 있습니다.
KeyPath에 대한 구구절절 설명은 멈추고 KeyPath를 사용해서 bind 메서드를 구현한 Bindable 클래스를 만나보겠습니다.
import Foundation final class Bindable<BindingType> { private var observers = [(BindingType) -> Void]() private var value: BindingType? init(value: BindingType? = nil) { self.value = value } func bind<Object: AnyObject, T>( _ sourceKeyPath: KeyPath<BindingType, T>, to anyObject: Object, _ objectKeyPath: ReferenceWritableKeyPath<Object, T?> ) { self.addObserver(for: anyObject) { object, observed in let value = observed[keyPath: sourceKeyPath] anyObject[keyPath: objectKeyPath] = value } } private func addObserver<T: AnyObject>( for object: T, completion: @escaping (T, BindingType) -> Void ) { self.value.map { completion(object, $0) } self.observers.append { [weak object] value in guard let object else { return } completion(object, value) } } }
Swift
복사
일단, value는 이전에 Observable에서 봤던 역할과 동일한 역할을 가집니다. 값을 가지는 역할이죠.
bind 메서드가 이전보다 많은 매개변수를 받는 걸 볼 수 있습니다. KeyPath를 매개변수로 받네요. 자세한 설명은 bind를 직접 연결하는 부분에서 계속 하도록 하겠습니다.
ViewModel에 Observable처럼 Bindable 타입의 프로퍼티를 만들어 줬습니다. 해당 프로퍼티는 "Grey Button is Tapped" 라는 String를 value로 가질겁니다.
final class GreyViewModel { let text: Bindable<String> = Bindable(value: "Grey Button is Tapped") }
Swift
복사
그리고, 버튼이 눌렀을 때 bind가 호출되도록 했습니다. bind가 호출되면 어떤 일이 일어날까요?
@IBAction func didTapGreyButton(_ sender: Any) { self.greyViewModel.text.bind(\String.self, to: self.descriptionLabel, \.text) }
Swift
복사
1.
먼저 bind가 호출되면서 addObserver가 실행됩니다.
anyObject로 들어간 UILabel 클래스가 addObserver 메서드의 인수로 들어갑니다. 그리고 self.value.map { completion(object, $0) } 부분이 실행되면서 UILabel과 value로 들어간 String이 bind 메서드로 넘어갑니다.
2.
bind 메서드에 있는 Closure가 실행됩니다.
Closure에 object로 UILabel이, observed로 “Grey…” 텍스트가 들어옵니다. observed[keyPath: sourceKeyPath] 에서 sourceKeyPath는 KeyPath<String, String> 타입입니다. String 타입에서 String 타입의 속성만 받겠다는 뜻이죠.
observed는 String 타입입니다. 그 안에서 String 타입의 속성만 가져오게 되면 “Grey..” 텍스트를 value로 가지게 됩니다.
3.
UILabel의 text로 해당 value값이 들어갑니다.
anyObject는 UILabel 클래스인 descriptionLabel입니다. 그리고 objectKeyPath는 참조 타입인 클래스에서 사용할 수 있는 ReferenceWritableKeyPath<UILabel, String> 타입이 들어왔습니다. UILabel 타입에서 String 타입의 속성만 받겠네요.
아까 해당 부분에 \.text 를 넣었습니다. 앞에 UILabel 타입이 생략되었기 때문에 \UILabel.text 겠죠. anyObject[keyPath: objectKeyPath] 라는 코드는 한 마디로 descriptionLabel의 text에 해당 value를 넣으라는 뜻 입니다.
따라서, 해당 코드가 실행되고 나면 descriptionLabel의 텍스트에 “Grey…”이 들어가는 겁니다.
회색 버튼을 눌렀을 때 라벨이 바뀌는걸 볼 수 있습니다. ᖭི(ˊᗜˋ*)ᖫྀ

  마치며

이번 포스팅에서는 라이브러리를 사용하지 않고 양방향 바인딩을 구현해보았습니다.
하지만, 라이브러리없이 바인딩을 구현하기 위해서는 기본적으로 설정해줘야 하는 것들이 많습니다. 당장 Bindable 클래스만 보더라도 bind 메서드를 구현해주기 위해서 많은 작업을 해줘야 합니다.
라이브러리를 사용하면 우리가 작업해주지 않더라도 많은 기능들을 사용해볼 수 있습니다.
다음에는 반응형 프로그래밍 라이브러리를 사용해서 바인딩을 구현해보도록 하겠습니다.
예제 코드는 해당 레포에서 보실 수 있습니다. ⸜( ˙ ˘ ˙)⸝♡

 참고 자료