Swift: Async와 Await, 등장 배경과 사용법

Heechan
HcleeDev
Published in
12 min readJun 19, 2021
Photo by Jessica Lewis on Unsplash

최근 들어 Swift, SwiftUI 개발자들이 동시성에 매우 신경쓰고 있다는 것이 느껴진다. 이번 WWDC21에서도 Swift나 SwiftUI 신기능을 소개할 때 async, await, 그리고 structed concurrency에 대해 꽤 강조하는 것으로 보였다. 학교 수업 때도 비동기가 나를 꾸준히 괴롭혀왔는데… 요즘 트렌드에선 어쩔 수 없는가보다.

기존 Swift 언어의 단점 중 하나가 비동기 처리가 쉽지 않다는 점이 있었는데, 오늘 소개할 async/await가 그 문제를 크게 해결해줬다. 이번주는 Async와 Awiat가 왜 만들어졌는지, 어떻게 사용하는지 한번 알아보도록 하겠다.

기존 비동기 처리 방식은?

DispatchQueue에 직접 접근하거나, 몇몇 API에서는 completionHandler를 전달받아 작업이 완료되었을 때 호출하는 방식을 통해 비동기적으로 작업을 처리한다.

DispatchQueue에 대해 간단히 소개하자면, 스레드가 실행할 작업이 들어있는 Queue다. 애플은 Grand Central Dispatch, GCD라는 이름으로 DispatchQueue를 제공하는데, 이 DispatchQueue는 main 큐와 global 큐를 제공한다.

main 큐는 UI에 직접 드러나는 작업들을 쌓아두는 큐로, 앱 화면을 구현하는 곳에서 일어나는 모든 작업이 main에서 굴러간다고 볼 수 있다. global 큐는 UI에 직접 보이는 작업이 아닌, 백그라운드에서 굴러가는 로직들이 쌓인다.

DispatchQueue.global.async {
//do something
}

개발하다보면 이런 코드를 짠 적이 많지 않을까 싶다. 특정 작업을 비동기적으로 처리하기 위해서 global 큐에 .async를 붙여 작업을 넘겨준 것으로, 여기서 넘겨준 작업은 들어가면 global 큐의 다른 작업 중에 sync로 굴러가고 있는 작업이 없으면 바로 실행된다. 당연히 async로 굴러가기 때문에, global 큐는 이 작업을 시작하고 나면 바로 다시 새로운 작업이 들어오기를 대기한다.

이런 식으로 직접 DispatchQueue에 접근해서 작업을 관리할 때도 있지만, 몇몇 API에서는 굳이 그럴 필요까진 없다.

예를 들어 URL 주소로 네트워크 통신을 도와주는 URLSession API에는 completionHandler 로 클로저를 받아와 통신이 완료되었을 때 해당 클로저를 실행하는 방식으로 비동기적으로 작업을 처리한다.

let task = URLSession.shared.dataTask(with: url, completionHandler: { data, response, error in
//do something
})
task.resume()

이 예시를 보며 얘기해보자. 우리가 만약 url 주소에서 이미지를 받아와야 한다고 생각해보면, 일단 데이터를 요청하고, 다운로드하는데 딜레이가 있을 수 밖에 없다.

하지만 그 사진이 올 때까지 무한정 기다릴 수는 없다. 만약 스레드가 사진을 가져오는 작업이 끝날 때까지 다른 일을 하지 못한다면, View는 멈춰있을 것이다.

그래서 데이터를 다 받아올 때까지 다른 작업을 수행할 수 있도록, URLSession은 completionHandler를 데이터를 다 받아왔을 때 호출한다. 백그라운드에서 데이터를 받는 작업이 진행되고, 메인 스레드는 다른 작업을 하다가 데이터를 다 받아오면 메인 스레드에 completionHandler를 올려서 받아온 데이터를 바탕으로 작업을 할 수 있도록 할 수 있는 것이다.

근데 사실 이 completionHandler도 내부 구현에서는 아마 DispatchQueue를 사용하고 있지 않을까 싶다.

이외에도 비동기 처리를 위한 라이브러리를 몇가지 이용하기도 한다. 가장 많이 사용하는 것이 RxSwift, 그리고 애플에서 제공하는 Combine이 있다. 우리 회사는 Combine을 사용하긴 하는데, iOS 개발자 공고를 보면 RxSwift를 사용하는 회사가 정말 많다.

Combine 프레임워크를 자세히 설명하는건 다음 기회에 하겠지만, 간단히 설명하자면 Publisher와 Subscriber로 나눠진다. Publisher에 변화가 생기면 그 Publisher를 구독하고 있는 Subscriber가 반응하면서 작업을 처리할 수 있도록 한다. 이 방식이 꽤 나쁘진 않지만, Subscriber를 계층 사이에서 전달하는 방식이다 보니 코드가 그렇게 깔끔하지는 않다.

기존 방식의 단점

Swift 개발자들이 가장 불편하다고 느낀 것은 completionHandler다. Async, await를 설명한 Proposal 문서를 보면 새 기능을 만들게 된 계기를 상세히 설명해주고 있다.

  1. ‘파멸의 피라미드’

이런 식으로 여러 비동기 작업을 연속적으로 실행해야 하는 경우에 클로저 안에 클로저, 그 안에 또 클로저… 이런 식으로 코드가 쌓여있으면 보기 좋을 수가 없다. 근데 만약에 여기 에러 핸들링까지 한다면…

2. 복잡한 에러핸들링

각 클로저 안에서 비동기 작업이 끝난 후 에러가 났을 경우를 대비해 에러 핸들링을 넣어줘야 하는데, 이렇게 넣어주면 코드가 매우 보기 안좋다.

3. 조건문에서의 처리가 쉽지 않음

조건에 따라 다른 작업을 해야 할 경우, if와 else의 결과 타입이 같아야 한다. 하지만 하나는 비동기 작업을, 하나는 그렇지 않을 시 똑같이 Void로 결과가 나올 수 있도록 swizzle 작업을 continuation closure로 작성한 것이다. 하지만 이런 식으로 작성하기도 쉽지 않고, 에러가 뿜어져 나올 가능성도 많다.

4. 실수 하기가 쉬움

위 사진에서 보듯 return만 써버리고 completionBlock은 부르지 않는다든가, completionBlock은 실행했는데 return은 실행하지 않는다든가 하는 실수를 하기 쉽다. 클로저를 계속 넘겨주고 작업들의 실행 흐름을 기억하면서 구현해야 하다보니 헷갈리기 일수다.

이처럼 completionHandler를 사용하는 방식이 여러 단점이 있다보니…

5. 많은 API, 라이브러리들이 completionHandler를 사용하지 않고 그냥 synchronous하게 코드를 작성함

API, 라이브러리 작성자들이 복잡하고 문제가 생기기 쉬운 completionHandler를 사용하지 않고 그냥 기능을 동기적으로 구현하도록 유도되었다. 이렇게 동기적으로 기능을 구현했을 때 잘못 처리하면 UI 구현과 로직 구동에 있어서 성능 문제를 불러일으킬 수 있다.

결국 Swift 언어에선 비동기 처리가 편하지 않았기 때문에 좋은 퀄리티의 API가 나오기 힘든 환경이 되었다. 따라서 개발자들은 이를 해소하기 위해 Async와 Await를 구현하게 되었다.

Async와 Await

Swift 5.5부터 새롭게 등장한 Async, Await를 이용해 위에서 등장했던 코드를 작성한 모습이다.

원래 completionHandler로 복잡하게 쌓여있던 코드가 정말 깔끔하게 정리된 모습이다.

Async와 Await의 역할을 이 코드와 함께 살펴보자.

우선 함수 processImageData 의 뒤에는 async 가 붙어있다. 이 async 는 이 함수가 비동기적으로 작업을 처리하는 함수임을 알리는 역할을 한다. 이런 함수를 async 함수 타입이라고 하며, 이 함수의 타입을 따지자면 () async -> Image 로 표현할 수 있다.

async 를 이용해 이 함수가 비동기적으로 수행될 것임을 표현하긴 했지만, 그 내부에 있는 모든 명령이 비동기적으로 수행되는 것은 아니다.(물론 위 사진에 있는 코드는 모두 그렇지만… 실제로는 안그럴 수 있다) 그래서 우리는 특정 명령에 대해서만 await 를 사용해 ‘여기가 비동기적으로 작업이 일어나는 곳이다!’라고 명시적으로 알려주어야 한다.

우리는 await 를 Suspension Point가 발생할 수 있는 명령에 붙여줘야 한다. 이 Suspension Point는 이 함수가 실행되는 도중 스레드를 포기한 시점을 뜻한다. 그리고 백그라운드에서 진행되는 작업이 완료되면 다시 이 Suspension Point부터 함수를 재시동한다.

예를 들어 코드를 이렇게 단순화한다고 쳤을 때,

func process() async -> Image {
let result = await loadWebResource("dataSource")
return result
}
let image = process()

process()의 실행 흐름은 대충 아래와 같다.

1 : process() 실행
2 : loadWebResource("dataSource")로 데이터 받아오는 요청함
3 : 받아온 데이터를 result에 저장함
4 : result를 return함

여기서 2에서 3 사이가 Suspension Point라고 생각할 수 있다.

loadWebResource 는 데이터를 받아오는 요청을 하고, 데이터를 받아오는 작업을 백그라운드에서 실행하도록 한다.

하지만 이를 동기적으로 실행하면 데이터를 받아오는 동안 다른 코드들이 실행될 수가 없어서 앱 화면이 멈춰버리는 문제가 발생할 것이다.

그래서 비동기적으로 실행하기 위해 await loadWebResource() 처럼 작성해 여기서 Suspension Point가 발생할 수 있다고 알려준다.

실제로 2번 작업까지 끝나고 나면 이 함수는 데이터 다운이 완료될 때까지 다른 작업들이 실행될 수 있도록 스레드를 포기한다. 그러면 Suspension Point가 2번과 3번 사이에 생기는 것이고, 데이터 다운로드가 완료된 후에는 다시 이 함수가 스레드를 할당받아 Suspension Point부터 작업을 실행한다. 3번부터 다시 자연스럽게 작업을 시작할 수 있는 것이다.

주의해야 할 점은 sync 함수 안에서 async 함수를 사용하는 경우다. 이를 명확히 인지해야 하는 이유는 동기, 비동기적 작업의 성격 때문에 에러가 발생할 수 있기 때문이다.

Sync 함수 내에서 비동기적으로 움직일 수 있는 작업을 전달하는 것은 좋은 생각이 아니다. 처리 방식에 따라 2가지 문제가 생길 가능성이 있다.

첫 번째는 여러번 말한 것이지만, 비동기 작업이 끝날 때까지 이 함수가 스레드를 붙잡고 하염없이 기다리게 될 수 있다. 위에서도 언급했지만 이미지가 모두 다운로드될 때까지 앱이 멈춰있을 수도 있으니, 성능 문제가 생길 수 있다.

두 번째는 그냥 비동기 함수의 결과물을 무시하게 될 수도 있다. Async 함수는 실행될 때 Suspension Point를 설정해두고, 의도한 작업이 완료되면 다시 그 시점부터 시작하게 된다. 하지만 Sync 함수는 내부에 있는 Async 명령이 백그라운드 작업을 시작하게만 해두고, 명령을 실행하긴 실행했으니 백그라운드 작업의 완료를 기다리지 않고 함수를 끝내버릴 수도 있다. 그러면 Async가 의도했던 작업이 제대로 이뤄지지 않고, Sync 함수 입장에서도 제대로 된 값을 받지 못해 기능을 잘 하지 못할 수 있다.

그렇기 때문에 Swift 컴파일러는 Sync 함수 내에 Async 함수가 들어올 경우 아예 에러를 내버린다.

반대로 Async 안에는 동기적으로 실행하는 함수를 넣어도 된다. 비동기적으로 돌리는게 필요한 작업은 await 로 표시하면 되고, 다른 일반적인 작업들은 평소처럼 다 동기적으로 실행하면 되기 때문이다. 거기다 Sync 함수를 하나 끼워넣는다고 크게 이상한 것은 아니라 괜찮다.

위 사진에서는 throw도 다루고 있는데, 에러를 뱉는 함수는 에러를 안받는 함수를 받아도 되지만 에러를 안뱉는 함수가 에러를 뱉는 함수를 받으면 문제가 생길 수 있기 때문에 이처럼 함께 표현해준 것으로 보인다.

정리하면 async 는 이 함수가 비동기적으로 실행될 여지가 있는 함수라고 알려주는 역할을 하고, await 는 그 함수 안에서 실제로 어떤 명령에서 Suspension Point가 생겨날 수 있는지 명시하는 역할을 한다고 볼 수 있다. 이 점을 기억하고 사용하면 헷갈리지 않을 수 있을 것 같다.

결론

Swift의 고질적 문제점을 꽤 멋지게 해결해서 놀랍다. 사실 우리 앱에서는 URLSession을 직접 사용하지는 않는데, 이런 비동기 처리가 불편한 점이 있어 Alamofire라는 라이브러리를 사용하고 있었기 때문이다. 하지만 이제 URLSession같은 completionHandler를 이용하는 API들을 Async/Await를 이용할 수 있도록 리팩토링해준다고 하니 앞으론 좀 더 배우기 쉽지 않을까 기대된다. 다만 이를 이용해 Combine, RxSwift를 대체할 수 있도록 작성할 수 있을지는 아직 잘 모르겠다.

그리고 이번에 쓰다보니까 앞으로도 블로그에 쓸 내용이 되게 많은 것 같다. 쉽지 않구만…

참고한 것

swift-evolution/0296-async-await.md at main · apple/swift-evolution (github.com)

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science