오늘은 async/await에 대해서 작성해보려고 한다.

비동기 함수에 대해 알아보기 전에 기존 동기 함수가 어떻게 동작되는지 알아보자.
기존 (동기)함수를 호출하면 스레드가 차단되어 다른 함수가 완료될 때까지 기다려야 한다. 해당 함수가 완료될 때까지 다른 작업을 수행할 수 없는 것이다.
반면 비동기 함수는 어떨까?
비동기 버전의 함수를 호출하면 해당 함수 수행하는 동안 다른 작업을 수행할 수 있다. 이렇게 작업을 수행할 수 이있어 함수 비동기 함수가 시작되는 시점과 완료되는 시점을 알기 어렵다.
이때, 우리는 비동기 함수가 완료되면 completionHandler라는 클로저를 호출하여 비동기 함수가 모두 실행이 완료되었다는 것을 알려주고 동작 완료 결과를 받을 수 있다.
그렇다면 기존 GCD 방식이 어떻게 동작되는지 알아보자!
fetchThumbnail 함수는 서버에서 이미지를 다운받고 이미지 데이터를 UIImage로 변환하고 UIImage를 축소판으로 랜더링을 실행하는 함수이다.

fetchThumbnail 함수의 주요 실행과정은 다음과 같다.
1. String을 ThumbnailURLRequest 메서드를 통해 URLRequest를 생성한다.
2. URLSession의 dataTask 메서드는 해당 request에 대한 데이터를 가져온다.
3. UIImage initWithData를 통해서 이미지를 생성한다.
4. 생성한 UIImage에 대해서 prepareThumbnail을 통해 원본이미지를 축소판으로 랜더링한다.
이 함수의 특징은 무조건 순서대로 이루어져야 한다. 당연하다. 하나라도 순서대로 이루어지지 않으면 제대로 된 썸네일 이미지를 만들 수 없기 때문이다.
그렇다면 여기서 어떤 task 비동기 작업으로 수행해야 되는가?에 대해서 생각해보자.
작업 속도가 빠른 것들에 대해서는 비동기로 수행할 필요성이 떨어진다. 하지만 작업 시간이 오래걸릴 작업을 동기로 처리한다면 해당 작업을 수행하는 시간동안 다른 작업을 수행하지 못하고 기다려야 한다.
따라서 시간이 오래 걸리는 task에 대해서 비동기 작업을 수행해야 한다.
그렇다면 동기, 비동기 처리 작업을 구분해보면 아래와 같이 구분할 수 있다.
동기 처리 작업
1. String 에서 URLRequest 생성하는 것
2. 주어진 데이터에 대해서 UIImage를 생성하는 것
비동기 처리 작업
1. dataTask: SDK에서 비동기식으로 제공
2. prepareThumbnail
func fetchThumbnail(for id: String, completion: @escaping (UIImage?, Error?) -> Void) {
let request = thumbnailURLRequest(for: id) // 동기 코드
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(nil, error)
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(nil, FetchError.badID)
} else {
guard let image = UIImage(data: data!) else {
completion(nil, FetchError.badImage)
return
}
image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
guard let thumbnail = thumbnail else {
completion(nil, FetchError.badImage)
return
}
completion(thumbnail, nil)
}
}
}
task.resume()
}



해당 코드를 스레딩 측면을 살펴보자.
위 코드에서는 무려 3개의 컨텍스트가 존재한다.
따라서, data races 와 같은 스레딩 이슈를 피하기 위해서 극도의 주의를 기울여야 한다.
이렇게 completionHandler를 이용하는 비동기 코드를 작성하면 실수할 확률이 매우 높다.

지금 작성된 fetchThumbnail에 대해서도 실수가 존재한다.
함수를 통한 모든 경로에서 completion을 통해서 그 상황을 알릴 필요가 있다.
-> 모든 분기 처리에 대해서 completionHandler가 호출되지 않았다는 것이다. 2개의 guard let 구문에서 에러를 포함하는 completion이 호출되지 않았다는 것이다.

하지만 컴파일러가 이런 에러를 잡아주지 못한다. 또한 깊이가 길어지고 이해하기가 어려운 코드
async/await를 사용하면 어떨까?
async 메서드는 concurrent context 에서만 실행할 수 있다.
즉, 다른 asnyc 메서드와 Task 를 통해서 수동으로 concurrent context 를 제공할 때 사용할 수 있습니다.
(Task 는 비동기 작업의 단위입니다. 비동기 컨텍스트를 생성해서 동기 컨텍스트에서도 비동기를 호출 할 수 있습니다.)
func fetchThumbnail(for id: String) async throws -> UIImage {
let request = thumbnailURLRequest(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badID }
let maybeImage = UIImage(data: data)
guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage }
return thumbnail
}
fetchThumbnail이 호출되면 마찬가지로 thumbnailURLRequest를 호출하면 시작한다.
동기식이므로 쓰레드가 차단되어 작업을 수행한다.

URLSession.shared에서 새롭게 realeasee된 data를 호출하여 데이터 다운로드를 실행한다.

코드 길이가 20줄에서 6줄로 줄었고 일직선 코드여서 코드의 의미를 더 잘 이해할 수 있다.
프로퍼티, 생성자도 비동기가 될 수 있다.
guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage }
속성을 비동기로 선언할 때 조건이 있다.
extension UIImage {
var thumbnail: UIImage? {
get async {
let size = CGSize(width: 40, height: 40)
return await self.byPreparingThumbnail(ofSize: size)
}
}
}
for loop에서도 async/await를 사용할 수 있다.
for await id in staticImageIDsURL.lines {
let thumbnail = await fetchThumbnail(for: id)
collage.add(thumbnail)
}
let result = await collage.draw()
이처럼 'await'이라는 keyword를 통해서 비동기 작업이 여기서 멈출 수 있다는 것을 나타낸다.

but, 비동기 함수의 경우는 어떨까?
비동기 함수에서는 일시 중단을 통해 스레드 제어 포기가 가능하다

이렇게 함수가 일시 중지되면 앱 상태가 크게 바뀔 수도 있다. (일시 중지 상태가 될 수 있고 완전히 다른 쓰레드에서 함수가 실행될 수도 있다.)
SwiftUI에서 async function을 쓸 때, context에서 concurrency를 지원하지 않는다는 에러가 발생한다.

해결책: async task function을 사용하는 것!
Task 는 비동기 작업의 단위이다.
클로저 안의 작업을 패키지화하고 Global Dispatch Queue의 비동기 함수와 같이 사용가능한 스레드에서 즉시 실행되도록 시스템에 보낸다.
async function은 concurrency context에서만 호출될 수 있는데 Task를 통해서 동기 context 내에서도 concurrency context를 생성하여 async function을 호출할 수 있는 것이다.
struct ThumbnailView: View {
@ObservedObject var viewModel: ViewModel
var post: Post
@State private var image: UIImage?
var body: some View {
Image(uiImage: self.image ?? placeholder)
.onAppear {
Task {
self.image = try? await self.viewModel.fetchThumbnail(for: post.id)
}
}
}
}
빠르게 기존 API에 대한 비동기 대안으로 천천히 적용해보자
기존 SDK에서도 async로 변화되고 있다.

⬇️

이미 completionHandler 형태로 존재하고 있는 경우에도 async로 바꿀 수 있다.

⬇️
import ClockKit
extension ComplicationController: CLKComplicationDataSource {
func currentTimelineEntry(for complication: CLKComplication) async -> CLKComplicationTimelineEntry? {
let date = Date()
let thumbnail = try? await self.viewModel.fetchThumbnail(for: post.id)
guard let thumbnail = thumbnail else {
return nil
}
let entry = self.createTimelineEntry(for: thumbnail, date: date)
return entry
}
}
핵심 데이터 저장소에 유지된 모든 게시물을 검색하는 함수
// Existing function
func getPersistentPosts(completion: @escaping ([Post], Error?) -> Void) {
do {
let req = Post.fetchRequest()
req.sortDescriptors = [NSSortDescriptor(key: "date", ascending: true)]
let asyncRequest = NSAsynchronousFetchRequest<Post>(fetchRequest: req) { result in
completion(result.finalResult ?? [], nil)
}
try self.managedObjectContext.execute(asyncRequest)
} catch {
completion([], error)
}
}
// Async alternative
func persistentPosts() async throws -> [Post] {
typealias PostContinuation = CheckedContinuation<[Post], Error>
return try await withCheckedThrowingContinuation { (continuation: PostContinuation) in
self.getPersistentPosts { posts, error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(return ing: posts)
}
}
}
}

async function은 suspended될 수 있으며 이때 쓰레드의 제어권을 시스템에게 넘기게 된다.
쓰레드의 제어권을 돌려받고 비동기 함수를 실행할 때, 중단 전까지 함수 context가 필요하다.
-> 이렇게 suspended되도 실행하는데 필요한 함수 컨텍스트 및 제어권을 돌려받아 실행할 수 있는 흐름을 continuation이라고 한다.

Continuation은 suspend되는 async function이 연속된 흐름을 가질 수 있도록 해서 함수가 일지 중지되도 정상적으로 동작할 수 있도록한다.
🗣️주의할 점
지금까지 async/await에 대해서 알아보았다. 함수의 실행을 잠시 중단하고 제어권을 시스템에 넘긴다는 사실이 매우 흥미로웠다. async, await 키워드 또한 어떤 함수가 비동기이고 어떤 부분에 일시 중지되는지 명시적으로 확인이 가능한다는 점에서 매력적이다.
또한, 확실히 기존 GCD 방식과 비교하면 코드의 양이 적어지고 더 실수할 확률이 줄어드는 기능이다. 보기에 더 작성하고 실수하기 편하다는 것을 제외하고 내부적으로 어떤 성능 차이가 있을지 궁금하다.