iOS - Async & Await

이한솔·2024년 7월 15일
0

iOS 앱개발 🍏

목록 보기
52/54

Async & Await

기존의 비동기 작업을 실행할때 사용하던 completionHandler Closure는 코드의 흐름이 동기적이지 않아 읽기가 어렵고, 콜백이 중첩될때는 코드가 장황해지고, 호출을 잊을 수 있다는 단점이 있다.

이를 보완하고, 효율적으로 비동기 작업을 실행하기 위해 Swift 5.5부터 Async & Await가 도입되었다.



사용방법

비동기적으로 네트워크 요청을 수행하고, 결과를 반환하는 함수에 async 키워드와, 에러를 던질 수 있다는 throws 키워드를 명시한다. 이를 호출하는 쪽에서는 마치 동기 함수처럼 try await를 사용하여 결과를 받을 수 있다. await 키워드는 해당 비동기 작업이 완료될 때까지 기다리도록 한다.

throws로 던지는 에러는 do-catch 구문을 통해 비동기 작업의 오류를 처리할 수 있다.

func fetchUserData() async throws -> UserData {
    let url = URL(string: "https://api.example.com/user")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let userData = try JSONDecoder().decode(UserData.self, from: data)
    return userData
}

Task {
    do {
        let userData = try await fetchUserData()
        print("User Data: \(userData)")
    } catch {
        print("Error fetching user data: \(error)")
    }
}


비동기 컨텍스트

async 함수는 비동기 컨텍스트에서만 호출될 수 있다. 따라서 다른 동기 코드인 async 함수 내에서나, 비동기 작업을 실행할 수 있는 컨텍스트를 제공하는 Task를 사용하여 비동기 작업을 수행해야 한다.

💡 왜 비동기 컨텍스트에서만 호출 가능할까?
async 함수가 비동기 컨텍스트에서만 호출될 수 있는 이유는 비동기 작업의 흐름 제어, 동기 코드와의 호환성 문제 방지, 그리고 스레드 차단을 방지하기 위함이다.
Async 함수는 비동기 작업을 수행하고, 해당 작업이 완료될 때까지 다른 작업을 중단시키지 않는다. 이를 위해서는 비동기 작업이 완료되기 전까지 대기할 수 있는 컨텍스트가 필요하다. 비동기 컨텍스트는 이러한 흐름 제어를 지원하는 환경을 제공한다.

Task {
    do {
        let userData = try await fetchUserData()
        print("User Data: \(userData)")
    } catch {
        print("Error fetching user data: \(error)")
    }
}


동시 API 호출

async let 사용

async let을 사용하면 여러 비동기 작업을 쉽게 병렬로 실행할 수 있다. 그러나 async let은 고정된 개수의 작업에 적합하며, 동적으로 작업을 추가하거나 삭제하는 데는 제한적이다.

 func fetchImagesWithAsyncLet() async throws -> [UIImage] {
        async let fetchImage1 = fetchImage(urlString: "https://picsum.photos/300")
        async let fetchImage2 = fetchImage(urlString: "https://picsum.photos/300")
        async let fetchImage3 = fetchImage(urlString: "https://picsum.photos/300")
        async let fetchImage4 = fetchImage(urlString: "https://picsum.photos/300")
        
        let (image1, image2, image3, image4) = await (try fetchImage1, try fetchImage2, try fetchImage3, try fetchImage4)
        return [image1, image2, image3, image4]
    } 
    
    private func fetchImage(urlString: String) async throws -> UIImage {
        guard let url = URL(string: urlString) else {
            throw URLError(.badURL)
        }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
            if let image = UIImage(data: data) {
                return image
            } else {
                throw URLError(.badURL)
            }
        } catch {
            throw error
        }
    }

TaskGroup 사용

TaskGroup을 사용하면 동적으로 작업을 추가하거나 삭제할 수 있어 확장성 있게 코드를 작성할 수 있다. withTaskGroup을 사용하여 에러를 무시할 수도 있고, withThrowingTaskGroup을 사용하여 작업 실패 시 에러를 던지도록 처리할 수도 있다.

func fetchImagesWithTaskGroup() async -> [UIImage] {
    let urlStrings = [
        "https://picsum.photos/300",
        "https://picsum.photos/300",
        "https://picsum.photos/300",
        "https://picsum.photos/300",
        "https://picsum.photos/300",
    ]
    return await withTaskGroup(of: UIImage?.self) { group in
        var images: [UIImage] = []
        
        // 이미지 배열 크기 예약
        images.reserveCapacity(urlStrings.count)
        
        for urlString in urlStrings {
            group.addTask {
                try? await self.fetchImage(urlString: urlString)
            }
        }
        
        for await image in group {
            if let image = image {
                images.append(image)
            }
        }
        
        return images
    }
}


Continuation

기존의 콜백 기반 API를 Async & Await 방식으로 변환해야 하는 경우에는 continuation을 사용한다.
Continuation은 비동기 작업의 흐름을 중단했다가 나중에 재개할 수 있는 기능을 제공한다. withCheckedContinuation으로 에러를 던지지 않는 비동기 작업을 변환하거나,
withCheckedThrowingContinuation 에러를 던질 수 있는 비동기 작업을 변환할 때 사용하여 콜백 기반 비동기 작업을 Async & Await 방식으로 변환할 수 있다.

기존 콜백 기반 API

func fetchData(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
        } else if let data = data {
            completion(.success(data))
        }
    }.resume()
}

Continuation을 사용한 async/await 변환

// URLSession의 데이터 요청을 async/await 방식으로 변환
func fetchData(from url: URL) async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let data = data {
                continuation.resume(returning: data)
            } else {
                continuation.resume(throwing: URLError(.badServerResponse))
            }
        }.resume()
    }
}


Actor

Actor는 기존의 클래스와 유사하지만, 상태를 안전하게 보호하기 위해 내부적으로 직렬화된 접근을 보장하고, 데이터 레이스를 방지하여 동시성 문제를 해결한다. Actor 내의 코드는 비동기 컨텍스트에서만 실행 가능하며, 외부에서는 Actor의 상태를 직접 변경할 수 없다. Actor 내의 변수나 메서드라도 데이터 레이스가 발생하지 않는 경우에는 nonisolated 키워드를 사용하여 Actor의 격리 없이 접근할 수 있다.

import Foundation

// 사용자 모델 정의
struct User {
    let id: String
    let name: String
}

// 사용자 정보를 관리하는 Actor
actor UserManager {
    private var users: [String: User] = [:]
    
    func addUser(_ user: User) {
        users[user.id] = user
    }
    
    func getUser(byID id: String) -> User? {
        return users[id]
    }
}


// Actor 사용 예제
func performUserOperations() async {
    let userManager = UserManager()
    
    let user1 = User(id: "1", name: "Alice")
    let user2 = User(id: "2", name: "Bob")
    
    // 비동기적으로 사용자 추가
    await userManager.addUser(user1)
    await userManager.addUser(user2)
    
    // 비동기적으로 사용자 조회
    if let user = await userManager.getUser(byID: "1") {
        print("Retrieved user: \(user.name)")
    }
}

// 비동기 컨텍스트에서 실행
Task {
    await performUserOperations()
}

GlobalActor

GlobalActor는 특정 글로벌 컨텍스트에서 실행되는 액터다. 이는 코드가 전역적으로 직렬화된 방식으로 실행되도록 보장한다. GlobalActor를 사용하면 앱의 특정 부분에서 코드가 동시에 실행되지 않도록 쉽게 제어할 수 있다.
@GlobalActor 키워드를 사용하여 글로벌 액터를 정의한다.

MainActor

@MainActor는 Swift에서 제공하는 글로벌 액터 중 하나로, 메인 스레드에서 실행되는 작업을 지정하는 데 사용된다. @MainActor를 사용하면 해당 클래스나 메서드가 메인 스레드에서 실행되도록 보장하여 UI 업데이트와 같이 메인 스레드에서 실행되어야 하는 작업을 안전하게 관리할 수 있다.

@MainActor
class ViewModel {
    private var value = 0
    
    func increment() {
        value += 1
    }
    
    func getValue() -> Int {
        return value
    }
}

Task {
    let viewModel = ViewModel()
    await viewModel.increment()
    let currentValue = await viewModel.getValue()
    print("ViewModel value: \(currentValue)")
}


Sendable

Sendable 프로토콜은, 데이터가 안전하게 다른 스레드로 전달될 수 있음을 보장한다. 특정 타입의 값이 스레드 간에 안전하게 전달될 수 있음을 명시하고 컴파일러가 해당 타입이 스레드 간에 안전하게 전달될 수 있는지 검증한다.

Int, String, Array 등 기본 타입은 Sendable 프로토콜을 자동으로 준수한다. 구조체나 클래스가 Sendable을 준수하도록 만들려면, 그 내부의 모든 속성도 Sendable을 준수해야 한다.

// String과 Int로 구성되어 있으므로 자동으로 Sendable을 준수한다.
struct MyStruct: Sendable {
    var name: String
    var age: Int
}


// 클래스는 더이상 상속이 불가능하도록 final class이고, 모든 속성을 let으로 선언하여 불변성을 유지해야 한다.
final class MySendableClass: Sendable {
    let name: String
    let age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

Actor와 Sendable

Actor는 내부적으로 직렬화된 접근을 보장하여 데이터 레이스를 방지한다. 따라서 Actor에 객체를 보내기 위해서는 해당 객체가 Sendable 프로토콜을 준수해야 한다.

struct UserData: Sendable {
    let id: Int
    let name: String
}

actor DataManager {
    private var users: [UserData] = []

    func addUser(_ user: UserData) {
        users.append(user)
    }

    func getUsers() -> [UserData] {
        return users
    }
}

Task {
    let dataManager = DataManager()
    let newUser = UserData(id: 1, name: "Alice")
    
    await dataManager.addUser(newUser)
    
    let allUsers = await dataManager.getUsers()
    print("All Users: \(allUsers)")
}


참고

0개의 댓글