-
Async/await in SwiftiOS 2022. 12. 3. 17:56
비동기 프로그래밍은 복잡해서 잘못된 비동기 코드를 작성하기 쉽습니다. 클로저가 중첩될수록 구조가 더욱 복잡해지며 이에 따라 에러 처리가 어려워지고, 실수로 결과 처리 블록을 빼먹을 수도 있습니다. 하지만 async/await로 이를 해결할 수 있을 뿐만이 아니라 코드도 간결해집니다.
기존에 completion handler로서 escaping closure를 매개변수로 받아 사용하던 것을, async/await 패턴을 사용하면 코드를 더 쉽게 읽고 이해할 수 있습니다. 이는 비동기 코드를 일반 코드처럼 작성할 수 있기 때문입니다. 따라서 코드를 작성하면서 혼란을 겪지 않으며, 의도를 더욱 정확하게 전달할 수 있습니다. 제가 기존의 방식으로 작성했던 코드에 async/await를 적용하면서 어떻게 변하는지 알아보겠습니다.
우선, API 호출을 하여 데이터를 받아오기 위해 사용하는 메서드를 알아보겠습니다.
URLSession의 dataTask(with:completionHandler:)의 declaration
func dataTask( with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void ) -> URLSessionDataTask
지정된 URLRequest 객체를 기반으로 URL의 콘텐츠를 가져오는 작업을 만듭니다. 사진, JSON 포맷의 파일 등의 데이터를 가져오는 오랜 시간이 걸리는 작업은 비동기로 작동될 필요가 있습니다. 그러면 이 메서드를 실행하는 동안 스레드는 다른 작업을 수행할 수 있습니다. 그리고 작업이 완료되는 어느 시점에는 다시 돌아와서 처리해줘야 합니다. 이때, completionHandler를 호출하여 알려줍니다. 물론 작업이 완료된 시점은 사람이 정하며 실수는 여기에 기인한 것입니다.
URLSession의 awaitable method, data(for:delegate:)의 declaration
func data( for request: URLRequest, delegate: URLSessionTaskDelegate? = nil ) async throws -> (Data, URLResponse)
data(for:delegate:)는 표준 라이브러리에서 제공하는 수많은 awaitable 메서드 중 하나입니다. 지정된 URLRequest 객체를 기반으로 URL의 콘텐츠를 다운로드하고 데이터를 비동기식으로 전달합니다.
Return Value
URL의 콘텐츠로서 Data 인스턴스 및 URLResponse를 포함하는 비동기적으로 전송되는 튜플입니다.
다음은 dataTask(with:completionHandler:)를 사용한 예시입니다.
Swift 표준 라이브러리의 Result를 사용한 dataTask(with:completionHandler:)
func requestData(with url: URL, completion: @escaping (Result<Data, FetchError>) -> Void) { let request = URLRequest(url: url) let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in guard error == nil else { completion(.failure(FetchError.internetConnectionProblem)) return } guard let data = data else { completion(.failure(FetchError.didNotReceiveData)) return } guard let response = response as? HTTPURLResponse else { return } // <- forgot to call the completion if !(response.statusCode == 200) { switch response.statusCode { case 401: completion(.failure(FetchError.apiKeyError)) case 404: completion(.failure(FetchError.cityNameError)) default: completion(.failure(FetchError.undefined)) } } completion(.success(data)) } dataTask.resume() }
주석 처리된 부분이 처음에 말했던 실수입니다. guard - else 구문에 익숙해져서 아무런 처리 없이 반환하게 되는 것입니다. 다시 위로 올라가서 dataTask(with:completionHandler:)의 declaration을 보면, completionHandler는 반환 값이 없으므로 컴파일러는 문제가 발생할 수 있다고 인지하지 못합니다. 따라서 completion 블록으로 처리해줘야 하는 곳곳에 위험이 도사리고 있습니다. 테스트하면서 또는 사용하면서 문제가 발생한 적이 없으면 사람도 잘못 작성했다는 것을 인지하지 못하기 때문입니다. 실제로 저는 몇 달 동안 알아차리지 못하다가 async/await를 학습하고 적용해보면서 알게 되었습니다.
기존의 비동기 함수 호출과 에러 처리
func requestWeather(with url: URL) { apiManager.requestData(with: url) { result in switch result { case .success(let data): guard let currentWeather = DecodingManager.decode(with: data, modelType: Weather.self) else { return } self.setBackgroundImage(with: currentWeather.image) self.setCurrentWeather(weather: currentWeather.weather, temperature: currentWeather.temp) case .failure(let error): alertWillAppear(message: apiManager.errorMessage(error)) } } }
Result는 성공 혹은 실패에 대한 정보를 담는 타입입니다. 함수에서 결괏값( + 연관값)을 반환하고 그 결괏값을 처리하는 흐름으로 사용합니다.
requestData(with:completion:) - 결괏값을 반환
requestWeather(url:) - 결괏값을 처리
async/await 패턴을 사용한 data(for:delegate:)
비동기 함수(이 글에서 파란색으로 색칠한 '비동기 함수'는 반환 타입 또는 throws 키워드 이전에 'async'를 표기한 awaitable 함수를 말합니다.)에 throws를 표기하여 에러를 던질 수 있음을 알려줄 수 있습니다.
enum FetchError: Error { case apiKeyError case cityNameError case internetConnectionProblem case didNotReceiveData case undefined }
func requestData(with url: URL) async throws -> Data { let request = URLRequest(url: url) let (data, response) = try await URLSession.shared.data(for: request) //------------값을 반환받을 때까지 함수의 진행 일시 중단------------ // 일시 중단된 동안 다른 일이 발생할 수 있음. 스레드의 제어권을 넘겨줬기 때문. // 다른 스레드에서 재개될 수 있음. guard let response = response as? HTTPURLResponse else { throw FetchError.undefined } if !(response.statusCode == 200) { switch response.statusCode { case 401: throw FetchError.apiKeyError case 404: throw FetchError.cityNameError default: throw FetchError.undefined } } return data }
URLSession의 data(for:delegate:)또한 비동기 함수이기 때문에 await를 표기해 중단할 수 있음을 알려야 합니다. 그리고 URLResponse에 대해서 처리를 해주고(여기에서는 반환 타입이 Void가 아닌 Data이기 때문에 처리 블록이 누락될 우려가 없음.) 일반 함수처럼 데이터를 반환합니다.
async/await를 사용한 비동기 함수 호출과 에러처리
던진 에러를 받기위해서 다음과 같이 do-catch 구문을 사용했습니다.
func requestWeather(with url: URL) async { do { let data = try await apiManager.requestData(with: url) guard let currentWeather = DecodingManager.decode(with: data, modelType: WeatherOfCity.self) else { return } setBackgroundImage(with: currentWeather.image) setCurrentWeather(weather: currentWeather.weather, temperature: currentWeather.temp) } catch { alertWillAppear(message: apiManager.errorMessage(error)) } }
1. 비동기 함수에 대한 호출: 데이터를 받아올 때까지 기다려야 하므로 await를 표기해서 일시 중단
2. 받아온 JSON 형식의 데이터를 디코드
3. 이미지 사용
4. 날씨와 온도 데이터를 사용
5. 에러를 받으면 따로 처리
코드의 흐름이 위에서 아래로 한눈에 들어옵니다. 비동기 코드가 마치 일반 코드처럼요. 이런 장점은 복잡성이 증가할 때 더욱 드러납니다. 아래와 같이 completionHandler를 사용해서 비동기 코드를 작성했을 때, 에러는 Result 타입을 사용해서 쉽게 처리할 수 있지만, 클로저 중첩 문제는 어쩔 수 없습니다. 따라서 흐름을 찾기도 힘들고 코드가 지저분합니다.
func requestImage(url: URL, @escaping completion: Result<Image, Error> -> Void) { requestData(with: url) { result in switch result { case .success(let resource): prepareImage(with: resource) { result in switch result { case .success(let preparedImage): processImage(with: preparedImage) { result in switch result { case .success(let processedImage): completion(.success(processedImage)) case .failure(let error): completion(.failure(error)) } } case .failure(let error): completion(.failure(error)) } } case .failure(let error): completion(.failure(error)) } } }
이를 아래와 같이 async/await를 사용하여 간단하게 개선할 수 있습니다.
func requestData(with url: URL) async throws -> Resource func prepareImage(_ resource: Resource) async throws -> Image func processedImage(_ image: Image) async throws -> Image func requestImage(url: URL) async throws -> Image { let resource = try await requestData(with: url) let preparedImage = try await prepareImage(resource) let processedImage = try await processImage(preparedImage) return processedImage }
Task
Task는 비동기 함수와 함께 작동하는 비동기 작업의 단위입니다. 비동기 코드를 실행하기 위한 새로운 context를 제공합니다. Task 인스턴스를 생성할 때, 수행할 작업을 클로저에 담아서 제공하면 안전하고 효율적일 때 병렬로 실행하도록 자동으로 예약합니다. 생성 후 즉시 실행될 수 있으며, 명시적으로 시작하거나 예약하지 않습니다.
비동기 함수를 호출하기 위해서는 비동기 context 내에서 호출해야 하므로 Task 인스턴스를 생성해서 비동기 작업을 실행합니다. Task는 클로저 내에서 작업을 패키지화하고, global dispatch queue의 async{}처럼, 사용할 수 있는 스레드에서 즉시 실행하기 위해서 시스템으로 보냅니다.
Task { await requestWeather(with: url) }
그룹화
private func requestCityWeather(_ cityName: String) async { // fetch url guard let currentWeatherURL = networkManager.getCurrentWeatherURL(with: cityName), let forecastWeatherURL = networkManager.getForecastURL(with: cityName) else { return } // 비동기 함수 호출 1, 2 guard let currentWeather = await requestCurrentWeather(with: currentWeatherURL), let forecastWeather = await requestForecast(with: forecastWeatherURL) else { return } // 그룹화한 작업을 바탕으로 모델 레이어에 데이터 추가 let city = AnotherCity(name: cityName, currentWeather: currentWeather, forecastWeather: forecastWeather) model.appendCity(city) // diffable datasource apply apply() }
비동기 함수가 완료되지 않으면 코드가 진행되지 않는다는 특성을 활용해서 위와 같이 비동기 작업들을 그룹화할 수도 있습니다.
async/await facts
1. 함수에 async를 표기하면 일시 중단할 수 있음. 함수가 일시 중단되면, 호출자도 일시 중단됨. 따라서 호출자도 비동기(context, 함수 등)여야 함.
2. 비동기 함수에서 한 번 또는 여러 번 일시 중단될 수 있는 위치를 지정하기 위해 await 키워드를 사용함.
3. 비동기 함수가 일시 중단되는 동안 스레드가 차단되지 않음. 따라서 시스템은 다른 작업을 자유롭게 예약(schedule) 가능.
4. 일시 중단됐던 비동기 함수가 다시 시작되면 반환된 결과가 원래 함수로 다시 흐르고 중단된 지점부터 실행이 계속됨.
async/await 동작
1. 비동기 함수 호출 시 스레드에 대한 제어권 부여
2. await를 만나면 스레드 제어권을 시스템에 넘겨줌
3. 따라서 시스템은 자유롭게 스레드를 사용하여 다른 작업을 수행
4. 어떠한 시점(API 호출 예에서는 다운로드 작업을 끝내고 data와 response를 반환하여 넘겨받는 시점)에 돌아와서 나머지 작업 처리
참고
WWDC 2021 Meet async/await in Swift
(https://developer.apple.com/videos/play/wwdc2021/10132)
Apple github
(https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md)
'iOS' 카테고리의 다른 글
Actor in Swift concurrency (0) 2022.12.31 Diffable DataSource (2) 2022.12.29 Localization in iOS (0) 2022.12.14 Operator, Combine Framework in Swift (0) 2022.12.11 Publisher와 Subscriber, Combine Framework in Swift (0) 2022.12.09