이글은 "A practical MVVM example in Swift – Part 1"에 대한 번역입니다.
(http://candycode.io/a-practical-mvvm-example-in-swift-part-1/)
(번역 허락을 받은 것은 아니지만, 도움이 필요하신 분들을 위해서 올려봅니다. 문제가 될 경우 바로 삭제하겠습니다. ㅡㅜ)
한 동안 꽤 괜찮았던 MVC 패턴이라는 것이 있습니다.
MVC라는 약어는 보통 Model-View-Controller를 의미하지만,
iOS에서는 주로 Massive-View-Controller라고 불리고 왜 그렇게 불리는지 iOS 개발을 해본 사람이면 이유를 잘 알 겁니다.
(일반적으로 View와 Controller가 분리되어야 하는데, iOS에서는 Xcode에 기본 화면 처리 단위가 ViewController이고,
View와 Controller가 결합되어 있는 형태라서 UI관련 부분과 로직 처리 부분이 혼재되어 거대해지기 쉽습니다.
그래서 Massive-View-Controller라고 부르는 것 같습니다.)
1000 라인 넘는 view controllers 들은 아주 흔합니다. 왜 그럴까요?
아마도 거대한 view contoller를 작성하기가 너무 쉽기 때문일 것입니다. 심지어 애플조차도 자신들의 예제 코드에서
MVC 패턴을 따르지 않는 것으로 유명합니다.
MVC가 어떤 모습이어야 하는지를 보여주는 아름다운 구조가 여기 있습니다.
(Model과 View는 서로 간섭하지 않으며, Controller를 통해서 상호작용합니다.
이런 분리된 구조를 가짐으로써 dependency를 낮추가나 없앱니다.)
대부분의 MVC 구현이 결국 어떤 모양이 되어 버리는지를 보여주는 구조도 여기 있습니다.
(View에서 대한 많은 처리를 Model에서 하게 되고, dependency가 높아집니다.)
게다가, Controller는 너무 많은 일을 처리하고 있기 때문에 점점 더 거대해집니다.
하지만 이 글은 MVC에 관한 글이 아닙니다.
우리는 (Model-View-ViewModel을 의미하는)MVVM에 대해서 살펴볼 것입니다.
이론적으로는 MVVM은 아래와 같은 형태가 됩니다.
심지어 첫번째 그림보다는 좀 더 명확해 보입니다. 그렇지 않은가요?
Controller는 이제 Model은 신경쓰지 않고, View Model리 제공하는 것을 보여주는 역할을 하게 되었습니다.
View도 마찬가지입니다. 이제 UILable, UITextField 그리고 UIImageView들은 View Model이 제공하는 것만 단지 보여줍니다.
이렇게 함으로써 얻는 이득이 뭔가요?, 이렇게 물어 볼지도 모르겠습니다.
흠, 예를들어 Controller는 덜 커질 겁니다. 만약 우리가 Model 데이터를 일반적이지 않은 형태로 처리하기를 원한다면, 그런 것을 View Model 에서 쉽게 처리할 수 있습니다. 전형적인 예로 NSDate를 formatting하여 보여주는 것을 들 수 있습니다. 보통 우리는 이런 것을 Controller 안에서 하는데, 이런 것은 이미 거대해진 class에 불필요한 코드를 추가한다는 것을 의미합니다.
대신에 MVVM에서는 formatting은 View Model에 두고, 그렇게 함으로 써 우리는 정확히 어떤 클래스와 파일이 NSDate를 String으로 변환하는 역할을 하는지 알게 됩니다. 그런 다음 변환된 String은 Controller에 의해서 View에 할당될 수 있습니다.
MVVM을 채택할 때의 다른 장점은 여러분의 앱이 더 테스트하기 쉬워진다는 것입니다. View Model에 대해서 쉽게 테스트를 작성할 수 있고, 유저에게 보여지기 원하는 것을 정확하게 지정할 수 있습니다. 결합이 더 느쓴한 코드를 작성하게 되기 때문에 Controller들이 테스트하기 더 쉬운 구조가 됩니다.
실습해 봅시다!
Model, ViewModel 그리고 Unit Test 예제에서 Car를 표현하는 상대적으로 간단한 class를 사용할 것입니다. 이렇게 생겼습니다.
class Car {
var model: String?
var make: String?
var horsepower: Int?
var photoURL: String?
init(model: String, make: String, horsepower: Int, photoURL: String) {
self.model = model
self.make = make
self.horsepower = horsepower
self.photoURL = photoURL
}
}
이제, CarViewModel 이라고 부를 우리의 ViewModel을 소개합니다.
class CarViewModel {
private var car: Car?
var modelText: String?
var makeText: String?
var horsepowerText : String?
var titleText: String?
var photoURL: NSURL?
init(car: Car) {
self.car = car
}
}
주의: 저는 CarViewModel을 좀 더 한정적으로 유지하기 위해서 Car.swift에 같이 넣었습니다. CarViewModel을 다른 분리된 파일에 넣어도 됩니다.
이제 TDD를 할겁니다. 준비합시다. 좀 전에, MVVM을 적용하면 앱이 좀 더 테스트하기 쉬워진다고 했습니다. 그래서 우리의 CarViewModel 이 제대로 작동하는지 검증하는 테스트를 추가해 봅시다. 주의: 저는 Xcode navtive Unit Testing Case class들을 사용합니다.
우리의 첫 unit test는 Ferrari F12가 CarViewModel에 의해서 적절하게 표시되는지를 확인합니다.
func testCarViewModelWithFerrariF12() {
let ferrariF12 = Car(model: "F12", make: "Ferrari", horsepowser: 730, photoURL: "http://auto.ferrari.com/en_EN/wp-content/uploads/sites/5/2013/07/Ferrari-F12berlinetta.jpg")
let ferrariViewModel = CarViewModel(car: ferrariF12)
XCTAssertEqual(ferrariViewModel.modelText, "F12")
XCTAssertEqual(ferrariViewModel.makeText, "Ferrari")
XCTAssertEqual(ferrariViewModel.horseposerText, "730 HP")
XCTAssertEqual(ferrariViewModel.photoURL, NSURL(string: ferrariF12.phtoURL!)
XCTAssertEqual(ferrariViewModel.titleText, "Ferrari F12")
}
"커멘트 키" + U 를 누르면 각각의 모든 assertion이 실패할 겁니다. TDD는 red/green/refactor 순서로 가야 하는 것이기 때문에 제대로 되고 있는 겁니다.
(사족: TDD에서는 실제 코드를 작성하기 이전에 테스트를 먼저 작성합니다. 그리고 실행시켜서 테스트가 실패(red)하면, 실제 코드를 작성해서 테스트를 통과(green)하게 만들고, 작성된 실제 코드들을 리팩토리하도록 합니다. 왜 이렇게 하는지 좀 더 설명하자면, TDD에서는 코드를 작성하기 전에 해당 코드가 어떻게 작동해야 하는지를 먼저 코드로 정의한 이후에 실제 코드를 작성하도록 합니다. 즉, 명세/설계를 먼저 생각/정의하고 코드를 작성하는 습관을 시스템적으로 강제화시키는 것입니다. 기능에 대한 정의/설계를 먼저 생각하는 것이 중요합니다. 이렇게 하면 코드의 품질도 좋아지고, regression test에 사용할 수 있는 테스트 코드들도 덤으로 얻을 수 있습니다. 많은 프로그래머들이 기능 명세/정의/설계 없이 일단 코드 부터 작성하는 습관이 많은데, 이렇게 작성하게 되면, 어떻게든 돌아가는 코드를 만들어 내긴하지만, 나중에 테스트하기 어려운 구조가 되기 쉽고, 작성된 코드량이 많아 질 수록 디버깅/테스트에 더 많은 시간을 소요하게 되는 유지보수 하기 점점 힘든 코드가 만들어 진다는 겁니다.ㅡㅜ)
이제 우리의 CarViewModel을 수정하고 테스트를 성공시켜 봅시다.
class CarViewModel {
private var car: Car?
var modelText: String? {
return car?.model
}
var makeText: String? {
return car?.make
}
var horsepowerText: String? {
guard let horsepower = car?.horsepower else {
return nil
}
return "\(horsepower) HP"
}
var titleText: String? {
guard let make = car?.make, model = car?.model else {
return nil
}
return "\(make) \(model)"
}
var photoURL: NSURL? {
guard let photoURL = car?.photoURL else {
return nil
}
return NSURL(string: photoURL)
}
init(car: Car) {
self.car = car
}
}
이렇게 MVVM 방식을 채택할 때의 장점을 알 수 있습니다. 우리의 Car 클래스는 그것 자체로 간결합니다. CarViewModel은 간결한 car 클래스에 약간의 장식을 더합니다. 예를 들어 titleText 변수를 보세요. 이 변수는 car의 make와 model을 연결시킵니다. horsepowerText 는 car 의 horsepower 값 뒤에 HP를 더합니다. photoURL 변수는 실제 car의 사진을 다운로드하는데 사용할 NSURL을 생성합니다.
"커멘드 키" + U 를 누르고 테스트가 동작하면, 테스트가 통과된 것을 알 수 있습니다. 우와! 멋지지 않나요?
독자 중에 한분이 지적하셨듯이, 예제에서는 아직 TDD의 세번째 단계 - refactoring 를 빠뜨리고 있습니다. 몇분들은 Car과 CarViewModel 클래스에서 과도하게 사용된 optional들을 알아채셨을 겁니다. Car와 CarViewModel 둘다 non-optional 파라미터들만 를 받는 initializer를 구성했기 때문에 우리의 코드를 복잡하게(과도하게 optional을 사용하여) 만들 필요가 없습니다. 그래서 리팩토링 된 코드는 이렇습니다.
class Car {
var model: String
var make: String
var kilowatts: Int
var photoURL: String
init(model: String, make: String, kilowatts: Int, photoURL: String) {
self.model = model
self.make = make
self.kilowatts = kilowatts
self.photoURL = photoURL
}
}
class CarViewModel {
private var car: Car
static let horsepowerPerKilowatt = 1.34102209
var modelText: String {
return car.model
}
var makeText: String {
return car.make
}
var horsepowerText: String {
let horsepower = Int(round(Double(car.kilowatts) * (CarViewModel.horsepowerPerKilowatt)))
return "\(horsepower) HP"
}
var titleText: String {
return "\(car.make) \(car.model)"
}
var photoURL: NSURL? {
return NSURL(string: car.photoURL)
}
init(car: Car) {
self.car = car
}
}
모든 guard문이 사라지니 좀 더 짧아졌죠? 추신:non-optional들로 변경되었기 때문에, unit test도 마찬가지로 작은 것 하나를 변경해야 합니다.
XCTAssertEqual(ferrariViewModel.photoURL, NSURL(string: ferrariF12.photoURL)) //더이상 unwrap 할 필요가 없습니다.
바로 그겁니다. 우리는 red/green/refactor의 TDD 방식을 따랐습니다! 이제 좀 더 재미있어지는 user interface로 넘어가겠습니다.
MVVM을 좀 더 시연하기 위해서, UITableView에 보여지는 car들의 배열을 사용합시다.
우선, UITableViewController를 상속하는 TableViewController라고 불리는 새로운 class를 추가 할겁니다.
Single View Application으로 프로젝트를 시작하고, 존재하는 View Controller를 삭제하고, 새로운 UITableViewController를 추가 합니다. 저는 그 class를 TableViewController로 설정했습니다.
그런 다음, tableView 의 cell style을 "Right Detail"로 설정하고 reuse identifier를 "CarCell"로 설정했습니다. (이제 car의 photo, title, horsepower를 표시할 수 있습니다.)
새롭게 생성된 TableViewController에 코드를 작성할 시간입니다. 우리가 MVVM 패턴을 적용하고 있다면, 우리의 TableViewController는 Car model에 대해서 전혀 알지 못합니다. 단지 CarViewModel만 압니다.
실제 응용 프로그램에서 우리는 외부에서 데이터를 읽어 오게 되지만, 예시를 위해서 그냥 AppDelegate에 car 배열을 두겠습니다.
그리고 우리의 (AppDelegate에 있는) car 배열은 실제로 CarViewModel 객체들의 배열입니다.
let cars: [CarViewModel] = {
let ferrariF12 = Car(model: "F12", make: "Ferrari", horsepower: 730, photoURL: "http://auto.ferrari.com/en_EN/wp-content/uploads/sites/5/2013/07/Ferrari-F12berlinetta.jpg")
let zondaF = Car(model: "Zonda F", make: "Pagani", horsepower: 602, photoURL: "http://storage.pagani.com/view/1024/BIG_zg-4-def.jpg")
let lamboAventador = Car(model: "Aventador", make: "Lamborghini", horsepower: 700, photoURL: "http://cdn.lamborghini.com/content/models/aventador_lp700-4_roadster/gallery_2013/roadster_21.jpg")
return [CarViewModel(car: ferrariF12), CarViewModel(car: zondaF), CarViewModel(car: lamboAventador)]
}()
TableViewController 에 car 배열을 유지해야 합니다.
let cars: [CarViewModel] = (UIApplication.sharedApplication().delegate as! AppDelegate).cars
tableView 관련 처리를 위한 boilerplate(기본적으로 필요한) 함수:
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return cars.count
}
그리고 tableView와 car 배열의 실제 연결 부분:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("CarCell", forIndexPath: indexPath)
let carViewModel = cars[indexPath.row]
cell.textLabel?.text = carViewModel.titleText
cell.detailTextLabel?.text = carViewModel.horsepowerText
loadImage(cell, photoURL: carViewModel.photoURL)
return cell
}
위의 코드에서 하나 모르는 부분은 이 예제에서 중요하지 않은 loadImage이고, 우리는 이미지 데이터를 로딩하는 동안에 main thread를 block시키고 싶지 않습니다. 이 지점에서 이미지 다운로드에 관해서 도움을 받기 위해 보통 Alamofire 같은 것을 사용합니다. 하지만, 이 예제를 너무 복잡하게 만들고 싶지 않기 때문에, 간단한 구현으로 대충 마무리합니다.
func loadImage(cell: UITableViewCell, photoURL: NSURL?) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
guard let imageURL = photoURL, imageData = NSData(contentsOfURL: imageURL) else {
return
}
dispatch_async(dispatch_get_main_queue()) {
cell.imageView?.image = UIImage(data: imageData)
cell.setNeedsLayout()
}
}
}
주의:만약 이 예제를 따라하고 있다면, info.plist에 다음의 키를 추가 해야 합니다. 그렇지 않으면 이미지가 로드되지 않을 겁니다.(주소가 https가 아니고 http 이므로 보안 이슈로 인해 동작안함.)
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
이것으로 TableViewController를 마무리 합니다. 앱을 실행시키면, 적절하게 car 배열을 보여주는 tableView를 볼 수 있을 겁니다. (저 배열의 차들이 실제로 제 차고에 있다면 좋겠네요.)
마무리 하기 위해서, 모든 것이 동작하는 것을 확인하기 위한 간단한 UI Test를 작성해봅시다.
class MVVMUITests: XCTestCase {
override func setup() {
super.setUp()
continueAfterFailure = false
XCUIApplication().launch()
}
override func tearDown() {
super.tearDown()
}
func testFerrariF12DataDisplayed() {
let app = XCUIApplication()
let table = app.tables.elementBoundByIndex(0)
let ferrariCell = table.cells.elementBoundByIndex(0)
XCTAssert(ferrariCell.staticTexts["Ferrari F12"].exists)
XCTAssert(ferrariCell.staticTexts["730 HP"].exists)
let zondaCell = table.cells.elementBoundByIndex(1)
XCTAssert(zondaCell.staticTexts["Pagani Zonda F"].exists)
XCTAssert(zondaCell.staticTexts["602 HP"].exists)
let lamboCell = table.cells.elementBoundByIndex(2)
XCTAssert(lamboCell.staticTexts["Lamborghini Aventador"].exists)
XCTAssert(lamboCell.staticTexts["700 HP"].exists)
}
}
모든 것이 완벽합니다! 그런데 왜 테스트를 하나구요?
이 예제는 너무 간단해서 좀 더 재미있게 하기 위해서 좀 더 복잡하게 만들어야 합니다.
어떤 이유 때문에 Car model이 변경되어야 한다고 해봅시다. data source가 horsepower 을 return하는 것에서 부터 kilowatts를 리턴하는 것으로 변경했습니다. 먼저, Car model을 변경하고 horseposer를 kilowatts 와 교체합니다.
class Car {
var model: String?
var make: String?
var kilowatts: Int?
var photoURL: String?
init(model: String, make: String, kilowatts: Int, photoURL: String) {
self.model = model
self.make = make
self.kilowatts = kilowatts
self.photoURL = photoURL
}
}
테스트가 kilowatts가 아니라 horsepowerText를 기대하고 있기 때문에, 마찬가지로 CarViewModel도 약간 변경해야 할 겁니다.
...
static let horsepowerPerKilowatt = 1.34102209
var horsepowerText: String? {
guard let kilowatts = car?.kilowatts else {
return nil
}
lset horsepower = Int(round(Double(kilowatts) * CarViewModel.horsePerKilowatt))
return "\(horsepower) HP"
}
물론, app 을 통한 initializer들도 수정해야 합니다. 그렇지 않으면 build error들이 날겁니다. (Car의 init 함수가 horsepower 대신에 kilowatts를 받도록 변했기 때문에)
// AppDelegate.swift
let cars: [CarViewModel] = {
let ferrariF12 = Car(model: "F12", make: "Ferrari", kilowatts: 544, photoURL: "http://auto.ferrari.com/en_EN/wp-content/uploads/sites/5/2013/07/Ferrari-F12berlinetta.jpg")
let zondaF = Car(model: "Zonda F", make: "Pagani", kilowatts: 449, photoURL: "http://storage.pagani.com/view/1024/BIG_zg-4-def.jpg")
let lamboAventador = Car(model: "Aventador", make: "Lamborghini", kilowatts: 522, photoURL: "http://cdn.lamborghini.com/content/models/aventador_lp700-4_roadster/gallery_2013/roadster_21.jpg")
return [CarViewModel(car: ferrariF12), CarViewModel(car: zondaF), CarViewModel(car: lamboAventador)]
}()
// MVVMTests.swift
let ferrariF12 = Car(model: "F12", make: "Ferrari", kilowatts: 544, photoURL: "http://auto.ferrari.com/en_EN/wp-content/uploads/sites/5/2013/07/Ferrari-F12berlinetta.jpg")
테스트를 실행(커멘트 키 + U) 시킨다면, data source는 변했고, Controller는 그대로 임에도 불구하고 Model에 대해서 신경쓰지 않기 때문에, 여전히 모든 것이 잘 동작하는 것을 볼 수 있을 겁니다. ViewModel(CarViewModel)가 기대되는 값들을 가지고 있는 한 우리는 괜찮을 겁니다.
온전히 동작하는 프로젝트는 깃헙에서 당신을 기다리고 있습니다.(https://github.com/jurezove/mvvm-swift/tree/v1.0) (zip 버전으로도 다운로드 받을 수 있습니다. - https://github.com/jurezove/mvvm-swift/archive/v1.0.zip)
대부분의 앱들은 어떤 종류의 User interaction을 요구합니다. 이런 User interaction은 Controller가 ViewModel에 거꾸로 갱신을 넘겨줄 책임이 있고, ViewModel은 Model 자체를 갱신해야 하는 책임이 있다는 것을 의미합니다. 이런 동작을 binding 매커니즘을 위한 이상적인 동작입니다. 우리는 part 2에서 이런 것들을 다룰 겁니다. 이메일 주소를 남겨주시면(원사이트에-http://candycode.io/a-practical-mvvm-example-in-swift-part-1/) 포스트가 작성될 때 알려드립니다.(이미 작성되어 있음.)
이 글을 읽어 주셔서 감사합니다.
UPDATE: Part 2 with RxSwift implementation is now available! (http://candycode.io/a-practical-mvvm-example-in-swift-part-2/)







