Swift Concurrency 완전 가이드: async/await와 Actor 실전 패턴

모바일 개발

Swiftasync/awaitConcurrencyActoriOS

이 글은 누구를 위한 것인가

  • 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가 계속 돌면 메모리 누수다.