기존의 비동기 작업을 실행할때 사용하던 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)")
}
}
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을 사용하면 동적으로 작업을 추가하거나 삭제할 수 있어 확장성 있게 코드를 작성할 수 있다. 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
}
}
기존의 콜백 기반 API를 Async & Await
방식으로 변환해야 하는 경우에는 continuation
을 사용한다.
Continuation은 비동기 작업의 흐름을 중단했다가 나중에 재개할 수 있는 기능을 제공한다. withCheckedContinuation
으로 에러를 던지지 않는 비동기 작업을 변환하거나,
withCheckedThrowingContinuation
에러를 던질 수 있는 비동기 작업을 변환할 때 사용하여 콜백 기반 비동기 작업을 Async & Await
방식으로 변환할 수 있다.
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()
}
// 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
내의 변수나 메서드라도 데이터 레이스가 발생하지 않는 경우에는 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
키워드를 사용하여 글로벌 액터를 정의한다.
@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
프로토콜은, 데이터가 안전하게 다른 스레드로 전달될 수 있음을 보장한다. 특정 타입의 값이 스레드 간에 안전하게 전달될 수 있음을 명시하고 컴파일러가 해당 타입이 스레드 간에 안전하게 전달될 수 있는지 검증한다.
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는 내부적으로 직렬화된 접근을 보장하여 데이터 레이스를 방지한다. 따라서 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)")
}