SwiftUI로 코로나 현황 앱 만들기 Part 4 - MVVM

Sat, 26 Feb 2022 00:00:00 +0000

튜토리얼 Part 4에서는 MVVM 디자인 패턴에 대해 알아보고, 이 패턴으로 앱을 구성하기 위해 WorldwideViewModel을 만들고 뷰 모델과 뷰를 어떻게 연결하는지 다룬다.

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를 만들어 보겠다.