티스토리 뷰

반응형

 

안녕하세요 :) Zedd입니다.

오늘은 매우 핫한 async, await를 한번 보려고 합니다.

2021.03.25일 기준 async-await proposalSwift 5.5에서 구현된 상태입니다.

+ ) 2021.06.15 글 수정. @asyncHandler 내용 삭제. 

 

# 문제 

1. Swift 개발에서 Closure 및 completion handlers를 사용하는 asynchronous(비동기) 프로그래밍을 많이 함.

2. 많은 비동기 작업 / 오류 처리 / 비동기 호출간의 제어 흐름이 복잡할 때 문제가 됨 

 

[1] 많은 비동기 작업

일련의 비동기 작업에는 deeply-nested closures가 필요. 

func processImageData1(completionBlock: (_ result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}

processImageData1 { image in
    display(image)
}

 

[2]  오류 처리

콜백은 오류처리를 어렵고 매우 장황하게 만듬. 

// (2c) Using a `switch` statement for each callback:
func processImageData2c(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        switch dataResourceResult {
        case .success(let dataResource):
            loadWebResource("imagedata.dat") { imageResourceResult in
                switch imageResourceResult {
                case .success(let imageResource):
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        switch imageTmpResult {
                        case .success(let imageTmp):
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        case .failure(let error):
                            completionBlock(.failure(error))
                        }
                    }
                case .failure(let error):
                    completionBlock(.failure(error))
                }
            }
        case .failure(let error):
            completionBlock(.failure(error))
        }
    }
}

processImageData2c { result in
    switch result {
    case .success(let image):
        display(image)
    case .failure(let error):
        display("No image today", error)
    }
}

Swift.Result가 Swift 5.0에서 추가되면서..error를 처리하는게 더 쉬워졌지만,

여전히 closure 중첩 문제는 남아있음.

 

[3] 비동기 호출간의 제어 흐름이 복잡할 때 

비동기 함수를 조건부로 실행하는 것은 고통 그자체...

예를들어, 이미지를 얻은 후 "swizzle"해야한다고 할 때,

1. 이미지가 있으면 바로 swizzle

2. 이미지가 없으면 decode후 swizzle

func processImageData3(recipient: Person, completionBlock: (_ result: Image) -> Void) {
    let swizzle: (_ contents: Image) -> Void = {
      // ... continuation closure that calls completionBlock eventually
    }
    if recipient.hasProfilePicture {
        swizzle(recipient.profilePicture)
    } else {
        decodeImage { image in
            swizzle(image)
        }
    }
}

그래서 위와같은 코드가 필요.

이 함수를 구조화 하는 방법은 위 코드와 같이 completion Handler에서 swizzle 코드를 작성하는 것. 

func processImageData3(recipient: Person, completionBlock: (_ result: Image) -> Void) {
    let swizzle: (_ contents: Image) -> Void = {
      // ... continuation closure that calls completionBlock eventually ✅✅
    }
    if recipient.hasProfilePicture {
        swizzle(recipient.profilePicture)
    } else {
        decodeImage { image in
            swizzle(image)
        }
    }
}

1. 이 패턴은 함수의 자연스러운 하향식 구성을 반전시킴.

2. swizzle closure가 completion handler에서 사용되므로 capture에 대해 신중하게 생각해야함


조건부로 실행되는 비동기 함수의 수가 증가함에 따라 문제는 더욱 악화
&&
본질적으로 반전된 "파멸의 피라미드(pyramid of doom)"를 생성.

 

[4] 실수하기 쉬움

func processImageData4a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource, error in
        guard let dataResource = dataResource else {
            return // ⚠️ <- forgot to call the block 
        }
        loadWebResource("imagedata.dat") { imageResource, error in
            guard let imageResource = imageResource else {
                return // ⚠️ <- forgot to call the block 
            }
            ...
        }
    }
}

completionBlock을 호출하지 않고 그냥 return하고 잊어버리면 디버깅하기 어려움.

니가 만약 잊지않고 completionBlock을 호출했다고 치자? 

func processImageData4b(recipient:Person, completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    if recipient.hasProfilePicture {
        if let image = recipient.profilePicture {
            completionBlock(image) // ⚠️ <- forgot to return after calling the block
        }
    }
    ...
}

completionBlock호출하고 나서 return호출하는 것도 까먹을 수 있음 ㅎ

(guard는 return을 하지 않으면 컴파일에러를 주긴 한다만..항상 guard를 쓰는건 아니니까)

 

# 해결

위와같은 문제를 해결하기 위해 async-await proposal은 Swift에 코루틴(coroutine) 모델을 도입.

 ➞ 비동기 함수의 semantics 정의 But 동시성을 제공하지는 않음

비동기함수(async/await)를 사용하면?

- 비동기 코드를 마치 동기 코드인것 처럼 작성 할 수 있음. ➞ 프로그래머가 동기 코드에서 사용할 수 있는 동일한 언어 구조를 최대한 활용 가능.

- 자연스럽게 코드의 의미 구조를 보존 ➞ 언어에 대한 최소한 3가지 교차 개선에 필요한 정보를 제공...(뭔소리지)

1) 비동기 코드의 성능 향상(better performance for asynchronous code)

2) 코드를 디버깅, 프로파일링 및 탐색하는 동안 보다 일관된 경험을 제공하기 위한 더 나은 도구 (better tooling to provide a more consistent experience while debugging, profiling, and exploring code)

3) 작업 우선 순위 및 취소와 같은 동시성 기능을 위한 기반. (a foundation for future concurrency features like task priority and cancellation)

 

# 예제

위에서 봤던 

func processImageData1(completionBlock: (_ result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}

processImageData1 { image in
    display(image)
}

코드를 async/await를 사용하여 리팩토링하면,

func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i : Image) async throws -> Image

func processImageData() async throws -> Image {
  let dataResource  = try await loadWebResource("dataprofile.txt")
  let imageResource = try await loadWebResource("imagedata.dat")
  let imageTmp      = try await decodeImage(dataResource, imageResource)
  let imageResult   = try await dewarpAndCleanupImage(imageTmp)
  return imageResult
}

짜잔

이렇게 마치 동기적으로 일어나는 것 처럼 코드를 작성할 수 있게 된다. 

어떤식으로 사용하는지는...좀 사용해보고 보겠음. 

 

# 사용해보기 (1) 

Swift 5.5는 아직 릴리즈는 안됐고..스냅샷을 다운받아야 한다. 

(Swift Snapshot써보기 글 참고)

Snapshots의 Xcode를 다운 받아준다. 

Swift Snapshot써보기에 다 있으므로..따로 설명은 안함. 

이렇게 뜨면 된것.

 

# 사용해보기 (2)

[1] -Xfrontend -enable-experimental-concurrency 플래그 추가

🚨 만약 Xcode 13 베타를 사용중이라면 안해도 됨 🚨

그냥 iOS 앱 프로젝트를 만든다고 치겠음.

타겟 > Build Settings > Other Swift Flags 선택

-Xfrontend -enable-experimental-concurrency추가.

이렇게 되어있으면 된다. 

(사실 이걸 안해도 async/await는 사용할 수 있는 것 같은..?)

 

[2] 콜백 지옥 만들기

override func viewDidLoad() {
    super.viewDidLoad()
    
    self.getData { data in
        self.decode(data: data) { contents in
            print(contents) // "<!doctype html>~~~~
        }
    }
}

func getData(completion: (Data) -> Void) {
    let url = URL(string: "https://zeddios.tistory.com")!
    let data = try! Data(contentsOf: url)
    completion(data)
}

func decode(data: Data, completion: (String) -> Void) {
    let contents = String(data: data, encoding: .utf8)!
    completion(contents)
}

대충 이런코드를 만들어줬다. 강제 언래핑과 try!는 일단 무시좀..

이걸 async/await를 바꿔보자

 

[3] completion을 return으로 바꿈 

func getData() -> Data {
    let url = URL(string: "https://zeddios.tistory.com")!
    let data = try! Data(contentsOf: url)
    return data
}

func decode(data: Data) -> String {
    let contents = String(data: data, encoding: .utf8)!
    return contents
}

 

[4] 메소드에 async 키워드 추가

func getData() ✅ async ✅ -> Data {
    let url = URL(string: "https://zeddios.tistory.com")!
    let data = try! Data(contentsOf: url)
    return data
}

func decode(data: Data) ✅ async ✅ -> String {
    let contents = String(data: data, encoding: .utf8)!
    return contents
}

 

[5] 사용하는 쪽에서 await와 함께 호출해준다.

func process() async {
   let data = ✅ await ✅ self.getData()
   let contents = ✅ await ✅ self.decode(data: data)
   print(contents)
}

 

 

[6] process 호출

override func viewDidLoad() {
    super.viewDidLoad()
    
    Task {
        await self.process() // <!doctype html>~~~~
     }
}

이런식으로 하면 된다.

 

단 흐름만 보여주기 위해서 상세한 설명은 안했는데...

async/await를 하나씩 봐보자.

 

async

- 함수를 비동기 함수로 만들겠다. 

[특징] 

1. 프로토콜에서도 요구 가능하다.

protocol SomeDelegate {
    func process() async
}

 

2. async와 throws를 같이 쓸 수 있다.

func getData() ✅ async throws ✅ -> Data {
    let url = URL(string: "https://zeddios.tistory.com")!
    let data = try Data(contentsOf: url)
    return data
}

주의할 점은 반드시 async 키워드가 throws키워드 보다 먼저 선언되어야 한다. 

throws async 이런거 안됨. rethrows역시 마찬가지. 

 

await 

- 비동기 함수 호출시 potential suspension point(잠재적인 일시 중단 지점) 지정

func process() async {
   let data = await self.getData()
   let contents = await self.decode(data: data)
   print(contents)
}

예를 들어, getData(), decode()를 호출하는 동안 작업이 일시 중단 되어야 함.

(왜냐면 data 진짜 다 가져올 때 까지 작업을 더 진행 시키면 안되니까!!)

따라서 각 비동기 함수 호출 지점에는 potential suspension point(잠재적인 일시 중단 지점)이 있어야 하고

그것을 await로 할 수 있음. 

suspension point에 관한 더 자세한 이야기는 proposal 참고 

 

만약 비동기 함수가 에러를 던질 수 있다면, 

func getData() async ✅ throws ✅ -> Data {
   let url = URL(string: "https://zeddios.tistory.com")!
   let data = try Data(contentsOf: url)
   return data
}

func decode(data: Data) async ✅ throws ✅ -> String {
   let contents = String(data: data, encoding: .utf8)!
   return contents
}

func process() async {
   do {
       let data = ✅ try ✅ await self.getData()
       let contents = ✅ try ✅ await self.decode(data: data)
       print(contents)
   } catch {
       
   }
}

await 역시 try와 함께 써줘야 한다. 

 

💡 proposal에서는 async는 "keyword", await는 "operand(피연산자)"로 표현하고 있다.
💡 글 내에서 "함수"와 "메소드"를 혼용해서 사용하고 있는데..함수 vs 메소드의 정의에 따라 
의도적으로 달리 사용했습니다.

혹시나 왜 다르게 사용했지 라고 생각하실 분들을 위해 남깁니다..! 

 

참고

github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md

반응형