Swift의 async/await에 대해서 알아보자

Swift 5.5부터 도입된 async/await는 기존의 비동기 처리방식인 DispatchQueue나 completionHandler를 사용해 처리했지만 더욱 직관적이고 관리하기 쉽게 만들어 주는 기능이다.

 

기존의 비동기 처리 방식과 비교해 코드의 가독성과 유지보수성을 크게 향상시킬 수 있다는 장점을 가지고 있다.

https://developer.apple.com/videos/play/wwdc2021/10132/

 

Meet async/await in Swift - WWDC21 - Videos - Apple Developer

Swift now supports asynchronous functions — a pattern commonly known as async/await. Discover how the new syntax can make your code...

developer.apple.com

 

비동기 프로그래밍의 필요성

먼저 왜 비동기로 데이터를 처리 해야하는지 짚고 넘어가보자. 앱을 만들면서 네트워크, 파일 CRUD, 데이터베이스 연결 등 시간이 오래 걸릴 수 있는 작업을 처리하는 경우가 많다.

 

이 경우 동기적으로 처리하게 되면 속도가 매우 느리고 앱이 정상적으로 작동하지 않기 때문에 하나의 작업을 처리하는 것을 기다리지 않고 다른 스레드에서 바로 작업이 실행될 수 있도록 비동기 처리를 해주는 것이 필수이다.

 

기존의 DispatchQueue나 completionHandler를 활용해서 비동기 작업을 처리했었는데 이를 중첩해서 사용하게 되면 콜백지옥이라고 불리우는 아주 복잡한 코드 구조를 만나게 되는 경우가 발생하였다.

 

이를 해결하기 위해 새로운 방식인 async/await가 등장했다고 생각하면 된다 🙂

 

async/await의 기본 개념

async/await는 비동기 작업을 하기 위해 복잡하게 작성해야 했던 코드 구조를 동기 작업 처럼 간편하게 작성할 수 있게 해주는 기능이다.

 

함수 앞에 async를 사용해 비동기 함수임을 명시하고, 이 함수가 완료 될 때 까지 기다리면 await 키워드를 사용한다. 이를 통해 비동기 작업을 처리하는 코드를 직관적으로 확인할 수 있다.

 

async

async는 함수가 비동기적으로 실행될 것임을 나타낸다. 이 함수는 호출 즉시 결과를 반환하는 것이 아닌 나중에 결과를 반환 할 것을 약속한다.

func fetchData() async -> String {
    // 비동기 작업을 수행
    return "Hello, World!"
}

await

await는 async 함수의 실행이 완료될 때 까지 기다린다. 이 키워드를 사용하면, 비동기 함수가 동기 함수처럼 순차적으로 실행되는 것 처럼 보인다.

let result = await fetchData()
print(result) // "Hello, World!" 출력

이 개념을 바탕으로 비동기 네트워크 요청하는 예시를 통해 이해해보자

import Foundation

struct User: Decodable {
    let id: Int
    let name: String
    let username: String
}

func fetchUserData(from url: URL) async throws -> User {
    let (data, response) = try await URLSession.shared.data(from: url)
    
    guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    
    let user = try JSONDecoder().decode(User.self, from: data)
    return user
}

@main
struct MyApp {
    static func main() async {
        let url = URL(string: "<https://jsonplaceholder.typicode.com/users/1>")!
        
        do {
            let user = try await fetchUserData(from: url)
            print("User name: \\(user.name)")
        } catch {
            print("Failed to fetch user data: \\(error)")
        }
    }
}

여기서 fetchUserData 는 비동기적으로 데이터를 가져온다. 이 함수는 async와 throws를 활용해 비동기 작업 중 오류가 발생 할 수 있음을 나타낸다.

 

URLSession.shared.data(from:) 는 비동기적으로 URL에서 데이터를 가져온다. 데이터와 응답을 동시에 반환하고 await 키워드를 사용해 해당 작업이 완료될 때 까지 기다린다.

 

그 이후 가져온 데이터는 JSONDecoder를 통해 User모델로 디코딩 된다.

이 방식으로 네트워크 처리를 비동기 처리할 수 있다는 예시가 있다 🙂

 

Task Group

Task Group은 여러 개의 비동기 작업을 병렬로 실행하고, 모든 작업이 완료될 때 까지 기다리는 기능을 제공한다. 이를 통해 병렬 처리가 필요한 상황에서 성능을 최적화 할 수 있다.

 

Task Group은 withTaskGroup 또는 withThrowingTaskGroup함수를 활용해 사용한다.

 

  • withTaskGroup은 오류를 발생시키지 않는 비동기 작업 그룹을 생성한다.
  • withThrowingTaskGroup은 오류를 발생시킬 수 있는 비동기 작업 그룹을 생성한다. 그룹 내 작업 중 하나라도 오류가 발생하면 전체 그룹이 중단된다.

withTaskGroup

import Foundation

func fetchNumber(_ n: Int) async -> Int {
    return n * n // 단순히 n의 제곱을 반환하는 함수
}

func sumOfSquares() async -> Int {
    return await withTaskGroup(of: Int.self) { group in
        var sum = 0
        
        // 여러 작업을 그룹에 추가
        for i in 1...5 {
            group.addTask {
                return await fetchNumber(i)
            }
        }
        
        // 그룹의 모든 작업이 완료될 때까지 대기하면서 결과 합산
        for await result in group {
            sum += result
        }
        
        return sum
    }
}

@main
struct MyApp {
    static func main() async {
        let result = await sumOfSquares()
        print("Sum of squares: \\(result)") // 1^2 + 2^2 + 3^2 + 4^2 + 5^2 = 55
    }
}

withTaskGroup를 사용해 여러 비동기 작업을 병렬로 실생한다. 이 함수는 1부터 5까지의 숫자를 그룹에 추가하고 각 작업이 완료되면 결과를 합산하여 반환한다.

 

group.addTask를 통해 그룹에 비동기 작업을 추가할 수 있다.

 

withThrowingTaskGroup

import Foundation

enum FetchError: Error {
    case invalidNumber
}

func fetchValidNumber(_ n: Int) async throws -> Int {
    if n < 0 {
        throw FetchError.invalidNumber
    }
    return n * n
}

func sumOfValidSquares() async throws -> Int {
    return try await withThrowingTaskGroup(of: Int.self) { group in
        var sum = 0
        
        let numbers = [1, 2, -1, 3, 4]
        
        for n in numbers {
            group.addTask {
                return try await fetchValidNumber(n)
            }
        }
        
        for try await result in group {
            sum += result
        }
        
        return sum
    }
}

@main
struct MyApp {
    static func main() async {
        do {
            let result = try await sumOfValidSquares()
            print("Sum of valid squares: \\(result)")
        } catch {
            print("Error: \\(error)")
        }
    }
}

sumOfValidSquares 함수는 withThrowingTaskGroup을 사용해 오류가 발생할 수 있는 비동기 작업 그룹을 생성한다. 그 이후 그룹 내 오류가 발생하면 withThrowingTaskGroup는 오류를 던지고 그룹 내 모든 작업을 중단한다.

 

이처럼 withThrowingTaskGroup는 오류를 발생할 수 있는 비동기 작업을 진행할 때 사용하게 된다.

 

결론적으로 Task Group은 비동기 작업을 병렬로 처리하고 작업 간 흐름을 관리하는데 사용이 가능하고 아주 편리하게 활용할 수 있다. 이를 통해 복잡한 비동기 코드도 직관적이고 명확하게 작성할 수 있다. 특히 여러 비동기 작업이 동시에 수행되어야 하는 상황에서 성능과 유지보수성을 크게 향상 시킬 수 있는 장점이 있다.

 

이렇게 장점 많은 async/await지만 확실히 더 깊이 공부해야할 것 같다 계속해서 공부하면 언젠간 완전 정복하는 날이 오겠지 🙂

 

오늘은 여기까지!