이전 글들에서 GCD와 completionHandler 클로저에 대해 Swift Concurrency의 등장과 async/await를 사용한 코드에 대해 전반적으로 살펴보았고, GCD 이후의 새로운 동시성 프로그래밍의 개념인 Swift Concurrenc에서 Coroutine과 Continuation의 개념도 알아보았는데요,
[Swift] Swift의 동시성 프로그래밍 (1) | GCD
[Swift] Swift의 동시성 프로그래밍 (2) | Swift Concurrency의 등장 (feat. async-await & Coroutine & Continuation)
[Swift] Swift의 동시성 프로그래밍 (3) | async/await (WWDC 2021)
이번 글에서는 async/await에 대한 WWDC의 세션을 기반으로 async/await가 어떻게 동작하는지 알아보려 합니다!
Meet async/await in Swift (WWDC 2021)
우리가 만약 썸네일을 fetch 하는 과정이 필요하다고 했을 때 아래 그림과 같은 과정을 코드로 구현해야 하겠죠. 이렇게 비동기 작업이 연속적으로 필요할 때 completionHandler를 사용한 코드로 구현했을 때와 async/await를 사용한 코드로 구현했을 때 어떤 점이 다른지 한 번 비교해 봅시다

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()
}
앞서 작성했던 글에서 설명했던 것을 요약하여 보자면,
completionHandler로 완료 혹은 에러 처리를 할 경우 가독성을 포함하여 불편할 점이 많고,
중첩되어 들어가 있는, completion 콜백함수 호출이 필요한 과정임에도 불구하고 호출이 누락되어도 swift는 이 것을 알아채지 못하고 호출을 강제해 줄 수 있는 방법도 없습니다.
func fetchThumbnail(for id: String, completion: @escaping (Result<UIImage, Error>) -> Void) {
let request = thumbnailURLRequest(for: id)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(.failure(FetchError.badID))
} else {
guard let image = UIImage(data: data!) else {
completion(.failure(FetchError.badImage))
return
}
image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
guard let thumbnail = thumbnail else {
completion(.failure(FetchError.badImage))
return
}
completion(.success(thumbnail))
}
}
}
task.resume()
}
결과에 대한 성공/에러 처리를 좀 더 가시적으로 하기 위해 Result 타입을 completionHandler의 파라미터로 받아준다고 하더라도,
completion 콜백함수가 호출되어야 할 곳에서 호출되지 않는 개발자의 실수가 있을 수 있는 것은 Result 타입을 사용하지 않았던 위와 동일하죠!
async/await를 사용했을 때
반면, async/await 문법을 사용하는 것으로 코드를 수정해 보면 가독성을 비롯해 구현 과정이 훨씬 간편하고 개발자의 실수가 없도록 구현할 수 있죠?
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
}
- 썸네일을 fetch 하는 fetchThumnail 함수는 async 키워드를 사용해서 비동기 함수로 만들어졌으며, throws키워드를 통해 에러 발생이 가능한 함수라는 것도 알 수 있다.
- 함수 실행이 성공하면 UIImage 타입이 리턴되고,
- 만약 중간에 에러를 만나면 에러를 throw 한다.
이제부터 fetchThumbnail 함수가 호출됐을 때 이 함수 내부 코드들이 어떻게 작동하는지 자세히 알아보겠습니다.
📍비동기 함수인 URLSession의 data(for:)메서드 실행과 awaitable
completionHandler를 사용했을 때 dataTask(with:)를 사용한 것과 다르게 여기서는 data(for:)를 사용했네요.
- data(for:) 이 메서드는 awaitable 하기 때문에 호출되는 순간 그 작업은 일시정지(suspend)되고, 작업했던 스레드에서는 다른 작업을 할 수 있게 된다고 합니다.
- 그렇다면 비동기 함수의 실행이 awaitable 하다는 것은?
- ⭐️ 작업이suspend 될 수 있다는 것을 뜻하고
- ⭐️ 스레드 제어권이 시스템에 넘어간다는 것을 뜻합니다.
- ⭐️ 하지만, GCD에서 비동기 작업실행 시 스레드 +1을 하고 작업 중이었던 스레드를 block 하는 것과 다르게!! 스레드를 block 하지 않기 때문에(unblocking the thread) 작업 중이었던 스레드에서는 다른 작업이 진행될 수도 있다는 것을 암시하기도 합니다.
여기서 이전 글에서 봤던 GCD와 SwiftConcurrency에서 스레드 관리의 차이점을 다시 한번 짚고 넘어가자면
- GCD를 사용할 때는
- 비동기 task를 실행하면 기존 작업을 suspend 하고 스레드를 +1 생성하며,
- 기존 작업 중이던 스레드는 block 하여 resume 되기 전까지는 다른 작업을 할 수 없는 상태가 된다.
- async/await를 사용하여 비동기를 구현할 때는
- awaitable 한 비동기 함수를 호출하면 기존 작업을 suspend 하는 것은 동일하지만,
- 스레드 제어권을 시스템에 넘기는 것과
- 기존에 작업 중이었던 스레드를 block 하지 않고, 이로 인해 기존 스레드에서도 다른 작업이 진행될 수 있어 좀 더 효율적인 스레드 활용이 가능하다.
다시 data(for:) 함수 설명으로 넘어오자면 세션에서도 data(for:) 함수를 이렇게 묘사합니다.
Like dataTask, this method is also provided by Foundation and is also asynchronous.
But unlike dataTask, the data method is awaitable.
So after it’s called, it suspends itself quickly, unblocking the thread. The thread is then free to do other work.


📍data(for:)메서드 호출 시 앞에 try가 붙는 이유!
data 메서드 앞에 try 키워드가 붙는 이유는 data 메서드가 에러를 던지는 메서드(throws로 정의된 함수)이기 때문이다.
- data 메서드의 실행에서 에러가 나면 이 메서드가 실행된 fetchThumbnail 함수에서 에러를 던지고
- data 메서드의 실행이 성공하면 값을 리턴한다.

그렇담 "completionHandler를 모든 블럭에서 호출한 코드의 예시처럼 어떻게 에러 or 성공 상황에 따라 completion 콜백함수를 호출했는지"에 대한 모든 게 이 try에 담겨 있다고 보면 되겠네요?
에러를 던지던 or 성공해서 값을 리턴하던지 하니까요!
📍data(for:) 메서드 작업(데이터 다운로딩)이 끝났으면, 원래 작업했던 함수로 resume
awaitable 해서 suspend 할 수 있었던 data(for:) 메서드의 작업이 끝났으면, 다시 원래 작업했던 함수로 resume 된다.
data(for:)메서드의 작업에 에러가 나면 fetchThumbnail 함수에서 에러를 던지고 data 메서드의 실행이 성공하면 값을 리턴한다고 했다. completionHandler방식(dataTask 메서드)과 async-await방식(data 메서드) 모두 URLSession에 의해 error와 value가 생성되지만! 후자(async-await)의 방식이 훨씬 심플하다는 것을 다시 한번 깨달을 수 있죠! (the awaitable version is so much simpler. )



📍data(for:) 메서드 실행 이후의 작업들
data메서드가 실행 완료 되어 fetchThumbnail 함수로 resum 된 이후의 작업들을 요약하자면
- 다운로드한 데이터로 UIImage 생성
- → 생성된 이미지로 .thumbnail 비동기 프로퍼티에 접근해서 이미지가 렌더링
- → 그리고 그 값을 fetchThumnail함수의 리턴값으로 리턴할 것입니다.



async/await를 사용했을 때 completionHandler로 처리한 비동기 처리와 다른 점
위에서 async/await를 사용했을 때의 비동기 함수 처리에 대해 알아봤는데 completionHandler 콜백함수로 비동기 함수의 완료 처리를 했을 때와 비교했을 때 개선점은 무엇이 있을까???
- 📍코드의 길이가 짧아지고
- 📍 중첩하지 않아도 된다. ( strait-line code)
- 📍 순서대로 진행되어야 하는 작업이 모두 차례대로 나열될 수 있다. ( 클로저 중첩 코드에서는 어떤 작업이 우선순위인지 파악하기 어렵다 )
- 📍 task들 실행 시 문제가 있을 때 error를 던지던지, value를 리턴하던지 두 가지 중에 하나를 반드시 하도록 Swift가 보장한다. => 조용히 실패할 일은 없다!!
async/await를 사용했을 때 비동기 함수의 스레드 제어권
위에서 async 비동기 함수가 어떻게 동작하는지 봤다면 이제 스레드 제어권을 중심으로 어떻게 동작하는지 살펴볼까요?
비동기 함수가 실행될 때 await 키워드를 작성해 주었는데
이것은 "일시 정지(suspend) -> 재개(resume)" 가 가능하다는 뜻이라고 설명했었죠.
그럼 suspend-> resume 과정에서 스레드 제어권은 어떻게 될까요?
📌 1. 우선은 비동기 함수인 fetchThumbnail가 스레드 제어권 갖고 있다가
📌 2. 비동기 함수 task를 만나면 비동기 함수에 스레드 제어권 넘김
- fetchThumbnail함수는 비동기 함수 task인 data(for: ) 함수에 스레드 제어권을 넘겨준다. ( 스레드 제어 포기)
- 그리고, 이 함수에 await키워드가 있다는 것은 나중에 실행됐을 때 awaitable 한 작업이라는 것을 인지하게 하고 suspend 할 수 있도록 하는 것이죠

📌 3. 비동기 함수가 실행되면서 await로 비동기 함수가 일시 정지 suspending 되면 시스템에 제어권 넘긴다.
- -> 비동기 함수도 스레드 제어를 포기
- 시스템에 제어권 넘어가면 우리가 정의한 fetchThumbnail함수도 suspend되게 된다.

📌4. 이때, 시스템은 자유롭게 판단할 수 있는데, 일단 시스템이 비동기함수가 가장 중요한 작업이라고 판단하면 비동기 함수를 다시 재개(resume)함 → 이 비동기 함수는 값이나 에러를 리턴함
- ⭐️suspending 된다는 것은 함수가 시스템에 스레드 제어권을 넘기고 "할 일이 많다는 것을 알고 있으니 시스템 네가 무엇이 가장 중요한지 결정해라”라고 하는 것인데요
- 시스템은 자유롭게 스레드를 사용하여 다른 작업을 수행할 수 있습니다.
- 즉, 이 시점에서 시스템의 판단이 중요한데! 시스템에서 "수행해야 할 가장 중요한 작업이 이전에 일시 중지된 비동기 기능을 계속 실행하는 것"이라고 판단한다면, 비동기 함수를 재개(resume)하게 되고
- -> 비동기 함수가 다시 스레드 제어권을 갖게 될 것이고, 그럼 해당 비동기 함수 작업을 계속 진행할 수 있는 것입니다.

📌 5.비동기함수가 종료되며 값이나 오류를 반환하고 스레드 제어를 fetchThumbnail함수에 다시 넘겨줍니다.

Suspending상태는 뭘 의미하고 await키워드가 필요한 이유
계속 비동기 함수의 일시정지.. suspend... suspend... 라는 내용을 반복하고 있는데
이게 뭘 의미하는 걸까?
⭐️함수가 suspend 된다는 것은
- 시스템에 스레드 제어권을 넘기고 "할 일이 많다는 것을 알고 있으니 무엇이 가장 중요한지 결정해라, 그리고 작업들의 일정을 스케줄링해라”라고 하는 것
- 즉, "다른 작업이 먼저 일어날 수도 있다"를 전제하는 것 ( suspend 된 작업이 바로 resume 되지 않을 수도 있다 )
- 예를 들어, fetchThumbnail이 호출된 후 사용자가 일부 데이터를 업로드하는 버튼을 탭 한다고 가정했을 때 post 하는 반응을 해야 하는데
- 시스템은 이전에 대기열에 추가된 작업(data(for:)) 전에 사용자의 반응을 게시하기 위해 작업(urgentlyLikePost(id:))을 자유롭게 실행할 수 있다.

==> 즉 "함수가 일시중지되면 (중간에 많은 작업이 일어날 수도 있기 때문에) 앱의 상태가 크게 바뀔 수도 있다는 점을 인지하고 있어야 한다. "는 점이 핵심입니다.
⭐️await키워드가 필요한 이유! 도 이 suspending과 연결해서 생각할 수 있는데
- ⭐️위에서 설명한 것처럼 awaitable한 비동기함수가 suspend된 동안 다른 작업을 수행할 수 있는데, 이러한 사실이 Swift가 비동기 호출을 await 키워드로 표시하도록 주장하는 이유입니다!
- 함수가 suspend 되면 다른 작업을 수행하여 앱의 상태가 크게 바뀔 수도 있는데, 이 것을 await키워드를 통해 나타내줄 수 있기 때문이죠!
- 코드 블록이 하나의 트랜잭션(작업단위)으로 실행되지 않는다는 것을 알 수 있는 키워드이기도 합니다.
async-await에서 주의해야 할 점
- 함수를 비동기(async)로 표시하면 해당 함수가 일시 중지(suspend)되는 것을 허용하는 것입니다. 그리고 함수가 자신을 일시 중지하면 호출자도 일시 중지됩니다. 따라서 호출자(caller)도 비동기여야 합니다. ( data(for:)라는 비동기 함수를 호출하는 함수 fetchThumbnail 함수도 비동기 )
- 비동기 함수에서 한 번 또는 여러 번 일시 중단될 수 있는 위치를 지적하기 위해 await 키워드가 사용된다.
- 비동기 기능이 일시 중단되는 동안 스레드는⭐️ block되지않습니다⭐️block 되지 않습니다⭐.따라서 시스템이 스레드 제어권을 갖게 되었을 때 다른 작업을 같은 스레드에 자유롭게 스케줄링할 수 있습니다. 나중에 시작되는 작업이라도 먼저 실행할 수 있습니다. -> 이는 기능이 일시 중지된 동안 앱 상태가 크게 변경될 수 있음을 의미
- 마지막으로 비동기 함수(ex. data(for : ))가resume 되면호출한 비동기 함수에서 반환된 결과가 원래 함수로 다시 유입되고 중단된 부분부터 실행이 계속된다.
⭐️ 이렇게 suspend이후에 동일한 스레드에서 다른 작업들을 실행하다가 다시 작업을 resume 할 수 있는 것은 앞 선 글에서 알아본 continuation이라는 비동기 함수 컨텍스트를 추적할 수 있는 객체 덕분!!입니다.
⭐️ 그리고 suspend 될 수 있는 것은 비동기 함수들이 모두 Coroutine으로 만들어졌기 때문이겠죠!
이번에 동시성 프로그래밍에 대해 글을 작성하면서 GCD와 Swift Concurrency 그리고, completionHandler를 활용한 비동기 처리 방식과 async-await를 활용한 비동기 처리 방식들에 대해 알아보았는데,
동시성 프로그래밍에 대해 또 궁금한 점이 생기면 이어서 작성해 보겠습니다 :)
'Swift' 카테고리의 다른 글
[Swift] 프로토콜 뽀개볼까 (2) | Comparable (1) | 2024.04.27 |
---|---|
[Swift] 프로토콜 뽀개볼까 (1) | Equatable & ==, === 차이 (1) | 2024.04.21 |
[Swift] Swift의 동시성 프로그래밍 (2) | Swift Concurrency의 등장 (feat. async-await & Coroutine & Continuation) (0) | 2024.03.24 |
[Swift] Swift의 동시성 프로그래밍 (1) | GCD (0) | 2024.03.17 |
SwiftGen 사용기 | font, color, asset에 적용 (0) | 2024.03.03 |