오늘은 Async/Await
에 관하여 github 문서를 읽어보고 항목에 따라 천천히 알아가 봅시다!
Modern Swift Development
는 많은 비동기 프로그래밍을 closure
와 completion handlers
를 사용하고 있지만, 이러한 API
들은 사용하기 어렵다는 문제점이 있습니다.
특히 이러한(closure & completion handlers
) 방식을 사용하는 것은 많은양의 비동기 작업이 사용되거나 오류처리가 필요한 상황, 그리고 asynchronous calls
간 제어 흐름이 복잡해 지는 경우에 특히 문제가 됩니다.
이러한 문제점을 해결하고자 async/await
을 해당 github 문서에서 소개하고 있다고 합니다..!
closure
랑 completion handlers
를 사용합니다.그렇다면 우리가 이 문서를 읽고 알아가야 할건 다음과 같습니다.
async/await
방식이 어떻게 더 좋은지 알아봅니다.기존의 비동기 처리 방식은 GCD(Grand Central Dispatch)
를 통해 Thread
에 Task
를 개발자가 지정해주는 방식을 사용하거나, 몇몇 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
로 받아온 데이터에 대한 처리를 할 수 있게 만드는 것을 확인 할 수 있습니다.
위의 두 코드를 보면, 알수 있듯 기존에는 DispatchQueue
와 escaping 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
하게 구현을 많이 했다고 합니다.
따라서 이러한 동기적인 기능들에 대해서 비동기적 기능 구현을 잘 못 할 경우 UI/일반로직 실행에서 문제가 발생 할 수 있습니다.
이러한 5가지 문제점 때문에 기존의 비동기 방식에서
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
에 대한 코드를 보면,
우선 각 메소드들 뒤에 async
와 throws
라는 키워드가 붙는 것을 바로 볼 수 있습니다.
이런식으로 async
키워드가 붙는 것으로 해당 메소드나 함수가 비동기적으로 작업을 처리한다는 것을 명시적으로 컴파일러에게 알리게 됩니다.
async
키워드를 쓰면, 위의 문단에서 언급 한 것처럼 해당 메소드나 함수가 비동기적으로 동작 할 수 있다 라는 것에 대한 명시이긴 하지만 async
를 가지고 있는 메소드나 함수 내부의 line by line 이 비동기적으로 프로그램을 실행하는 것은 아닙니다.
그렇다면 이러한 line by line 에서 실제로 비동기적인 작업이 일어나는 곳은 어떻게 알 수 있을까요?
정답은
await
키워드를 사용하는 것입니다
await
키워드는 Swift 공식 문서에서 제시하는 것 처럼 실행 흐름에 있어 해당 명령줄이 Suspension Point
로서 역할을 한다 라는 것을 알려주는 역할을 합니다.(깃에 나와있는 용어로 표현을 하면 스레드를 포기하는 시점? 을 말한다고 합니다.)
그렇기에, 백그라운드로 작업을 진행하게 두고 해당 작업이 완료가 되면 이 Point
를 기점으로 메소드의 실행 흐름을 재개 시키게 됩니다.
기존에 URLSession
을 사용해서 Completion Handler
를 사용해서 비동기 처리를 했던게 정말 이해하기 힘들어서 헤맸던 기억이 납니다.
굉장히 깔끔하고 직관적으로 이해하기 편하게 개발이 된 것 같은데 앞으로 Swift 개발을 시작하는데 있어 어느정도 허들을 낮춰주는 도구가 되지 않을까? 생각이 듭니다 ㅎㅎ