The Hobbyist Programmer
Posts about Swift, Python, and life, by Hyeonmin

SwiftUI로 코로나 현황 앱 만들기 Part 3 - Refactoring, Codable

February 19, 2022

이 글에 사용된 버전: Xcode 13.3, Swift 5.6

2022-04-24: API 수정

Part 2에서는 Swift URL 타입을 이용하여 URL을 구성하고, 그 URL을 이용하여 API를 통해 실제로 데이터를 다운로드하였다. Part 2 파일은 여기에서 찾을 수 있다.

Part 3에서는 우선 앞서 작성한 코드의 구조를 관리하기 용이하게 바꾸는 refactoring을 먼저 진행하고, 그러고 나서 Part 2에서 다운로드하는데 성공한 데이터를 Part 1에서 만든 데이터 모델의 인스턴스로 디코딩 한다.

Refactoring

Code Refactoring은 소프트웨어의 기능 등 외형적 변화 없이 코드를 재구조화 하는 것이다. 리팩터링을 함으로써 코드의 가독성을 높이고, 향후 코드가 많아 짐에 따라 확장성 및 유지보수를 용이하게 하는 것이 목적이다. 리팩터링을 할 때 한 가지 고려해야 할 원칙이 있다.

Separation of Concerns

컴퓨터 사이언스에서 프로그램을 만들 때 적용되는 디자인 원칙 중에 separation of concerns라는 것이 있다. 어떤 프로그램이 수행해야 하는 작업을 최대한 세분화하여 특정 타입, 즉 object가 특정한 작업만 수행하도록 업무를 분리하여 관리하는 원칙이다. 예를 들어 네트워킹을 담당하는 object를 따로 두는 것처럼 말이다. 이렇게 세분화하여 모듈화하면, object 간 의존도가 낮아지고, 코드를 관리하기가 더 쉬워진다는 장점이 있다.

지금까지 우리가 작성한 코드 중 이 원칙에 맞게 작성된 것은 모델 한 가지이다. 우리가 사용할 데이터의 구조를 담당할 데이터 모델을 별도의 타입으로 만들었을 뿐 나머지 코드는 모두 ContentView.swift 안에 작성하였는데, 원래 ContentView의 주 기능은 View 즉 화면이 어떻게 구현되는지를 담당하는 것이다. 하지만 우리는 여기에 URL을 만들고 네트워킹 하는 코드도 작성하였다. 이제 이 코드들을 리팩터링 해보자.

URL 생성

먼저 URL 타입의 인스턴스를 생성하는 코드를 리팩토링 하자. Part 2에서 우리가 URL 인스턴스를 만든 코드는 아래와 같다.

let url = URL(string: "https://disease.sh/v3/covid-19/all?yesterday=true")!

이 코드도 기능적으로는 문제가 없다. 실제로 데이터 다운로드에 성공하기도 했으니 말이다. 하지만 이 코드에는 몇 가지 문제가 있다. 우선 URL 인스턴스를 생성할 때 String 값을 사용하였는데, String 값은 오타가 날 가능성이 높고 코드를 컴파일할 때 오류를 잡아낼 수 없는 문제점이 있기 때문에 직접적 사용을 가급적 피하는 것이 좋다. 또 다른 문제는 optional을 해제하기 위해서 force unwrap 방법을 사용한 것인데, 이 방법 또한 값이 존재할 것이 확실하지 않는 한 가급적 사용을 피하는 것이 좋다. 값이 없는 데도 불구하고 force unwrap 할 경우 앱에 크래쉬가 발생한다. 마지막 문제는 쿼리에 변화를 줄 경우 해당 코드를 사용할 수 없다는 점, 즉 확장성 문제이다.

이제 이 문제를 해결하기 위해 별도의 타입을 만든다. 참고로 이 방법은 John Sundell의 Swift clip: Managing URLs and endpoints 글을 참고하였다.

먼저 Xcode 프로젝트 내에 Models처럼 Networking이라는 그룹(폴더)을 만들고, 그 안에 Endpoint.swift 파일을 생성하고 아래와 같이 코드를 작성하자.

//  Endpoint.swift

import Foundation

struct Endpoint {
    let path: String
    var queries: [URLQueryItem] = []
}

이제 이 정도의 코드는 별도의 설명이 필요 없으리라 생각한다. 계속해서 코드를 작성한다.

// Endpoint.swift

extension Endpoint {
    var url: URL {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "disease.sh"
        components.path = "/" + path
        components.queryItems = queries

        guard let url = components.url else {
            preconditionFailure("Invalid URL components: \(components)")
        }

        return url
    }
}

extension이라는 것은 어떤 타입의 기능을 말 그대로 확장하기 위한 것이다. 지금 같은 경우는 struct Endpoint 내에 작성해도 기능이 동일하지만 간혹 특정한 조건 하에서만 작동하는 코드 같은 경우는 extension을 사용하여야만 한다. 나 같은 경우는 기능이 동일하더라도 코드의 가독성을 높이기 위해 extension을 많이 사용하는 편이다.

var url: URL 코드를 잘 보면 = 사인이 없이 블록이 시작하는데 이러한 property를 computed property라고 한다. 일반적인 property는 데이터가 할당되어 저장되는데 반해, computed property는 데이터가 저장되어 있지 않고 그 property의 데이터를 읽으려고 할 때마다 블록 내의 코드가 새로이 실행되어 반환된 데이터를 사용하는 것이다.

블록 내의 코드는 새로운 URLComponents 인스턴스를 생성하여 Part 2에서 보았던 URL의 구성 요소 값을 할당한 뒤 URLComponents에서 제공하는 url을 읽어 오는 내용이다. 여기서 guard/else 구문은 조건문으로써 guardelse 사이 조건을 만족하면 else 다음의 블록 코드는 건너 뛰고 계속 실행하는 것이고, 조건이 만족되지 않는다면 else 다음 블록이 실행되는 구문이다.

URLComponents.urlURL?로 optional이기 때문에 let url = components.url로 값을 읽어오는데 성공하면 return 줄로 진행되고, 값이 nil이면 preconditionFailure 함수가 실행된다. preconditionFailure()는 메시지와 함께 앱을 고의로 크래쉬 나도록 하는 함수이다. 여기서 URL이 제대로 구성되지 않는다면 무언가 프로그래머가 실수한 상황이기 때문에 반드시 바로잡고 가야 하는 오류가 된다. 따라서 이 경우 오류를 바로 잡을 기회를 얻는 것이기 때문에 앱이 크래쉬 나는 것이 꼭 나쁜 것만은 아니게 된다.

이제 계속 이어서 아래 코드를 작성하자.

// Endpoint.swift

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

Swift에서 어떤 타입의 인스턴스에 속하는 property나 method가 아닌 해당 타입 자체에 속한 property나 method가 필요한 경우가 있다. 예를 들면 인스턴스마다 다른 데이터가 아닌 모두 같은 데이터를 공유해야 하는 경우가 그렇다. 이런 property나 method를 static property, static method라고 부르고 static 키워드를 사용한다.

대문자 Self는 소문자 self가 해당 인스턴스를 지시하는데 반해 타입 그 자체를 지시한다. 이 경우 Endpoint의 인스턴스가 아니라 타입 그 자체를 말한다.

블록 안에는 전 세계 데이터에 사용할 Endpoint 인스턴스를 생성해서 반환하는데, 이처럼 다른 구문 없이 바로 return하는 경우 return 키워드를 생략할 수 있다. Endpoint 다음 줄을 바꾼 것은 옆으로 코드가 너무 길어질 경우 가독성이 떨어지기 때문에 가독성을 높이기 위해 줄을 바꾼 것에 불과하다.

위 코드에서는 필요할 때마다 매번 Endpoint 인스턴스를 생성할 필요 없이 계속 사용할 인스턴스를 worldwide라 이름 붙이고 static computed property로 만들었다. 이렇게 만드는 편리함은 나중에 볼 수 있을 것이다.

이제 이렇게 만든 코드가 잘 작동하는지 시도해보자. 다시 ContentView.swift 파일로 가서, .task 블록을 아래와 같이 수정하자.

// ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                let endpoint = Endpoint.worldwide
                print(endpoint.url.absoluteString)
            }
    }
}

이 코드는 단순히 만들어진 URL을 콘솔에 출력하는 코드이다. command + R로 앱을 실행해보면, URL이 잘 만들어지고 있는 것을 확인할 수 있다.

https://disease.sh/v3/covid-19/all?yesterday=true

Networking

이네 네트워킹 담당 코드를 팩터링 할 차례이다. 네트워킹을 담당하는 object를 별도로 두기 위해 NetworkManager를 만들겠다. Networking 그룹 안에 NetworkManager.swift 파일을 생성하고 아래와 같이 코드를 작성하자.

// NetworkManager.swift

import Foundation

struct NetworkManager {
    let session: URLSession

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

실제 네트워킹을 위해서는 URLSession을 사용할 것이므로 property로 정의하고 initializer를 통해 URLSession의 singleton 인스턴스인 .shared를 기본값으로 주었다. initializer 없이 아래와 같이 작성해도 동일한 결과가 나오지만, .shared 외에 따로 인스턴스를 생성해야 할 경우에 대비하여 확장성을 위해 initializer를 두었다.

struct NetworkManager {
    let session: URLSession = .shared
}

작성된 코드에 대해서 조금 더 알아보자.

Initialzer

self 키워드는 NetworkManager의 인스턴스를 말한다. 따라서 self.sessionNetworkManager 인스턴스에 있는 session property를 지시한다. self.session = session에서 두 번째 session은 initializer에 파라미터로 주입되는 session을 말한다. 따라서, 이 initializer는 처음에 object를 만들 때 URLSession 타입을 건네받아 session이라는 property에 할당한다. 그리고 건네는 URLSession 인스턴스를 특정하지 않는다면 URLSession.shared 인스턴스를 할당하게 된다.

이제 아래와 같이 데이터를 다운로드하는 메소드를 init 아래에 작성하자.

// NetworkManager.swift

func download(from endpoint: Endpoint) async throws -> Data {
    let (data, _) = try await session.data(from: endpoint.url)
    return data
}

이제 이 메소드를 활용하여 데이터를 다운로드할 차례다. 다시 ContentView.swift 파일로 가서 .task 블록 내에 코드를 수정하자.

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                let networkManager = NetworkManager()

                do {
                    let data = try await networkManager.download(from: .worldwide)
                    print(data)
                } catch {
                    print(error.localizedDescription)
                }
            }
    }
}

앱을 실행해보면 아래와 같이 성공적으로 데이터를 다운로드하는 것을 확인할 수 있다.

494 bytes

NetworkManager.download(from:) 메소드에 .worldwide를 주목하자. 아까 Endpoint에 static property를 만든 이유가 바로 이것이다. Xcode에서 코드를 작성할 때 .만 눌러도 자동완성 기능이 활성화되며 .worldwide가 생기는 것을 확인할 수 있을 것이다. 전체 코드 양을 보면 Part 2 대비 리팩토링 된 것이 맞는가 하는 생각이 들겠지만, 이전보다 코드를 작성할 때 실수할 여지가 줄었고, 확장성도 좋아졌다. 앞으로 끊임 없이 계속 코드를 수정하면서 리팩토링 할 것이기 때문에 우선 첫 리팩토링은 여기까지 하고, 이제 JSON 데이터를 우리의 데이터 모델로 디코딩할 차레다.

JSON Decoding

JSONDecoder

Swift에서는 JSON 포맷을 디코딩하는 기능을 담당하는 object가 내장되어 있는데 JSONDecoder 클래스가 바로 그 object이다. JSONDecoderdecode 메소드로 JSON 데이터를 우리의 데이터 모델 인스턴스로 디코딩할 수 있다. 하지만 decode 메소드를 사용하기 위해서는 제약사항이 있다.

Protocol

protocol은 어떤 특정한 작업이나 기능을 위한 메소드, 프로퍼티, 또는 다른 필요사항에 대한 청사진 같은 것이다. protocolstructclass, enum 같은 타입이 채택할 수 있고, 채택하게 되면 해당 protocol이 제공하는 기능을 사용할 수 있게 된다. JSONDecoderdecode 메소드는 디코딩하는 목표 타입이 Decodable 프로토콜을 채택해야만 하는 제약사항을 두고 있다. 반대로 어떤 타입이 Decodable 프로토콜을 채택하게 된다면 별다른 기능 구현 없이 채택하는 것만으로도 JSON 디코딩할 수 있게 되는 것이다. 프로토콜에 대해서는 별도 포스트를 통해 상세히 다룰 기회가 있을 것이다.

우리 Worldwide 데이터 모델이 Decodable 프로토콜을 채택하기 위해 Worldwide.swift 파일로 가서 struct Worldwide 옆에 : Decodable을 추가하여 아래와 같이 수정하자.

// Worldwide.swift

struct Worldwide: Decodable {
    let updated: Int
    let cases: Int
    let todayCases: Int
    let deaths: Int
    let todayDeaths: Int
    let recovered: Int
    let todayRecovered: Int
    let active: Int
    let population: Int
    let affectedCountries: Int
}

간단하게 이렇게 추가하는 것만으로 디코딩 가능한 타입으로 바뀌었다. 참고로 Decodable 프로토콜을 채택하기 위한 조건은 해당 타입 내의 프로퍼티의 타입도 모두 Decodable을 채택해야 하는 것인데, 우리 데이터 모델에는 Int 타입의 프로퍼티만 있고, Int는 이미 Decodable을 채택하고 있기 때문에 Decodable 채택 조건을 모두 만족한다.

Decode

이제 실제로 데이터를 디코딩할 차례이다. ContentView.swift 파일로 가서 코드를 아래와 같이 수정하자.

// ContentView.swift

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                let networkManager = NetworkManager()

                do {
                    let data = try await networkManager.download(from: .worldwide)
                    let result = try JSONDecoder().decode(Worldwide.self, from: data)
                    print(result)
                } catch {
                    print(error.localizedDescription)
                }
            }
    }
}

decode 메소드도 throwing 함수이기 때문에 try 키워드를 붙였다. 위 코드는 data로부터 디코딩한 결과를 result에 저장한 후 result를 콘솔에 출력하는 내용이다. 앱을 실행해보면 아래와 같은 결과를 확인할 수 있다.

Worldwide(updated: 1644672833394, cases: 408697981, todayCases: 2333164, deaths: 5820065, todayDeaths: 11193, recovered: 328714234, todayRecovered: 2592340, active: 74163682, population: 7881008985, affectedCountries: 225)

데이터가 계속 바뀌기 때문에 숫자는 위 결과와 다를 수 있지만, 위와 유사하게 나온다면 성공적으로 디코딩한 것이다.

여기까지 해서 Part 3를 마친다. Part 3에서 지금까지 우리는...

을 진행했다. Part 4에서는 MVVM 디자인에 대해 알아보고 이 디자인에 따라 첫 번째 view model을 만들어 볼 것이다.