HoneyBee를 사용한 Swift의 고급 동시성
게시 됨: 2022-03-11Swift에서 동시 알고리즘을 설계, 테스트 및 유지 관리하는 것은 어렵고 세부 정보를 올바르게 얻는 것이 앱의 성공에 매우 중요합니다. 동시 알고리즘(병렬 프로그래밍이라고도 함)은 더 많은 하드웨어 리소스를 활용하고 전체 실행 시간을 줄이기 위해 동시에 여러 작업을 수행하도록 설계된 알고리즘입니다.
Apple 플랫폼에서 동시 알고리즘을 작성하는 전통적인 방법은 NSOperation입니다. NSOperation의 디자인은 프로그래머가 동시 알고리즘을 개별 장기 실행 비동기 작업으로 세분화하도록 초대합니다. 각 작업은 NSOperation의 자체 하위 클래스에 정의되고 이러한 클래스의 인스턴스는 런타임에 부분적인 작업 순서를 생성하기 위해 객관적인 API를 통해 결합됩니다. 동시성 알고리즘을 설계하는 이 방법은 7년 동안 Apple 플랫폼의 최신 기술이었습니다.
2014년 Apple은 동시 작업 표현의 극적인 단계로 GCD(Grand Central Dispatch)를 도입했습니다. GCD와 함께 제공되고 강화된 새로운 언어 기능 블록과 함께 GCD는 비동기 요청을 시작한 직후 비동기 응답 핸들러를 간략하게 설명하는 방법을 제공했습니다. 더 이상 프로그래머는 수많은 NSOperation 하위 클래스의 여러 파일에 동시 작업의 정의를 퍼뜨리도록 권장되지 않았습니다. 이제 전체 동시 알고리즘이 단일 메서드 내에서 실행 가능하게 작성될 수 있습니다. 이러한 표현력과 유형 안전성의 증가는 앞으로의 중요한 개념적 변화였습니다. 이 쓰기 방식의 일반적인 알고리즘은 다음과 같습니다.
func processImageData(completion: (result: Image?, error: Error?) -> Void) { loadWebResource("dataprofile.txt") { (dataResource, error) in guard let dataResource = dataResource else { completion(nil, error) return } loadWebResource("imagedata.dat") { (imageResource, error) in guard let imageResource = imageResource else { completion(nil, error) return } decodeImage(dataResource, imageResource) { (imageTmp, error) in guard let imageTmp = imageTmp else { completion(nil, error) return } dewarpAndCleanupImage(imageTmp) { imageResult in guard let imageResult = imageResult else { completion(nil, error) return } completion(imageResult, nil) } } } } }
이 알고리즘을 조금 분해해 보겠습니다. processImageData
함수는 작업을 완료하기 위해 자체적으로 4개의 비동기 호출을 수행하는 비동기 함수입니다. 4개의 비동기 호출은 블록 기반 비동기 처리에 가장 자연스러운 방식으로 서로 중첩됩니다. 결과 블록에는 각각 선택적 Error 매개변수가 있고 하나만 제외하고 모두 aysnc 작업의 결과를 나타내는 추가 선택적 매개변수가 있습니다.
위 코드 블록의 모양은 아마도 대부분의 Swift 개발자에게 친숙하게 보일 것입니다. 그러나 이 접근 방식이 잘못된 것은 무엇입니까? 다음과 같은 문제점 목록도 똑같이 친숙할 것입니다.
- 중첩된 코드 블록의 이러한 "파멸의 피라미드" 모양은 빠르게 다루기 어려워질 수 있습니다. 두 개의 비동기 작업을 더 추가하면 어떻게 됩니까? 넷? 조건부 연산은 어떻습니까? 재시도 동작 또는 리소스 제한에 대한 보호는 어떻습니까? 실제 코드는 블로그 게시물의 예제만큼 깨끗하고 간단하지 않습니다. "파멸의 피라미드" 효과로 인해 읽기 어렵고 유지 관리가 어렵고 버그가 발생하기 쉬운 코드가 쉽게 생성될 수 있습니다.
- 위의 예에서 오류 처리 시도는 Swifty이지만 실제로는 불완전합니다. 프로그래머는 매개변수가 2개인 Objective-C 스타일의 비동기 콜백 블록이 항상 두 매개변수 중 하나를 제공한다고 가정했습니다. 그들은 결코 동시에 0이 되지 않을 것입니다. 이것은 안전한 가정이 아닙니다. 동시 알고리즘은 작성 및 디버그하기 어려운 것으로 유명하며 근거 없는 가정이 그 이유의 일부입니다. 완전하고 정확한 오류 처리는 실제 세계에서 작동하려는 모든 동시 알고리즘에서 피할 수 없는 필수 요소입니다.
- 이 생각을 더 발전시키면 비동기 함수라고 불리는 프로그래머는 당신만큼 원칙적이지 않았을 것입니다. 호출된 함수가 콜백에 실패하는 조건이 있으면 어떻게 합니까? 아니면 두 번 이상 다시 전화를 하시겠습니까? 이러한 상황에서 processImageData의 정확성은 어떻게 됩니까? 프로는 기회를 잡지 않습니다. 미션 크리티컬 기능은 제3자가 작성한 기능에 의존하는 경우에도 정확해야 합니다.
- 아마도 가장 설득력 있는 고려된 비동기 알고리즘은 차선책으로 구성됩니다. 처음 두 개의 비동기 작업은 모두 원격 리소스의 다운로드입니다. 상호 의존성이 없더라도 위의 알고리즘은 다운로드를 병렬이 아닌 순차적으로 실행합니다. 그 이유는 분명합니다. 중첩된 블록 구문은 이러한 낭비를 조장합니다. 경쟁 시장은 불필요한 지연을 용납하지 않습니다. 앱이 가능한 한 빨리 비동기 작업을 수행하지 않으면 다른 앱이 수행합니다.
어떻게 하면 더 잘할 수 있습니까? HoneyBee는 Swift 동시 프로그래밍을 쉽고, 표현적이고, 안전하게 만드는 future/promises 라이브러리입니다. 위의 비동기 알고리즘을 HoneyBee로 다시 작성하고 결과를 살펴보겠습니다.
func processImageData(completion: (result: Image?, error: Error?) -> Void) { HoneyBee.start() .setErrorHandler { completion(nil, $0) } .branch { stem in stem.chain(loadWebResource =<< "dataprofile.txt") + stem.chain(loadWebResource =<< "imagedata.dat") } .chain(decodeImage) .chain(dewarpAndCleanupImage) .chain { completion($0, nil) } }
이 구현이 시작하는 첫 번째 줄은 새로운 HoneyBee 레시피입니다. 두 번째 줄은 기본 오류 처리기를 설정합니다. HoneyBee 레시피에서 오류 처리는 선택 사항이 아닙니다. 문제가 발생할 수 있는 경우 알고리즘이 이를 처리해야 합니다. 세 번째 줄은 병렬 실행을 허용하는 분기를 엽니다. loadWebResource
의 두 체인이 병렬로 실행되고 결과가 결합됩니다(5행). 로드된 두 리소스의 결합된 값은 완료가 호출될 때까지 decodeImage
등으로 체인 아래로 전달됩니다.
위의 문제점 목록을 살펴보고 HoneyBee가 이 코드를 개선한 방법을 살펴보겠습니다. 이제 이 기능을 유지 관리하는 것이 훨씬 쉬워졌습니다. HoneyBee 레시피는 그것이 표현하는 알고리즘처럼 보입니다. 코드는 읽기 쉽고 이해하기 쉬우며 빠르게 수정할 수 있습니다. HoneyBee의 설계는 명령어의 잘못된 순서로 인해 런타임 오류가 아닌 컴파일 시간 오류가 발생하도록 합니다. 이 기능은 이제 버그와 사람의 실수에 훨씬 덜 취약합니다.
가능한 모든 런타임 오류가 완전히 처리되었습니다. HoneyBee가 지원하는 모든 기능 서명(38개 있음)은 완전히 처리됩니다. 이 예에서 Objective-C 스타일의 2개 매개변수 콜백은 오류 핸들러로 라우팅될 nil이 아닌 오류를 생성하거나 체인을 따라 진행되는 nil이 아닌 값을 생성합니다. 그렇지 않으면 둘 다 값이 nil이면 HoneyBee는 함수 콜백이 계약을 이행하지 않는다는 오류를 생성합니다.
HoneyBee는 또한 함수 콜백이 호출되는 횟수에 대한 계약상의 정확성을 처리합니다. 함수가 콜백 호출에 실패하면 HoneyBee는 설명 실패를 생성합니다. 함수가 콜백을 두 번 이상 호출하면 HoneyBee는 보조 호출을 억제하고 경고를 기록합니다. 이러한 오류 응답(및 기타)은 모두 프로그래머의 개별 요구에 맞게 사용자 지정할 수 있습니다.
바라건대, 이 형태의 processImageData
는 최적의 성능을 제공하기 위해 리소스 다운로드를 적절하게 병렬화한다는 것이 이미 명백해야 합니다. HoneyBee의 가장 강력한 디자인 목표 중 하나는 레시피가 표현하는 알고리즘과 같아야 한다는 것입니다.
훨씬 낫다. 오른쪽? 그러나 HoneyBee는 훨씬 더 많은 것을 제공합니다.
경고: 다음 사례 연구는 마음이 약한 사람들을 위한 것이 아닙니다. 다음 문제 설명을 고려하십시오. 모바일 앱은 CoreData
를 사용하여 상태를 유지합니다. 백엔드 서버에 업로드된 미디어 자산을 나타내는 Media라는 NSManagedObject
모델이 있습니다. 사용자는 한 번에 수십 개의 미디어 항목을 선택하고 일괄적으로 백엔드 시스템에 업로드할 수 있습니다. 미디어는 먼저 Media 객체로 변환되어야 하는 참조 문자열을 통해 표현됩니다. 다행히도 앱에는 다음과 같은 작업을 수행하는 도우미 메서드가 이미 포함되어 있습니다.
func export(_ mediaRef: String, completion: @escaping (Media?, Error?) -> Void) { // transcoding stuff completion(Media(context: managedObjectContext), nil) }
미디어 참조가 미디어 개체로 변환된 후 미디어 항목을 백엔드에 업로드해야 합니다. 다시 네트워크 작업을 수행할 준비가 된 도우미 기능이 있습니다.
func upload(_ media: Media, completion: @escaping (Error?) -> Void) { // network stuff completion(nil) }
사용자는 한 번에 수십 개의 미디어 항목을 선택할 수 있으므로 UX 디자이너는 업로드 진행 상황에 대해 상당히 강력한 피드백을 지정했습니다. 요구 사항은 다음 네 가지 기능으로 요약되었습니다.
/// Called if anything goes wrong in the upload func errorHandler(_ error: Error) { // do the right thing } /// Called once per mediaRef, after either a successful or unsuccessful upload func singleUploadCompletion(_ mediaRef: String) { // update a progress indicator } /// Called once per successful upload func singleUploadSuccess(_ media: Media) { // do celebratory things } /// Called if the entire batch was considered to be uploaded successfully. func totalProcessSuccess() { // declare victory }
그러나 앱이 때때로 만료되는 미디어 참조 소스를 제공하기 때문에 비즈니스 관리자는 업로드의 절반 이상이 성공한 경우 사용자에게 "성공" 메시지를 보내기로 결정했습니다. 즉, 동시 프로세스는 시도한 업로드의 절반 미만이 실패하면 승리를 선언하고 totalProcessSuccess
를 호출해야 합니다. 개발자로서 전달받은 사양입니다. 그러나 숙련된 프로그래머로서 적용해야 하는 요구 사항이 더 많다는 것을 알고 있습니다.
물론 Business는 일괄 업로드가 가능한 한 빨리 발생하기를 원하므로 직렬 업로드는 문제가 되지 않습니다. 업로드는 병렬로 수행되어야 합니다.
하지만 너무 많지는 않습니다. 전체 배치를 async
하게 비동기화하면 수십 개의 동시 업로드가 모바일 NIC(네트워크 인터페이스 카드)에 넘쳐 흐르고 업로드는 실제로 직렬보다 느리게 진행되지만 더 빠르지는 않습니다.
모바일 네트워크 연결은 안정적인 것으로 간주되지 않습니다. 짧은 트랜잭션이라도 네트워크 연결의 변경으로 인해 실패할 수 있습니다. 업로드가 실패했다고 진정으로 선언하려면 적어도 한 번은 업로드를 다시 시도해야 합니다.
재시도 정책은 일시적인 오류가 발생하지 않으므로 내보내기 작업을 포함하지 않아야 합니다.
내보내기 프로세스는 컴퓨팅 바운드이므로 기본 스레드에서 수행해야 합니다.
내보내기는 컴퓨팅 바운드이므로 프로세서 스래싱을 방지하기 위해 나머지 업로드 프로세스보다 동시 인스턴스 수가 적어야 합니다.
위에서 설명한 네 가지 콜백 함수는 모두 UI를 업데이트하므로 모두 메인 스레드에서 호출해야 합니다.
미디어는 NSManagedObject
에서 가져오고 존중되어야 하는 자체 스레딩 요구 사항이 있는 NSManagedObjectContext
입니다.
이 문제 사양이 약간 모호해 보입니까? 미래에 이와 같은 문제가 도사리고 있는 경우 놀라지 마십시오. 나는 내 자신의 작업에서 이와 같은 것을 만났다. 먼저 기존 도구를 사용하여 이 문제를 해결해 보겠습니다. 버클을 채우십시오. 이것은 예쁘지 않을 것입니다.
/// An enum describing specific problems that the algorithm might encounter. enum UploadingError : Error { case invalidResponse case tooManyFailures } /// A semaphore to prevent flooding the NIC let outerLimit = DispatchSemaphore(value: 4) /// A semaphore to prevent thrashing the processor let exportLimit = DispatchSemaphore(value: 1) /// The number of times to retry the upload if it fails let uploadRetries = 1 /// Dispatch group to keep track of when the entire process is finished let fullProcessDispatchGroup = DispatchGroup() /// How many of the uploads fully completed. var uploadSuccesses = 0 // this notify block is called when the full process has completed. fullProcessDispatchGroup.notify(queue: DispatchQueue.main) { let successRate = Float(uploadSuccesses) / Float(mediaReferences.count) if successRate > 0.5 { totalProcessSuccess() } else { errorHandler(UploadingError.tooManyFailures) } } // start in the background DispatchQueue.global().async { for mediaRef in mediaReferences { // alert the group that we're starting a process fullProcessDispatchGroup.enter() // wait until it's safe to start uploading outerLimit.wait() /// common cleanup operations needed later func finalizeMediaRef() { singleUploadCompletion(mediaRef) fullProcessDispatchGroup.leave() outerLimit.signal() } // wait until it's safe to start exporting exportLimit.wait() export(mediaRef) { (media, error) in // allow another export to begin exportLimit.signal() if let error = error { DispatchQueue.main.async { errorHandler(error) finalizeMediaRef() } } else { guard let media = media else { DispatchQueue.main.async { errorHandler(UploadingError.invalidResponse) finalizeMediaRef() } return } // the export was successful var uploadAttempts = 0 /// define the upload process and its retry behavior func doUpload() { // respect Media's threading requirements managedObjectContext.perform { upload(media) { error in if let error = error { if uploadAttempts < uploadRetries { uploadAttempts += 1 doUpload() // retry } else { DispatchQueue.main.async { // too many upload failures errorHandler(error) finalizeMediaRef() } } } else { DispatchQueue.main.async { uploadSuccesses += 1 singleUploadSuccess(media) finalizeMediaRef() } } } } } // kick off the first upload doUpload() } } } }
와! 주석이 없으면 약 75줄입니다. 논리를 끝까지 따랐습니까? 새 직장에서 첫 주에 이 괴물을 만난다면 어떤 기분이 들겠습니까? 그것을 유지하거나 수정할 준비가 되었다고 느끼시겠습니까? 오류가 포함되어 있는지 알 수 있습니까? 오류가 포함되어 있습니까?

이제 HoneyBee 대안을 고려하십시오.
HoneyBee.start(on: DispatchQueue.main) .setErrorHandler(errorHandler) .insert(mediaReferences) .setBlockPerformer(DispatchQueue.global()) .each(limit: 4, acceptableFailure: .ratio(0.5)) { elem in elem.finally { link in link.setBlockPerformer(DispatchQueue.main) .chain(singleUploadCompletion) } .limit(1) { link in link.chain(export) } .setBlockPerformer(managedObjectContext) .retry(1) { link in link.chain(upload) // subject to transient failure } .setBlockPerformer(DispatchQueue.main) .chain(singleUploadSuccess) } .setBlockPerformer(DispatchQueue.main) .drop() .chain(totalProcessSuccess)
이 양식이 당신에게 어떤 영향을 줍니까? 하나씩 살펴보겠습니다. 첫 번째 줄에서는 메인 스레드에서 시작하여 HoneyBee 레시피를 시작합니다. 메인 스레드에서 시작하여 모든 오류가 메인 스레드의 errorHandler(라인 2)로 전달되도록 합니다. 3행은 mediaReferences
배열을 프로세스 체인에 삽입합니다. 다음으로 일부 병렬 처리를 준비하기 위해 전역 백그라운드 대기열로 전환합니다. 5행에서 각 mediaReferences
에 대해 병렬 반복을 시작합니다. 이 병렬 처리를 최대 4개의 동시 작업으로 제한합니다. 또한 하위 체인의 절반 이상이 성공하면 전체 반복이 성공적인 것으로 간주될 것이라고 선언합니다(오류하지 않음). 6행은 아래 서브체인의 성공 여부에 관계없이 호출될 finally
링크를 선언합니다. finally
링크에서 메인 스레드(라인 7)로 전환하고 singleUploadCompletion
(라인 8)을 호출합니다. 10행에서 내보내기 작업(11행) 주변에서 최대 병렬화를 1(단일 실행)로 설정했습니다. 13행은 managedObjectContext
인스턴스가 소유한 개인 대기열로 전환합니다. 14행은 업로드 작업(15행)에 대한 단일 재시도를 선언합니다. 17행은 다시 메인 스레드로 전환되고 18행은 singleUploadSuccess
를 호출합니다. 타임 라인 20이 실행될 때까지 모든 병렬 반복이 완료되었습니다. 반복의 절반 미만이 실패하면 20번째 줄은 마지막으로 주 대기열로 전환하고(각각이 백그라운드 대기열에서 실행되었음을 기억하십시오), 21번은 인바운드 값을 삭제하고(여전히 mediaReferences
), 22 totalProcessSuccess
를 호출합니다.
HoneyBee 양식은 유지 관리가 더 쉬울 뿐만 아니라 더 명확하고 깨끗하며 읽기 쉽습니다. Map 함수와 같은 배열로 Media 객체를 재통합하기 위해 루프가 필요하다면 이 알고리즘의 긴 형태는 어떻게 될까요? 변경한 후 알고리즘의 모든 요구 사항이 여전히 충족되고 있다고 얼마나 확신할 수 있습니까? HoneyBee 형식에서 이 변경은 병렬 맵 기능을 사용하기 위해 각각을 맵으로 대체하는 것입니다. (예, 감소도 있습니다.)
HoneyBee는 비동기 및 동시 알고리즘을 더 쉽고 안전하고 표현력 있게 작성하도록 하는 Swift용 강력한 future 라이브러리입니다. 이 기사에서 HoneyBee가 알고리즘을 유지 관리하기 쉽고 정확하며 빠르게 만드는 방법을 살펴보았습니다. HoneyBee는 또한 재시도 지원, 다중 오류 처리기, 리소스 보호 및 수집 처리(비동기 형식의 맵, 필터 및 축소)와 같은 다른 주요 비동기 패러다임을 지원합니다. 전체 기능 목록은 웹사이트를 참조하십시오. 자세히 알아보거나 질문하려면 새로운 커뮤니티 포럼을 참조하십시오.
부록: 비동기 함수의 계약상 정확성 보장
기능의 계약적 정확성을 보장하는 것은 컴퓨터 과학의 기본 교리입니다. 거의 모든 최신 컴파일러에는 값을 반환하도록 선언한 함수가 정확히 한 번 반환되는지 확인하는 검사가 있습니다. 두 번 미만 또는 두 번 이상 반환하면 오류로 처리되어 전체 컴파일을 적절하게 방지합니다.
그러나 이 컴파일러 지원은 일반적으로 비동기 함수에 적용되지 않습니다. 다음 (유쾌한) 예를 고려하십시오.
func generateIcecream(from int: Int, completion: (String) -> Void) { if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } completion("Pistachio") } else if int < 2 { completion("Vanilla") } }
generateIcecream
함수는 Int를 받아들이고 비동기적으로 String을 반환합니다. swift 컴파일러는 위의 형식이 몇 가지 명백한 문제가 있음에도 불구하고 기꺼이 올바른 것으로 받아들입니다. 특정 입력이 주어지면 이 함수는 완료를 0번, 1번 또는 2번 호출할 수 있습니다. 비동기 함수로 작업한 프로그래머는 종종 자신의 작업에서 이 문제의 예를 기억할 것입니다. 우리는 무엇을 할 수 있습니까? 확실히, 우리는 코드를 더 깔끔하게 리팩토링할 수 있습니다(범위 케이스가 있는 스위치가 여기에서 작동할 것입니다). 그러나 때로는 기능적 복잡성을 줄이기가 어렵습니다. 컴파일러가 정기적으로 함수를 반환하는 것처럼 정확성을 확인하는 데 도움을 줄 수 있다면 더 좋지 않을까요?
방법이 있다는 것이 밝혀졌습니다. 다음 Swifty 주문을 준수하십시오.
func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } // else completion("Pistachio") } else if int < 2 { completion("Vanilla") } }
이 함수의 맨 위에 삽입된 네 줄은 컴파일러가 완료 콜백이 정확히 한 번 호출되었는지 확인하도록 합니다. 즉, 이 함수는 더 이상 컴파일되지 않습니다. 무슨 일이야? 첫 번째 줄에서 우리는 이 함수가 궁극적으로 생성하기를 원하는 결과를 선언하지만 초기화하지는 않습니다. 정의하지 않은 상태로 두면 사용하기 전에 한 번 할당되어야 하고, 선언하면 두 번 할당되지 않도록 합니다. 두 번째 줄은 이 함수의 최종 작업으로 실행될 연기입니다. 함수의 나머지 부분에 의해 할당된 후 finalResult
를 사용하여 완료 블록을 호출합니다. 3행은 콜백 매개변수를 숨기는 completion이라는 새로운 상수를 생성합니다. 새로운 완성은 공개 API를 선언하지 않는 Void 유형입니다. 이 줄은 이 줄 이후에 완료를 사용하면 컴파일러 오류가 되도록 합니다. 2행의 지연은 완료 블록의 유일하게 허용된 사용입니다. 4행에서는 사용되지 않는 새 완료 상수에 대해 표시될 컴파일러 경고를 제거합니다.
그래서 우리는 이 비동기 함수가 계약을 이행하지 않는다고 보고하도록 swift 컴파일러를 성공적으로 강제했습니다. 올바르게 만들기 위한 단계를 살펴보겠습니다. 먼저 콜백에 대한 모든 직접 액세스를 finalResult
할당으로 교체하겠습니다.
func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } // else finalResult = "Pistachio" } else if int < 2 { finalResult = "Vanilla" } }
이제 컴파일러는 두 가지 문제를 보고합니다.
error: AsyncCorrectness.playground:1:8: error: constant 'finalResult' used before being initialized defer { completion(finalResult) } ^ error: AsyncCorrectness.playground:11:3: error: immutable value 'finalResult' may only be initialized once finalResult = "Pistachio"
예상대로 함수에는 finalResult
가 0번 할당되는 경로와 두 번 이상 할당되는 경로가 있습니다. 이러한 문제를 다음과 같이 해결합니다.
func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } else { finalResult = "Pistachio" } } else if int < 2 { finalResult = "Vanilla" } else { finalResult = "Neapolitan" } }
"피스타치오"가 적절한 else 절로 옮겨졌고 우리는 우리가 "나폴리탄"인 일반적인 경우를 다루지 못했다는 것을 깨달았습니다.
방금 설명한 패턴은 선택적 값, 선택적 오류 또는 일반적인 Result 열거형과 같은 복잡한 유형을 반환하도록 쉽게 조정할 수 있습니다. 콜백이 정확히 한 번 호출되었는지 확인하도록 컴파일러를 강제함으로써 비동기 함수의 정확성과 완전성을 주장할 수 있습니다.