[WWDC21] Meet async/await in Swift

WWDC21 영상보면서 이해한대로 대충 끄적여보기 첼린지

정확한 이해가 필요하신 분들은 영상을 직접 시청하는 것을 권합니다.

이미지를 요청함에 있어서 completion handler로 비동기처리를 하는데 completion handler의 중첩과 guard let으로 에러처리하는 모습을 보여줍니다.

이건 URLSession을 써보면 누구나 느낄 수 있는 문제점에 대해 왜 이런 걸 만들었는지? 밑밥을 깔기 위한 거라고 생각하면 될듯..

그냥 completion으로 UIImage? 따로 Error?따로 받았을 때랑 Result 타입을 써서 handling 했을 때를 보여주네요..

8분 - Using completion handlers with the Result Type

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

너무나 익숙한 코드구만…

8분 30초 - Using 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
}

와.. ㄷㄷ 위에 코드가 이렇게 바뀌네요.. 아름답다..

await에 관해서 말하자면, await는 말그대로 비동기를 block 없이 쓰기 위한 키워드고 함수에 async와 함께 써줘야 합니다. 해석 그대로 completion처럼 비동기로 처리하지만 기다려주는거죠.

어.. 음.. 뭔가 말이 좀 이상한거같은데 block 없는 completion이라고 보면 될까요…?

여기서 이제 try await를 썼는데 다중 await를 쓰게 될 경우, await를 한번만 써도된다고 하네요… 뭔가 쓰는걸 직접 봐야할듯..?

try도 마찬가지로 다중 throwing function을 쓰게 될 경우, 한번만 쓸 수 있다고 합니다.

13분 15초 - Async properties

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

이런 식으로 extension으로 property를 정의해서 async/await를 쓸 수 있다라는 것을 보여주는 예시입니다. byPreparingThumbnail이라는건 원래 있는거겠죠..?

get async로 쓸 수 있네요.. 👍

14분 17초 - Async sequences

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

loop를 asynce로 제어할 수 있다는 것은 알겠는데, staticImageIDsURL이.. 음.. async sequence인건가.. 아니면 lines가 그건가.. 암튼 뭐 이렇게도 쓸 수 있다고 하네용..

이거에 대해서 잘 알 고 싶으면 Meet AsyncSequence와 Explore structured concurrency in Swift를 시청하라고 하네요..

사실 이미 Explore structured concurrenty in Swift를 보고 온건데 확실하게 이해가 되질 않네.. 역시 직접 써봐야하는건가.. 빨리 업데이트 좀..

Async/await facts

  • async
    • enables a function to suspend
  • await
    • marks where an async function may suspend execution

Other work can happen during a suspendsion

Once an awaited async call completes, execution resumes after th await

suspend라는게 중단하다? 유예하다? 정도로 알고있는데, 음.. 비동기처리를 위해 잠시 멈출 수 있다라는 의미로 받아들여야할 것 같아요.. 멈춘다기 보단 비동기처리를 잠시 기다리는..?

근데 이게 기다린다고 해서 다른 work를 할당할 수 없는게 아니고 system은 다른 work를 할당할 수 있다고 하네요.. 이게 suspend한다고해서 무작정 기다리는게 아니고 다른 업무를 할당받는 것에 자유롭다고 합니다. 어찌보면 당연한 얘기긴하죠..

이게 안돼면 만들었으면 안돼지..

마지막 꺼는 suspend한다고 했잔아요..? 이게 그 work(function)를 suspend했다가 awaited async가 완료했다고하면, 다시 work는 재개한다는 의미입니다.

21분 22초 - Testing using XCTestExpectation

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

오호… 유닛테스트구만.. 이건 async/await를 안썼을 때입니다.

21분 56초 - Testing using async/await

class MockViewModelSpec: XCTestCase {
    func testFetchThumbnails() async throws {
        XCTAssertNoThrow(try await self.mockViewModel.fetchThumbnail(for: mockID))
    }
}

오우야… 아주 좋습니다..

22분 30초 - Bridging from sync to async

struct ThumbnailView: View {
    @ObservedObject var viewModel: ViewModel
    var post: Post
    @State private var image: UIImage?

    var body: some View {
        Image(uiImage: self.image ?? placeholder)
            .onAppear {
                async {
                    self.image = try? await self.viewModel.fetchThumbnail(for: post.id)
                }
            }
    }
}

이건 SwiftUI에서 적용한 모습인데요.. 아까 get async랑 비슷하게 async 블럭을 만들어서 처리해주는 모습을 볼 수 있습니다. 아직 SwiftUI 코드가 익숙하지 않은데요.

뭐.. 지금 코드는 async를 어떻게 쓰는지만 알고가면 될 것 같아요..

자세하게 알고 싶으면 Explore structured concurrency in Swift, Discover concurrency in SwiftUI를 보라고 합니다~

Async alternatives

  • Swift concurrency should be adopted gradually
  • Offer async alternatives to completion handler APIs
  • Xcode’s asnyc refactoring actions can help

음.. 요약하자면 점진적으로 지금 나온 이 코드들로 completion handler를 대체하자 뭐 그런 내용이네요..

26분 59초 - Async alternatives and continuations

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

// Async alternative
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)
            }
        }
    }
}

이건 core data 예시입니다. 사실 core data 안써봐서 잘 모르겠지만, 나중에 쓸 일 생기면 참고하려구요..

아마 용도에 따라 조금씩 코드는 다르겠지만 원리는 자체는 똑같은 것 같아요..

비동기를 처리할 부분은 await로 돌리고 기다리는 동안 thread 자체가 기다리는 것이 아닌 thread는 다른 업무를 할당받아서 처리하고 있고, async function만 멈추는 거죠.. await가 complete를 call하면 다시 function이 재개되는 형태입니다.

Checked continuations

  • Continuations must be resumed exactly once on every path
  • Discarding the continuation without resuming is not allowed
  • Swift will check your work!

음.. 아직 안써봐서 정확하게는 잘 모르겠는데요.. 재개할 때 값을 줄지, error를 던질지 제어하는 것 같아요.. 직접 써봐야 알긴할듯..

위에 코드보시면 resume function을 쓰는데 여러번 호출할 수 없다고 합니다.

31분 44초 - Storing the continuation for delegate callbacks

class ViewController: UIViewController {
    private var activeContinuation: CheckedContinuation<[Post], Error>?
    func sharedPostsFromPeer() async throws -> [Post] {
        try await withCheckedThrowingContinuation { continuation in
            self.activeContinuation = continuation
            self.peerManager.syncSharedPosts()
        }
    }
}

extension ViewController: PeerSyncDelegate {
    func peerManager(_ manager: PeerManager, received posts: [Post]) {
        self.activeContinuation?.resume(returning: posts)
        self.activeContinuation = nil // guard against multiple calls to resume
    }

    func peerManager(_ manager: PeerManager, hadError error: Error) {
        self.activeContinuation?.resume(throwing: error)
        self.activeContinuation = nil // guard against multiple calls to resume
    }
}

어우.. 이것도 core data 써봐야 알듯.. 원리 자체는 비슷한거 같긴한데.. 학습이 더 필요한 부분이네요..