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

SwiftUI로 코로나 현황 앱 만들기 Part 2 - 네트워킹

February 12, 2022

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

2022-04-24: API 수정

Part 1에서는 새로운 Xcode 프로젝트를 생성하고, 전 세계 코로나19 현황 데이터를 위한 모델을 구축하였다. Part 1 파일은 여기에서 찾을 수 있다.

Part 2에서는 API를 이용하여 앱에서 인터넷으로 데이터를 다운로드하는 코드를 작성해 본다. 그전에 우선 URL에 대해 먼저 알아보자.

URL

URL이란?

URL은 Uniform Resource Locator의 약자로 특정 리소스를 찾아가는 경로(주소)를 말한다. 웹에서는 특정 페이지를 찾아가는 주소가 된다. URL은 몇 개의 파트로 구성되어 있는데 아래 예시를 통해 살펴보자. 이 예시는 iTunes 검색 API를 이용하여 iTunes에서 Beatles를 검색하는 URL이다.

URL

scheme은 네트워킹에서 미리 약속된 규약, 즉 프로토콜을 말한다. http 또는 https가 주로 사용된다.

host는 네트워크 요청을 받는 서버의 도메인 이름이다. 위 예시에서 host를 세부적으로 더 구분하자면, itunes는 서브도메인, apple.com은 도메인으로 구분할 수 있다.

path는 서버 내에서 특정 리소스로 접근하는 경로이다.

query는 서버에 요청하는 추가적인 parameter로 물음표 뒤에 위치하며, key/value의 페어로 구성된다. 예시의 경우 term이 key가 되고 beatles가 value이다. Parameter가 여러 개인 경우 &로 구분한다. 예를 들어 위 URL에서 음악에서만 Beatles를 검색하고 싶다면,

https://itunes.apple.com/search?term=beatels&media=music

이렇게 URL이 구성된다. URL을 구체적으로 어떻게 구성해야 하는지는 해당 API에서 제공하는 문서를 참조하여야 한다.

이제 URL에 대해 이해했으니, Part 1에서 사용된 URL을 다시 보자.

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

schemehttps가 사용되고, corona.lmao.ninjahost, /v2/allpath, yesterdayquery라는 것을 알 수 있다. 다만 query의 경우 key 값만 yesterday로 되어 있고, value는 생략되어 있는 상태다. API 문서를 참조해 보면, yesterday라는 것은 어제 기준 데이터를 요청할 것인지를 결정하는 인자로 true, false, 1, 0을 value로 사용할 수 있다. 디폴트 값이 무엇인지는 나와있지 않으나 저렇게 생략할 경우 디폴트 값으로 할당되는 것으로 추측된다.

Swift에서 URL 구성하기

Swift에서 사용되는 URL은 Swift에 기본으로 탑재된 Foundation 프레임워크에 있는 URL 타입이고, struct이다. Swift에서 URL을 만드는 가장 쉬운 방법은 String 값을 이용하여 만드는 것이다.

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

StringInt처럼 Swift에 기본 제공되는 타입으로, 문자열 형태의 데이터이며 큰따옴표(")를 이용하여 만든다.

let someString = "Some string"

URL 타입에 관한 Swift 문서를 찾아보면 String 값을 받는 initializer는 이렇게 정의되어 있다.

init?(string: String)

init은 어떤 타입의 인스턴스를 만들 때(initializing) 사용하는 고유의 method로 initializer라고 부른다. 어떤 타입의 인스턴스를 처음 생성할 때 사용되는 특수 함수라고 이해하면 되겠다. init 뒤에 ?는 타입을 구성할 때 실패할 수도 있다는 뜻으로 이런 initializer를 failable initializer라고 부른다. 타입의 initializing이 실패할 경우 값이 없는 상태, 즉 nil이 된다. Swift에서는 값이 있을 수도, 없을 수도 있는 이런 특정한 값을 optional이라고 부른다. Optional에 대한 자세한 내용은 다른 글을 통해 따로 다루기로 한다. optional을 일반 데이터처럼 사용하기 위해서는 unwrap이라는 과정이 필요한데, 우선 예제에서는 간단하게 사용하기 위하여 강제로 unwrapping 하는 force unwrapping을 사용하였고 끝에 ! 마크가 붙은 것이 force unwrap을 의미한다.

이제 URL을 구성하였으니 이 URL을 이용하여 실제 네트워킹 코드를 작성할 차례이다.

URLSession

URLSession?

Swift에서 네트워킹 작업을 하기 위해서는 Alamofire와 같은 외부 라이브러리를 이용할 수도 있겠지만, 외부 라이브러리에 의존하게 되면 그 라이브러리에 변경이 있을 때 내 코드가 동작하지 않을 위험이 있다는 점과 Swift에 기본으로 내장된 라이브러리만으로도 충분히 기능을 구현할 수 있다는 점, 그리고 애플이 만든 기본 라이브러리를 더 신뢰할 수 있다는 점 때문에 나는 가급적이면 외부 라이브러리를 이용하지 않고 Swift에 내장된 라이브러리를 사용하는 편이다. Swift에서 네트워킹을 담당하는 object는 바로 URLSession 클래스이다.

Concurrency

네트워킹 코드를 작성하기에 앞서 네트워킹이라는 것에 대해 생각해 볼 필요가 있다. 컴퓨터 프로그램이 작동되는 방식은 기본적으로 우리가 작성한 코드가 CPU에서 실행할 작업이 되어서 이 작업이 매우 빠른 속도로 순서대로 하나씩 실행되는 것이다. 그런 가운데 네트워킹 코드가 중간에 있으면 어떻게 될까?

현대의 컴퓨터 CPU나 모바일 AP는 꽤나 복잡한 연산도 0.000 몇 초 내로 순식간에 처리해낸다. 그에 반해 인터넷과 같은 네트워크를 통해 데이터를 주고받는 작업은 서버로 요청을 보내는데 소요되는 시간, 서버로부터 응답이 오는데 걸리는 시간, 실제 데이터를 다운로드하거나 업로드하는 시간 등 컴퓨터의 연산 속도에 비해 굉장히 긴 시간이 요구된다. 당장 웹브라우저에서 네이버만 가봐도 페이지가 표시되기까지 1초 정도 시간이 소요되니, 데이터 용량이 큰 경우에는 그 차이가 더 커질 것이란 건 쉽게 상상할 수 있다. 게다가, 와이파이 연결 상태가 좋지 않다거나, 혹은 무언가가 잘못되어 서버에서 응답이 없다거나 하는 경우에 네트워킹 작업은 무한정 시간이 걸릴 수도 있는 작업이 된다(지하철 와이파이를 생각해 보자).

Swift는 시간이 많이 소요될지도 모르는 코드의 실행은 스레드를 나누어 백그라운드 스레드(background thread)에서 처리한다. 스레드라는 것은 CPU 코어가 작업을 그룹으로 나누어 놓은 것을 말한다. iOS에서는 어떤 앱이 실행되면 그 앱이 주로 사용하는 적어도 하나의 스레드를 가지는데 이를 메인 스레드(main thread)라고 한다. 메인 스레드는 기본적으로 여러 가지 작업을 담당하지만 메인 스레드에서만 처리해야 하는 작업이 있는데, 그건 바로 사용자가 화면으로 보게 되는 UI를 업데이트하는 작업이다. UI 업데이트 작업을 메인 스레드에서 처리하지 않고 백그라운드 스레드에서 처리하도록 코드를 작성하게 되면 앱이 제대로 작동하지 않을 수도 있다. 반대로, 시간이 많이 걸리는 네트워킹과 같은 작업을 메인 스레드에서 처리하게 되면 UI 처리가 늦어져 안 좋은 사용자 경험을 줄 수도 있다. 그런 이유로 UI 코드는 메인 스레드에서, 네트워킹 코드는 백그라운드 스레드에서 처리해야 한다.

이렇게 메인 스레드 외에 추가적인 스레드가 더해져 여러 개의 작업이 동시에 이루어지는 것을 concurrency 또는 asynchronous라고 표현한다. Swift 5.5에서 이러한 asynchronuos 함수를 이전보다 간단하게 처리할 수 있는 새로운 기능이 추가되었는데, 그것이 바로 async/await 키워드이다.

async/await

이제 async/await를 이용하여 ContentView.swift.padding() 밑에 .task 블록을 만들고 아래와 같이 코드를 작성하자.

// ContentView.swift

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                let url = URL(string: "https://disease.sh/v3/covid-19/all?yesterday=true")!

                do {
                    let (data, _) = try await URLSession.shared.data(from: url)
                    print(data)
                } catch {
                    print(error.localizedDescription)
                }
            }
    }
}

.task의 기능은 .task가 붙어있는 SwiftUI View가 화면에 나타나면 바로 블록 안에 있는 코드를 실행하도록 하는 특수 메서드로, View object 아래에 붙는 이러한 특수한 메서드를 view modifier라고 부른다. 바로 위 .padding()도 view modifier에 해당한다. .onAppear.task와 유사한 기능을 수행하지만 .onAppear는 asynchronous 코드를 실행하지 못하기 때문에 async 코드를 실행하려면 반드시 .task를 사용해야 한다.

URLSession 다음에 .shared는 별도로 URLSession의 인스턴스를 생성하지 않고 기본적으로 있는 shared라고 이름붙여진 싱글턴 인스턴스를 사용한다는 의미이다. 싱글턴에 대해서는 지금은 깊게 생각하지 말고, 단지 대부분의 경우에서는 URLSession.shared라고 시작한다고만 기억하자.

.task 블록 안에서 먼저 눈여겨봐야 하는 부분은 URLSession에서 URL을 통해 실제로 데이터를 다운로드하는 data(from:delegate:) 메서드이다. 이 메서드는 async/await와 함께 URLSession 클래스에 추가된 것으로 Swift 문서에서 함수 선언 부분을 가져오면 아래와 같다.

func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)

func는 함수를 만드는 키워드로 func 뒤에 함수 이름이 온다. 그래서 data란 이름의 함수가 된다. 함수 이름 뒤에 () 부분은 이 함수가 건네받을 수 있는 파라미터를 정의하는 곳이다. URLURLSessionnTaskDelegate를 함수 내에서 사용하도록 각각 urldelegate라는 이름의 파라미터로 받는데, URLSessionTaskDelegate 같은 경우 optional, 즉 파라미터 값을 굳이 건네지 않아도 되고 이 경우 디폴트 값인 nil이 할당된다. async 키워드가 붙어서 asynchronous 함수라는 것을 알 수 있고, throws 키워드는 이 함수가 실행될 때 에러가 발생할 수 있는 함수, 즉 throwing 함수라는 것을 의미한다. ->는 함수의 실행 결과로 값을 반환(return) 한다는 의미로, Data, URLResponse 두 타입을 튜플(tuple) 데이터 형태로 반환한다.

다시 우리가 작성한 코드로 돌아오자.

// ContentView.swift

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                let url = URL(string: "https://disease.sh/v3/covid-19/all?yesterday=true")!

                do {
                    let (data, _) = try await URLSession.shared.data(from: url)
                    print(data)
                } catch {
                    print(error.localizedDescription)
                }
            }
    }
}

do, try, catchthrows 키워드가 붙은 throwing 함수를 호출할 때 발생할 수 있는 에러를 핸들링하기 위한 코드로 해당 함수를 do 블록 안에서 try 키워드와 함께 호출해야 한다. 만약 에러가 발생한다면 catch 블록 내의 코드가 실행되며, 여기서 에러가 발생하는 경우의 코드를 작성하여 에러가 발생할 경우에 대비한다. 그리고 async 함수를 호출할 때는 await 키워드가 필요하므로 try 뒤에 await도 함께 사용했다.

data(from:delegate:) 메서드는 DataURLResponse 두 가지를 반환하는데 URLResponse는 당장 사용하지 않을 것이기 때문에 _로 처리했다. 우리가 작성한 코드는 다운로드한 데이터를 (data, _) 상수에 할당하고, 그런 다음 그 데이터를 콘솔 화면에 프린트하는 것이다.

let (data, _) = try await URLSession.shared.data(from: url)

Concurrency가 등장하는 부분은 바로 이 부분이다. 이 코드에 await 키워드가 있기 때문에 그 이후의 코드, 즉 print(data) 코드는 data(from:delegate) 함수의 결괏값이 반환된 이후에나 실행된다. async/awiat API가 아름다운 점은 바로 이렇게 단순한 한 줄의 코드로 멀티스레드 작업을 다룬다는 점이다.

드디어 이 함수를 사용하여 실제로 데이터를 다운로드할 차례이다. command + R로 시뮬레이터를 실행하면 잠시 후 아래쪽 콘솔 화면에서 아래와 유사한 메시지를 확인할 수 있다. 만약 콘솔 화면이 보이지 않는다면 command + shift + C 단축키로 표시할 수 있다.

490 bytes

이 의미는 490 바이트 크기의 데이터를 다운로드하는데 성공하였다는 것이다. 호출하는 시점에 따라 데이터의 양이 다르기 때문에 정확히 같은 메시지가 출력되지 않을 수도 있다. 혹은 어떤 사유로 에러가 발생하였다면 에러 메시지가 출력될 것이지만 그건 그것대로 네트워킹에 성공한 것이 되겠다.

여기까지가 Part 2이다. 우리는 지금까지...

에 대해 알아보았다. Part 3에서는 여기서 거칠게 작성한 코드를 구조를 더 깔끔하게 유지하기 위해 refactoring 하는 작업을 진행하고, 그런 다음 다운로드한 단순 데이터 덩어리를 우리가 사용하는 모델로 디코딩 하는 작업을 진행할 예정이다.