
💬 본 전사문은 ChatGPT 4o에 의해 번역되었습니다.
♪ 베이스 음악 재생 중 ♪
네이트 칸들러: 안녕하세요. 저는 애플의 Swift 팀에서 엔지니어로 일하고 있는 네이트입니다. 오늘 제 동료 로버트와 함께 Swift의 async/await에 대해 설명해 드리겠습니다. 비동기 프로그래밍은 많은 분들에게 일상적인 작업입니다. 따라서 비동기 코드를 작성하는 것이 얼마나 번거롭고 복잡하며 심지어 오류가 발생하기 쉬운지 잘 아실 것입니다. Swift의 async/await은 이러한 문제를 해결하는 데 도움을 줄 수 있습니다. 이를 사용하면 비동기 코드를 일반 코드처럼 쉽게 작성할 수 있습니다. 그렇게 하면 코드가 여러분의 아이디어를 더 잘 반영할 뿐만 아니라, 더 안전해지기도 합니다.
뿐만 아니라, SDK에는 여러분이 사용할 수 있는 수백 개의 대기 가능한(awaitable) 메서드가 있습니다. 예를 들어, UIKit은 UIImage에서 썸네일을 생성하는 기능을 제공합니다. 사실, 이 작업을 수행하기 위해 동기 및 비동기 함수 모두를 지원합니다.

간단히 상기해 드리자면, 동기(synchronous) 함수를 호출하면 해당 함수가 실행을 마칠 때까지 스레드가 블로킹됩니다. 예를 들어, fetchThumbnail 함수가 UIKit에서 제공하는 동기 함수인 preparingThumbnail을 호출하면, 함수가 끝날 때까지 스레드는 다른 작업을 수행할 수 없습니다.
반면, 비동기 버전인 prepareThumbnail(of:completionHandler:)을 호출하면 함수가 실행되는 동안에도 스레드는 다른 작업을 수행할 수 있습니다. 그리고 실행이 완료되면, 함수가 완료 핸들러(completion handler)를 호출하여 완료 사실을 알려줍니다.
SDK는 여러 가지 비동기 함수를 제공합니다. 이 함수들은 작업이 완료되었음을 다양한 방식으로 알려줍니다. 어떤 함수들은 위와 같이 완료 핸들러를 사용하고, 다른 함수들은 델리게이트(delegate) 콜백을 활용하기도 합니다. 또한, 많은 함수들이 async로 표시되어 있고, 단순히 값을 반환하는 방식으로 동작합니다.
이러한 비동기 함수들이 공통적으로 가지는 특징은 다음과 같습니다. 함수를 호출하면 작업을 시작한 후 즉시 스레드가 블로킹되지 않고 해제된다는 점입니다. 즉, 실행 시간이 긴 작업이 진행되는 동안에도 해당 스레드는 다른 작업을 수행할 수 있습니다. 이 차이를 더 잘 이해하기 위해 많은 분께 익숙할 만한 예제를 살펴보겠습니다.

로버트와 제가 함께 개발하는 앱에는 항목 리스트가 있으며, 각 행에는 서버에 저장된 이미지의 썸네일이 표시됩니다. 리스트에 썸네일을 표시할 때가 되면, 뷰 모델에서 fetchThumbnail 메서드가 호출됩니다. 이 메서드는 문자열을 일련의 단계를 거쳐 UIImage로 변환합니다. 먼저, 뷰 모델의 thumbnailURLRequest 메서드가 문자열을 URLRequest로 변환합니다. 다음으로, URLSession의 dataTask 메서드가 해당 요청의 데이터를 가져옵니다. 그런 다음, UIImage의 initWithData 메서드가 해당 데이터를 이용해 이미지를 생성하고, 마지막으로 UIImage의 prepareThumbnail 메서드가 원본 이미지에서 썸네일을 렌더링합니다. 이러한 모든 작업은 이전 단계의 결과에 의존하기 때문에 순차적으로 수행되어야 합니다.

이 작업들 중 일부는 빠르게 값을 반환합니다. 예를 들어, 문자열에서 URLRequest를 생성하거나, 데이터에서 UIImage를 만드는 작업은 즉시 완료되므로 현재 실행 중인 스레드에서 동기적으로 처리해도 문제가 없습니다. 하지만 일부 작업은 시간이 걸립니다. 예를 들어, 이미지를 구성하는 모든 데이터를 다운로드하는 데는 시간이 필요하며, 고품질 썸네일을 렌더링하는 작업도 기기에서 상당한 연산을 요구합니다. 이런 이유로, SDK는 이러한 작업을 수행할 수 있도록 비동기 함수를 제공합니다. 따라서 이러한 호출들은 비동기적으로 실행되는 것이 적절합니다.
로버트와 제가 async/await을 사용하기 전에, 우리는 이 함수를 완료 핸들러를 사용하여 작성하고 있었습니다.

이 함수는 두 개의 인자를 받습니다. 첫 번째 작업의 입력값이 되는 문자열과 결과를 호출자에게 반환하는 데 사용되는 완료 핸들러입니다. fetchThumbnail 함수가 호출되면, 먼저 thumbnailURLRequest를 호출합니다. 이 메서드는 동기적으로 동작하므로 완료 핸들러가 필요하지 않습니다. 그다음, URLSession 인스턴스에서 dataTask를 호출하면서 URLRequest와 완료 핸들러를 전달합니다. 이 호출은 동기적으로 URLSessionDataTask 객체를 생성하며, 비동기 작업을 시작하려면 resume()을 호출해야 합니다. 이후 fetchThumbnail 함수는 반환되며, 스레드는 다른 작업을 수행할 수 있게 됩니다. 이는 매우 중요한데, 이미지 다운로드는 시간이 걸리므로, 데이터를 받아올 때까지 스레드를 블로킹하는 것은 바람직하지 않기 때문입니다. 결국, 이미지 다운로드가 완료되거나 오류가 발생하게 됩니다. 어느 경우든 요청이 완료되면, dataTask에 전달된 완료 핸들러가 호출되며, 여러 개의 옵셔널 값(데이터, 응답, 오류)을 반환합니다.

오류 발생 시, 우리는 완료 핸들러를 호출하고 오류를 전달해야 합니다. 만약 모든 과정이 정상적으로 진행되었다면, UIImage의 initWithData를 사용하여 데이터로부터 이미지를 생성합니다. 이 메서드는 동기적으로 동작하므로 결과를 처리하는 코드를 일반적인 순차적 코드로 작성할 수 있습니다. 이미지를 생성하지 못했다면 작업을 종료합니다. 그러나 이미지가 생성되었다면, 마지막으로 UIKit의 prepareThumbnail 메서드를 호출하고 완료 핸들러를 전달합니다. 이 작업이 진행되는 동안, 스레드는 블로킹되지 않으며 다른 작업을 수행할 수 있도록 해제됩니다. 썸네일 준비가 완료되면, 해당 완료 핸들러가 호출됩니다. 썸네일 생성이 성공하면 이미지가 전달되며, 실패하면 nil이 반환됩니다. 만약 성공했다면, 최종적으로 완료 핸들러를 호출해 생성된 이미지를 전달합니다.

하지만 로버트가 지적했듯이, 여기에는 문제가 있습니다. fetchThumbnail의 호출자는 이 함수의 작업이 끝났을 때(설령 실패하더라도) 반드시 알림을 받아야 합니다. 그런데 현재 코드에서는 호출자가 아무런 응답도 받지 못하는 상황이 발생할 수 있습니다. 저는 "guard else return"을 너무 익숙하게 사용하다 보니, 완료 핸들러를 두 번 호출해야 한다는 사실을 깜빡했습니다. 따라서, 데이터에서 UIImage를 생성하거나 썸네일을 준비하는 과정에서 실패하면, fetchThumbnail을 호출한 쪽에서는 아무런 알림도 받지 못하게 됩니다. 그 결과, 해당 리스트의 행은 업데이트되지 않고, 로딩 스피너(spinner)가 계속 돌아가기만 할 것입니다.
그래서 fetchThumbnail을 작성하는 우리 같은 개발자들은, 어떤 일이 발생하든 호출자에게 반드시 알림을 보내는 것이 매우 중요합니다. 즉, 함수의 모든 실행 경로에서 호출자가 응답을 받을 수 있도록 해야 합니다. 이를 위해, 오류가 발생하면 완료 핸들러를 호출하고 오류를 전달해야 합니다. 일반적인 함수에서는 오류를 호출자에게 반환할 때 throw를 사용합니다. Swift는 함수가 어떻게 실행되든 간에, 반환값이 없는 경우 반드시 오류가 발생하도록 보장해 줍니다. 하지만 여기서는 Swift의 일반적인 오류 처리 방식을 사용할 수 없습니다. 즉, 완료 핸들러 내부에서 오류가 발생했다고 해서 단순히 throw를 사용할 수 없습니다.
이것이 문제인 이유는 Swift가 우리의 실수를 체크해 줄 수 없기 때문입니다. fetchThumbnail에서 사용하는 완료 핸들러는 Swift 입장에서 단순한 클로저일 뿐입니다. 우리는 반드시 완료 핸들러가 호출되도록 하고 싶지만, Swift는 이를 강제할 방법이 없습니다. 그래서 guard문에서 단순히 return을 했을 때도 컴파일 오류가 발생하지 않았습니다. 결국, 로버트가 문제를 지적해 주기 전까지 저는 이 실수를 인지하지 못했습니다. 결국, 완료 핸들러가 항상 호출되도록 하는 것은 개발자의 책임입니다.
우리가 처음 이 함수를 작성할 때 목표는 단순했습니다. 몇 가지 작업을 순차적으로 실행하려 했죠. 그중 두 개는 동기적이었고, 나머지 두 개는 완료 핸들러를 사용하는 비동기 작업이었습니다. 우리는 이 목표를 달성하긴 했지만, 그 과정에서 약 20줄에 달하는 코드가 생겼고, 그 안에는 미묘한 버그가 숨어 있을 가능성이 다섯 번이나 있었습니다. 우리가 원했던 것은 네 가지 작업을 순차적으로 수행하는 것이었습니다. 그러나 실제로 작성된 코드는 이해하기 어렵고, 실수하기 쉬우며, 우리의 의도를 명확하게 드러내지 못했습니다.

물론, 이 코드를 조금 더 안전하게 만들 방법이 있었습니다. 예를 들어, 표준 라이브러리의 Result 타입을 사용할 수도 있었습니다. 이렇게 하면 약간 더 안전해지긴 하지만, 코드가 불필요하게 복잡해지고 길어지는 단점이 있습니다. 또한, 일부 개발자들은 Future 같은 기법을 사용하여 비동기 코드를 개선하기도 합니다. 하지만 이러한 접근 방식들도 코드가 단순하고, 이해하기 쉽고, 안전한 형태가 되도록 보장해 주지는 않습니다. 그러나 async/await를 사용하면 더 좋은 해결책을 만들 수 있습니다. 그래서 로버트와 저는 네 가지 단계를 수행하는 이 함수를 async/await를 사용하여 다시 작성했습니다.

이 함수는 여전히 문자열을 인수로 받습니다. 하지만 이전에는 완료 핸들러도 함께 전달해야 했던 반면, 이번에는 함수가 async로 선언되었습니다. 함수를 async로 선언할 때는, 함수 선언부에서 throws 키워드가 있다면 그 앞에 async를 추가해야 합니다. 만약 throws가 없다면, 반환 타입의 앞에 async를 붙이면 됩니다. 함수를 async로 선언하면 코드와 함수 선언부가 훨씬 간결해집니다. 썸네일 생성이 성공하면, 단순히 그 이미지를 반환하면 되고, 오류가 발생하면 throw를 사용해 던지기만 하면 됩니다. 이제 fetchThumbnail이 호출되면, 이전과 마찬가지로 thumbnailURLRequest를 먼저 호출합니다. 이 함수는 동기적인 함수이므로, 실행되는 동안 스레드는 블로킹됩니다.
그다음, fetchThumbnail은 URLSession 인스턴스에서 data(for: request)를 호출하여 데이터를 다운로드하기 시작합니다. 이 메서드는 dataTask와 마찬가지로 Foundation에서 제공하는 비동기 함수입니다. 하지만 dataTask와 달리, data(for:)는 대기할 수 있는 메서드입니다. 즉, 이 메서드를 호출하면 함수는 즉시 일시 중단(suspend) 되며, 스레드는 블로킹되지 않고 다른 작업을 수행할 수 있습니다.
여기서 try가 사용된 이유는 data 메서드가 throws로 선언되어 있기 때문입니다. 이전 버전에서는 오류가 발생했는지 확인한 뒤, 명시적으로 완료 핸들러를 호출해야 했던 것을 기억하시죠? 하지만 대기할 수 있는 버전에서는 이 모든 코드가 단순히 try 한 줄로 축약됩니다. throws로 선언된 함수를 호출할 때 try가 필요한 것처럼, async로 선언된 함수를 호출할 때는 await이 필요합니다. 만약 하나의 표현식에서 여러 개의 async 함수가 호출된다면, 마치 여러 개의 예외를 던질 수 있는 함수가 있을 때 try를 한 번만 쓰면 되는 것처럼, await도 한 번만 쓰면 됩니다. 따라서 최종적으로 함수 호출은 try await으로 표시됩니다. 그리고 async 함수가 throws까지 포함하는 경우, try는 await 앞에 와야 합니다. 즉, 위와 같은 형식으로 작성해야 합니다.
결국, 데이터 다운로드가 완료되면 data 메서드가 다시 재개(resume) 되어 fetchThumbnail로 돌아옵니다. 이 시점에서 data 메서드가 반환하는 값들이 들어오거나, 오류가 발생했을 경우 오류가 전달됩니다. 만약 data 메서드가 오류를 던지면, fetchThumbnail도 그대로 해당 오류를 던지게 됩니다. 그렇지 않다면, data와 response 변수에 값이 할당됩니다. 이 과정은 이전 버전에서 URLSession의 dataTask 메서드에 전달된 완료 핸들러가 호출되었을 때와 유사합니다. 하지만 async/await을 사용하면 코드가 더 직관적이고 간결해집니다.
두 버전 모두에서 URLSession의 비동기 메서드가 생성한 값과 오류가 흐르게 됩니다. 하지만 대기할 수 있는 버전은 훨씬 더 간결합니다. 이 방식은 우리가 원하는 바를 정확하게 표현해 줍니다. “이 요청을 보내고, 반환된 값을 변수에 할당하여 사용하라. 그리고 문제가 발생하면 오류를 던져라.” 이처럼 async/await을 사용하면 코드가 더욱 직관적이고 가독성이 높아집니다.
그다음, fetchThumbnail은 다운로드한 데이터를 이용해 UIImage를 생성하려고 시도합니다. 만약 성공하면, 해당 이미지의 thumbnail 속성을 사용하여 썸네일을 렌더링합니다. 이 과정에서 스레드는 블로킹되지 않으며, 썸네일 프로퍼티가 최종적으로 실행을 재개하고 fetchThumbnail 로 값을 반환할 때까지 다른 작업을 수행할 수 있습니다. 썸네일이 정상적으로 렌더링되면, fetchThumbnail은 이를 반환합니다. 그렇지 않다면, 오류를 던지게 됩니다.
완료 핸들러 버전과 달리, 썸네일이 생성되지 않을 경우 Swift가 반드시 오류를 던지거나 값을 반환하도록 강제합니다. 즉, 조용히 실패(silently fail)하는 일이 없습니다. 이게 전부입니다. 우리가 필요한 모든 코드가 여기에 담겨 있습니다. 이 함수는 이전의 완료 핸들러 버전과 동일한 작업을 수행하지만, 20줄이었던 코드가 단 6줄로 줄었습니다. 그리고 모든 코드가 순차적(straight-line)으로 작성되어 있어 가독성이 훨씬 좋아졌습니다.

순차적으로 실행해야 하는 네 가지 작업이 그냥 순서대로 나열되어 있을 뿐입니다. 그리고 Swift는 반드시 호출자에게 작업 완료를 알리도록 강제합니다. 즉, 정상적으로 실행되면 값을 반환하고, 문제가 발생하면 오류를 던지도록 보장합니다. 이것은 async/await이 비동기 코드를 어떻게 변화시킬 수 있는지 보여주는 한 가지 예시일 뿐입니다. 이를 활용하면 코드를 더 안전하게, 더 짧게 만들 수 있으며, 우리의 의도를 더욱 명확하게 표현할 수 있습니다.
이제 fetchThumbnail의 구현을 좀 더 자세히 살펴보겠습니다. 마지막에서 두 번째 줄을 보면, 함수 호출이 없음에도 불구하고 await이 붙어 있습니다. 그 이유는 thumbnail 프로퍼티가 async이기 때문입니다. 비동기가 적용될 수 있는 대상은 함수뿐만이 아닙니다. 프로퍼티도 async가 될 수 있으며, 생성자 역시 가능합니다. 그런데 thumbnail 프로퍼티는 SDK의 일부가 아닙니다. 사실, 이 프로퍼티는 로버트가 직접 추가한 것입니다. 이제 thumbnail 프로퍼티가 어떻게 구현되었는지 살펴보겠습니다.

로버트는 UIImage의 익스텐션에 thumbnail 프로퍼티를 정의했습니다. 이 프로퍼티의 구현은 매우 간결합니다. 먼저 CGSize를 생성한 후, 이를 byPreparingThumbnail(ofSize:) 메서드에 전달하고 그 결과를 await으로 기다립니다. 참고로, 이 메서드는 우리가 이전에 사용했던 동기 메서드의 대기할 수 있는 버전입니다.
여기서 주목할 점이 몇 가지 있습니다. 첫째, 명시적인 getter가 사용되었습니다. 이는 프로퍼티를 async로 선언하려면 반드시 필요합니다. Swift 5.5부터는 프로퍼티의 getter도 throws를 사용할 수 있습니다. 그리고 async와 throws를 모두 사용하는 경우, 함수 선언부와 마찬가지로 async 키워드는 throws 앞에 와야 합니다.
둘째, 이 프로퍼티에는 setter가 없습니다. 즉, 읽기 전용(read-only) 프로퍼티만 async로 선언할 수 있습니다.

함수, 프로퍼티 그리고 생성자에서 await은 특정 표현식이 실행될 때 스레드가 블로킹되지 않음을 나타냅니다. 하지만 await을 사용할 수 있는 또 다른 곳이 있습니다. 바로 for 루프에서 비동기 시퀀스(async sequence)를 반복할 때입니다. 비동기 시퀀스는 일반 시퀀스와 유사하지만, 요소를 비동기적으로 제공합니다. 따라서 다음 요소를 가져올 때(next 함수가 호출될 때), await을 사용해야 하며, 이는 비동기 작업임을 의미합니다.
이 함수가 비동기 시퀀스를 반복적으로 순회할 때, 다음 요소를 기다리는 동안 스레드는 해제될 수 있으며, 그런 다음 반복문의 구현부에서 다음 요소와 함께 재개되거나, 남은 요소가 없으면 반복문 이후로 다시 재개될 수 있습니다. 비동기 시퀀스에 대해 더 알고 싶다면 “Meet AsyncSequence” 세션을 시청하세요. 또한, 여러 비동기 작업을 병렬로 실행하는 것에 관심이 있다면 “Structured Concurrency in Swift” 세션을 확인해 보세요.
따라서 await를 사용할 수 있는 곳이 많습니다. 이 키워드는 비동기 함수가 해당 지점에서 일시 중단될 수 있음을 나타냅니다. 그렇다면 비동기 함수가 일시 중단된다는 것은 무엇을 의미할까요? 이를 이해하기 위해 함수 호출 시 어떤 일이 발생하는지 생각해 봅시다.

어떤 함수를 호출하면 현재 실행 중인 스레드의 제어권은 해당 함수로 넘어갑니다. 여기서 thumbnailURLRequest와 같은 일반 함수를 호출하는 경우, 스레드는 해당 함수가 완료될 때까지 그 작업을 수행하는 데 완전히 점유됩니다.
그 작업은 함수 구현부 내에서 이루어질 수도 있고, 호출된 다른 함수들에서 수행될 수도 있습니다. 결국 해당 함수는 값을 반환하거나 오류를 발생시키면서 종료됩니다. 함수가 종료되면 스레드 제어권은 다시 호출한 함수로 돌아갑니다. 일반 함수가 스레드 제어권을 넘길 수 있는 유일한 방법은 종료되는 것입니다. 그리고 그 제어권은 오직 호출한 함수에게만 돌아갑니다.

그러나 호출된 함수가 비동기 함수라면 상황이 다릅니다. 일반 함수처럼 비동기 함수도 실행이 완료되면 종료되고 스레드 제어권을 호출한 함수로 반환합니다. 하지만 일반 함수와 달리, 비동기 함수는 일시 중단을 통해 스레드 제어권을 넘길 수 있습니다.
일반 함수와 마찬가지로, 비동기 함수를 호출하면 해당 함수에 스레드 제어권을 넘깁니다. 비동기 함수가 실행되면 일시 중단될 수 있습니다. 일시 중단될 때, 해당 함수는 스레드 제어권을 포기합니다. 하지만 제어권이 호출한 함수로 돌아가는 대신, 시스템에 넘겨집니다. 이때 호출한 함수도 함께 일시 중단됩니다. 일시 중단은 함수가 시스템에 “당신은 할 일이 많잖아요. 가장 중요한 작업을 결정하세요.”라고 말하는 방식입니다. 꽤 협조적이죠? 함수가 스스로 일시 중단되면, 시스템은 해당 스레드를 자유롭게 사용하여 다른 작업을 수행할 수 있습니다. 그러다 어느 순간 시스템은 이전에 일시 중단된 비동기 함수를 다시 실행하는 것이 가장 중요한 작업이라고 판단하게 됩니다. 그러면 시스템은 그 함수를 다시 재개하며, 비동기 함수는 스레드 제어권을 되찾고 작업을 계속 진행할 수 있습니다. 그리고 필요하면 다시 일시 중단될 수도 있습니다. 사실, 필요에 따라 원하는 만큼 여러 번 일시 중단될 수 있습니다.
반면, 비동기 함수가 반드시 일시 중단될 필요는 없습니다. 비동기 함수는 일시 중단될 수 있지만, async로 표시되었다고 해서 반드시 일시 중단되는 것은 아닙니다. 마찬가지로, await 키워드가 있다고 해서 그 지점에서 반드시 일시 중단되는 것은 아닙니다. 하지만 결국, 한 번도 일시 중단되지 않든 여러 번 중단 후 다시 실행되든 비동기 함수는 종료됩니다. 그리고 실행이 끝나면, 값을 반환하거나 오류를 발생시키면서 스레드 제어권을 호출한 함수로 돌려줍니다.

fetchThumbnail 함수가 일시 중단될 때 어떤 일이 일어나는지 다시 살펴보겠습니다. fetchThumbnail 함수가 URLSession의 data 메서드를 호출하면, data 메서드는 일반적인 방식이 아닌, 오직 비동기 함수만이 할 수 있는 특별한 방식으로 실행을 멈춥니다. 즉, 일시 중단됩니다. 이 과정에서 data 메서드는 스레드 제어권을 시스템에 넘기고, 시스템에 URLSession의 data 메서드 작업을 스케줄링하도록 요청합니다.
하지만, 이 시점에서는 시스템이 스레드 제어권을 가지고 있으며, 해당 작업이 즉시 시작되지 않을 수도 있습니다. 대신, 스레드는 다른 작업에 사용될 수 있습니다. 어떻게 이런 일이 발생할 수 있는지 살펴보겠습니다. 예를 들어, fetchThumbnail이 호출된 후 사용자가 버튼을 눌러 게시물에 반응을 남기는 경우를 생각해 봅시다. 그러면 시스템은 이전에 스케줄링된 작업보다 사용자의 반응을 서버에 전송하는 작업을 우선 실행할 수 있습니다.
사용자의 반응을 처리하는 작업이 완료되면, URLSession의 data 메서드가 다시 실행될 수 있습니다. 하지만 시스템은 그 전에 다른 작업을 실행할 수도 있습니다. 마지막으로, data 메서드가 완료되면 fetchThumbnail 함수로 실행 흐름이 돌아옵니다. 비동기 함수가 일시 중단되는 동안 다른 작업이 수행될 수 있다는 사실은 Swift에서 비동기 호출에 await 키워드를 반드시 사용해야 하는 이유를 보여줍니다.

함수가 일시 중단되는 동안 앱의 상태가 크게 변경될 가능성이 있기 때문에, 이를 명확하게 인식할 필요가 있습니다. 이는 완료 핸들러를 사용할 때도 마찬가지입니다. 하지만 async/await 코드에서는 완료 핸들러에서 발생하는 복잡한 구문과 중첩 없이 비동기 작업을 표현할 수 있습니다. await 키워드는 코드 블록이 하나의 연속적인 실행 흐름으로 동작하지 않을 수 있다는 점을 보여줍니다. 즉, 함수가 일시 중단될 수 있으며, 그 사이에 다른 작업들이 실행될 수도 있습니다. 그뿐만 아니라, 해당 함수는 완전히 다른 스레드에서 다시 실행될 수도 있습니다. 이러한 문제에 대해 알아보려면 “Protect mutable state with Swift actors” 세션을 참조하세요.

다음은 async/await에 대해 기억해야 할 몇 가지 중요한 사항입니다. 첫째, 함수를 async로 표시하면 해당 함수가 일시 중단될 수 있도록 허용하는 것입니다. 그리고 함수가 자체적으로 일시 중단되면 이를 호출한 함수들도 함께 일시 중단됩니다. 따라서 호출하는 함수들도 async여야 합니다. 둘째, async 함수에서 한 번 또는 여러 번 일시 중단될 수 있는 위치를 명확히 하기 위해 await 키워드를 사용합니다. 셋째, async 함수가 일시 중단되는 동안 스레드는 블로킹되지 않습니다. 따라서 시스템은 자유롭게 다른 작업을 스케줄링할 수 있습니다. 심지어 나중에 시작된 작업이 먼저 실행될 수도 있습니다. 즉, 함수가 일시 중단되는 동안 앱의 상태가 크게 변경될 수 있습니다. 마지막으로, async 함수가 다시 재개될 때 호출했던 async 함수에서 반환된 결과가 원래 함수로 흘러들어가고, 실행은 중단된 지점부터 계속됩니다.
Swift에서 async/await가 어떻게 작동하는지 보았습니다. 이제 로버트가 여러분의 프로젝트에서 이를 활용하는 방법을 보여드릴 것입니다.
로버트 위드만: 감사합니다, 네이트. 앞서 네이트가 우리가 함께 개발 중인 앱을 보여줬죠. 그가 async/await을 적용하도록 변환한 썸네일 함수는 여러 곳에서 호출되기에 이 부분들도 동시에 처리하도록 마이그레이션해야 합니다.


현대 소프트웨어 개발에서 중요한 것부터 시작해 보겠습니다. 바로 테스트입니다. 우리는 비동기 코드를 동기 코드만큼 쉽게 테스트하기를 원했고, 그래서 XCTest는 기본적으로 async를 지원합니다.
기존에는 Expectation을 설정하고, 테스트할 API를 호출한 뒤, Expectation을 충족(fullfill)시키고, 임의의 시간만큼 대기하는 번거로운 과정이 필요했지만, 이제는 단순히 테스트 함수에 async 키워드를 추가하고, XCTestExpectation, fullfill과 _wait코드를 제거한 후, 네이트가 앞서 보여준 새로운 fetchThumbnail 비동기 함수를 호출하여 그 결과를 기다리기만 하면 됩니다.
테스트를 완료하였으니 이제 애플리케이션 코드 자체를 자세히 살펴보겠습니다. 특히, 이 리스트의 각 행에 있는 썸네일 뷰를 구성하는 SwiftUI 코드에 집중해 보겠습니다.


이미지 셀은 post와 함께 생성되며, 각 post에는 고유한 ID가 있습니다. 이 ID를 뷰 모델에 전달하면 비동기적으로 썸네일을 가져올 수 있습니다. 테스트 코드에서 이 호출을 변환하는 방법을 이미 보았으니 직접 시도해 보겠습니다. 먼저, 완료 핸들러를 제거합니다. 그 다음, 오류 처리를 위해 try를 추가하고, 비동기 함수 호출을 완료하기 위해 await을 추가합니다. 하지만 이렇게 변경한 후 빌드를 시도하면 문제가 발생합니다. Swift 컴파일러가 비동기 함수는 비동기 컨텍스트 내에서만 호출할 수 있다고 알려줍니다. 여기서 onAppear 수정자는 일반적인 동기 클로저를 사용하므로, 동기 코드와 비동기 코드를 서로 연결할 방법이 필요합니다.

해결책은 Task 구조체를 사용하는 것입니다. Task 구조체는 클로저 내부의 작업을 패키징하여 시스템에 전달하며, 사용 가능한 다음 스레드에서 즉시 실행됩니다. 이는 글로벌 디스패치 큐에서 비동기 함수가 실행되는 방식과 유사합니다. 여기서 가장 큰 장점은 동기 컨텍스트 내에서도 비동기 코드를 호출할 수 있다는 점입니다. 코드를 다시 빌드하면, 이번에는 컴파일됩니다. Task 구조체는 동시성을 활용한 Swift 코드를 익숙하고 자연스러운 구조로 작성할 수 있도록 돕는 API의 일부입니다.
더 자세히 알아보려면 “Explore structured concurrency in Swift” 세션을 확인하세요. 또한, SwiftUI 앱에서 비동기 코드를 최대한 활용하는 방법을 알고 싶다면 “Discover concurrency in SwiftUI” 세션을 참고하세요.

이제 fetchThumbnail 함수를 호출하던 모든 곳에서 마이그레이션을 완료했습니다. 하지만 우리 앱에는 async/await를 적용할 수 있는 더 많은 코드가 남아 있습니다. 빠르게 시작하려면, 기존 API에 새로운 비동기 코드를 점진적으로 도입하는 것이 좋습니다. SDK에는 수백 개의 API가 제공되며, 이 API들은 비동기적으로 작업을 수행하기 위해 완료 핸들러를 사용합니다. 이러한 API들을 나란히 비교해 보면, 일정한 패턴이 보이기 시작할 것입니다.

비록 이름과 목적이 다를 수 있지만, 이러한 함수들은 모두 동일한 기본 API 계약을 따릅니다. 즉, 함수를 호출하면 제공된 완료 핸들러로 결과를 전달해 다시 호출자에게 응답하는 방식입니다. 앞서 네이트가 보여준 것처럼, 비동기 함수의 결과를 기다릴 수 있다면 더 자연스럽게 코드를 작성할 수 있습니다. 그렇다면 이러한 콜백 블록을 비동기 함수로 변환할 수 있다면 정말 편리하지 않을까요?

Swift 5.5부터는 바로 이것이 가능해졌습니다. Swift 컴파일러는 Objective-C에서 가져온 완료 핸들러 기반 코드를 자동으로 분석하여 async 코드로 제공합니다. 하지만 여기서 멈추지 않았습니다. 많은 델리게이트 API도 완료 핸들러를 사용하는 메서드를 포함하고 있습니다. 이러한 핸들러를 호출하면 비동기 작업이 완료되었음을 프레임워크에 알릴 수 있습니다.

예를 들어, ClockKit 컴플리케이션 데이터 소스에서 fetchThumbnail을 호출하여 특정 게시물에 대한 타임라인 항목을 표시하는 경우를 생각해봅시다. 이전과 마찬가지로, 모든 코드 경로에서 완료 핸들러를 호출해야 합니다. 그리고 클로저로 인해 불필요한 코드가 많아져 가독성이 떨어지는 문제가 있습니다.

async/await를 사용하면 더 이상 이런 번거로움이 필요하지 않습니다. 이 델리게이트는 대신 사용할 수 있는 비동기 함수를 제공합니다. 먼저, 비동기 함수에서 get과 같은 단어를 제거합니다. 우리는 일반적으로 비동기 함수에서 호출 결과가 즉시 반환되지 않는 경우, 이러한 단어를 생략하는 것을 권장합니다. 결국, 이 함수는 비동기적으로 타임라인 항목을 직접 반환하므로 get을 포함할 필요가 없습니다. 이제 비동기 컨텍스트가 설정되었으므로 fetchThumbnail의 비동기 버전을 호출할 수 있습니다. 마지막으로, 이제는 더 이상 완료 핸들러를 호출할 필요 없이 메서드에서 타임라인 항목을 직접 반환하면 됩니다.
우리가 여기서 다룬 비동기 API들은 빙산의 일각에 불과합니다. 더 자세한 내용을 알고 싶다면, 아래 세션들을 참고하세요. 이 세션들은 API 자체와 async/await를 적용하는 방법에 대해 훨씬 깊이 있게 다룹니다.
이 모든 예제들은 Swift가 자동으로 비동기 코드를 생성해 주는 상황들입니다. 하지만 코드 내에서 직접 비동기 코드를 만들어야 하는 경우도 반드시 발생할 것입니다. 그럼, 실제로 어떻게 구현할 수 있는지 살펴보겠습니다.

우리 앱에서는 getPersistentPosts 함수를 사용하여 Core Data에 저장된 게시물을 가져옵니다. 이 함수는 fetchThumbnail 비동기 함수보다 훨씬 더 많은 곳에서 호출되므로 모든 호출을 한꺼번에 비동기로 변경하는 것은 큰 변화가 될 수 있습니다. 또한, NSAsynchronousFetchRequest를 사용하고 있기 때문에, 이 함수를 비동기 코드로 변환하는 게 적합해 보입니다.

먼저, 비동기 함수를 만들고 반환 값을 작성합니다. 이 함수는 오류가 발생할 가능성이 있으므로 throws 키워드를 추가하여 오류를 던질 수 있도록 합니다. 그다음, 완료 핸들러 버전의 getPersistentPosts를 호출하려고 하지만, 여기서 문제가 발생합니다.
완료 핸들러의 결과를 persistentPosts 비동기 함수를 호출한 곳으로 반환해야 합니다. 뿐만 아니라, 이 함수의 호출자들은 현재 일시 중단된 상태이므로 올바른 시점에 올바른 데이터를 사용해 다시 재개될 수 있도록 해야 합니다. 그래야만 나머지 작업을 정상적으로 이어갈 수 있습니다. 앞서 네이트가 Swift와 시스템이 협력하여 비동기 코드를 자동으로 재개하는 방법을 보여주었습니다. 이제 일시 중단 및 재개 과정이 어떻게 작동하는지 더 깊이 살펴보고, 이를 활용해 현재 문제를 해결할 수 있는 방법을 찾아보겠습니다.

비동기 버전의 persistentPosts가 호출되면, Core Data에 데이터를 요청합니다. 그리고 나중에 Core Data가 완료 핸들러를 호출하여 가져온 결과를 전달합니다. 이 상황은 이전에 네이트가 보여준 사례와 거의 동일합니다. 당시 fetchThumbnail 함수는 Core Data가 아니라 시스템에 비동기 함수 호출을 재개하도록 요청했었습니다.
여기서 필요한 것은 완료 핸들러를 대기하도록 하고, 가져온 요청 결과와 함께 실행을 재개할 수 있도록 연결해 주는 브리지(bridge)입니다. 이러한 패턴은 매우 자주 등장하며, 이를 컨티뉴에이션(Continuation)이라고 합니다. 이번 세션에서 네이트와 저는 이미 여러 개의 컨티뉴에이션 예제를 보여드렸습니다. 즉, 완료 핸들러를 사용하는 메서드들이 바로 컨티뉴에이션의 대표적인 예입니다.
메서드의 호출자는 함수 호출 결과를 기다리며, 이후 실행할 작업을 정의하는 클로저를 제공합니다. 함수 호출이 완료되면, 완료 핸들러가 호출되어 호출자가 원하는 작업을 결과와 함께 재개할 수 있도록 합니다. 이러한 협력적인 실행 방식은 Swift의 비동기 함수가 작동하는 원리와 동일합니다. 이를 더 명확하게 표현할 수 있도록, Swift는 고수준에서 안전하게 컨티뉴에이션을 생성, 관리, 재개할 수 있는 기능을 제공합니다.

우리 예제로 돌아가서, 컨티뉴에이션을 활용하여 비동기 코드를 완성하는 방법을 살펴보겠습니다. withCheckedThrowingContinuation 함수는 오류를 포함하는 완료 핸들러에서 예외를 던질 수 있는 비동기 함수로 변환해 줍니다. 오류를 발생시키지 않는 함수의 경우, 대응되는 withCheckedContinuation을 사용할 수 있습니다. 이러한 함수들은 일시 중단된 비동기 함수 실행을 재개할 수 있도록 컨티뉴에이션 값을 제공하는 핵심 도구입니다. 이를 활용하면 getPersistentPosts 호출을 기다릴 수 있도록 연결하는 첫 번째 브리지를 구축할 수 있습니다.
이제 브리지를 완성해 보겠습니다. 컨티뉴에이션 값을 사용하면 resume 함수를 호출할 수 있으며, 여기에서 완료 핸들러의 결과를 전달합니다. 뿐만 아니라, resume은 persistentPosts 함수의 결과를 기다리며 일시 중단된 호출을 다시 재개하는 핵심 연결 고리 역할을 합니다. 이렇게 해서 완료 핸들러에서 비동기 함수로 변환하는 완전한 브리지가 하나의 깔끔한 패턴으로 완성되었습니다.

컨티뉴에이션은 비동기 함수의 실행을 수동으로 제어할 수 있는 강력한 방법을 제공하지만, 몇 가지 유의해야 할 사항이 있습니다. 컨티뉴에이션은 단순하지만 중요한 계약을 따릅니다. 모든 경로에서 resume이 정확히 한 번 호출되어야 합니다. 하지만 걱정할 필요는 없습니다. Swift가 이를 검사해 줍니다. 만약 컨티뉴에이션이 resume을 호출되지 않은 상태로 종료되면, Swift 런타임은 이를 경고합니다. 왜냐하면 이는 비동기 호출이 계속 중단된 상태로 남아 있게 만들기 때문입니다.
그러나 하나의 함수 내에서 컨티뉴에이션이 여러 번 재개되면 더 심각한 오류로 이어질 수 있으며 프로그램 데이터가 손상될 가능성이 있습니다. 이를 방지하기 위해 Swift 런타임은 resume이 여러 번 호출되는 시도를 감지하고, 두 번째 호출 시 치명적인 오류(fatal error)가 발생하도록 합니다. 이를 염두에 두고, 이제 CheckedContinuation을 사용할 수 있는 또 다른 중요한 경우를 살펴보겠습니다.

많은 API는 이벤트 기반으로 동작합니다. 이러한 API는 특정 중요한 지점에서 우리 애플리케이션에 알림을 주기 위해 델리게이트 콜백을 제공하며, 이를 통해 적절한 대응이 가능하게 합니다. async/await을 올바르게 적용하려면 컨티뉴에이션 객체를 저장하고 나중에 다시 호출해야 합니다. 이전과 마찬가지로, 우리는 CheckedContinuation을 생성합니다. 그런 다음 이를 저장하고 작업을 시작합니다.
CheckContinuation의 API 계약을 준수하기 위해서 반드시 컨티뉴에이션을 재개한 후 nil로 설정하여 한 번 이상 호출하는 실수를 방지해야 합니다. 항상 기억하세요. 여기서 컨티뉴에이션 값은 해당 API의 비동기 호출을 수동으로 재개할 수 있는 기능을 나타내므로, 모든 경로에서 반드시 호출되어야 합니다. 만약 델리게이트 API가 여러 번 호출되거나 특정 상황에서 전혀 호출되지 않는다면, 컨티뉴에이션을 정확히 한 번만 재개하는 것이 매우 중요합니다.
Swift의 동시성(Concurrency) 및 연속성을 포함한 더 낮은 수준의 세부 사항을 알아보려면 “Swift Concurrency: Behind the Scenes” 세션을 참고하세요.
지금까지 Swift의 async/await에 대한 빠른 개요를 살펴보았습니다. 런타임에서 async와 await 키워드가 어떻게 작동하는지, 그리고 이를 애플리케이션과 프레임워크에 어떻게 적용할 수 있는지 설명했습니다. 또한, 시작을 돕기 위해 SDK에서 제공하는 일부 비동기 API를 소개하고, 기존의 동기 코드에서 비동기 코드로 전환하는 방법을 보여드렸습니다.
async/await은 Swift 동시성 기능의 거대한 생태계를 구성하는 기초입니다. 이를 활용하여 여러분이 만들어낼 멋진 것들을 기대하고 있습니다. 시청해 주셔서 감사합니다.