[WWDC 2021] Meet Async/Await in Swift

Wongbing·2023년 2월 17일
0

WWDC

목록 보기
1/6
post-thumbnail
tags: 교육자료

시작

Async/Await 개념을 학습하는 이유

CompletionHandler 를 계속 사용하다 보면 @escaping 클로저를 연속적으로 사용해야 할 때가 있습니다. 예를들면 HTTP GET 요청의 response로 deleteKey 값을 받아와 그 deleteKey 값으로 HTTP DELETE 요청을 할 때 가 있는데요, 문맥을 두번 들여쓰기를 해야하고 이 연속요청이 많으면 많을수록 마치 굼벵이 주름같은 들여쓰기 코드가 생깁니다. 가독성에 악영향을 미치죠. 이를 해결할 방법이 없을까요? 요청의 결과값을 반환할 수는 없을까 많은 궁금증을 가졌었습니다.


문제해결 예시

@escaping 클로저를 사용하는 기본함수 예시

  • UIKit의 기본적인 메서드인 preparingThumbnail(of:) 사용하면, 그 함수가 끝날 때 까지 thread 가 block 되어 아무것도 할 수 없게 됩니다.
  • 반대로, completionHandler가 있는 비동기 함수인 prepareThumbnail(of: completionHandler:) 를 사용하면 thread 가 다른 일을 할 수 있게 됩니다. 이 함수가 끝나면 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 {
                return
            }
            image.prepareThumbnail (of: CGSize (width: 40, height: 40)) { thumbnail in
                guard let thumbnail = thumbnail else {
                    return
                }
                completion (thumbnail, nil)
            }
        }
    }
    task.resume()
}

completion 이 3개가 사용되고 있네요! 이러면 완벽한 메서드가 되었을까요?
자세히 보면 guard 문에서 생긴 에러 처리를 해주지 않고 있어요.

▼ guard 문에 대한 completion 2개를 추가하면 총 5개의 completion 이 사용 될 것입니다.

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()
}

이렇게 하면 오류처리가 완벽하게 된 것으로 보입니다. 하지만, 우리는 Swift의 기본적인 에러 핸들링 메커니즘을 사용할 수 없게 됩니다. 문제 안에서 에러를 던질 수 없다는 것입니다.

두개의 guard문을 처리해주지 않고도 컴파일 에러가 나지 않은 이유는 swift가 우리의 작업을 확인할 수 없기 때문입니다(컴파일 단계에서 에러를 잡아낼 수 없다는 뜻)

▼ ResultType을 써서 좀 더 안전하게 처리해줄 수도 있습니다. 하지만 가독성에 악영향을 미치죠..

func fetchThumbnail(for id: String, completion: @escaping (Result<UlImage, 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 ()
}



Async/await 도입으로 해결

  • thumbnailURLRequest(for:)
  • dataTask(with:)
  • UIImage(data:)
  • prepareThumbnail(of:)

▼ 위 네가지 스텝을 async/await 을 사용하여 구현 해보겠습니다.

func fetchThumbnail(for id: String) async throws -> UIImage {
    let request = thumbnailURLRequest(for: id) // 1.동기함수이기 때문에 thread block
    let (data, response) = try await URLSession.shared.data(for: request) // 2
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badID }
    let maybeImage = UIImage(data: data) // 3 동기함수이기 때문에 thread block
    guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage } //4
    return thumbnail
}
  • 위처럼 async/await 함수임을 나타내는 키워드 async와 에러를 던질 수 있음을 나타내는 키워드 throws가 있습니다.
  1. thumbnailURLRequest(for:) 메서드는 동기함수 이므로 thread block이 됩니다.
  2. data(for:) 메서드는 dataTask()와 마찬가지로 Foundation에서 제공되는 비동기 함수입니다. 하지만 기존의 dataTask와는 달리 await 키워드를 통해 기다려줄 수 있습니다. 이 메서드는 스스로 중지시켜서 thread unblock을 합니다. 다른 작업들이 실행될 수 있는 것이죠. 이전에 dataTask 를 통해 생긴 에러를 모두 completion에 보내주었던 것 기억 하시나요? 여기선 try 키워드 하나로 처리를 해주게 됩니다 !! 데이터 다운로드가 끝나면 data(for:)메서드가 재개(resume) 되고 fetchThumbnail로 돌아옵니다. 이때 가져오는 에러는 이 함수에서 처리됩니다.
  3. 동기함수 이므로 thread block이 됩니다.
  4. thumbnail 프로퍼티가 재개되고 fetchThumbnail로 돌아올 때 까지 다른 작업들이 실행될 수 있습니다.

completionHandler를 쓰던 이전버전과는 반대로, 여기서 thumbnail이 받아와지지 않으면 에러를 던져주거나 값을 반환해주어야 합니다. 그러지 않으면 컴파일 에러가 날 것입니다.(스위프트가 우리의 작업을 확인해줄 수 있다는 것입니다.)

이게 다입니다. 20줄 가량의 코드를 고작 6줄로 바꿨습니다. 이렇게 변환하게 되면 코드도 줄고 에러도 안전하게 처리할 수 있을 것입니다.

▼ 함수 뿐만 아니라 프로퍼티 또한 async 키워드를 사용할 수 있습니다. thumbnail 프로퍼티의 구현부 입니다

extension UIImage {
    var thumbnail: UIImage? {
        get async {
            let size = CGSize(width: 40, height: 40)
            return await self.byPreparingThumbnail(ofSize: size)
        }
    }
}
  • 오직 읽기전용 프로퍼티에만 async 키워드를 사용할 수 있습니다. (swift 5.5 이후부터 프로퍼티의 getter 또한 throw를 사용할 수 있습니다.)

▼ async는 for-in loop 에서도 사용할 수 있습니다.

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



설명

async 함수가 suspend 한다는 것은 무엇을 뜻할까요 ? [15:20]

보통의 함수가 실행하고 끝나는 모습입니다. 함수가 끝나면 쓰레드 제어권을 포기하게 됩니다.
async 함수도 마찬가지로 실행이 끝나면 다시 상위 함수에게 제어권을 줍니다. 다만, 쓰레드 포기하는 방법이 전혀 다릅니다. 바로 suspending 입니다.

async 함수는 내부가 실행되고, await 할 수 있습니다. 이때 다시 함수에게 쓰레드 제어권이 가는 것이 아닌 system으로 갑니다. 이렇게 되면 상위 함수도 중지가 됩니다.
suspending 은 함수가 시스템에게 "할 일이 많은 것을 알아. 가장 중요한 일을 결정해!" 라고 알리는 방법입니다.
함수가 한번 중지되면 시스템은 다른 작업을 하는데에 해당 쓰레드를 사용할 수 있게 됩니다.
resume이 되면 suspend는 끝나고 다시 상위 함수로 돌아오게 됩니다.

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 { throws FetchError.badID}
    let maybeImage = UIImage(data: data)
    guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage}
    return thumbnail
}

위 fetchThumbnail 에서 data 메서드 에서 중지되는 동안 데이터를 업로드 하는 어떤 버튼이 눌렸다고 가정해봅시다. 이때는 이전에 큐에 들어가있던 작업보다 이 버튼작업이 먼저 실행됩니다. 버튼 작업이 끝나고 나서야 data 메서드가 resume 되거나, 다른작업을 실행합니다. data 메서드가 한번 끝나면 다시 fetchThumbnail 함수로 돌아옵니다.
이는 async 함수가 중지되어있는 동안 다른 작업(버튼 탭)이 실행될 수 있고, 그래서 await 키워드를 붙여주는 것이라고 할 수 있겠습니다.

async 함수는 suspend 된 동안 다른 작업이 실행될 수 있기 때문에 함수가 다른 스레드에서 실행될 수도 있습니다. 해당 이슈는 Protect mutable state with Swift actors 여기서 확인할 수 있습니다.


Async/await facts

  • async 키워드를 다는 것은 중지를 허용하는 것입니다.
  • 함수를 중지한다면 그 함수의 호출자도 중지됩니다. 그래서 똑같이 호출자도 async 키워드를 써야만 합니다.
  • 중지될 수 있는 async 함수의 앞에 await 키워드를 사용합니다.
  • await 키워드가 무조건 중지한다는 뜻은 아닙니다
  • async 함수가 중지되면, 쓰레드가 block 되지 않습니다. 그래서 system은 다른 작업들을 스케쥴링 할 수 있게 됩니다. (나중으로 미뤄진 작업이 첫번째로 실행될 수도 있습니다.)
  • async 함수 호출이 한번 완료되면 await 이후에 실행이 재개됩니다.



Adopting async/await [21:00]

테스트에 적용 해보기

class MockViewModelSpec: XCTestCase {
    func testFetchThumbnails() throws {
        let expectation = XCTestExpectation(description: "mock thumbnails completion")
        self.mockViewModel.fetchThumbnail(for: mockID) { result, error in
            XCTAssertEaqual(result?.size, CGSize(width: 40, height: 40))
            expectation.fulfill()
        }
        wait(for: [expectation], timeout: 5.0)
    }
}

▼ 기본 completionHandler 를 사용하여 비동기 처리를 했을 때, 테스트 방식입니다. 이젠 async를 이용해 간단하게 표현할 수 있습니다.

class MockViewModelSpec: XCTestCase {
    func testFetchThumbnails() throws {
        let result = try await self.mockViewModel.fetchThumbnail(for: mockID)
        XCTAssertEaqual(result?.size, CGSize(width: 40, height: 40))
    }
}

Bridging from sync to async

Task 로 감싸는 것은 Global Dispatch Queue로 보내는 것과 같습니다.

Async APIs in the SDK

swift 5.5 부터 기본내장 SDK에 async 코드가 추가되었습니다.
deprecated getCurrentTimelineEntry

위 링크에서 특정 함수가 async로 적용된 형태를 설명하고 있습니다. 심지어 기존의 함수는 이제 사용되지 않을 것이라고 설명되어 있습니다.


Async alternatives and continuations

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 ?? [1, nil)
        }
        try self.managedObjectContext.execute (asyncRequest)
    } catch {
        completion([], error)
    }
}

위 함수를 우리가 직접 async 함수로 변환하는 과정을 거쳐보겠습니다.

func persistentPosts() async throws -> [Post] {
    self.getPersistentPosts { posts, error in
        
    }
}

반환값을 어떻게 넘겨주어야 할까요?
이러한 흐름은 모든 aync 적용에서 보이는 패턴입니다. 이를 continuation 이라고 합니다.

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(returning: posts)
            }
        }
    }
}

completionHandler 를 async 함수로 만드는 과정을 보셨습니다. continuation 은 async함수의 실행을 수동제어 하기에 강력한 방법입니다.
그러나 주의할 점이 있습니다. resume 은 모든 path에서 한번! 호출되어야 합니다. 0번 실행시 warning이 뜨고, 2번이상 호출시에는 compiler에서 더욱 심각한 에러를 발생시킵니다.
더 자세한 내용은 Swift concurrency: Behind the scenes이 링크를 확인해주세요

빠진 내용이 있을 수 있습니다. 가장 좋은 것은 직접 WWDC 영상을 확인해 보시면 좋을 것 같습니다.

Reference

profile
IOS 앱개발 공부

0개의 댓글