Swift 비동기 프로그래밍 | async/await, Task

이 글의 핵심

콜백 중첩으로 읽기 어려웠던 비동기 코드를 async/await는 순서대로 읽히는 코드로 바꿔 주지만, Task 수명과 액터 격리를 이해하지 못하면 데이터 레이스와 메모리 누수가 남습니다. Task 안에서 self를 쓸 때의 주의점, async let과 TaskGroup 중 무엇을 고를지 같은 판단 기준을 함께 짚습니다.

들어가며

async/await는 콜백 깊이를 줄이며, 구조화된 동시성(태스크·액터)과 함께 쓰기 좋습니다. 취소·우선순위는 Task API로 전달합니다.

Swift 5.5 이전의 비동기 코드는 completion: @escaping (Result<Data, Error>) -> Void 형태의 콜백이 기본이었습니다. 이 방식의 문제는 들여쓰기만이 아닙니다. 에러 경로에서 completion 호출을 빠뜨려도 컴파일러가 잡아 주지 못하고, 한 경로에서 두 번 호출해도 모릅니다. 콜백이 어느 스레드에서 불리는지도 API마다 달라 UI 갱신 전에 매번 DispatchQueue.main.async로 감싸야 했습니다. async/await는 “함수가 반드시 한 번 값을 반환하거나 에러를 던진다”는 보장을 타입 시스템으로 옮기고, 액터는 “이 상태는 어느 실행 맥락에서만 만질 수 있다”는 규칙을 컴파일러가 검사하게 만듭니다.


async/await와 async throws

기본 사용

func fetchData() async -> String {
    // 네트워크 요청 시뮬레이션
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    return "데이터"
}
// 사용
Task {
    let data = await fetchData()
    print(data)
}

await는 “여기서 함수가 일시 중단(suspend)될 수 있다”는 표시입니다. 중단되는 동안 현재 스레드는 막히지 않고 다른 작업을 처리하며, 결과가 준비되면 함수가 이어서 실행됩니다. 이때 이어서 실행되는 스레드가 중단 전과 같다는 보장은 없습니다. GCD 시절처럼 “이 코드는 어떤 스레드에서 돈다”고 생각하기보다는, 뒤에서 다룰 액터처럼 “어떤 격리 영역에서 돈다”고 생각해야 합니다.

Task.sleep(nanoseconds:)는 스레드를 재우는 Thread.sleep과 달리 태스크만 중단시키므로 스레드를 점유하지 않습니다. iOS 16/macOS 13 이상이라면 try await Task.sleep(for: .seconds(1))처럼 Duration을 받는 버전이 더 읽기 쉽습니다.

async 함수는 async 컨텍스트에서만 호출할 수 있습니다. 동기 함수(예: 버튼 액션 핸들러) 안에서 await fetchData()를 쓰면 'async' call in a function that does not support concurrency 에러가 나는데, 이 경계를 넘는 다리가 Task { }입니다.

async throws

enum NetworkError: Error {
    case badURL
    case timeout
}
func fetchData(from url: String) async throws -> String {
    guard !url.isEmpty else {
        throw NetworkError.badURL
    }
    
    try await Task.sleep(nanoseconds: 1_000_000_000)
    return "데이터"
}
// 사용
Task {
    do {
        let data = try await fetchData(from: "https://example.com")
        print(data)
    } catch {
        print("에러: \(error)")
    }
}

Task 생성과 async let 병렬 실행

Task 생성

// 기본 Task
let task = Task {
    let data = await fetchData()
    print(data)
}
// 우선순위 지정
let highPriorityTask = Task(priority: .high) {
    let data = await fetchData()
    print(data)
}
// 취소 가능한 Task
let cancellableTask = Task {
    for i in 1...10 {
        if Task.isCancelled {
            print("작업 취소됨")
            return
        }
        print(i)
        try? await Task.sleep(nanoseconds: 100_000_000)
    }
}
// 취소
cancellableTask.cancel()

Task { }는 현재 액터 컨텍스트와 우선순위, 태스크 로컬 값을 물려받습니다. @MainActor인 뷰 컨트롤러 메서드 안에서 만든 Task { }의 본문은 메인 액터에서 실행되므로, 그 안에서 무거운 동기 연산(대용량 JSON 파싱, 이미지 리사이즈)을 하면 UI가 멈춥니다. 컨텍스트를 물려받지 않는 Task.detached { }가 있지만, 우선순위와 태스크 로컬도 끊기고 취소도 수동으로 전파해야 해서 꼭 필요할 때만 씁니다. 보통은 무거운 작업을 nonisolated async 함수나 별도 액터로 분리하는 편이 낫습니다.

취소는 협력적(cooperative)입니다. cancel()은 태스크를 강제로 멈추지 않고 “취소됨” 플래그만 세웁니다. 태스크 본문이 Task.isCancelled를 확인하거나 try Task.checkCancellation()을 호출하거나, 취소를 인식하는 API(Task.sleep, URLSession.data(from:) 등)를 try await로 호출해야 실제로 멈춥니다. 위 예제에서 try? await Task.sleep(...)은 취소 시 던져지는 CancellationError를 try?로 삼켜 버리므로, 루프 맨 위의 isCancelled 확인이 없으면 취소 후에도 10까지 계속 출력됩니다. 처음 async 코드로 옮길 때 흔히 하는 실수가 바로 이 try?로, 에러 처리가 귀찮아 붙였다가 취소가 전혀 동작하지 않는 태스크를 만들게 됩니다.

또 하나 알아 둘 것은 Task { }로 만든 태스크는 비구조적(unstructured)이라는 점입니다. 반환된 task 핸들을 버려도 태스크는 계속 실행되고, 부모가 끝나도 자동으로 취소되지 않습니다. 화면을 닫을 때 취소하려면 핸들을 저장해 두었다가 deinit이나 onDisappear에서 cancel()을 호출해야 합니다. SwiftUI라면 .task { } 수정자를 쓰면 뷰가 사라질 때 자동으로 취소되어 이 관리가 필요 없습니다.

async let - 병렬 실행

func fetchUser() async -> String {
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    return "사용자"
}
func fetchPosts() async -> [String] {
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    return ["포스트1", "포스트2"]
}
// 순차 실행 (느림)
Task {
    let user = await fetchUser()
    let posts = await fetchPosts()
    print(user, posts)
}
// 병렬 실행 (빠름)
Task {
    async let user = fetchUser()
    async let posts = fetchPosts()
    
    let (u, p) = await (user, posts)
    print(u, p)
}

순차 실행은 약 2초, 병렬 실행은 약 1초가 걸립니다. async let은 선언하는 순간 자식 태스크를 시작하고, await하는 지점에서 결과를 기다립니다. 첫 번째 코드처럼 await를 연달아 쓰면 앞의 호출이 끝나야 다음 호출이 시작되므로, 서로 의존하지 않는 요청은 async let으로 묶는 것이 기본입니다.

async let은 구조적 동시성의 한 형태라서, 선언한 스코프를 벗어나기 전에 반드시 완료됩니다. 결과를 await하지 않고 스코프를 빠져나가면 Swift가 그 자식 태스크를 자동으로 취소하고 끝날 때까지 기다립니다. 또 한쪽이 에러를 던지면 나머지 자식은 취소됩니다. 이 보장 덕분에 “화면은 닫혔는데 요청은 계속 도는” 누수가 생기지 않습니다. 단점은 개수가 컴파일 시점에 정해져 있어야 한다는 것으로, 배열 길이만큼 요청을 보내야 한다면 뒤에서 볼 withTaskGroup을 써야 합니다.


Actor로 상태 격리하기

기본 Actor

actor Counter {
    private var count = 0
    
    func increment() {
        count += 1
    }
    
    func decrement() {
        count -= 1
    }
    
    func getCount() -> Int {
        return count
    }
}
// 사용
let counter = Counter()
Task {
    await counter.increment()
    await counter.increment()
    let count = await counter.getCount()
    print("Count: \(count)")  // 2
}

액터는 클래스처럼 참조 타입이지만, 내부 상태에 동시에 하나의 작업만 접근하도록 보장합니다. 락을 직접 걸지 않아도 count += 1이 두 스레드에서 겹쳐 실행되는 데이터 레이스가 생기지 않습니다. 대신 액터 바깥에서 메서드를 부를 때는 항상 await가 필요합니다. 다른 작업이 액터를 쓰고 있으면 차례를 기다려야 할 수 있기 때문입니다. 메서드를 async로 선언하지 않았는데도 호출할 때 await를 붙여야 하는 이유가 이것이고, 붙이지 않으면 Expression is 'async' but is not marked with 'await' 에러가 납니다.

increment()를 두 번 부른 뒤 getCount()를 따로 부르는 방식에는 함정이 있습니다. 세 번의 await 사이마다 다른 태스크가 끼어들 수 있으므로, 여러 태스크가 동시에 이 코드를 실행하면 출력되는 값이 2라는 보장은 없습니다. “읽고, 판단하고, 쓰는” 작업은 액터 메서드 하나 안에 모아야 원자적으로 실행됩니다.

Actor 격리

actor BankAccount {
    private var balance: Int
    
    init(balance: Int) {
        self.balance = balance
    }
    
    func deposit(amount: Int) {
        balance += amount
    }
    
    func withdraw(amount: Int) -> Bool {
        if balance >= amount {
            balance -= amount
            return true
        }
        return false
    }
    
    func getBalance() -> Int {
        return balance
    }
}
let account = BankAccount(balance: 1000)
Task {
    await account.deposit(amount: 500)
    let success = await account.withdraw(amount: 300)
    let balance = await account.getBalance()
    print("잔액: \(balance)")  // 1200
}

withdraw가 잔액 확인과 차감을 한 메서드 안에서 하는 것이 핵심입니다. 바깥에서 if await account.getBalance() >= 300 { await account.withdraw(...) }처럼 나눠 쓰면, 확인과 차감 사이에 다른 태스크가 먼저 출금해 잔액이 음수가 될 수 있습니다.

더 까다로운 문제는 액터 재진입(reentrancy)입니다. 액터는 메서드 실행 도중 await를 만나면 그 사이에 다른 호출을 받아들입니다. 예를 들어 withdraw 안에서 잔액을 확인한 뒤 await fraudCheck()로 외부 서비스를 호출하고 돌아와 차감한다면, 기다리는 동안 다른 withdraw가 실행되어 이미 잔액이 줄어 있을 수 있습니다. 액터는 “한 번에 하나의 코드 조각”을 보장할 뿐 “메서드 전체가 끊김 없이 실행됨”을 보장하지 않습니다. 제가 액터를 처음 도입할 때 가장 헷갈렸던 부분도 이것으로, 락처럼 동작할 거라고 생각했다가 await 이후 상태가 바뀌어 있는 버그를 만났습니다. 규칙은 간단합니다. await 뒤에서는 앞에서 읽은 상태가 여전히 유효하다고 가정하지 말고 다시 확인하십시오.


예제: API 클라이언트

actor APIClient {
    private let baseURL = "https://api.example.com"
    
    func fetchUser(id: Int) async throws -> User {
        let url = URL(string: "\(baseURL)/users/\(id)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }
    
    func fetchPosts(userId: Int) async throws -> [Post] {
        let url = URL(string: "\(baseURL)/users/\(userId)/posts")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Post].self, from: data)
    }
}
struct User: Codable {
    let id: Int
    let name: String
}
struct Post: Codable {
    let id: Int
    let title: String
}
// 사용
let client = APIClient()
Task {
    do {
        async let user = try client.fetchUser(id: 1)
        async let posts = try client.fetchPosts(userId: 1)
        
        let (u, p) = try await (user, posts)
        print("사용자: \(u.name)")
        print("포스트: \(p.count)개")
    } catch {
        print("에러: \(error)")
    }
}

이 예제에서 APIClient를 액터로 만든 것은 사실 과한 선택일 수 있습니다. 이 클래스에는 바뀌는 상태가 없고(baseURL은 let), URLSession은 이미 스레드 안전합니다. 액터로 만들면 두 요청이 액터 안에서 차례로 시작되기는 하지만, await URLSession.shared.data(from:)에서 중단되는 동안 액터가 풀려 다음 요청이 들어오므로 네트워크 대기 자체는 병렬로 진행됩니다. 즉 성능 손해는 크지 않지만 얻는 것도 없습니다. 액터가 제값을 하는 경우는 토큰 갱신 상태나 응답 캐시처럼 여러 요청이 공유하는 가변 상태가 있을 때입니다. 상태가 없다면 struct나 final class에 Sendable을 붙이는 편이 단순합니다.

또 URLSession.data(from:)는 HTTP 404나 500 응답에도 에러를 던지지 않습니다. 네트워크 연결 자체가 실패할 때만 URLError를 던지고, 서버 에러 응답은 정상 데이터로 돌아와 JSONDecoder가 keyNotFound 같은 엉뚱한 디코딩 에러를 냅니다. 실제 코드에서는 반환된 URLResponse를 HTTPURLResponse로 캐스트해 statusCode를 먼저 확인해야 원인을 정확히 알 수 있습니다.


withTaskGroup으로 병렬 요청 합치기

여러 ID에 대해 동시에 네트워크 호출을 하고 결과를 배열로 모을 때 사용합니다. (아래는 로컬에서 지연만 시뮬레이션합니다.)

import Foundation
func fetchName(id: Int) async throws -> String {
    try await Task.sleep(nanoseconds: 100_000_000)
    return "user-\(id)"
}
func loadAll(ids: [Int]) async throws -> [String] {
    try await withThrowingTaskGroup(of: (Int, String).self) { group in
        for id in ids {
            group.addTask {
                let name = try await fetchName(id: id)
                return (id, name)
            }
        }
        var dict: [Int: String] = [:]
        for try await (id, name) in group {
            dict[id] = name
        }
        return ids.compactMap { dict[$0] }
    }
}
@main
struct Demo {
    static func main() async throws {
        let names = try await loadAll(ids: [1, 2, 3])
        print(names)
    }
}

참고: @main 엔트리는 파일 하나에만 두어야 합니다. 기존 main과 함께 쓰려면 Task { ... } 블록으로 호출하세요.

태스크 그룹의 결과는 추가한 순서가 아니라 완료된 순서로 for try await 루프에 도착합니다. 그래서 이 예제는 (id, name) 튜플로 결과를 받아 딕셔너리에 모은 뒤, 마지막에 원래 ids 순서로 다시 정렬합니다. 순서가 상관없다면 배열에 바로 append해도 되지만, 화면에 목록으로 보여 줄 데이터라면 이런 재정렬이 필요합니다.

withThrowingTaskGroup에서 자식 하나가 에러를 던지면, 그 에러가 for try await에서 다시 던져지면서 그룹을 빠져나가고 나머지 자식 태스크는 자동으로 취소됩니다. “하나라도 실패하면 전체 실패”가 아니라 “성공한 것만 모으기”를 원한다면 자식 태스크 안에서 에러를 잡아 Result나 옵셔널로 반환해야 합니다. ID가 수천 개라면 한 번에 수천 개의 요청을 여는 것도 문제가 되므로, 처음에 N개만 addTask하고 결과를 하나 받을 때마다 하나를 추가하는 방식으로 동시 실행 수를 제한하는 것이 일반적입니다.

Task 참조 순환·MainActor 격리에서 생기는 실수

  • Task {} 내부에서 강한 참조로 뷰 컨트롤러를 붙잡는 경우.
  • await 없이 async 함수 결과를 기다리지 않는 경우.
  • MainActor 격리를 무시하고 UI를 백그라운드에서 갱신하는 경우.
  • Task.sleep은 시간이 아니라 취소 협력점이기도 합니다. 장시간 작업은 주기적으로 Task.checkCancellation()을 호출하세요.

actor 네트워크 레이어와 에러 매핑

  • 네트워크 레이어는 actor로 직렬화하며, UI는 @MainActor로 통일합니다.
  • 에러는 URLError·도메인 에러로 매핑해 사용자 메시지를 한곳에서 관리합니다.

async/await, Combine, GCD 비교

패턴설명
async/await읽기 쉬운 제어 흐름
Combine스트림·백프레셔
GCD레거시 콜백 코드와 공존

참고 자료


Swift Concurrency 요약

  1. async/await: 비동기 함수 정의 및 호출
  2. Task: 비동기 작업 생성 및 관리
  3. async let: 병렬 실행
  4. Actor: 데이터 레이스 방지
  5. Task.sleep: 비동기 대기

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Task { } 안에서 self를 사용할 때 무엇을 조심해야 하나요?

A. Task 클로저가 self를 강하게 캡처하면 작업이 끝날 때까지 뷰 컨트롤러 같은 객체가 해제되지 않아, 화면을 벗어난 뒤에도 네트워크 요청과 갱신이 계속될 수 있습니다. 오래 걸리는 작업은 화면이 사라질 때 Task를 취소하고, 작업 안에서 Task.checkCancellation()으로 취소에 협력하도록 만드는 것이 좋습니다. UI 갱신은 @MainActor에서 하도록 통일해야 백그라운드에서 UI를 건드리는 문제도 막을 수 있습니다.