본 포스팅은 다음 버전을 기준으로 작성되었습니다.

  • iOS 15.6
  • Swift 5.6.1
  • Xcode 13.4.1

Intro

이번에 함께 공부하려고 들고 온 주제는 대망의 async / await 입니다. 이전 포스팅에서 얘기했었지만 원래는 GCD 와 관련된 내용을 먼저 작성하려고 했었어요. 근데 마음이 바뀌었습니다 ㅎㅎ

왜냐? 일단 최근에 Async, Await 을 공부할 필요성이 생겼어요. 그래서 공부를 하다보니 아직까지 우리나라에 마음에 드는 포스트가 없더라고요?

네 저도 Async, Await 참 좋아하는데요. 그렇다면 제가 한번 작성해 보겠습니다. 개드립 그만


Prerequisite

제일 먼저 이 새로운 문법들을 사용하려면 어떤 제약사항이 있는지 알아야 합니다. 확인 없이 프로젝트에 적용했다가 회사의 요구사항과 맞지 않으면 그야말로 낭패니까요. 처음 Async, Await 이 공개되었을 당시 사용에 대한 제약조건은 다음과 같았습니다.

  • iOS 15, macOS 12, watchOS 8, tvOS 15

엄청 높죠? 최신 OS 가 아니라면 사용할 수 없는 거에요. 그리고 스위프트는 오픈 소스라 다른 플랫폼에서도 사용할 수가 있는데요. 이 때는 스위프트의 버전이 최소 Swift 5.5 이상이어야 합니다.

하지만 좋은 소식이 있어요. Xcode 13.2 이상에서 프로젝트를 빌드할 경우 이전 OS 에 대한 호환성 지원을 시작했습니다. 즉 제약조건이 낮아졌어요.

  • iOS 13, macOS 10.15, watchOS 6, tvOS 13

정말 많이 낮아졌죠? 이제 웬만한 상용 프로젝트에도 적용해 볼만 해졌습니다. 그리고 이 제약조건만 넘긴다면 async / await 뿐만 아니라 actors, task API 등 스위프트에 추가된 새로운 모든 기능을 사용할 수 있어요. 하지만 이것은 언어에 대한 완전한 지원이고, 다른 API 들까지 모두 지원된다는 것은 아닙니다. 예를 들어 URLSession 에 추가된 async / await 사용을 도와주는 메서드 data(from:delegate:) 는 사용할 수 없으니까요.


Usage Example

Async, Await 은 사용법이 간단하면서도 의외로 또 파고들면 알아야 할 내용들이 많습니다. 한번에 다 다루기는 어려우니 이번에는 기본적인 사용법을 먼저 공부해 봅시다. 그럼 공식문서에는 뭐라고 나와있는지 한번 볼까요?

Document Task

왜 async / await 이 아닌 Task를 찾았냐고요?

바로 async / await 이 결국 Task 를 처리하기 위함이기 때문입니다. 결국 핵심은 Task 라는 것이죠. 그리고 이 Task 는 문서에 나와있듯 비동기적인 작업에 대한 단위입니다. 이제 Task 하면 비동기적인 작업이겠구나를 연상시켜 보세요.

그럼 이제 실제 코드로 구현을 하며 익숙해지도록 해보겠습니다. 먼저 기존에 우리가 사용하던 방식인 @escaping 을 통해 completionHandler 에 함수의 외부에서 클로저를 전달하는 CallBack 스타일로 비동기 작업을 처리하도록 구현하여 문제점을 파악해보고, 다음으로 새로운 방식인 async / await 을 사용해 구현함으로서 Callback 스타일에서 제기된 문제점들이 어떻게 해결되는지 알아보겠습니다.

공통 부분

먼저 비동기 처리 방식에 관계없이 공통으로 사용되는 부분들을 구현하겠습니다.

let url = URL(string: "https://api.quotable.io/random")

랜덤한 글귀를 가져오는 API 에요. 제한없이 자유롭게 사용할 수 있으니, 여러분도 그냥 복사해서 사용하시면 됩니다 ㅎㅎ 다음으로 통신 중 에러 발생 시 사용할 NetworkError 를 구현하겠습니다.

enum NetworkError: Error {
    case badUrl
    case badResponse
    case communicationError
    case decodeFailed
    case noData
}

마지막으로 API 에서 JSON 형식으로 전달 받으므로 받은 데이터를 처리할 수 있는 Decodable struct 를 구현하였습니다.

struct Quote: Decodable {
    let content: String
}

네 이렇게 공통적으로 사용될 부분을 모두 구현했어요. 여기까진 새로운 내용이 없었는데요. 이제부터 코드가 달라집니다. 먼저 우리에게 익숙한 CallBack 스타일로 비동기 작업을 처리해 볼게요.

CallBack

익숙한 코드이므로 추가적인 설명은 하지 않겠습니다.

func getQuote(from url: URL?, completion: @escaping (Result<Quote, NetworkError>) -> Void) {
    guard let url = url else {
        completion(.failure(.badUrl))
        return
    }
    URLSession.shared.dataTask(with: url,
                               completionHandler: { data, response, error in
        if let error = error {
            completion(.failure(.communicationError))
            print(error)
            return
        }
        if let response = response as? HTTPURLResponse,
           !(200..<300).contains(response.statusCode) {
            completion(.failure(.badResponse))
            return
        }
        guard let data = data else {
            completion(.failure(.noData))
            return
        }
        do {
            let quote = try JSONDecoder().decode(Quote.self, from: data)
            completion(.success(quote))
        } catch {
            completion(.failure(.decodeFailed))
        }
    }).resume()
}

이제 위 코드에 어떤 문제점들이 있는지 고민을 해볼 차례 입니다. 제가 생각하는 문제점은

  • 각 케이스 별로 적합한 Completion 에 대한 처리가 필요하지만, 실수로 처리를 하지 않더라도 에러가 발생하지 않는다.
  • Completion 에 대한 처리를 모두 정상적으로 진행 했더라도, 이어서 return 을 명시하지 않으면 의도하지 않게 코드가 계속 진행되어 버그가 발생할 수 있고, 역시 어떤 에러도 발생하지 않으므로 디버깅이 까다롭다.
  • 코드가 지저분하다.

이 정도가 있는 것 같습니다. 분명히 정상 작동하는 코드지만 개선할 부분이 많은 코드라는 생각이 드네요. 이제 실행부를 구현해볼까요?

getQuote(from: url,
         completion: { result in
    switch result {
    case .success(let quote):
        print(quote.content)
    case .failure(let error):
        print(error)
    }
})

역시 조금 지저분한 느낌이 듭니다. 콜백 스타일은 필연적으로 코드를 지저분하게 만들 수 밖에 없는 구조인 것 같아요. 그래도 실행해 보면 당연히 정상적으로 잘 작동합니다 :)

Async / Await

이번에는 같은 함수를 async / await 을 사용해 구현해 보고 조금 전 제기한 문제점들이 어떻게 해결되었는지 알아볼게요. 먼저 사용할 함수를 정의해 보겠습니다.

func getQuote(from: URL?) async throws -> Quote {}

기존 콜백 스타일에서 Completion 에 해당하는 부분이 없어지고 asyncthrows 가 추가 되었습니다. 그리고 일반적인 함수처럼 우리가 리턴하고자 하는 값에 대한 타입이 추가되었어요.

생소하지만 어려울 것은 없는 코드입니다. 해석을 하면 async 를 통해 이 함수는 비동기적으로 작동할 것이라는 것을 명시하였고, throws 를 통해 에러가 발생할 수도 있다는 것을 명시한 것 입니다.

그렇다면 함수의 내부를 구현하면서 어떻게 비동기 작업을 추가하고 에러를 던질 수 있는지 살펴볼게요. 미리 만들어 놓은 URL 을 사용하기 위해 URL 의 옵셔널을 바인딩하는 코드를 추가하겠습니다.

guard let url = url else {
    throw NetworkError.badUrl
}

URL 에서 nil 이 나올 경우 사용할 수 없는 주소이므로 NetworkError.badUrl 을 에러로 던져줍니다. 다음은 API 에 데이터를 요청해 볼게요.

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

뭔가 익숙한 것 같으면서도 처음보는 코드일거에요. URLSessionasync 한 방식으로 새롭게 추가된 data(from:delegate:) 메서드 입니다. 공식문서를 보면 이 메서드가 async 하게 처리된다는 것을 알 수 있어요.

Document URLSession Data Method

잘 생각해보면 우리가 조금 전 정의한 getQuote 함수와 비슷합니다. 우리는 아직 이 함수를 사용해 본 적이 없지만 이렇게 async 한 함수를 호출하려면 반드시 await 키워드를 함수 호출부 앞에 붙임으로서 비동기하게 처리될 것임을 명시해 주어야 하는 것 이에요. 이게 바로 새롭게 추가된 async / await 문법입니다.

리턴값으로는 (Data, URLResponse) 형태의 튜플을 반환하므로 각각 변수를 생성하여 할당해 주었습니다. 이렇게 async / await 을 사용한 처리를 하면 콜백 함수를 사용하지 않아도 해당 변수에 데이터가 있다는 것이 보장됩니다. 즉 변수에 데이터가 들어올 때까지 기다렸다가 다음 코드를 실행한다고 생각하면 됩니다. 이제 HTTPURLResponse 에 대한 처리를 해 볼게요.

if let response = response as? HTTPURLResponse,
    !(200..<300).contains(response.statusCode) {
    throw NetworkError.badResponse
}

Response 에 문제가 있다면 NetworkError.badResponse 를 던지게 됩니다. 이제 JSON 을 디코드하고 나온 값을 리턴하는 코드를 작성할게요.

guard let quote = try? JSONDecoder().decode(Quote.self, from: data) else {
    throw NetworkError.decodeFailed
}
return quote

중간에 await 처리가 된 부분을 제외하면 기존 코드와 크게 다르지 않습니다. 완성된 코드를 볼까요?

func getQuote(from: URL?) async throws -> Quote {
    guard let url = url else {
        throw NetworkError.badUrl
    }
    let (data, response) = try await URLSession.shared.data(from: url)
    if let response = response as? HTTPURLResponse,
       !(200..<300).contains(response.statusCode) {
        throw NetworkError.badResponse
    }
    guard let quote = try? JSONDecoder().decode(Quote.self, from: data) else {
        throw NetworkError.decodeFailed
    }
    return quote
}

훨씬 간결한 느낌이 듭니다. 그럼 async / await 을 사용하면 CallBack 스타일로 처리했을 때 발생하는 문제점들이 어떻게 해결되었는지 살펴볼게요.

먼저 각 케이스에 따른 Completion 을 처리하는 작업이 없어졌습니다. 즉 실수할 가능성이 없어진 것이죠. 그리고 일반적인 함수처럼 특정 값을 리턴하게 만들었기 때문에 만약 우리가 함수 끝부분에 리턴 처리를 잊으면 컴파일러가 우리에게 경고를 발생시킵니다.

두번째로 Completion 처리 후 return 을 할 필요도 없어졌습니다. 예시에서는 if 를 혼용하여 사용했지만, 전부 guard 로 처리한다면 예외상황에서 throw 처리를 하지 않을 경우 컴파일러가 경고를 하게 됩니다. 역시 실수 발생 가능성이 없어졌습니다. 그렇게 많이 바뀌지 않은 것 같으면서도 핵심적인 문제점들이 전부 해소되었어요.

그럼 마지막으로 async 함수를 실행해 봅시다.

Task {
    do {
        let quote = try await getQuote(from: url)
        print(quote.content)
    } catch {
        print(error)
    }
}

정상적으로 잘 작동하네요. 실행부 역시 코드의 가독성이 훨씬 좋아지고 길이도 짧아진 것을 확인할 수 있어요.

드디어 포스트 초반부에 잠깐 보았던 Task 가 등장했네요. 위에서 async 로 만들어진 함수를 호출하려면 문법상 반드시 await 을 명시해 주어야 한다고 했었잖아요? 사실 이 await 을 명시할 수 있는 조건이 크게 두가지가 있었습니다.

  • async 하게 구현된 함수의 내부
  • Task 의 클로저 내부

이전까지는 async 로 만들어진 함수의 내부에서 await 을 명시했기 때문에 에러가 발생하지 않았던 거에요. 일반적인 함수의 내부에서 async 하게 구현된 함수를 호출하면 에러가 발생하므로, 반드시 Task 클로저의 내부에서 실행해 주어야 합니다.

또한 Task 는 생성 이후 바로 실행되는 특성을 가지고 있어요. 즉 어떤 함수안에서 Task 를 생성하면, 해당 함수를 호출 할 때 Task 도 같이 실행되며 비동기 작업이 처리되겠죠?


Wrap Up

이렇게 기존 CallBack 스타일에서 async / await 기능을 활용해 기존 함수를 개선하는 방법을 공부해 보았습니다. 이번 포스팅을 작성하면서 async / await 에 많이 익숙해져서 앞으로 completionHandler 는 잘 사용하지 않게 될 것 같아요 ㅎㅎ

이렇게 보면 우리가 async / await 에 대해 많은 것을 알게 된 것 같지만 사실 오늘 공부한 내용은 정말 일부분 입니다. 앞으로 continuation, async let, Detached Task, Task Group 등 공부해야 할 것들이 많이 남았어요. 함께 하나씩 차근차근 정복해나가 봅시다! 정ㅋ벅ㅋ


Entire Code

Using CallBack

import UIKit

let url = URL(string: "https://api.quotable.io/random")

enum NetworkError: Error {
    case badUrl
    case badResponse
    case communicationError
    case decodeFailed
    case noData
}

struct Quote: Decodable {
    let content: String
}

func getQuote(from url: URL?, completion: @escaping (Result<Quote, NetworkError>) -> Void) {
    guard let url = url else {
        completion(.failure(.badUrl))
        return
    }
    URLSession.shared.dataTask(with: url,
                               completionHandler: { data, response, error in
        if let error = error {
            completion(.failure(.communicationError))
            print(error)
            return
        }
        if let response = response as? HTTPURLResponse,
           !(200..<300).contains(response.statusCode) {
            completion(.failure(.badResponse))
            return
        }
        guard let data = data else {
            completion(.failure(.noData))
            return
        }
        do {
            let quote = try JSONDecoder().decode(Quote.self, from: data)
            completion(.success(quote))
        } catch {
            completion(.failure(.decodeFailed))
        }
    }).resume()
}

getQuote(from: url,
         completion: { result in
    switch result {
    case .success(let quote):
        print(quote.content)
    case .failure(let error):
        print(error)
    }
})

Using Async / Await

import UIKit

let url = URL(string: "https://api.quotable.io/random")

enum NetworkError: Error {
    case badUrl
    case badResponse
    case communicationError
    case decodeFailed
    case noData
}

struct Quote: Decodable {
    let content: String
}

func getQuote(from: URL?) async throws -> Quote {
    guard let url = url else {
        throw NetworkError.badUrl
    }
    let (data, response) = try await URLSession.shared.data(from: url)
    if let response = response as? HTTPURLResponse,
       !(200..<300).contains(response.statusCode) {
        throw NetworkError.badResponse
    }
    guard let quote = try? JSONDecoder().decode(Quote.self, from: data) else {
        throw NetworkError.decodeFailed
    }
    return quote
}

Task {
    do {
        let quote = try await getQuote(from: url)
        print(quote.content)
    } catch {
        print(error)
    }
}

References

Swift - Apple Developer
Where is Swift concurrency supported?