completionHandler 방식으로 비동기 작업을 처리하던 중 점점 코드가 망가지고 있다는 느낌이 들었는데 async/await가 좋은 해결책이라고 생각하여 학습하고 정리해본다.
WWDC21에서 발표한 Meat async/await에서 아주 잘 알려준다.
Swift 5.5에서 도입된 비동기 프로그래밍의 개념 중 하나로 비동기 코드를 동기 코드처럼 작성할 수 있게 도입된 기능이다.
비동기 프로그래밍은 주로 오래 걸리는 작업, 예를 들면 네트워크 호출이나 데이터 다운로드와 같은 작업들을 효율적으로 다룰 때 필요하다.
이런 작업은 일반적으로 동기적인 방식으로 처리하면 애플리케이션의 성능을 떨어뜨리거나 사용자경험을 저하시킬 수 있다. 기존에는 클로저를 활용하여 비동기 작업의 완료 시점에 필요한 작업을 구현하였지만 복잡성과 가독성 문제가 발생하였고 이런 문제들을 해결하기 위해 awsync/await가 도입되었고, 효율적이고 읽기 쉬운 코드를 작성할 수 있도록 도와준다.
간단한게 동기와 비동기에대해 다시 한번 살펴보자
동기(sync)
: 동기적인 작업은 순차적으로 실행된다. 한 작업이 시작되면 그잡억이 완료될 대까지 다음 작업이 대기한다. 다음 작업은 이전 작업이 완료된 후에 시작된다.
즉, 해당 작업이 끝날때까지 기다린다.
비동기(async)
: 비동기적인 작업은 순차적으로 실행되지 않는다. 작업이 시작되면 해당 작업이 완료될 때까지 기다리지 않고 즉시 다음 작업으로 진행한다.
즉, 해당 작업이 완료 될 때까지 기다리지 않고 다른 작업을 수행한다.
비동기 작업은 위의 설명대로 기다리지 않고 다음 작업을 수행하고 시스템에 의해 적절한 방법으로 처리하게 되는데 이 때 비동기 작업의 완료 시점에 작업을 처리해야 하는 경우가 많다. 비동기 작업을 처리하는 대표적인 방식 중 하나는 escaping 클로저를 사용하는 것이다. 비동기 작업이 완료되면 호출하는 클로저로, 주로 completionHandler라고 부른다. 하지만 이 방식은 여러 문제점을 가지고 있다.
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()
}
위의 코드는 WWDC21 영상에서 보여준 completionHandler를 통해 비동기 작업을 처리하는 예시 코드이다. 모든 경우에 직접 completion을 호출해야 하는것을 볼 수 있다. 그리고 한눈에 봐도 복잡하고 Swift의 일반적인 에러 메커니즘 사용이 불가하다. Result를 사용하면 어느정도 보완 가능하지만 코드는 좀 더 복잡해진다.
콜백 지옥
: 여러 비동기 작업이 중첩되면서 코드가 복잡해지는 것을 콜백지옥이라고 한다.
이 상황이 계속해서 중첩되면 가독성이 나빠지고 유지보수가 어려워지는 결과를 초래한다.
에러 핸들링
: 각 콜백에서 발생하는 에러를 처리하고 전파하는 것이 복잡하며, 작업 종료시 항상 completionHandler를 직접 호출해야 하기 때문에 실수가 발생하기 쉽다.
가독성과 유지보수 어려움
: 콜백함수를 사용하다보면 코드의 흐름이 선형적이지 않게 된다. 코드를 이해하기 어렵고 유지보수하기 어려워진다.
이러한 이유 등으로 async/await가 도입되었다.
우선 async/await가 completionHandler와 비교했을 때 어떤 장점을 가지고 있는지 알아보자
가독성 향상
: async/await는 코드를 동기적으로 작성하듯이 표현할 수 있어 가독성이 향상된다.
콜백 지옥 해결
: 중첩된 콜백 함수 대신 선형적인 코드 구조로 작성할 수 있으므로 코드의 이해와 유지보수가 훨씬 쉬워진다.
에러 처리
: async.await는 일반적인 에러 처리 방식과 유하사게 동작하여 가독성을 향샹시키고 코드의 일관성을 유지할 수 있다.
-효율
async/await는 비동기 작업이 백그라운드에서 효율적으로 수행될 수 있게 설계되어 있다. 결국 앱의 성능을 향상시키고 자원을 효과적으로 활용할 수 있게 해준다.
async 함수
: 비동기 작업을 수행할 수 있는 함수다. 함수 이름 뒤에 'async'키워드를 작성하여 해당 함수가 비동기 함수임을 나타낸다.
await 키워드
await 키워드는 비동기 함수 내에서 다른 비동기 함수가 완료될 때까지 대기하고, 그 결과를 받아오는 역할을 한다. await 키워드를 통해 async 함수를 호출할 수 있다.
// async 함수
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
}
20줄이 넘는 코드를 6줄로 표현 가능해졌고 수행해야 하는 네 가지 작업이 순서대로 나열되었다.(가독성 UP!)
// async 프로퍼티
extension UIImage {
var thumbnail: UIImage? {
get async {
let size = CGSize(width: 40, height: 40)
return await self.byPreparingThumbnail(ofSize: size)
}
}
}
프로퍼티에도 사용 가능하다.
// async for 루프
for await id in staticImageIDsURL.lines {
let thumbnail = await fetchThumbnail(for: id)
collage.add(thumbnail)
}
let result = await collage.draw()
For 루프 문에서도 사용 가능하다.
먼저 일반적인 경우를 살펴보자
설명을 듣다보면 스레드가 차단된 상태라는 말이 나오는데 동기 작업을 수행할 때 특정 작업이 특정 스레드를 점유하여 스레드가 다른 작업을 수행하지 못하는 상태를 말하는 것이다.
즉 await 로 async 함수를 호출하는 함수의 스레드는 차단이 해제된다고 볼 수 있다.
함수를 async로 표현하면 해당 함수가 일시 중지 되도록 허용하는 것이다. 함수가 자신을 중지하면 호출자도 중지된다. 따라서 호출자도 async 여야 한다.
비동기 함수에서 한번에 여러번 중단(suspend) 될 수 있는 위치를 지정하기 위해 await 키워드를 사용한다.
비동기 함수가 중단(suspend)된 동안 스레드는 차단 되지 않는다. 스레드는 자유롭게 다른작업을 수행한다.
비동기 함수가 다시 시작되면 호출한 비동기 함수에서 반환된 결과가 원래 함수로 다시 유입되고 중단된 부분부터 실행된다.
비동기가 아닌 컨텍스트에서는 비동기 함수를 호출할 수 없음. (Task {} 를 활용해라)
async/aswait를 통해 도입된 비동기 프로그래밍은 기존의 completionHandler 방식보다 훨씬 가독성이 높고 효율적인 코드를 작성할 수 있게 해준다. 콜백 지옥을 피하고, 에러 처리를 간현하게 다룰 수 있으며, 코드의 흐름을 더 직관적으로 표현할 수 있다. async/await를 적용하여 기존 코드들을 개선해보도록 하겠다.