[Swift] Async/Await

.onNext("Volga")·2023년 2월 7일
0

Swift

목록 보기
2/2

들어가기 전

오늘은 Async/Await에 관하여 github 문서를 읽어보고 항목에 따라 천천히 알아가 봅시다!

Async/Await

Introduction

Modern Swift Development는 많은 비동기 프로그래밍을 closurecompletion handlers를 사용하고 있지만, 이러한 API들은 사용하기 어렵다는 문제점이 있습니다.

특히 이러한(closure & completion handlers) 방식을 사용하는 것은 많은양의 비동기 작업이 사용되거나 오류처리가 필요한 상황, 그리고 asynchronous calls간 제어 흐름이 복잡해 지는 경우에 특히 문제가 됩니다.

이러한 문제점을 해결하고자 async/await을 해당 github 문서에서 소개하고 있다고 합니다..!

그래서 흐름이 뭘까?

  • 기존에 비동기를 쓰던 방식은 closurecompletion handlers를 사용합니다.
  • 또한 기존의 비동기는 오류처리가 필요하거나, 제어 흐름이 복잡해 지는 경우 문제가 됩니다.

그렇다면 우리가 이 문서를 읽고 알아가야 할건 다음과 같습니다.

  1. 기존 비동기 방식이 왜 복잡한지 알아봅니다.
  2. 그래서 async/await 방식이 어떻게 더 좋은지 알아봅니다.

기존의 비동기 방식

기존의 비동기 처리 방식은 GCD(Grand Central Dispatch)를 통해 ThreadTask를 개발자가 지정해주는 방식을 사용하거나, 몇몇 API(ex-URLSession)에서는 Completion Handlers를 사용해서 작업을 처리해왔습니다.

보통 앱을 개발 할 경우 URLsession을 사용해서 API 통신을 하고 데이터를 받아오면 UI를 업데이트 하게 되는데 이 때, 보통 DispatchQueue를 사용하게 됩니다.

networkService.fetch(searchTarget: .searchDetailMovieInfo,
                     headers: nil,
                     queryItems: [QueryKeys.movieCode: movieCode]) {
    [weak self] (networkResponse: Result<MovieInfoDetailResult,
                 NetworkServiceError>) -> Void in
    switch networkResponse {
    case .success(let success):
        let info = success.movieInfoResult.movieInfo
        DispatchQueue.main.async {
            self?.updateLabel(info)
        }
    case .failure(let error):
        print(error.localizedDescription)
    }
}

위의 코드를 보면 escaping closure로 지정되어있는 부분에서 데이터를 획득하고 나면, 그에 따라 UI 업데이트를 위해 self?.updateLabel(info) 메소드를 DispatchQueue를 통해 실행시키고 있음을 확인 할 수 있습니다.

func fetch<T: Decodable> (
    searchTarget: URLInfo,
    headers: [String: String]? = nil,
    queryItems: [String: String]? = nil,
    completion: @escaping (Result<T,
                           NetworkServiceError>) -> Void) {
    guard let urlComponent = establishURLComponents(searchTarget: searchTarget,
                                                    queryItems: queryItems),
          let url = urlComponent.url
    else {
        completion(.failure(.invalidURLError))
        return
    }
    let urlRequest = createHTTPRequest(of: url,
                                       with: headers,
                                       httpMethod: HTTPMethod.get)
    let task = session.dataTask(with: urlRequest) { data, response, error in
        if error != nil {
            completion(.failure(.transportError))
            return
        }
        guard let httpResponse = response as? HTTPURLResponse else {
            completion(.failure(.transportError))
            return
        }
        guard (200..<300).contains(httpResponse.statusCode) else {
            completion(.failure(.serverError(code: httpResponse.statusCode)))
            return
        }
        guard let data = data else {
            completion(.failure(.emptyDataError))
            return
        }
        do {
            let decodedData = try JSONDecoder().decode(T.self, from: data)
            completion(.success(decodedData))
        } catch {
            completion(.failure(.decodingError))
        }
    }
    task.resume()
}

추가적으로 위의 DispatchQueue 를 호출하는 fetch 메소드를 봅시다.

위의 fetch 메소드의 내부 동작을 보게 되면 URLSession을 사용해서 네트워킹을 하게 됩니다.
또한, 데이터를 획득하게 되면 파라미터에 지정된 completion로 받아온 데이터에 대한 처리를 할 수 있게 만드는 것을 확인 할 수 있습니다.

위의 두 코드를 보면, 알수 있듯 기존에는 DispatchQueueescaping closure 같은 것들을 사용해서 비동기적으로 코드를 작성 한다는 사실을 알 수 있습니다..!

파멸의 피라미드

위의 기존 비동기 코드들은 사실 줄 수 자체는 굉장히 길지만 그렇게 지저분한 코드는 아닌 것 처럼 보입니다.

이번에는 그래서 기존의 방식이 문제가 되는지 알아보고자 합니다.

func processImageData1(completionBlock: (_ result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}

processImageData1 { image in
    display(image)
}

코드를 보면 알수 있듯, 비동기 작업을 연속적으로 실행해야 하는 경우가 있을 수 있습니다.
보면, 클로저 안에 클로저가 있고 Depth가 개발 의도랑은 다르게 굉장히.. 깊어지는 것을 알 수 있습니다. (보통 Depth가 깊어지는 방향으로 개발은 하지 않으니까요..!)

여기서 추가적인 문제점은, 에러 처리는 어떻게 해야할지 생각해보면 벌써 눈앞이 아찔하게 됩니다;;

에러 처리

// (2a) Using a `guard` statement for each callback:
func processImageData2a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource, error in
        guard let dataResource = dataResource else {
            completionBlock(nil, error)
            return
        }
        loadWebResource("imagedata.dat") { imageResource, error in
            guard let imageResource = imageResource else {
                completionBlock(nil, error)
                return
            }
            decodeImage(dataResource, imageResource) { imageTmp, error in
                guard let imageTmp = imageTmp else {
                    completionBlock(nil, error)
                    return
                }
                dewarpAndCleanupImage(imageTmp) { imageResult, error in
                    guard let imageResult = imageResult else {
                        completionBlock(nil, error)
                        return
                    }
                    completionBlock(imageResult)
                }
            }
        }
    }
}

processImageData2a { image, error in
    guard let image = image else {
        display("No image today", error)
        return
    }
    display(image)
}
// (2b) Using a `do-catch` statement for each callback:
func processImageData2b(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        do {
            let dataResource = try dataResourceResult.get()
            loadWebResource("imagedata.dat") { imageResourceResult in
                do {
                    let imageResource = try imageResourceResult.get()
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        do {
                            let imageTmp = try imageTmpResult.get()
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        } catch {
                            completionBlock(.failure(error))
                        }
                    }
                } catch {
                    completionBlock(.failure(error))
                }
            }
        } catch {
            completionBlock(.failure(error))
        }
    }
}

processImageData2b { result in
    do {
        let image = try result.get()
        display(image)
    } catch {
        display("No image today", error)
    }
}
// (2c) Using a `switch` statement for each callback:
func processImageData2c(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        switch dataResourceResult {
        case .success(let dataResource):
            loadWebResource("imagedata.dat") { imageResourceResult in
                switch imageResourceResult {
                case .success(let imageResource):
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        switch imageTmpResult {
                        case .success(let imageTmp):
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        case .failure(let error):
                            completionBlock(.failure(error))
                        }
                    }
                case .failure(let error):
                    completionBlock(.failure(error))
                }
            }
        case .failure(let error):
            completionBlock(.failure(error))
        }
    }
}

processImageData2c { result in
    switch result {
    case .success(let image):
        display(image)
    case .failure(let error):
        display("No image today", error)
    }
}

코드에도 나와있듯, 각 클로저 안에서 비동기 작업이 끝난 후에 에러가 발생하는 경우에 대해 처리를 하는 코드를 추가해야 하는데 기존의 completion 방식을 사용하게 되서 코드를 중첩하게 되면 에러처리가 아주 힘들게 됩니다.

어디서 에러처리를 했는지 알기도 참 어려워보이네요;;

Result를 써도 굉장히 지저분하다는 것을 알 수 있습니다

조건에 대한 실행이 어렵고 오류 발생이 쉬워집니다.

func processImageData3(recipient: Person, completionBlock: (_ result: Image) -> Void) {
    let swizzle: (_ contents: Image) -> Void = {
      // ... continuation closure that calls completionBlock eventually
    }
    if recipient.hasProfilePicture {
        swizzle(recipient.profilePicture)
    } else {
        decodeImage { image in
            swizzle(image)
        }
    }
}

예를들어, 위의 코드처럼 이미지를 얻은 이후에 swizzle 해야 한다고 가정해봅시다.

조건에 따라서 작업이 달라지는 경우라도 if/else시 결과에 대한 타입이 같아야합니다.
하지만 하나는 비동기 작업(decodeImage)를 하고 하나는 그렇지 않을 경우에도 Void로 리턴 타입을 swizzle 작업을 continuation closure로 작성 할 수 있습니다.

이게 말만 들으면.. 굉장히 할만 해보이는데 작성하는것도 굉장히 까다로운데 이 경우 에러가 생기면 어디서 에러가 생겼는지 프로덕트가 커지면 찾기 어렵겠다라는 생각이 듭니다.

실수가 발생하기 쉽습니다.

func processImageData4a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource, error in
        guard let dataResource = dataResource else {
            return // <- forgot to call the block
        }
        loadWebResource("imagedata.dat") { imageResource, error in
            guard let imageResource = imageResource else {
                return // <- forgot to call the block
            }
            ...
        }
    }
}
func processImageData4b(recipient:Person, completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    if recipient.hasProfilePicture {
        if let image = recipient.profilePicture {
            completionBlock(image) // <- forgot to return after calling the block
        }
    }
    ...
}

코드에서 보면 알수 있듯, return만 써버리고 Completion Block을 부르지 않거나, Completion Block을 호출했지만 return을 실행하지 않는다던가 하는 실수를 하기가 쉽습니다.

많은 API들이 Completion Handler를 사용하지 않고 Synchronous하게 코드가 작성되어 있습니다.

흔히 쓰는 서드파티나 API들의 개발자들은 복잡하고 어색한 Completion Handler를 사용하는 것 보다는 기능을 Synchronous하게 구현을 많이 했다고 합니다.

따라서 이러한 동기적인 기능들에 대해서 비동기적 기능 구현을 잘 못 할 경우 UI/일반로직 실행에서 문제가 발생 할 수 있습니다.

이러한 5가지 문제점 때문에 기존의 비동기 방식에서 async/await 방식이 도입 되었습니다.

Async/Await

Swift 5.5버전부터 새롭게 추가된 코드를 한번 봅시다

func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i : Image) async throws -> Image

func processImageData() async throws -> Image {
  let dataResource  = try await loadWebResource("dataprofile.txt")
  let imageResource = try await loadWebResource("imagedata.dat")
  let imageTmp      = try await decodeImage(dataResource, imageResource)
  let imageResult   = try await dewarpAndCleanupImage(imageTmp)
  return imageResult
}

코드를 보면 대충 추론이 되는게, 기존에 completion Handlers를 통해서 코드를 복잡하게 쌓아놓고 중첩해놨던 것들을 굉장히 짧은 줄 수로 정리 할 수 있다는 것을 볼 수 있습니다.

간단하게 코드를 보면서 async, await에 대한 코드를 보면,

우선 각 메소드들 뒤에 asyncthrows라는 키워드가 붙는 것을 바로 볼 수 있습니다.
이런식으로 async 키워드가 붙는 것으로 해당 메소드나 함수가 비동기적으로 작업을 처리한다는 것을 명시적으로 컴파일러에게 알리게 됩니다.

async 키워드를 쓰면, 위의 문단에서 언급 한 것처럼 해당 메소드나 함수가 비동기적으로 동작 할 수 있다 라는 것에 대한 명시이긴 하지만 async를 가지고 있는 메소드나 함수 내부의 line by line 이 비동기적으로 프로그램을 실행하는 것은 아닙니다.

그렇다면 이러한 line by line 에서 실제로 비동기적인 작업이 일어나는 곳은 어떻게 알 수 있을까요?

정답은 await 키워드를 사용하는 것입니다

await 키워드는 Swift 공식 문서에서 제시하는 것 처럼 실행 흐름에 있어 해당 명령줄이 Suspension Point로서 역할을 한다 라는 것을 알려주는 역할을 합니다.(깃에 나와있는 용어로 표현을 하면 스레드를 포기하는 시점? 을 말한다고 합니다.)
그렇기에, 백그라운드로 작업을 진행하게 두고 해당 작업이 완료가 되면 이 Point를 기점으로 메소드의 실행 흐름을 재개 시키게 됩니다.

결론

기존에 URLSession을 사용해서 Completion Handler를 사용해서 비동기 처리를 했던게 정말 이해하기 힘들어서 헤맸던 기억이 납니다.

굉장히 깔끔하고 직관적으로 이해하기 편하게 개발이 된 것 같은데 앞으로 Swift 개발을 시작하는데 있어 어느정도 허들을 낮춰주는 도구가 되지 않을까? 생각이 듭니다 ㅎㅎ

profile
iOS 개발자 volga입니다~

0개의 댓글