이 글은 누구를 위한 것인가
- DispatchQueue와 completion handler에서 async/await로 마이그레이션하려는 팀
- Actor를 언제 써야 하는지, MainActor가 무엇인지 모르는 iOS 개발자
- 여러 API를 병렬로 호출하고 결과를 합치는 패턴이 필요한 엔지니어
들어가며
Swift 5.5부터 도입된 async/await는 콜백 지옥을 끝냈다. 하지만 Actor, TaskGroup, AsyncSequence까지 제대로 이해하려면 개념 정리가 필요하다.
이 글은 bluefoxdev.kr의 Swift Concurrency 가이드 를 참고하여 작성했습니다.
1. Swift Concurrency 핵심 개념
[async/await 기본]
DispatchQueue 방식:
fetchUser { user in
fetchPosts(userId: user.id) { posts in
// 콜백 지옥...
}
}
async/await 방식:
let user = try await fetchUser()
let posts = try await fetchPosts(userId: user.id)
// 선형 코드, 가독성 ↑
[Task 계층]
Task { } → 새 비구조적 Task (현재 컨텍스트 상속)
async let → 구조적 병렬 실행
TaskGroup → 동적 병렬 실행
[Actor 규칙]
Actor 내부: 직렬 실행 (데이터 경쟁 방지)
Actor 외부: await로 접근
MainActor: UI 메인 스레드 보장
[취소 처리]
Task.checkCancellation() → 취소 시 throw
withTaskCancellationHandler { } → 취소 콜백
URLSession: 자동 취소 지원
2. 실전 패턴 구현
import Foundation
// 1. 병렬 API 호출 (async let)
func loadDashboard(userId: String) async throws -> Dashboard {
async let user = fetchUser(id: userId)
async let posts = fetchPosts(userId: userId)
async let notifications = fetchNotifications(userId: userId)
// 세 요청이 동시에 실행되고 모두 완료되면 반환
return try await Dashboard(
user: user,
posts: posts,
notifications: notifications,
)
}
// 2. TaskGroup: 동적 병렬 처리
func downloadImages(urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: (Int, Data).self) { group in
for (index, url) in urls.enumerated() {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return (index, data)
}
}
var results = [(Int, Data)]()
for try await result in group {
results.append(result)
}
// 원래 순서 복원
return results.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
// 3. Actor: 공유 상태 안전하게 관리
actor ImageCache {
private var cache: [String: Data] = [:]
private var inFlight: [String: Task<Data, Error>] = [:]
func image(for url: String) async throws -> Data {
if let cached = cache[url] {
return cached
}
// 이미 진행 중인 요청 재사용 (중복 요청 방지)
if let existing = inFlight[url] {
return try await existing.value
}
let task = Task<Data, Error> {
let (data, _) = try await URLSession.shared.data(from: URL(string: url)!)
return data
}
inFlight[url] = task
do {
let data = try await task.value
cache[url] = data
inFlight[url] = nil
return data
} catch {
inFlight[url] = nil
throw error
}
}
func clear() {
cache.removeAll()
}
}
// 4. MainActor: UI 안전하게 업데이트
@MainActor
class ViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
@Published var error: Error?
private let cache = ImageCache()
func loadUsers() async {
isLoading = true
defer { isLoading = false }
do {
// nonisolated 함수는 백그라운드에서 실행
let fetchedUsers = try await fetchUsersFromServer()
users = fetchedUsers // MainActor이므로 메인 스레드
} catch {
self.error = error
}
}
nonisolated func fetchUsersFromServer() async throws -> [User] {
// 네트워크 요청 (백그라운드 스레드)
let (data, _) = try await URLSession.shared.data(
from: URL(string: "https://api.example.com/users")!
)
return try JSONDecoder().decode([User].self, from: data)
}
}
// 5. Continuation: 콜백 → async 변환
func fetchDataLegacy(completion: @escaping (Result<Data, Error>) -> Void) {
// 기존 콜백 API
}
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
fetchDataLegacy { result in
continuation.resume(with: result)
}
}
}
// 6. AsyncSequence: 스트리밍 데이터
func streamUserUpdates(userId: String) -> AsyncStream<UserUpdate> {
AsyncStream { continuation in
let webSocket = WebSocketManager(userId: userId)
webSocket.onMessage = { update in
continuation.yield(update)
}
webSocket.onDisconnect = {
continuation.finish()
}
continuation.onTermination = { _ in
webSocket.disconnect()
}
webSocket.connect()
}
}
// 사용
func observeUpdates() async {
for await update in streamUserUpdates(userId: "user123") {
await MainActor.run {
// UI 업데이트
}
}
}
// 7. 취소 처리
func longRunningTask() async throws -> String {
for i in 0..<100 {
try Task.checkCancellation() // 취소 확인
try await Task.sleep(nanoseconds: 100_000_000) // 0.1초
}
return "완료"
}
struct User: Decodable { let id: String }
struct Post { }
struct Notification { }
struct Dashboard { let user: User; let posts: [Post]; let notifications: [Notification] }
struct UserUpdate { }
func fetchUser(id: String) async throws -> User { fatalError() }
func fetchPosts(userId: String) async throws -> [Post] { fatalError() }
func fetchNotifications(userId: String) async throws -> [Notification] { fatalError() }
마무리
Swift Concurrency의 핵심: async/await로 가독성 확보, async let으로 병렬화, Actor로 데이터 경쟁 방지, MainActor로 UI 스레드 보장. 기존 콜백 코드는 Continuation으로 래핑하면 점진적 마이그레이션이 가능하다. Task 취소를 반드시 처리하라 — 뷰가 사라졌는데 Task가 계속 돌면 메모리 누수다.