SwiftUI로 코로나 현황 앱 만들기 Part 4 - MVVM
이 글에 사용된 버전: Xcode 13.3, Swift 5.6
2022-04-24: @MainActor
가 사용된 @StateObject
이니셜라이징 관련 사항 수정
Part 3에서는 URL과 네트워킹 관련 코드를 리팩토링하여 URL을 제공하는 타입과 네트워킹을 담당하는 타입 각각으로 분리하고, 우리가 만든 데이터 모델에 Decodable
프로토콜을 채택하여 서버로부터 받은 JSON 데이터를 우리 데이터 모델로 디코딩 하였다. Part 3까지의 파일은 여기서 찾을 수 있다.
Part 4에서는 separation of concerns 원칙을 더 확장하여 앱 전체 아키텍처 디자인에 적용하려고 한다. 우리 앱에서 사용할 디자인 패턴은 MVVM이라고 한다. 우선 MVVM에 대해 알아보고, 첫 뷰 모델을 만들어보자.
MVVM(Model-View-ViewModel)
MVVM은 앱의 구조를 Model-View-View Model로 분리하여 관리하는 방식이다. iOS 앱을 UIKit
프레임워크로 개발할 때는 MVC(Model-View-Controller) 패턴으로 개발하였지만, SwiftUI에서는 뷰를 세세하게 컨트롤하기보다는(imperative) 뷰의 최종적인 모습을 서술(declarative)하는 것만으로도 UI를 원하는 모습대로 구성할 수 있기 때문에 MVVM 패턴이 더 적절해졌다고 생각한다. 물론 이 부분에 대해 다른 의견이 있을 수도 있다. 앞서 프로그래밍에는 결과에 이르는 방법이 한 가지만 있는 것이 아니라고 설명한 것처럼 MVVM이 꼭 정답인 것은 아니다. 이런 문제는 개인의 필요에 의한 선택의 문제이다. 그럼 MVVM의 구성 요소에 대해 조금 더 살펴보자.
Model
모델은 Part 1에서 설명한 것처럼 우리가 앱에서 사용하는 데이터의 청사진, 구조를 의미한다.
View
뷰는 사용자에게 화면을 통해 실제로 보이는 UI 부분을 지칭한다. 우리 앱에서는 현재 ContentView
타입이 뷰를 어떻게 렌더링 할지 담당하고 있는 오브젝트이므로 이를 떠올려보면 쉽게 이해할 수 있을 것이라 생각된다.
View Model
그 개념이 가장 이해하기 어려운 부분은 뷰 모델이 아닐까 한다. 일단 이름에서부터 뷰와 모델이 함께 들어가 있다. 그리고 MVVM에 대해서 설명하는 글들도 모두 조금씩 다르게 설명하는 경향이 있는 부분이 뷰 모델이 아닐까 한다. 따라서 그런 정의는 제쳐두고, 우리 앱에서는 모델과 뷰 사이에서 뷰가 렌더링 할 데이터를 공급하고, 뷰의 UI 업데이트 로직을 담당하는 부분이라고 정의하겠다.
WorldwideViewModel
뷰 모델을 만들기 전에 뷰 모델을 사용하게 될 뷰를 먼저 생성하도록 하겠다. 우리가 만들 첫 번째 화면은 전 세계 코로나 현황을 보여주는 화면이다. 이 화면과 관련된 파일들을 관리하기 쉽도록 Worldwide
라는 새로운 그룹을 만들고 그 안에 WorldwideView.swift
라는 SwiftUI View 파일을 추가하도록 하자. 자동으로 생성된 코드 템플릿의 Text
타입 내에 "Hello, world!"를 "Worldwide View"로 수정하자.
// WorldwideView.swift
import SwiftUI
struct WorldwideView: View {
var body: some View {
Text("Worldwide View")
}
}
만약 프리뷰를 켜뒀다면 글자를 수정할 때 프리뷰 화면에서도 실시간으로 바뀌는 것을 확인할 수 있을 것이다. WorldwideView
의 UI를 본격적으로 만드는 것은 다음 Part 5로 미뤄두고, 우선은 우리 앱이 실행되면 ContentView
대신에 WorldwideView
를 표시하도록 수정하자. COVIDNumbersApp.swift
파일은 아래와 같이 구성되어 있다.
// COVIDNumbersApp.swift
import SwiftUI
@main
struct COVIDNumbersApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@main
키워드는 이 앱의 시작점을 의미한다. App
, Scene
, WindowGroup
과 같은 키워드는 지금은 사용하지 않을 것이므로 우선은 넘어가도록 한다. 우리 앱이 실행되었을 때 ContentView
대신에 WorldwideView
를 표시하도록 하려면 코드 내의 ContentView()
부분을 WorldwideView()
로 바꿔주면 된다.
// COVIDNumbersApp.swift
import SwiftUI
@main
struct COVIDNumbersApp: App {
var body: some Scene {
WindowGroup {
WorldwideView()
}
}
}
이제 앱을 실행해 보면, 화면에 "Worldwide View"라고 나오는 것을 통해 첫 화면이 ContentView
대신에 WorldwideView
로 바뀐 것을 확인할 수 있다. 이제 뷰 모델을 만들기 위해서 Worldwide
그룹 안에 WorldwideViewModel.swift
라는 새로운 Swift 파일을 추가하고 아래와 같이 코드를 작성하자.
// WorldwideViewModel.swift
import Foundation
class WorldwideViewModel: ObservableObject {
@Published private(set) var worldwide: Worldwide? = nil
}
Single Source of Truth
지금까지는 타입을 정의할 때 struct
를 사용했지만 이번에는 class
를 사용하였는데 이는 struct
와 class
의 차이 때문이다. struct
는 해당 인스턴스가 다른 타입이나 함수로 전달될 때 그 인스턴스의 복사본, 즉 새로운 인스턴스가 전달되는데, 이를 value 타입이라고 부른다. 그에 반해 class
의 경우 인스턴스가 전달될 때 인스턴스 자체는 그대로 유지되고, 그 인스턴스에 접근할 수 있는 레퍼런스가 전달되는데, 이를 reference 타입이라고 부른다. class
는 레퍼런스 타입이기 때문에 아무리 여러 번 전달하더라도 단 하나의 인스턴스만 존재하고, 따라서 그 하나의 인스턴스를 참조하는 어느 한 군데에서 인스턴스에 변화를 주게 되면 다른 모든 곳에도 바뀌게 되는 특성이 있다.
WorldwideViewModel
은 WorldwideView
에 데이터를 공급하는 유일한 오브젝트로 작동할 예정이기 때문에 레퍼런스 타입이 더 적합하여 class
를 채택하였다. 프로그램 내에서 같은 데이터를 제공하는 곳이 여러 군데가 되면 그 사이에 불일치가 생겨 앱이 제대로 작동하지 않을 수도 있다. 그래서 데이터의 원천이 되는 곳을 한 군데로 관리한다는 원칙이 있는데 이를 single source of truth라고 부른다.
ObservableObject
와 @Published
클래스 정의 뒤에 ObservableObject
가 붙어 있는 것이 보일 것이다. ObservableObject
는 프로토콜이다. 이 프로토콜을 채택한 클래스는 objectWillChange
를 사용할 수 있게 되는데, objectWillChange
는 클래스 내의 프로퍼티 값이 바뀔 경우 바뀌기 직전에 바뀔 거라고 알려주는 퍼블리셔(publisher)이다. 하지만 직접적으로 objectWillChange
를 사용하는 경우는 많지 않고, ObservableObject
와 @Published
의 조합을 통해 objectWillChange
의 직접적 사용 없이 뷰에 자동으로 데이터의 변화를 알려 주는 방법이 주로 사용된다.
@Published
는 propertywrapper 중 하나로, propertywrapper는 특수한 기능이 내장된 프로퍼티 정도로 이해하면 되고, @Published
프로퍼티래퍼는 퍼블리셔 기능을 추가해 주는 것이다.
이 두 가지 조합의 장점은 ObservableObject
내에 프로퍼티를 @Published
로 정의하고, SwiftUI 뷰에서 그 프로퍼티를 사용하고 있을 경우, 그 프로퍼티의 값이 바뀔 때마다 SwiftUI가 뷰를 자동으로 업데이트해 주는 기능을 얻게 된다는 것이다. 이 기능에 대해서는 나중에 뷰를 구성할 때 다시 확인하게 될 것이다.
private(set)
키워드는 프로퍼티에 대한 접근 권한을 정의해 준 것이다. 프로퍼티의 값이 무분별하게 바뀌는 걸 방지하거나, 다른 타입에게 숨기고 싶을 때 사용하기 좋다. private
키워드는 해당 타입 안에서만, 즉 이 경우 WorldwideViewModel
안에서만 읽거나 값을 바꿀 수 있게 설정하는 것인데, 이 프로퍼티는 WorldwideView
에서 읽어들여야 하는 프로퍼티이므로 다른 타입에서 읽을 수는 있게 열어 두고, 값을 바꾸는 것만 타입 내에서 할 수 있도록 제한하는 것이 private(set)
이다.
마지막으로 우리는 이 프로퍼티를 Worldwide?
형태로 옵셔널로 정의하고 초깃값에 nil
을 할당하였다. 생각해 보면, 데이터를 네트워크에서 다운로드하기 전까지는 데이터가 존재하지 않기 때문에 옵셔널로 정의하는 것이 논리적으로 타당하다. 그리고 바로 이런 경우가 옵셔널을 활용하기 좋은 케이스이다.
ViewModel에서 데이터 가져오기
우리가 네트워크를 통해 데이터를 다운로드한다고 해도 그 데이터 그대로 쓰는 경우는 거의 없고, 대부분은 디코딩 과정을 거쳐서 사용하게 된다. 따라서 디코딩 코드를 별도로 두기보다는 NetworkManager.download(from:)
메서드 안에 포함하는 것이 관리하기 편할 것으로 생각된다. 우선 ContentView.swift
파일로 가서 .task
자체를 삭제하여 원래 상태로 되돌리자.
// ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
그런 다음 NetworkManager.swift
파일로 가서 download(from:)
메서드의 리턴 타입을 Worldwide
로 변경하고 디코딩 코드를 넣어서 아래와 같이 수정한다.
// NetworkManager.swift
func download(from endpoint: Endpoint) async throws -> Worldwide {
let (data, _) = try await session.data(from: endpoint.url)
let result = try JSONDecoder().decode(Worldwide.self, from: data)
return result
}
이제 이 메서드를 WorldwideViewModel
에서 호출하여 리턴된 값을 @Published
가 붙은 프로퍼티에 할당하면 된다. WorldwideViewModel.swift
로 가서 아래 함수를 추가하자.
// WorldwideViewModel.swift
func fetch() async {
let networkManager = NetworkManager()
do {
let result = try await networkManager.download(from: .worldwide)
worldwide = result
} catch {
print(error.localizedDescription)
}
}
이 메서드는 NetworkManager.download(from:)
메서드를 단순히 실행하는 래퍼 타입에 가깝다. 여기에 코드를 짠 작업은 다운로드하고 디코딩 한 결과를 result
에 저장했다가, 그 result
를 @Published private(set) var worldwide: Worldwide? = nil
로 정의해 놓은 프로퍼티에 할당하는 것이고, 만약 에러가 발생한다면 콘솔에 에러 메시지를 출력한다. 만약 네트워킹과 디코딩이 성공적이라면 worldwide
는 더 이상 nil
이 아니라 우리가 디코딩 값을 가지고 있게 될 것이다.
View와 ViewModel 연결하기
이제 뷰 모델이 준비되었으니 뷰에서 사용하기 위해 뷰 모델을 연결하는 일이 남았다. WorldwideView.swift
파일로 가서 아래와 같이 작성한다.
// WorldwideView.swift
import SwiftUI
struct WorldwideView: View {
@StateObject private var viewModel = WorldwideViewModel()
var body: some View {
Text("Worldwide View")
}
}
또 프로퍼티래퍼가 등장했다. 이번엔 @StateObject
이다. 앞서 ObservableObject
, @Published
두 가지만으로도 뷰 모델을 정의하는 것은 충분하지만, 뷰 모델을 읽어오는 쪽에서 연결고리가 필요한데 @StateObject
가 바로 그것이다. 따라서 ObservableObject
, @Published
, @StateObject
는 보통은 같이 쓰이게 된다고 이해하면 되겠다.
@StateObject
를 이해하려면 SwiftUI의 뷰를 알 필요가 있다. SwiftUI 뷰를 자세히 보면, 일단 struct
로 되어있다. 그리고 View
라는 프로토콜을 따르는데, 이 프로토콜을 따르기 위해서 필요로 하는 것은 var body: some View
라는 컴퓨티드 프로퍼티 하나이다. SwiftUI 뷰는 화면의 텍스트나 숫자가 바뀐다든지 하는 변화가 생길 경우 struct
내의 값만 변화시키는 것이 아니라 struct
자체를 새로 만들어낸다. 애초에 class
와 달리 struct
는 밸류 타입이라서 인스턴스 내의 값을 변화시키는 것이 불가능하고 새로운 인스턴스가 만들어진다. 그런데 만약에 그런 struct
내에 우리의 뷰 모델처럼 레퍼런스 타입이 있다면, 그 레퍼런스 타입도 계속해서 새로 이니셜라이징 되는 문제가 발생한다. 이 문제를 해결하는 것이 @StateObject
프로퍼티래퍼이다. @StateObject
를 앞에 붙여두면 뷰가 새로 만들어질 때에도 처음 이니셜라이징 한 그대로 계속해서 살아있게 된다.
여기서 중요한 점은 @StateObject
는 해당 인스턴스를 처음 이니셜라이징할 때 한 번만 사용해야 한다는 것이다. 만약 그 인스턴스를 뷰에 소속된 또 다른 뷰에 전달한다든지 하는 식으로 전달해야 한다면 그때는 @ObservedObject
를 사용한다.
이제 body
내의 코드를 아래와 같이 수정하자.
// WorldwideView.swift
import SwiftUI
struct WorldwideView: View {
@StateObject private var viewModel = WorldwideViewModel()
var body: some View {
Text("Cases: \(viewModel.worldwide?.cases ?? 0)")
.task {
await viewModel.fetch()
}
}
}
""
로 이루어진 String
타입 안에 \()
로 들어가는 것은 string interpolation이라고 해서 정적인 String
에 동적으로 값을 넣어줄 수 있는 방식이다. 코드를 심플하게 유지할 수 있어서 자주 쓰이는 방식이다.
viewModel.worldwide?.cases
와 같이 쓰인 것을 optional chaining이라고 한다. 옵셔널 체이닝은 옵셔널에도 불구하고 unwrap 과정 없이 계속해서 그 옵셔널 타입에 속한 프로퍼티에 접근하는 방식이다. 우리는 앞서 Part 1에서 Worldwide
를 정의할 때 cases
를 let cases: Int
와 같이 옵셔널이 아닌 단순 Int
타입으로 정의하였다. 하지만 옵셔널 체이닝하게 되면 그 이후 프로퍼티들도, 이 경우 worldwide?
이후 프로퍼티인 cases
도 옵셔널이 된다.
Text
타입은 String
으로 이니셜라이징할 때 옵셔널이 아닌 그냥 String
만 사용해야 하는데, 여기서는 언랩 과정을 거치기보다는 nil-coalescing을 사용했고 이를 나타내는 것이 ??
오퍼레이터이다. Nil-Coalescing은 단어는 어려운데 의미는 간단하다. Nil-coalescing 한 옵셔널이 값이 있으면 그 값을 사용하고, nil
이면 ??
오른쪽을 사용하라는 것인데, 이 경우 worldwide
가 값이 있어서 cases
도 값이 있으면 그 값을 사용하고, 아니면 0
을 사용하라는 의미가 된다.
이제 앱을 실행해 보면 화면에 Cases: 0
으로 표시되었다가 잠시 후 0이 실제 숫자로 바뀌는 것을 확인할 수 있을 것이다.
MainActor
그런데 WorldwideViewModel.swift
파일로 가보면 아래와 같은 에러가 보일 것이다.
Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
우리가 작성한 코드 중 worldwide = result
구문에서 @Published
가 붙어 있는 worldwide
를 변경하여 UI 업데이트를 촉발시키게 되는데, 문제는 해당 구문이 네트워크 코드와 함께 있기 때문에 네트워크 코드가 실행되었던 백그라운드 스레드에서 UI 업데이트가 이루어지고 있는 것이다. Part 2 Concurrency 부분을 설명하면서 UI 업데이트는 반드시 메인 스레드에서 이루어져야 한다고 설명한 적 있는데 위 에러는 바로 이것을 위반했다는 의미이다.
이 문제를 해결하기 위해서는 해당 코드가 메인 스레드에서 실행되도록 조치해 주어야 하는데, 의외로 간단하게 처리할 수 있다. class
정의 위에다가 @MainActor
만 붙여주면 된다.
// WorldwideViewModel.swift
@MainActor
class WorldwideViewModel: ObservableObject {
...
}
@MainActor
가 붙은 오브젝트나 메서드는 그 작업을 메인 스레드에서 하기 때문에 대부분의 경우 UI 업데이트 코드를 작동시켜도 정상적으로 작동하게 된다. 이제 앱을 다시 실행해 보면 마찬가지로 작동하지만 보라색 에러가 사라진 것을 확인할 수 있다.
Swift 5.6이 되면서 @MainActor
와 @StateObject
를 같이 사용할 때, 별도의 이니셜라이저를 통해 @StateObject
를 이니셜라니징하는 과정이 필요하게 되었다. WorldwideView.swift
파일로 가서 아래와 같이 변경한다.
// WorldwideView.swift
import SwiftUI
struct WorldwideView: View {
@StateObject private var viewModel: WorldwideViewModel
init() {
_viewModel = StateObject(wrappedValue: WorldwideViewModel())
}
...
}
코드 개선하기
Part 4를 마치기 전에 몇 가지 개선을 위해 코드를 조금 더 수정하고자 한다.
ProgressView
현재는 앱이 실행되면 Cases: 0
에서 어느 순간 숫자가 바뀌게 되는데 이런 경우 데이터를 로딩하는 동안 앱이 제대로 작동하고 있는지 유저가 제대로 인지하기 어렵다는 문제가 있다. 우선 이 부분을 개선하기 위해 WorldwideViewModel.swift
파일로 가서 프로퍼티를 하나 추가하자.
// WorldwideViewModel.swift
import Foundation
@MainActor
class WorldwideViewModel: ObservableObject {
@Published private(set) var isLoading = false
...
}
isLoading
프로퍼티에는 로딩 중인지 여부를 저장할 예정으로 디폴트 값은 false
를 할당했다. 이제 네트워킹을 시작할 때 이 프로퍼티를 true
로 바꿔주고, 네트워킹이 끝나면 false
로 다시 바꾸어 주자.
// WorldwideViewModel.swift
func fetch() async {
isLoading = true
let networkManager = NetworkManager()
do {
let result = try await networkManager.download(from: .worldwide)
worldwide = result
} catch {
print(error.localizedDescription)
}
isLoading = false
}
이제 이 프로퍼티를 실제로 사용하여 로딩 화면을 표시하면 된다. WorldwideView.swift
파일로 가서 아래와 같이 수정한다.
// WorldwideView.swift
import SwiftUI
struct WorldwideView: View {
@StateObject private var viewModel: WorldwideViewModel
init() {
_viewModel = StateObject(wrappedValue: WorldwideViewModel())
}
var body: some View {
if viewModel.isLoading {
ProgressView()
} else {
Text("Cases: \(viewModel.worldwide?.cases ?? 0)")
}
}
}
if
-else
구문은 control flow 구문이다. 간단하게 if
에 붙은 조건이 true
라면 그 블록 내의 코드가 실행되고, false
라면 else
블록 코드가 실행된다. 따라서 이 경우, isLoading
이 true
라면 ProgressView
를 보여주고, false
라면 이전과 같은 화면을 보여주는 것이 된다.
ProgressView
는 SwiftUI에서 기본적으로 제공하는 뷰로 각종 시간이 걸리는 작업을 진행할 때 제대로 진행 중이라는 시그널을 주는데 적합하다. 위에서는 기본형을 사용하였는데 기본형은 원형의 로딩 화면이다. 실제 아이폰을 사용하면서 많이 봐온 것이기 때문에 보는 순간 어떤 것인지 알 것이다.
이제 앱을 실행해 보면, 로딩 표시가 잠깐 나타났다가 이전과 같이 Cases: 숫자
로 표시되는 것을 확인할 수 있을 것이다.
Swift Result 타입
다음으로 개선할 사항은 WorldwideViewModel.fetch()
메서드 안에 do
, try
, catch
구문이 복잡하게 느껴져서 조금 더 단순화하고, 네트워킹에서 발생할 가능성이 있는 에러를 조금 더 세분화하여 보여 주는 것이다.
이를 위해서 Swift의 Result
타입을 활용하고자 한다. 옵셔널이 값이 있거나 없거나 두 가지 케이스를 가지는 타입이라면 Result
타입은 성공했거나 실패했거나 두 가지 케이스를 가지는 타입이다. 우리 코드 중 네트워킹이나 디코딩 과정은 어떤 이유에서 실패할 수도 있는, 달리 말하면 에러가 발생할 수도 있는 작업이기에 성공했거나 실패했거나 두 가지 케이스를 가지는 Result
타입을 사용하기에 알맞은 곳이라 하겠다. Result
타입은 Result<Success, Failure>
형태로 정의한다.
NetworkManager.swift
파일로 가서 download(from:)
메서드를 다음과 같이 수정한다.
// NetworkManager.swift
func download(from endpoint: Endpoint) async -> Result<Worldwide, Error> {
do {
let (data, response) = try await session.data(from: endpoint.url)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
return .failure(URLError(.badServerResponse))
}
guard data.count > 0 else {
return .failure(URLError(.zeroByteResource))
}
let result = try JSONDecoder().decode(Worldwide.self, from: data)
return .success(result)
} catch {
return .failure(error)
}
}
메서드의 리턴 타입 자체를 Result<Worldwide, Error>
로 변경한 것을 확인하라. 이렇게 변경하게 되면, Result
타입은 Worldwide
나 Error
둘 중 하나의 값을 갖게 된다. <
, >
사이가 바로 그런 의미이다. Result
타입을 리턴할 때는 .success(Success)
와 .failure(Failure)
형태로 사용하는 것도 확인할 수 있을 것이다. 즉, 성공적인 경우를 리턴하고 싶다면 .success
로, 실패한 케이스를 리턴하고 싶으면 .failure
로 미리 스킴을 짜 둘 수 있게 된다.
일단 전체 구조를 보면, do
, try
, catch
구문을 통해 do
블록 내에 try
키워드가 있는 곳에서 에러가 발생하면 catch
블록으로 가서 해당 에러를 .failure
케이스로 리턴하도록 작성했다.
do
블록 내에서는 첫 번째 guard
-else
구문을 통해 HTTP response 코드를 다루었다. HTTP 프로토콜로 네트워킹을 하게 되면 response 코드가 서버에서 돌아오게 되는데 코드가 200
인 경우는 정상이다. 여기서는 200
, 즉 정상이 아니면 URLError
중 .badServerResponse
를 보내는 것으로 코딩하였다. 여기서는 200
아 아닌 경우에 대해서만 다루었는데, 위 코드를 응용하면 나머지 다양한 코드에 대해서도 각각 경우를 설정하여 에러 메시지를 보내줄 수 있을 것이다. HTTP Response에 대해서는 이 사이트를 참조하자.
두 번째 guard
-else
구문에서는 HTTP response 코드는 정상적으로 200이라고 하더라도, 어떤 이유에서 우리가 다운로드한 데이터가 0인 경우 .zeroByteResource
에러를 담고 있는 failure
케이스를 리턴하도록 구성하였다.
마지막으로 아무런 문제 없이 do
블록 끝에 도달한다면, 디코딩 한 결과를 .success
케이스로 리턴한다. 지금 당장은 이 메서드가 오히려 복잡해 보이겠지만, 이렇게 함으로써 이 메서드를 호출하는 곳에서는 더 간략하게 표현이 가능하다. 그리고 나중에 알게 되겠지만 이 메서드는 반복해서 사용할 것이기 때문에 조금 복잡하더라도 한 번 제대로 정의해두고 나면 이후가 편해진다.
이제 리턴 타입이 Result
타입으로 바뀌었으므로 WorldwideViewModel.swift
파일로 가서 fetch
메서드를 아래와 같이 수정하자.
// WorldwideViewModel.swift
func fetch() {
isLoading = true
let networkManager = NetworkManager()
Task {
let result = await networkManager.download(from: .worldwide)
isLoading = false
switch result {
case .success(let data):
worldwide = data
case .failure(let error):
print(error.localizedDescription)
}
}
}
fetch()
메서드 정의에 async
키워드를 빼고, 메서드 내부의 concurrency 코드를 Task
블록으로 묶어서 Task
가 concurrency를 핸들링하도록 하였다. 이렇게 하면 나중에 fetch()
메서드를 호출할 때 await
키워드를 붙이지 않아도 되게 된다. switch
-case
키워드는 앞서의 if
, else
와 같은 control flow 코드로, 한정된 경우의 수를 빠짐없이 모두 다루어야 할 때 유용하게 사용할 수 있다. 특히 이 경우처럼 Result
타입과 쌍으로 사용하기에 적절하다. 코드 내용은 이전과 동일하게 성공한 경우 .success
케이스와 함께 리턴된 값을 data
란 이름에 일단 할당했다가 이것을 다시 worldwide
프로퍼티에 할당하고, 실패한 경우 .failure
와 함께 리턴된 값을 error
란 이름에 일단 할당했다가 에러 메시지를 콘솔에 출력한다. 에러가 발생하는 경우 지금은 그냥 콘솔에 출력하고 끝내고 있는데 나중에 뷰를 구성할 때 에러를 유저에게 보여주는 방법도 같이 다룰 예정이다.
마지막으로 처음에 WorldwideView
가 화면에 나타나면 제일 먼저 할 일이 fetch()
메서드를 호출하는 일이 될 것이므로, 이 호출을 뷰 측면에서 다루지 말고 WorldwideViewModel
이 이니셜라이징 될 때 바로 호출하도록 이니셜라이저를 추가하고 그 안에서 호출하도록 하자. 최종 모습은 아래와 같다.
// WorldwideViewModel.swift
import Foundation
@MainActor
class WorldwideViewModel: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var worldwide: Worldwide? = nil
init() {
fetch()
}
func fetch() {
isLoading = true
let networkManager = NetworkManager()
Task {
let result = await networkManager.download(from: .worldwide)
isLoading = false
switch result {
case .success(let data):
worldwide = data
case .failure(let error):
print(error.localizedDescription)
}
}
}
}
이니셜라이저 init()
은 그 타입의 인스턴스가 처음 생성될 때 자동으로 호출되는 것으로 자동으로 무언가를 실행하려면 이니셜라이저 안에서 호출하는 것이 좋다.
이제 앱을 실행해 보면 이전과 마찬가지로 잘 작동하는 것을 확인할 수 있다.
여기서 Part 4를 마친다. Part 4에서는 지금까지...
- MVVM 디자인 패턴이 어떤 것인지
WorldwideViewModel
을 통해 뷰 모델을 어떻게 구성하는지- 뷰 모델과 뷰를 어떻게 연결하는지
에 대해서 알아보았다. 다음 Part 5에서는 실제로 UI를 구성해 볼 것이며, 그 첫 번째로 WorldwideView
를 만들어 보겠다.