[iOS][WWDC] Meet Async/await in Swift

Hyunndy·2023년 2월 12일
0

iOS-Concurrency

목록 보기
2/4

🐸

Async/await를 블로그 글과 예제로만 학습하고 정작 WWDC는 안봤어서 정리하는김에 WWDC도 정리합니다.


UIKit은 UIImage에서 썸네일을 형성하는 기능을 제공합니다.

// UIImage
/// 동기 방식
func preparingThumbnail(of size: CGSize) -> UIImage?
/// 비동기 방식
func prepareThumbnail(of size: CGSize, completionHandelr: @escaping (UIImage?) -> Void)

(WWDC의 설명은 기본적으로 Synchronous = (Synchronous + Blocking)방식으로 표현한다.)

동기(sync) 방식은 A함수를 호출하면 A함수가 완료될 때 까지 쓰레드가 차단됩니다.
비동기(async) 방식은 A함수를 호출하면 작업을 시작한 후 스레드가 신속하게 차단 해제 되어 A함수와 상관없이 다른 작업을 수행할 수 있습니다.

FetchThumbnail() 이라는 함수에서
UIImage의 동기 방식인 preparingThumbnail() 을 호출한다면 이 함수가 완료될 때 까지 이 쓰레드는 다른 작업을 할 수 없다.

하지만 비동기 방식인 prepareThumbnail(completionHandler)를 호출한다면 이 함수가 완료되던말던 이 쓰레드는 자유롭게 다른 작업을 수행할 수 있다.
완료되면 completionHandler로 알려준다.

많은 SDK가 이와같은 비동기 방식의 함수를 제공한다.
작업 완료를 받는것도 여러 방법이 있다.

  • CompletionHandler (@escaping 클로저)
  • Delegate CallBacks

서버에서 이미지를 받아와 썸네일을 세팅하는 Flow입니다.

  1. ViewModel의 thumbnailURLRequest 함수는 URLRequest를 생성합니다.
  2. URLSession의 dataTask 메서드는 해당 요청에 대한 데이터를 가져온다.
  3. 받아온 데이터로 UIImage를 생성한다.
  4. UIImage가 preapareThumbnail 함수를 호출해 원본 이미지에서 썸네일을 로드해서 렌더링 합니다.

이 작업들은 모두 순서대로 수행되어야 한다.

1번/3번은 매우 빠르게 수행될 수 있기 때문에 Sync식으로 구현될 수 있지만,
2번/4번은 시간이 걸리는 작업이기 때문에 Async방식으로 구현해야 합니다.

위 Flow를 CompletionHandler 방식으로 구현하면 이렇습니다.

    func fetchThumbnail(for id: String, completion: @escaping (UIImage?, Error?) -> Void) {
        
        // MARK: 1번
        let request = URLRequest(url: URL(string: id)!)
        // MARK: 2번
        let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
            if let error {
                completion(nil, error)
            } else if (response as? HTTPURLResponse)?.statusCode != 200 {
                completion(nil, HyunndyError.badId)
            } else {
                // MARK: 3번
                guard let image = UIImage(data: data!) else {
                    completion(nil, HyunndyError.noImage)
                    return
                }
                
                // MARK: 4번
                image.prepareThumbnail(of: CGSize(width: 40.0, height: 40.0), completionHandler: { thumbnail in
                    guard let thumbnail else {
                        completion(nil, HyunndyError.noImage)
                        return
                    }
                    
                    completion(thumbnail, nil)
                })
            }
        })
        
        task.resume()
    }

이 복잡한 로직속에..
우리가 모든 경우(성공 or 실패)에 completionHandler를 호출해주지 않으면,
우리의 호출자에게 반환 결과가 영원히 가지 않을 겁니다.

기본적으로 Swift는 함수를 통해 실행이 어떻게 진행되든 값이 반환되지 않으면 오류가 발생하게 합니다.

하지만!🤦‍♀️
Async한 호출 + CompletionHandler를 쓰는 이 방식에서는 Swift의 일반적인 오류 처리 방법을 사용할 수 없습니다.

왜?
Swift에게 이 CompletionHandler는 그냥 클로저일 뿐입니다.
따라서 우리는 항상 호출되는지 확인하고 싶지만, Swift에서는 이걸 강제할 방법이 없습니다.
그래서 만약에 저 코드의 guard문에서 completion을 빼먹어도 컴파일 오류가 나질 않습니다 😂 😂 😂 😂

즉, async한 함수 + completionHandler의 사용은 휴먼 에러를 발생시킬 확률이 높습니다.

👬

그래서 발표자와 그의 동료는 저 위의 코드를 좀 더 안전하게 바꿔보기로 합니다.

1️⃣ Result 타입으로 바꿔본다.
-> 안전하긴 하지만 코드를 좀 더 추하고 길게 만듭니다.

    func fetchThumbnail(for id: String, completion: @escaping (Result<UIImage, Error>) -> Void) {
        
        // MARK: 1번
        let request = URLRequest(url: URL(string: id)!)
        // MARK: 2번
        let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
            if let error {
                completion(.failure(error))
            } else if (response as? HTTPURLResponse)?.statusCode != 200 {
                    completion(.failure(HyunndyError.badId))
            } else {
                // MARK: 3번
                guard let image = UIImage(data: data!) else {
                    completion(.failure(HyunndyError.noImage))
                    return
                }
                
                // MARK: 4번
                image.prepareThumbnail(of: CGSize(width: 40.0, height: 40.0), completionHandler: { thumbnail in
                    guard let thumbnail else {
                        completion(.failure(HyunndyError.noImage))
                        return
                    }
                    
                    completion(.success(thumbnail))
                })
            }
        })
        
        task.resume()
    }

하...이게 최선일까?
골머리를 앓던...두 개발자에게 Async/await가 내려옵니다.


Async/await

두 개발자는 Async/await을 이용해 다시 코드를 짜봅니다.

    func fetchThumbnail(for id: String) async throws -> UIImage {
    	// MARK: 1번
        let request = URLRequest(url: URL(string: id)!)
        // MARK: 2번
        let (data, response) = try await URLSession.shared.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw HyunndyError.badId }
        // MARK: 3번
        let maybeImage = UIImage(data: data)
        // MARK: 4번
        guard let thumbnail = await maybeImage?.byPreparingThumbnail(ofSize: CGSize(width: 40.0, height: 40.0)) else { throw HyunndyError.noImage }
        return thumbnail
    }

오마이갓!!!! 😱
코드가 대폭 줄었습니다...
어떻게 된 코드인지 뜯어보겠습니다.

func fetchThumbnail(for id: String) async throws -> UIImage { }

Async/await을 도입한다더니 선언부에 async throws 라는 키워드가 붙었습니다.

async (이 함수는 비동기) + throws (에러를 방출) 한다는 뜻이 겠네요.

함수에 async 표시는 다음 위치에 배치 합니다.

  • throws 앞
  • thorws가 없다면 반환형 -> 앞

CompletionHandler 방식과 동일하게 Sync 방식인 1,3번은 냅두고 2,4번을 보겠습니다.

let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
	throw HyunndyError.badId
}

URLSession의 data(for: ) 함수를 호출할 때 try await 키워드를 붙이네요.
함수 원형은 이렇습니다.

public func data(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)

async throws가 붙어있습니다.

🐸 오 그렇다면..!

async 키워드가 붙은 함수를 호출할 때는 await 키워드가 필요하고,
throws 키워드도 같이 붙은 함수를 호출 할 때는 try 키워드도 붙여줘야 하는군요.

💡
식에 여러 개의 async 함수 호출이 있는 경우 "await"을 한 번만 작성하면 됩니다.
여러 throws 함수 호출이 있는 경우 "try"가 한 번 필요한 것과 같습니다.

CompletionHandler 방식에서 썻던 URLSession.dataTask(with: ) 함수와 달리 URLSession.data(for: ) 함수는 awaitable 합니다.

CompletionHandler 방식에서는 모든 오류에 개발자가 명시적으로 CompletionHandler 클로저를 호출해줬어야 했습니다.
하지만!
awaitable 방식에서는 모든 에러 코드가 try 키워드로 압축되는 것 입니다.

다음 4번을 볼까요?

guard let thumbnail = await maybeImage?.byPreparingThumbnail(ofSize: CGSize(width: 40.0, height: 40.0)) else {
	throw HyunndyError.noImage
}

byPreparingThumbnail()이 비동기 함수고, await 키워드만 붙어있네요.
그럼 원형에 try가 없고 error를 반환하지 않겠네요.
그래서 반환받은 UIImage가 옵셔널 체이닝되지 않으면 Error를 throw 해줍니다.

open func byPreparingThumbnail(ofSize size: CGSize) async -> UIImage?

역시 원형에 async만 표시되어있습니다.

💡 정리
async 키워드
-> 이 함수가 비동기(async) 방식이다.
await 키워드
-> async 함수 호출 후 스레드를 차단 해제 할 수 있는 위치를 나타내는 키워드
-> (밑에서 추가 설명) async 함수가 그곳에서 일시 중단(suspend) 될 수 있음을 나타내는 키워드

🐸 정리

CompletionHandler를 쓰던 방식과 비교해보면..

1️⃣
CompletionHandler

  • async의 결과를 CompletionHandler 클로저 안에서 Result형으로 받아 처리

Async/awit

  • (try await / await) 으로 async의 결과를 받아 처리

2️⃣
CompletionHandler

  • 성공/실패 모든 경우에 개발자가 명시적으로 CompletionHandler 호출 필수
  • CompletionHandler도 결국 Swift에게 클로저이기 때문에, 호출을 강제할 수 없음

Async/await

  • 값을 return하지 않거나, throws 하지 않는 경우 컴파일 에러 발생

Async/await을 쓰려면..

1️⃣
이 함수가 비동기 방식이다.
-> async 키워드를 붙인다.

에러 처리 까지 하고싶다.
-> async 뒤에 throws를 붙인다.

2️⃣
async 키워드가 달려있는 함수를 호출하고 싶다.
-> await 키워드를 사용한다.

async 키워드가 달려있는 함수를 여러개 호출하고 싶다.
-> await 블록을 사용한다.

async 함수가 Error을 throws 한다.
-> try await으로 호출한다.


Async Properties

💡
함수만이 async 키워드를 가질 수 있는것은 아닙니다.
프로퍼티, 이니셜라이저도 비동기가 될 수 있습니다.

Async Property의 중요 특징

1️⃣ 명시적 getter
getter에 async / async throws를 명시한다.
그럼 getter에서 비동기 함수를 호출할 수 있다.

2️⃣ Only read-only propery can be async
읽기 전용 프로퍼티만 async property가 될 수 있다.

예시

위에 연습했던 코드 중 썸네일을 async property로 변경해봅니다.

UIImage의 extension으로 thumbnail을 받을 수 있게 작성합니다.
이 때, 사용되는 byPreparingThumbnail(ofSize: ) 함수는 async 함수 입니다.

extension UIImage {
    var thumbnail: UIImage? {
        get async {
            let size = CGSize(width: 40.0, height: 40.0)
            return await self.byPreparingThumbnail(ofSize: size)
        }
    }
}

그럼 위 Flow를 다른 방식으로 쓸 수 있습니다.

    func fetchThumbnail(for id: String) async throws -> UIImage {
        let request = URLRequest(url: URL(string: id)!)
        let (data, response) = try await URLSession.shared.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw HyunndyError.badId }
        let maybeImage = UIImage(data: data)
        guard let thumbnail = await maybeImage?.thumbnail else { throw HyunndyError.noImage }
        return thumbnail
    }

Async sequences

비동기 시퀀스를 반복하는 in for 루프 에서도 사용할 수 있습니다.

for await id in staticImageIDsURL.lines {
    let thumbnail = await fetchThumbail(for: id)
    collage.add(thumbnail)
}

let result = await collage.draw()

함수가 Async Sequence를 계속해서 반복함에 따라 다음 요소를 기다리는 동안 스레드 차단을 해제한 다음 다음 요소를 루프 본문으로 다시 시작하거나 남아있지 않은 경우 루프 후에 재개할 수 있습니다.

WWDC2021 AsyncSequence
세션을 시청하랍니다.

그리고 많은 비동기 작업을 병렬로 실행하는 데 관심이 있다면,
WWDC2021 Structed concurrency
요것도 시청하랍니다!

다음 정리할 세션들이 정해졌군요ㅎㅎ


await

위에서
async 키워드: 이 함수가 비동기로 동작합니다.
await 키워드: 비동기 함수가 실행된 뒤 스레드를 차단 해제 시키는 위치를 나타낸다.
라고 정리했는데요.

await 키워드를 사용할 수 있는 곳은 많이 있습니다.

💡
await 키워드는 비동기 함수가 그곳에서 일시 중단(suspend) 될 수 있음을 나타내는 말이기도 합니다.

그렇다면

비동기(Async) 함수가 일시 중단(suspend) 된다는 것은 무엇을 의미합니까?

함수를 호출할 때 어떤일이 발생하는지 알아봅시다.
A함수에서 B함수를 호출하면, A함수에서 실행 중인 스레드의 제어권을 호출된 B함수로 넘깁니다.

B가 일반 Sync 함수 였다면..

여기서 B가 일반(Sync) 함수였다면, 스레드는 함수가 완료될 때 까지 해당 함수 대신 작업을 수행하는데 사용됩니다.

여기서의 "작업"은 함수 자체의 본문에 있을 수도 있고, 함수 내부에서 호출하는 다른 함수에 있을 수도 있습니다.

결국 함수는 값을 반환하거나, 오류를 발생시켜 완료됩니다.

🐸 왜냐 sync는 작업을 실행하기 전 다른 작업의 완료를 기다리고 작업을 실행하는 방식이기 때문입니다.

Sync 함수가 스레드 제어를 포기할 수 있는 유일한 방법은 완료입니다.
그리고 이 함수가 스레드 제어권을 줄 수 있는 유일한 only one 입니다.

만약 B함수가 비동기(async) 함수 였다면 어땠을까요?
일반 함수와 마찬가지로 작업이 완료되면 A함수로 스레드 제어권을 반환합니다.
하지만 일반 함수와 달리, 일시 중단(Suspending) 이라는 방식으로 스레드 제어를 포기할 수 있습니다.

비동기 함수가 호출되면, 일반 함수 처럼 스레드에 대한 제어권이 부여되긴 합니다.
하지만 비동기(async) 함수이기 때문에 일시 중단 (suspend)될 수 있습니다.

그렇게되면 B함수가 스레드 제어권을 포기하는데,
A함수에게 반환하는게 아닌 System에게 스레드 제어권을 줍니다.
만약 그렇게 되면 A함수도 일시 중지 됩니다.

비동기 함수가 일시 중단된다는건..

B함수💻: 시스템아. 너 바쁜거 알아. 뭐가 제일 중요하니? 너가 판단해서 자유롭게 쓰레드 써서 제일 중요한거 해

라고 말하는 것과 같습니다.
시스템이 일을 쳐내다보면, 언젠가 일시 중단된 B함수의 차례가 돌아올 것 입니다.

시간이 지나면 재개(resume) 할 수도 있고, 다시 정지할 수도 있고, 여러번 할 수 있습니다.

하지만?
async 함수라고 반드시 일시 중단된다는 의미는 아닙니다.

예시

위의 fetchThumbnail() 함수로 예시를 들어봅시다.

    func fetchThumbnail(for id: String) async throws -> UIImage {
    	// MARK: 1번
        let request = URLRequest(url: URL(string: id)!)
        // MARK: 2번
        let (data, response) = try await URLSession.shared.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw HyunndyError.badId }
        // MARK: 3번
        let maybeImage = UIImage(data: data)
        // MARK: 4번
        guard let thumbnail = await maybeImage?.byPreparingThumbnail(ofSize: CGSize(width: 40.0, height: 40.0)) else { throw HyunndyError.noImage }
        return thumbnail
    }

data() 함수가 비동기 함수이고, 이게 suspend 된다고 가정해봅시다.
suspend 된 시점에 스레드 제어권은 시스템이 갖고 있으며, fetchThumbnail() 함수도 suspend 되었습니다.
근데 어떤 경우에 이게 일시중단 되는걸까요?

fetchThumbnail()이 실행되고 있는 동안,
사용자가 어떤 게시물에 좋아요 버튼을 계속 누른다고 합시다.

그럼 시스템은 대기 중인 data() 대신 사용자의 반응에 대한 작업을 먼저 수행시킬 수도 있습니다.

이런식으로 System은 작업의 순서를 계속 조정할 수 있습니다.

함수가 일시 중단된 동안 다른 작업을 수행할 수 있다는 사실 때문에 Swift는 await키워드로 비동기 호출을 표시해야 한다고 합니다.

함수가 일시 중단되면, 앱의 상태가 극적으로 변할 수 있음을 알고있어야 합니다.
함수가 완전히 다른 스레드에서 재개될 수도 있습니다.

-> 이것에 대해 더 알아보고싶다면, Protect mutable state with swift actors
를 봅시다.
ㅠㅠㅠ 왤케 많아..

여기까지 봤을 때 디테일한 내용은 다른 세션을 더 보아야겠지만,

비동기(Async) 함수가 일시 중단(suspend) 된다는 것은 무엇을 의미합니까?
-> 🐸 시스템이 스레드 제어권을 갖고 현재 함수를 일시 중단 시키고 다른 작업을 수행하고 올 수 있다는 것을 의미합니다. 여러번이 될 수도 있고, 한 번도 안할수도 있습니다.


Async/await facts

다음으로 async/await에 대해 기억해야 할 몇 가지 중요한 사항입니다.

1️⃣ async enables a function to suspend

함수를 async 표기 하면 일시 중단 될 수 있습니다.
함수가 자기 자신을 중단하면, 호출자도 일시 중단됩니다.
그렇기때문에!! 호출자도 비동기여야 합니다.

2️⃣ await marks where an async function may suspend execution

비동기 함수에서 한 번 또는 여러번 일시 중단 될 수 있는 위치를 지정하기 위해 await 키워드를 사용합니다.

3️⃣ Other work can happen during a suspension

비동기 함수가 일시 중단 되는 동안 스레드가 차단 되지 않습니다. 즉 시스템은 다른 작업을 신나게 실행할 수 있습니다! 앱의 상태가 아주 극적으로 변할 수도 있구요. 다른 스레드로 바뀔 수도 있습니다.

4️⃣ Once an awaited async call completes, execution resumes after the await

비동기 함수가 일시 중단 되었다가 다시 시작되거나, 비동기 함수로 인한 결과를 받으면
호출한 비동기 함수에서 반환된 결과가 원래 함수로 다시 흐르고 중단된 시점부터 실행이 계속 됩니다.


여기까지가 이론이고,
실제 코드로 Async/await로 변환하는 작업 예시를 봅시다.

Bridging from sync to async


SwiftUI의 ThumbnailView이고, 위 예시의 fetchThumbnail()이 CompletionHandler 방식으로 쓰였습니다.

이를 Async/await을 사용한 fetchThumbnail()로 바꾸려면...
CompeltionHandler를 지우고,
let image = try? await self.viewModel.fetchThumbnail(for: post.id)
self.image = image
요런식으로 가야겠죠?

하지만...!!!! 컴파일러는 여기서 에러를 발생시킵니다.

비동기가 아닌 컨텍스트에서는 비동기 함수를 호출할 수 없다고 합니다 😂

비동기 함수가 불리는 onAppear 클저는 일반 비동기 클로저이므로...
동기 <-> 비동기 세계 사이의 Bridge가 필요합니다.

바로

Async Task Function

입니다.

Async Task는 클로저에서 작업을 패키지화 하고, 사용 가능한 다음 쓰레드에서 즉시 실행하기 위해 시스템으로 보냅니다. 마치 Global Dispatch Qeue의 async function 처럼요!
중요한 점은, 동기화(sync) 컨텍스트 내부에서 비동기 코드를 호출할 수 있다는 것 입니다.
요렇게요.

Async Task Function에 대해 더 알려면,
Structed Concurrency in swift
를 시청하세요!

API를 async/await로 변환하는법을 자세히 보려면,
use async/await with urlsession
을 신청하세요!


[이 파트는 이해 못했습니다.] Async alternatives and continuations

Async alternative란..
Swift에서는 API를 사용하는 모든 부분을 async/await으로 변환하고, 레거시를 남기지 말라고 합니다.
그리고 마이그레이션하기 쉽도록 애플에서는 상당수의 API를 async/await로 변환해줬는데,
아직 변환되지 않은 애들이 있습니다.
이걸 남겨두느냐..? 아닙니다. wrapper 형태로 async/await를 구현해 밖에서는 아직 구식의 비동기처리를 한다는걸 모르게끔 합니다ㅎㅎ


CoreData 저장소에 보관된 모든 게시물을 검색하는 함수 입니다.
요게 아직 async/await 변환이 안되었기 때문에, 사용자가 직접! 만들어보도록 하겠습니다.


persistentPosts()라는 async/await wrapper를 만들었는데,
이 다음부터 막혔습니다.

이론적으로 보면

getPersistentPosts에서 CoreData에 스레드 제어권을 주고 기다리고 있는건데,
이건 아까 봤던 비동기 함수가 suspend되서 System에 스레드 제어권을 준 그림과 같습니다.

그럼 여기선 빠진게 뭐냐?
CompletionHandler이 돌아오고(await), 그걸 resume하는것 뿐입니다.

요! suspend, resume, suspend, resume 하는 패턴을 continuation(연속성) 이라고 합니다.

다시 예제로 돌아와서 Continuation을 구현해봅시다.

withCheckedThrowingContinuation 함수는 Error을 갖고 있는 CompletionHandler를 async 함수까지 끌어옵니다.
Error가 없는 CompletionHandler는 withCheckedContinuation 함수를 쓰면 됩니다.

이 두 함수는, 일시 중단(suspend)된 비동기 함수를 resume하는데 사용할 수 있는 연속성을 얻게 해줍니다.

await + Continuation을 씀으로써, 우리는 getPersistentPosts 함수에 대한 호출을 awaitable 할 수 있게 되었습니다.

continuation은 추가로 resume 할 수 있는 기능도 제공합니다!

continuation은.. 비동기 함수의 실행을 수동으로 제어할 수 있는 강력한 방법을 제공하지만, 몇 가지 주의 사항이 있습니다.

  • resume은 모든 경로에서 정확히 한 번만 호출해야 합니다.

느낀점

Swift Async/await의 초석이 되는 세션이었습니다.
봐야할 내용이 너무많네요

WWDC2021 AsyncSequence

WWDC2021 Structed concurrency

Protect mutable state with swift actors

use async/await with urlsession

관련된 세션들도 봐야하니..ㅠㅠ 차차 정리해보겠습니다.

일단 CompletionHandler로 처리하던 비동기 작업들을 직관적으로 처리할 수 있어서 좋은데,
Task Function에 대한 정리가 필요합니다.

왜냐면 어쨋든 await 함수를 호출하려면 Task를 호출해야 하니깐요..

연속성에 관해서는... wrapper라는것 까지 이해가 갔는데, 그 이후 내용은 솔직히 들으면서도 이해가안되서 다 적진 않았습니다.

profile
https://hyunndyblog.tistory.com/163 티스토리에서 이사 중

0개의 댓글