SwiftUI로 코로나 현황 앱 만들기 Part 6 - CountriesView

Sat, 12 Mar 2022 00:00:00 +0000

튜토리얼 Part 6에서는 Generics를 사용하여 네트워킹 코드를 재사용 가능하도록 수정하고, CountriesView에서 List와 ForEach 타입을 사용하여 동적인 리스트를 만드는 것을 다룬다.

2022-04-24: API 수정

Part 5에서는 첫 뷰로 WorldwideView를 만들어 보았다. 그 과정에서 여러 개의 뷰를 나란히 놓을 수 있는 VStack, HStack을 사용하고, 리스트 형식의 데이터를 나타내기 위해 List도 사용하였다. 그리고 복잡한 뷰를 부분으로 나누어 구성하는 방법에 대해서도 알아보았고, NavigationView를 사용하여 제목 바와 그 바에 버튼을 위치시키기도 하였다. Part 5까지의 파일은 여기에서 다운로드할 수 있다.

Part 6를 시작하기 전에 Part 5에서 만든 뷰를 먼저 개선하려고 한다. WorldwideView에서 리스트 각 줄의 데이터 제목 부분에 .bold()를 적용한 것이 조금 과한 느낌이 들어서 .bold()를 먼저 제거하려고 한다. 한쪽은 검은색, 다른 한쪽은 회색으로 대비를 준 것만으로도 충분해 보인다. WorldwideListRow.swift 파일에서 .bold() 부분을 삭제하자.

//  WorldwideListRow.swift

struct WorldwideListRow: View {
    let title: String
    let number: Int

    var body: some View {
        HStack {
            Text(title)

            Spacer()

            Text("\(number)")
                .foregroundColor(.secondary)
        }
    }
}

다음은 WorldwideListView에서 이미지 부분의 여백을 없애기 위해서 .padding() 괄호 안에 값을 -20을 주자. 패딩에 마이너스 값을 주게 되면 이미지가 해당 이미지의 프레임을 넘어서서 표시된다. 이미지 바깥의 흰색 부분 자체가 이미 모서리가 둥글게 되어 있으므로 .cornerRadius(10)은 더 이상 필요가 없을 것 같다. 수정을 끝내면 Image 부분 코드가 아래와 같이 된다.

Image("covid")
    .resizable()
    .scaledToFit()
    .padding(-20)

기존 코드 수정은 마쳤으니 이제 Part 6에서는 두 번째 뷰로 여러 나라가 표시되는 리스트를 만들어 보고자 한다. 이를 위해 새로운 데이터 모델을 정의해야 하고, 네트워킹 코드도 수정해야 한다. 그럼 바로 시작해 보자. 여기부터는 데이터 모델을 만들고, 뷰 모델을 만들고, 뷰를 만드는 비슷한 작업이 이어지기에 조금 더 빠르게 진행할 수 있을 것으로 기대한다.

Country Model

NovelCOVID API 사이트에서 나라별 데이터를 다운로드하는 API는 GET v3/covid-19/countries 부분에 있으며, 아래와 같이 되어 있다.

https://disease.sh/v3/covid-19/countries?yesterday&sort

이 API를 사용해서 가져온 샘플 데이터는 이렇게 생겼다.

[
    {
        "updated": 1645935456341,
        "country": "Afghanistan",
        "countryInfo": {
            "_id": 4,
            "iso2": "AF",
            "iso3": "AFG",
            "lat": 33,
            "long": 65,
            "flag": "https://disease.sh/assets/img/flags/af.png"
        },
        "cases": 173146,
        "todayCases": 0,
        "deaths": 7585,
        "todayDeaths": 0,
        "recovered": 155026,
        "todayRecovered": 0,
        "active": 10535,
        "critical": 1124,
        "casesPerOneMillion": 4288,
        "deathsPerOneMillion": 188,
        "tests": 887545,
        "testsPerOneMillion": 21982,
        "population": 40375742,
        "continent": "Asia",
        "oneCasePerPeople": 233,
        "oneDeathPerPeople": 5323,
        "oneTestPerPeople": 45,
        "activePerOneMillion": 260.92,
        "recoveredPerOneMillion": 3839.58,
        "criticalPerOneMillion": 27.84
    },
    {
        "updated": 1645935456330,
        "country": "Albania",
        "countryInfo": {
            "_id": 8,
            "iso2": "AL",
            "iso3": "ALB",
            "lat": 41,
            "long": 20,
            "flag": "https://disease.sh/assets/img/flags/al.png"
        },
        "cases": 271141,
        "todayCases": 0,
        "deaths": 3458,
        "todayDeaths": 0,
        "recovered": 264287,
        "todayRecovered": 0,
        "active": 3396,
        "critical": 13,
        "casesPerOneMillion": 94389,
        "deathsPerOneMillion": 1204,
        "tests": 1736486,
        "testsPerOneMillion": 604499,
        "population": 2872604,
        "continent": "Europe",
        "oneCasePerPeople": 11,
        "oneDeathPerPeople": 831,
        "oneTestPerPeople": 2,
        "activePerOneMillion": 1182.2,
        "recoveredPerOneMillion": 92002.59,
        "criticalPerOneMillion": 4.53
    },

    ...

앞에서 사용했던 API에 비해 일단 데이터양이 굉장히 많다. 그리고 두 가지 점에서 차이가 보인다. 첫째는 [ ]로 감싸진 곳 안쪽으로 같은 패턴이 반복되는 { } 형태의 JSON으로 되어 있다는 것이고, 두 번째는 countryInfo에 보면 JSON 안에서 또 JSON 패턴이 나타나고 있다는 것이다.

우선 [ ]이 나타내는 것은 여러 개의 JSON 오브젝트가 담겨 있는 것이라고 보면 되며, Swift에서 여기에 대응하는 것은 Array 타입이다. Array는 여러 오브젝트를 담는 collection의 하나이고, collection 형태 중 순서가 있는 collection이다. 순서가 있다는 말은 [ ]안에 담긴 오브젝트의 순서가 유지된다는 말이다. 순서가 없는 collection으로 Set 타입이 있으며 순서가 필요한가 아닌가에 따라 사용하는 종류를 달리하여 더 효율적인 프로그램을 만들 수 있다. Swift의 Array 타입도 JSON에서와 동일하게 [ ] 마크를 사용하여 만든다. 만약 우리가 Country라는 데이터 모델을 만든다면, 이 API의 결과는 [Country] 형태가 되는 것이다.

두 번째로 계층화된 JSON에 대해서도 간단히 해결할 수 있다. 한 JSON 안에 있는 다른 JSON에 대해서도 데이터 모델을 만들면 된다. 예를 들어서 이런 형태로 말이다.

struct Country {
    ...
    let countryInfo: CountryInfo
}

struct CountryInfo {
    ...
}

이제 앞서 설명한 방법대로 데이터 모델을 만들자. Models 그룹에 Country.swift라는 이름으로 새로운 Swift 파일을 추가하고 아래와 같이 작성한다.

//  Country.swift

import Foundation

struct Country: Decodable {
    let updated: TimeInterval
    let country: String
    let countryInfo: CountryInfo
    let cases: Int
    let todayCases: Int
    let deaths: Int
    let todayDeaths: Int
    let recovered: Int
    let todayRecovered: Int
    let population: Int
    let continent: String
}

extension Country {
    struct CountryInfo: Decodable {
        let iso2: String
        let iso3: String
        let lat: Int
        let long: Int
        let flag: String
    }
}

extension Country: Identifiable {
    var id: String { country }
}

API에서 제공하는 많은 정보 중 사용하지 않을 정보는 제외하고 우리가 사용할 데이터만 프로퍼티로 설정하였다. CountryInfo 타입은 Country 타입 내에서만 사용될 예정이므로 Country 타입 안에다가 정의하였다. 이렇게 타입 안쪽에 정의된 타입을 Nested Types라고 부른다. 마지막 Identifiable은 프로토콜이며 이 프로토콜은 SwiftUI가 각 인스턴스들을 구별하는 데 도움을 준다. 이렇게 Identifiable을 따름으로써 나중에 보게 될 ForEach 구문이 조금 간단해지는 편리함이 생긴다. 이 프로토콜을 따를 때 요구되는 것은 id 프로퍼티이다. 여기에는 절대 중복될 일 없는 값을 할당하여야 하는데 국가 이름이 겹칠 일은 없으니 그냥 country를 사용하도록 컴퓨티드 프로퍼티로 정의하였다. 만약 인스턴스 간에 id 값이 같은 경우가 생기면 프로그램에 문제가 생기니 주의하도록 하자.

Generics

이제 네트워킹 코드를 작성할 차례다. 우선 새로운 API를 호출하기 위한 Endpoint 타입이 필요하다. Endpoint.swift 파일로 가서 extension 내에 코드를 추가하자. 여기에 queries에 사용한 것도 어레이에 해당한다.

// Endpoint.swift

extension Endpoint {
    static var worldwide: Self {
        ...
    }

    static var allCountries: Self {
        Endpoint(
            path: "v3/covid-19/countries",
            queries: [
                URLQueryItem(name: "yesterday", value: "true"),
                URLQueryItem(name: "sort", value: "")
            ]
        )
    }
}

NetworkManager.swift 파일에 전에 작성한 download(from:) 메서드는 Result<Worldwide, Error> 타입만 리턴하도록 고정되어 있다. 이번에 필요한 메서드를 하나 더 작성하자니, 코드가 거의 동일하다. Part 5에서 보았던 반복하지 말라는 DRY(Don't Repeat Yourself) 원칙을 기억한다면 거의 동일한 코드를 하나 더 작성하는 것은 별로 좋은 생각이 아니다. 그러면 같은 메서드에서 다른 타입을 사용하도록 하는 방법은 없을까? 이때 사용하는 것이 바로 Generic이다.

Swift는 타입을 굉장히 엄격하게 사용하는 편이다. Swift 언어의 특징 중 하나가 Type Safety이다. Type Safety는 사용하는 값의 타입을 명확하게 하는 것으로, 데이터 모델을 정의할 때를 생각해 보면 모든 프로퍼티에 Int, String과 같이 타입을 기재한 것이 바로 그것이다. 하지만 Type Safety는 런타임 에러를 줄이는 긍정적인 면도 있지만, 언어가 너무 경직되어 버린다는 단점도 가져온다. 조금 더 유연한 코드를 위해서는 코드 작성 때 타입을 특정하지 않고 컴파일 타임에 결정되도록 하는 경우가 필요하다. 바로 지금처럼 말이다. 이럴 때 사용하는 것이 Generic인데, 제네릭을 사용함으로써 코드 중복은 줄이고 재사용률은 높일 수 있다.

우선은 코드부터 작성하자. NetworkManager.swift 파일로 가서 아래와 같이 수정한다.

// NetworkManager.swift

struct NetworkManager<T: Decodable> {
    let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func download(from endpoint: Endpoint) async -> Result<T, 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(T.self, from: data)

            return .success(result)
        } catch {
            return .failure(error)
        }
    }
}

함수나 타입 이름 옆에 < > 기호와 그 사이에 플레이스홀더(placeholder)로 사용할 타입의 이름을 적어 넣으면 그 함수나 타입 내에서는 그 플레이스홀더를 타입처럼 사용할 수 있게 되며 이를 제네릭이라 한다. Swift 커뮤니티에서는 관행적으로 플레이스홀더를 T, U, V 같은 순서로 사용한다. 아마도 T는 Type에서 온 것이 아닐까 추측한다.

여기서 <T: Decodable>이라고 쓴 것은 이 함수에서 사용하는 타입이 Decodable 프로토콜을 따르는 것만 사용할 수 있다는 제약사항(type constraints)을 준 것이다. 이런 제약사항을 준 이유는 JSONDecoder.decode(_:from:) 메서드는 Decodable 프로토콜을 채택한 타입만 사용할 수 있기 때문이다.

이제 플레이스홀더로 T를 설정해 주고 나면, 이 타입 내에서는 플레이스홀더 T로 구체적인 타입을 대체할 수 있다. Result<T, Error>와 let result = try JSONDecoder().decode(T.self, from: data)이 바로 그렇게 적용한 부분이다.

이렇게 작성하고 command + B 단축키로 빌드를 해보려고 하면 WorldwideViewModel.swift 파일에서 오류가 날 것이다. 오류가 나는 부분은 바로 이 줄이다.

let networkManager = NetworkManager()

Generic parameter 'T' could not be inferred

이 에러는 컴파일러가 T가 어떤 타입인지 추론할 수 없어서 발생하는 에러이다. 제네릭을 사용할 때 때로는 컴파일러가 어떤 타입인지를 컴파일 시에 알아서 추론하여 찾을 수 있는 때가 있고, 지금처럼 그렇지 않은 때가 있다. 이럴 땐 제네릭을 사용하는 곳에서 타입을 특정하여 주면 된다. 해당 줄을 아래와 같이 수정하자.

let networkManager = NetworkManager<Worldwide>()

이제 에러가 사라졌고, 앱도 예전처럼 정상적으로 작동하는 것을 확인할 수 있다.

TabView

뷰 모델을 만들기 전에 우선은 껍데기만이라도 뷰를 먼저 만들려고 한다. Countries라는 새로운 그룹을 만들고, 그 안에 CountriesView.swift라는 이름으로 SwiftUI View 파일을 추가하자. 뷰의 구별을 쉽게 하기 위해서 "Hello, world!" 부분을 "Countries View"라고 바꾸자.

//  CountriesView.swift

import SwiftUI

struct CountriesView: View {
    var body: some View {
        Text("Countries View")
    }
}

이제 스크린 단위의 뷰가 두 개가 되는 셈인데 이 두 뷰를 어떻게 서로를 오가도록 하여야 할까? 물론 여러 가지 방법이 있다. 앞에서 사용한 NavigationView도 하나의 방법이 된다. 하지만 NavigationView는 뷰 간에 상하 계층적 구조 관계에 있을 때 이를 오가는 데에 더 알맞은 방법이다. 우리 뷰는 전 세계 데이터를 보여주는 뷰와 국가 리스트를 보여주는 뷰로 그 성격이 대등한 관계에 있다. 이럴 때 더 어울리는 방법은 바로 TabView이다.

탭 뷰가 어떤 것인지는 아이폰에서 앱스토어를 들어가 보면 볼 수 있다. 맨 아래쪽에 위치한 것이 바로 탭 뷰이다.

탭 뷰를 사용하려면 탭 뷰를 담고 있는 뷰가 하나 필요한데, 이를 위해서 다시 첫 화면으로 사용하는 뷰로 ContentView를 다시 부활시키자. COVIDNumbersApp.swift 파일로 가서 WorldwideView() 부분을 다시 ContentView()로 변경한다.

//  COVIDNumbersApp.swift

import SwiftUI

@main
struct COVIDNumbersApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

이제 ContentView.swift 파일로 가서 아래와 같이 작성해 준다.

//  ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        TabView {
            WorldwideView()

            CountriesView()
        }
    }
}

TabView를 사용하는 것은 위에서 보이는 것처럼 간단하다. TabView 블록을 만들고 그 안에 각 탭으로 구성하고 싶은 뷰를 넣으면 된다. 하지만 무언가 이상하다.

아래쪽에 분명 탭 뷰로 추정되는 빈 공간도 생겼고 실제로 터치해 보면 이동도 가능한데 그냥 하얗기만 하고 아무것도 나오지 않는다. 앱스토어처럼 저 빈 공간에 유저가 알아볼 수 있게 버튼을 넣으려면 .tabItem을 추가해 주어야 한다.

//  ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        TabView {
            WorldwideView()
                .tabItem {
                    Label("Worldwide", systemImage: "globe.asia.australia")
                }

            CountriesView()
                .tabItem {
                    Label("Countries", systemImage: "flag.2.crossed")
                }
        }
    }
}

이제 탭 바가 제대로 작동하고, 앱이 더 그럴듯해져 간다.

CountriesViewModel

이제 CountriesView에 데이터를 공급할 뷰 모델을 만들자. Countries 그룹 안에 CountriesViewModel.swift 이름으로 Swift 파일을 추가하고, 아래와 같이 작성한다.

//  CountriesViewModel.swift

import Foundation

@MainActor
class CountriesViewModel: ObservableObject {
    @Published private(set) var isLoading = false
    @Published private(set) var countries = [Country]()
    @Published private(set) var error: Error?

    init() {
        fetch()
    }

    func fetch() {
        isLoading = true
        countries = []
        error = nil

        let networkManager = NetworkManager<[Country]>()

        Task {
            let result = await networkManager.download(from: .allCountries)

            isLoading = false

            switch result {
            case .success(let data):
                countries = data
            case .failure(let error):
                self.error = error
            }
        }
    }
}

WorldwideViewModel과 거의 대부분 동일한 코드다. 그렇다는 것은 또 리팩토링할 여지가 있다는 것인데 이 부분에 대해서는 나중으로 미루도록 하자. WorldwideViewModel과 다른 점은 여기서 다루게 되는 데이터 타입이 다르다는 점이다.

@Published private(set) var countries = [Country]()

이 코드는 Country 타입을 담을 수 있는 빈 Array를 이니셜라이징 한 것이며, 아래와 같이 작성해도 동일하다.

@Published private(set) var countries: [Country] = []

두 코드 모두 기능이나 성능에서 아무런 차이가 없는 것으로 알고 있기 때문에 취향에 따라 선택하면 되겠다.

CountriesView

뷰 모델이 준비되었고, 이제 뷰를 만들 차례다. CountriesView.swift 파일로 가서 아래와 같이 작성한다.

//  CountriesView.swift

import SwiftUI

struct CountriesView: View {
    @StateObject private var viewModel: CountriesViewModel

    init() {
        _viewModel = StateObject(wrappedValue: CountriesViewModel())
    }

    var body: some View {
        if viewModel.isLoading {
            ProgressView()
        } else {
            NavigationView {
                List {
                    ForEach(viewModel.countries) { country in
                        Text(country.country)
                    }
                }
                .navigationTitle("Countries")
            }
        }
    }
}

기본적인 구조는 WorldwideView와 매우 흡사하다. 다만 List 안에 ForEach 부분만 추가로 설명이 필요할 것으로 보인다.

ForEach

우리가 WorldwideView에서 다룬 리스트는 한 줄(row) 한 줄이 그 안의 데이터는 변할지라도 줄의 수나 줄 자체가 변하지는 않는 정적인 리스트였다. 그런데 우리가 이번에 다룰 List는 한 줄 한 줄이 동일한 형식을 가지며 총 몇 줄인지도 API에서 데이터를 다운로드하기 전까지는 정해져 있지 않다. 국가마다 데이터 포맷이 동일한 이런 경우에 줄 하나하나를 코딩하는 것은 굉장히 비효율적이다. 따라서 이처럼 동적이지만 동일한 패턴이 반복되는 데이터를 List에 나타낼 수단이 필요한데 그것이 바로 ForEach이다.

ForEach는 말 그대로 각각에 대하여라는 뜻이다. 위의 경우 뷰 모델에 있는 countries에 담긴 Country 인스턴스 하나하나에 대하여가 된다. { 다음의 country in 부분에서 country는 그 각각의 인스턴스를 대표하여 그 인스턴스를 나타내는 플레이스홀더로 작동하고, ForEach의 클로저({ country in } 부분이 클로저이다) 안에 있는 Text(country.country)가 각 줄을 어떻게 나타낼지를 대표하게 되는 것이다. 이 경우 country 프로퍼티는 그 나라 이름이므로 매 줄마다 우리가 다운로드한 데이터의 나라 이름이 화면에 표시될 것으로 기대하면 된다.

이제 앱을 실행해 보면...

로딩 화면이 정상적으로 나타난 후, 로딩이 끝나고 내비게이션 뷰가 있는 화면이 나타났지만 정작 내용이 아무것도 없다.

Debugging

일단 무엇이 문제인지 파악하기 위해 에러 메시지가 출력되도록 하자. CountriesView를 다음과 같이 수정한다.

// CountriesView.swift

struct CountriesView: View {
    @StateObject private var viewModel: CountriesViewModel

    init() {
        _viewModel = StateObject(wrappedValue: CountriesViewModel())
    }

    var body: some View {
        if viewModel.isLoading {
            ProgressView()
        } else {
            NavigationView {
                Group {
                    if let error = viewModel.error {
                        Text(error.localizedDescription)
                            .foregroundColor(.red)
                            .multilineTextAlignment(.center)
                    } else {
                        List {
                            ForEach(viewModel.countries) { country in
                                Text(country.country)
                            }
                        }
                    }
                }
                .navigationTitle("Countries")
            }
        }
    }
}

앞서 WorldwideView와 유사한 구조이다. 그런데 여기서 에러를 표시하는 부분이 동일하게 반복되고 있다. 그래서 일단 이 부분을 리팩토링해서 재사용하도록 하자. SharedView라는 그룹을 새로 만들고 그 안에 ErrorView.swift라는 이름으로 SwiftUI View 파일을 새로 생성한 다음 아래와 같이 작성한다.

//  ErrorView.swift

import SwiftUI

struct ErrorView: View {
    let error: Error

    var body: some View {
        Text(error.localizedDescription)
            .foregroundColor(.red)
            .multilineTextAlignment(.center)
    }
}

struct ErrorView_Previews: PreviewProvider {
    static var previews: some View {
        ErrorView(error: URLError(.zeroByteResource))
    }
}

이제 CountriesView와 WorldwideView의 에러 부분을 아래와 같이 변경하자.

// CountriesView.swift

struct CountriesView: View {
            ...
                Group {
                    if let error = viewModel.error {
                        ErrorView(error: error)
                    } else {
            ...
}

// WorldwideView.swift

struct WorldwideView: View {
            ...
                    } else {
                        if let error = viewModel.error {
                            ErrorView(error: error)
                        }
                    }
                }
            ...
}

이제 앱을 다시 실행해 보면...

The data couldn't be read because it isn't in the correct format.

에러의 내용으로 봐서는 디코딩 과정에서 문제가 생긴 걸로 추측된다. 데이터의 포맷이 맞지 않다고 하는데, 그렇다면 데이터 모델을 정의하면서 프로퍼티의 타입이 잘못된 것이다.

샘플 데이터를 쭉 살펴보면서 문제가 되는 부분을 찾았다(간단하게 찾은 것 같지만 실제로는 상당한 시간이 걸린 작업이었다). Country.CountryInfo 타입에 lat와 long을 Int 타입으로 정의했는데, 어떤 데이터는 소수점이 있는 경우도 있어서 이 부분을 Double로 바꾸어 주어야 타입이 일치하게 된다.

// Country.swift

extension Country {
    struct CountryInfo: Decodable {
        ...
        let lat: Double
        let long: Double
        ...
    }
}

이제 문제가 해결되었기를 바라며 다시 앱을 실행해 보자.

또 에러가 발생했다.

The data couldn't be read because it is missing.

이번에는 데이터가 없는 경우가 있다는 말이다. 우리는 데이터 모델의 각 프로퍼티에 해당하는 데이터가 모두 존재할 것으로 생각하고 데이터 모델을 정의했는데, 아무래도 그렇지 않은 모양이다. 데이터가 없을 수도 있는 프로퍼티는 옵셔널로 바꾸어 주면 해결될 것으로 보인다. 하지만 어떤 데이터가 없을 수도 있는 데이터일까? API 공식 문서에 별다른 설명이 없어서 이건 하나하나 해 보면서 찾을 수밖에 없을 듯하다.

이것도 시간을 들여서 결국 찾았다. 이번에도 Country.CountryInfo가 문제였다. iso2와 iso3 두 프로퍼티가 문제여서 이 두 프로퍼티를 var로 그리고 옵셔널로 바꾸어 준다.

// Country.swift

extension Country {
    struct CountryInfo: Decodable {
        var iso2: String?
        var iso3: String?
        ...
    }
}

이제 앱을 다시 실행해 보면 정상적으로 작동한다.

이렇게 소프트웨어 개발은 버그를 찾아내고 잡는 것, 즉 디버깅에 많은 시간을 소모하기도 한다. 하지만 버그라는 것은 유저에게 유쾌하지 않은 경험을 주는 정도에서 끝날 수도 있지만, 결제 과정에서의 버그와 같이 커다란 금전적 손실을 가져오는 버그도 있다. 그래서 항상 코드를 테스트해 보는 것이 중요하다.

앱은 이제 정상적으로 작동하는데 리스트 스타일이 이 화면에서는 inset grouped가 별로 어울리지 않는 듯하다. 플레인 스타일로 변경하자.

// CountriesView.swift

List {
    ...
}
.listStyle(.plain)

CountriesView를 모두 완성하기에는 이 글이 너무 길어질 것 같아서 Part 6를 여기에서 마무리하려고 한다. Part 6에서는 지금까지...

Swift Generics를 사용하여 네트워킹 코드를 다른 데이터 모델에도 적용 가능하도록 리팩토링

TabView를 사용하여 여러 개의 화면을 가지는 UI를 구성

CountriesViewModel을 만들고, CountriesView에서 리스트 구성

하는 것을 진행하였다. Part 7에서는 리스트의 각 줄의 UI를 변경하고, 리스트에 검색 기능을 넣어서 원하는 국가를 더 빨리 찾을 수 있도록 뷰를 구성할 예정이다.