Swift async/await 흔한 실수와 디버깅 팁 | 실전 체크리스트

이 글의 핵심

async/await는 비동기 코드를 읽기 쉽게 만들지만, 동기 코드와의 경계나 Task 수명, 액터 격리를 잘못 다루면 컴파일 경고로 끝나지 않고 런타임 데이터 레이스로 이어집니다. Swift 6의 엄격한 동시성 검사에서 나는 경고를 읽는 법, 상황별 Task 종류 선택, 구조화된 동시성으로 정리하는 순서를 제시합니다.

들어가며

Swift의 async/await는 비동기 코드를 읽기 쉽게 만들지만, 동기 함수와의 경계·Task 생명 주기·액터 격리를 잘못 다루면 컴파일 경고가 아니라 런타임 데이터 레이스로 이어질 수 있습니다. Swift async·await 실수는 대부분 “경계 한 줄”에서 생깁니다. 특히 Swift 6의 엄격한 동시성 검사는 “예전에 돌아가던 코드”를 다시 드러냅니다. 이 글은 실무에서 반복되는 실수 패턴과 디버깅 순서를 정리했습니다. 기본 문법은 Swift 비동기 가이드를 먼저 보는 것을 권합니다.


개념 설명

  • async 함수: 실행이 중단(suspend)될 수 있으며, 반드시 다른 async 컨텍스트나 Task에서 시작되어야 합니다.
  • Task: 비동기 작업의 취소·우선순위를 담는 핸들입니다. 누수는 “아무도 기다리지 않는 장수명 Task”에서 자주 납니다.
  • MainActor: UI 갱신은 기본적으로 메인 스레드에서 이루어집니다. 백그라운드에서 UI 상태를 직접 바꾸지 않도록 격리를 맞춥니다.
  • 데이터 레이스: 같은 가변 상태를 여러 실행자가 동시에 쓰면 발생합니다. 액터·@MainActor·Sendable로 경계를 짓습니다.

실전 구현 (단계별 코드)

실수 1: 동기 함수에서 async 함수 호출 시도

❌ 잘못된 코드:

func loadData() {
    // 컴파일 에러: 'async' call in a function that does not support concurrency
    let data = await fetchFromAPI()
}

✅ 해결 방법 1: 함수를 async로 변경

func loadData() async {
    let data = await fetchFromAPI()
    processData(data)
}
// 호출부
Task {
    await loadData()
}

✅ 해결 방법 2: Task로 감싸기 (결과 필요 없을 때)

func loadData() {
    Task {
        let data = await fetchFromAPI()
        await MainActor.run {
            self.updateUI(with: data)
        }
    }
}

✅ 해결 방법 3: Task 값 반환 (결과 필요할 때)

func loadData() -> Task<Data, Error> {
    return Task {
        return try await fetchFromAPI()
    }
}
// 호출부
let task = loadData()
let data = try await task.value

세 방법 중 기본값은 1번입니다. async는 호출 체인을 따라 위로 “전염”되는데, 이것은 결함이 아니라 어디서 중단이 일어날 수 있는지를 타입으로 표시하는 설계입니다. 2번처럼 중간에서 Task { }로 끊으면 호출자는 작업이 언제 끝나는지, 실패했는지 알 수 없게 됩니다. 그래서 Task { }는 버튼 액션, viewDidLoad, 알림 콜백처럼 원래부터 동기인 진입점에서만 쓰고, 그 아래는 전부 async로 두는 것이 원칙입니다.

2번 코드에서 MainActor.run이 꼭 필요한지도 따져볼 만합니다. Task { }는 자신을 만든 곳의 액터 격리를 상속합니다. loadData()가 @MainActor 타입 안에 있다면 Task 본문도 이미 메인 액터에서 실행되므로 MainActor.run은 불필요한 홉입니다. 반대로 격리 없는 타입에서 만들었다면 필요합니다. 흔히 권장되는 우회로 DispatchSemaphore로 async 결과를 동기적으로 기다리는 코드가 돌아다니는데, Swift 동시성의 협력 스레드 풀은 CPU 코어 수만큼만 스레드를 두기 때문에 스레드를 막으면 기다리는 작업 자체가 실행될 스레드가 없어 교착에 빠질 수 있습니다.

실수 2: Task 누수 (생성만 하고 기다리지 않음)

❌ 잘못된 코드:

class DataManager {
    func startBackgroundSync() {
        Task {
            while true {
                await syncData()
                try await Task.sleep(for: .seconds(60))
            }
        }
        // Task 참조를 저장하지 않음 → 취소 불가
    }
}

✅ 해결 방법: Task 참조 저장 및 취소

class DataManager {
    private var syncTask: Task<Void, Never>?
    
    func startBackgroundSync() {
        syncTask?.cancel()  // 기존 작업 취소
        
        syncTask = Task { [weak self] in
            while !Task.isCancelled {
                await self?.syncData()
                
                do {
                    try await Task.sleep(for: .seconds(60))
                } catch {
                    break  // 취소 시 종료
                }
            }
        }
    }
    
    func stopBackgroundSync() {
        syncTask?.cancel()
        syncTask = nil
    }
    
    deinit {
        syncTask?.cancel()
    }
}

여기서 [weak self]가 핵심입니다. Task { } 클로저는 컴파일러가 self를 암시적으로 쓰도록 허용하기 때문에(self. 없이 syncData()를 호출해도 경고가 없습니다) 무한 루프를 도는 Task가 self를 강하게 붙잡기 쉽습니다. 그러면 DataManager를 참조하던 화면이 모두 사라져도 Task가 객체를 살려 두고, deinit이 호출되지 않으니 deinit 안의 cancel()도 영영 실행되지 않습니다. 저도 이 패턴에서 “화면을 닫았는데 동기화 로그가 60초마다 계속 찍히는” 현상을 겪은 적이 있는데, 원인은 정확히 이 강한 캡처였습니다. 짧게 끝나는 Task라면 강한 캡처가 오히려 안전하지만(작업이 끝날 때까지만 붙잡음), 끝나지 않는 루프를 도는 Task는 반드시 약한 참조나 명시적인 stop 호출로 수명을 끊어야 합니다.

실수 3: MainActor 격리 위반

❌ 잘못된 코드:

class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func loadItems() async {
        let data = await api.fetchItems()
        
        // ❌ 백그라운드에서 UI 상태 변경
        self.items = data  // 데이터 레이스 발생 가능
    }
}

✅ 해결 방법 1: 클래스를 MainActor로 격리

@MainActor
class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func loadItems() async {
        let data = await api.fetchItems()
        self.items = data  // ✅ 메인 스레드에서 실행 보장
    }
}

✅ 해결 방법 2: 명시적 MainActor.run

class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func loadItems() async {
        let data = await api.fetchItems()
        
        await MainActor.run {
            self.items = data  // ✅ 명시적으로 메인에서 실행
        }
    }
}

잘못된 코드가 왜 백그라운드에서 실행되는지는 Swift 5.7의 SE-0338 규칙을 알아야 이해됩니다. 액터에 격리되지 않은 async 함수는 호출한 곳이 메인 스레드여도 전역 동시 실행기(generic executor)로 옮겨져 실행됩니다. 그래서 await api.fetchItems() 이후의 self.items = data는 메인 스레드가 아닐 수 있고, SwiftUI는 “Publishing changes from background threads is not allowed” 경고를 런타임에 출력합니다. Swift 6 언어 모드에서는 ViewModel이 Sendable이 아닌 상태로 여러 격리 도메인을 오가는 것이 컴파일 에러로 잡힙니다.

두 해결책 중에서는 1번(@MainActor 클래스)을 권합니다. @Published 프로퍼티는 어차피 UI가 읽는 상태라 메인 액터에 두는 것이 자연스럽고, 2번은 MainActor.run을 빠뜨린 대입이 하나만 생겨도 같은 버그가 재발합니다. @MainActor를 붙여도 await api.fetchItems()는 네트워크를 기다리는 동안 메인 스레드를 막지 않습니다. 중단 지점에서 메인 액터는 다른 일을 처리하다가 결과가 오면 돌아옵니다.

실수 4: Task 그룹에서 에러 처리 누락

❌ 잘못된 코드:

func loadMultipleResources() async {
    await withTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask {
                return try await fetchData(from: url)  // ❌ 컴파일 에러: 비throwing 그룹에 throwing 클로저
            }
        }
        
        for await data in group {
            process(data)
        }
    }
}

✅ 해결 방법: Result 타입 사용

func loadMultipleResources() async {
    await withTaskGroup(of: Result<Data, Error>.self) { group in
        for url in urls {
            group.addTask {
                do {
                    let data = try await fetchData(from: url)
                    return .success(data)
                } catch {
                    return .failure(error)
                }
            }
        }
        
        for await result in group {
            switch result {
            case .success(let data):
                process(data)
            case .failure(let error):
                handleError(error)
            }
        }
    }
}

잘못된 코드는 실제로는 컴파일 단계에서 막힙니다. withTaskGroup의 addTask는 throw하지 않는 클로저만 받으므로 try를 쓰면 “Invalid conversion from throwing function … to non-throwing function” 계열의 에러가 납니다. 문제는 이 에러를 없애려고 try?를 붙이는 순간입니다. 그러면 컴파일은 되지만 실패한 요청이 nil로 조용히 사라지고, 아무 로그도 남지 않습니다.

Result로 감싸는 방식과 withThrowingTaskGroup은 실패 정책이 다릅니다. withThrowingTaskGroup에서 for try await가 첫 에러를 만나면 그 에러가 그룹 밖으로 던져지고, 남은 자식 작업은 자동으로 취소됩니다. “하나라도 실패하면 전체 실패”가 맞는 경우(트랜잭션처럼 모든 조각이 있어야 하는 경우)에 적합합니다. 반대로 이미지 목록처럼 일부 실패를 허용하고 나머지를 보여줘야 한다면 Result 방식이 맞습니다. 어느 쪽이든 결과는 완료된 순서대로 도착하므로, 입력 순서가 중요하면 (index, result) 튜플로 반환해 정렬해야 합니다.

실수 5: SwiftUI에서 onAppear로 Task 생성

❌ 잘못된 코드:

struct ContentView: View {
    @State private var data: [Item] = []
    
    var body: some View {
        List(data) { item in
            Text(item.title)
        }
        .onAppear {
            Task {
                data = await loadData()
            }
            // Task 참조 없음 → 뷰 사라져도 계속 실행
        }
    }
}

✅ 해결 방법: .task 모디파이어 사용

struct ContentView: View {
    @State private var data: [Item] = []
    
    var body: some View {
        List(data) { item in
            Text(item.title)
        }
        .task {
            data = await loadData()
        }
        // 뷰 사라지면 자동 취소
    }
}

✅ 해결 방법 2: Task 저장 및 취소

struct ContentView: View {
    @State private var data: [Item] = []
    @State private var loadTask: Task<Void, Never>?
    
    var body: some View {
        List(data) { item in
            Text(item.title)
        }
        .onAppear {
            loadTask = Task {
                data = await loadData()
            }
        }
        .onDisappear {
            loadTask?.cancel()
        }
    }
}

.task는 뷰가 나타날 때 작업을 시작하고 사라질 때 자동으로 취소하므로 대부분의 경우 수동 관리보다 낫습니다. 검색어처럼 값이 바뀔 때마다 다시 불러와야 한다면 .task(id: query)를 쓰면 이전 작업을 취소하고 새 작업을 시작해 줍니다. 주의할 점은 onAppear가 한 번만 호출된다고 가정하면 안 된다는 것입니다. NavigationStack에서 뒤로 갔다 돌아오거나 탭을 전환하면 다시 호출되므로, 로딩이 중복 실행되어 같은 요청이 여러 번 나가는 현상이 생깁니다. 또 여기서의 “자동 취소”는 취소 신호를 보낼 뿐이라, loadData() 안에서 취소를 확인하지 않으면 작업은 끝까지 실행됩니다(다음 실수 6 참고).

실수 6: 취소 확인 누락

❌ 잘못된 코드:

func processLargeDataset() async {
    for item in largeDataset {
        await processItem(item)  // 취소 확인 없음
    }
}

✅ 해결 방법: 주기적 취소 확인

func processLargeDataset() async throws {
    for item in largeDataset {
        try Task.checkCancellation()  // 취소되면 CancellationError 발생
        await processItem(item)
    }
}
// 또는
func processLargeDataset() async {
    for item in largeDataset {
        if Task.isCancelled {
            print("작업 취소됨")
            break
        }
        await processItem(item)
    }
}

Swift의 취소는 협력적(cooperative) 입니다. task.cancel()은 작업을 강제로 멈추지 않고 “취소됨” 플래그만 세웁니다. 실제로 멈추는 것은 작업 코드가 그 플래그를 확인할 때입니다. 다행히 Task.sleep, URLSession의 async API 같은 시스템 API는 스스로 취소를 확인해서 각각 CancellationError, URLError(.cancelled)를 던집니다. 문제는 순수 CPU 루프나 직접 만든 async 함수로, 이런 코드는 취소 지점이 없어서 화면을 닫아도 끝까지 돕니다. 두 방식 중 checkCancellation()은 에러로 호출자에게 “중간에 멈췄다”는 사실을 알리고, isCancelled 확인은 부분 결과를 반환하거나 정리 작업을 한 뒤 조용히 끝낼 때 적합합니다.

실수 7: Sendable 위반

❌ 잘못된 코드:

class Cache {
    var data: [String: Any] = [:]  // ❌ 가변 참조 타입
}
func processData() async {
    let cache = Cache()
    
    await withTaskGroup(of: Void.self) { group in
        for i in 0..<10 {
            group.addTask {
                cache.data["key\(i)"] = i  // ❌ 데이터 레이스
            }
        }
    }
}

✅ 해결 방법 1: Actor 사용

actor Cache {
    private var data: [String: Any] = [:]
    
    func set(_ key: String, value: Any) {
        data[key] = value
    }
    
    func get(_ key: String) -> Any? {
        return data[key]
    }
}
func processData() async {
    let cache = Cache()
    
    await withTaskGroup(of: Void.self) { group in
        for i in 0..<10 {
            group.addTask {
                await cache.set("key\(i)", value: i)  // ✅ 안전
            }
        }
    }
}

✅ 해결 방법 2: 값 타입 사용

struct CacheData: Sendable {
    let data: [String: Int]  // 불변
}
func processData() async -> [String: Int] {
    await withTaskGroup(of: (String, Int).self) { group in
        var result: [String: Int] = [:]
        
        for i in 0..<10 {
            group.addTask {
                return ("key\(i)", i)
            }
        }
        
        for await (key, value) in group {
            result[key] = value
        }
        
        return result
    }
}

잘못된 코드는 Swift 6 모드에서 “Capture of ‘cache’ with non-sendable type ‘Cache’ in a @Sendable closure” 같은 에러로 거부됩니다. addTask의 클로저는 다른 스레드에서 동시에 실행될 수 있으므로 캡처하는 값이 모두 Sendable이어야 하기 때문입니다. Swift 5 모드에서는 경고조차 없이 빌드되고, 딕셔너리 동시 쓰기가 가끔 크래시(EXC_BAD_ACCESS)로만 드러납니다.

해결책 1의 actor 예제에는 Swift 6에서 추가로 걸리는 부분이 있습니다. Any는 Sendable이 아니므로 cache.set("key\(i)", value: i)처럼 Any 값을 액터 경계 너머로 넘기면 역시 경고나 에러가 납니다. 실무에서는 값 타입을 any Sendable이나 구체 타입(Int, 직접 만든 Sendable 구조체)으로 좁히는 것이 맞습니다. 해결책 2가 더 나은 경우도 많습니다. 자식 작업은 결과만 반환하고, 모으는 일은 그룹을 연 부모 한 곳에서 하면 공유 가변 상태 자체가 사라지므로 액터도 락도 필요 없습니다.


고급 활용: MainActor와 Sendable

MainActor 격리 패턴

전체 클래스 격리:

@MainActor
class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    @Published var isLoading = false
    
    func loadItems() async {
        isLoading = true
        
        // 백그라운드 작업은 명시적으로 분리
        let data = await Task.detached {
            return await api.fetchItems()
        }.value
        
        // UI 업데이트는 자동으로 메인에서
        items = data
        isLoading = false
    }
}

이 예제의 Task.detached는 fetchItems()가 JSON 파싱처럼 동기적인 무거운 작업을 포함할 때만 의미가 있습니다. 네트워크 요청 자체는 이미 비동기라 await하는 동안 메인 스레드를 막지 않으므로 detached 없이 await api.fetchItems()만으로 충분합니다. detached를 쓰면 우선순위, 액터 격리, task-local 값이 상속되지 않고 부모가 취소돼도 따라서 취소되지 않으므로, 쓰는 이유를 설명할 수 없다면 쓰지 않는 편이 낫습니다. 무거운 동기 연산만 분리하고 싶다면 nonisolated 함수로 빼는 아래 패턴이 더 명확합니다. 부분 격리 해제 (nonisolated):

@MainActor
class ImageProcessor {
    var processedImages: [UIImage] = []
    
    // 메인 스레드에서 실행 (기본)
    func addImage(_ image: UIImage) {
        processedImages.append(image)
    }
    
    // 백그라운드에서 실행 가능
    nonisolated func processInBackground(data: Data) async -> UIImage? {
        // ❌ self.processedImages 접근 불가 (MainActor 격리)
        
        // ✅ 백그라운드 작업만 수행
        guard let image = UIImage(data: data) else { return nil }
        
        // 이미지 처리 (CPU 집약적)
        let processed = applyFilter(to: image)
        
        // UI 업데이트는 호출자가 MainActor에서 처리
        return processed
    }
}
// 사용
Task {
    let processor = ImageProcessor()
    
    // 백그라운드 처리
    if let processed = await processor.processInBackground(data: imageData) {
        // 메인 스레드에서 추가
        await processor.addImage(processed)
    }
}

Sendable 프로토콜

Sendable이 필요한 이유:

// ❌ 가변 참조 타입은 동시성 도메인 간 전달 위험
class Config {
    var settings: [String: String] = [:]
}
func processConfig() async {
    let config = Config()
    
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            config.settings["key"] = "value"  // ❌ 데이터 레이스
        }
        group.addTask {
            print(config.settings["key"])  // ❌ 데이터 레이스
        }
    }
}

✅ 해결 방법 1: 값 타입 (Struct)

// 타입 정의
struct Config: Sendable {
    let settings: [String: String]  // 불변
}
func processConfig() async {
    let config = Config(settings: ["key": "value"])
    
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            print(config.settings["key"])  // ✅ 안전 (복사본)
        }
        group.addTask {
            print(config.settings["key"])  // ✅ 안전 (복사본)
        }
    }
}

✅ 해결 방법 2: Actor

actor ConfigManager {
    private var settings: [String: String] = [:]
    
    func set(_ key: String, value: String) {
        settings[key] = value
    }
    
    func get(_ key: String) -> String? {
        return settings[key]
    }
}
func processConfig() async {
    let manager = ConfigManager()
    
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            await manager.set("key", value: "value")  // ✅ 직렬화됨
        }
        group.addTask {
            if let value = await manager.get("key") {  // ✅ 직렬화됨
                print(value)
            }
        }
    }
}

@unchecked Sendable (주의해서 사용):

class ThreadSafeCache: @unchecked Sendable {
    private let lock = NSLock()
    private var data: [String: Any] = [:]
    
    func set(_ key: String, value: Any) {
        lock.lock()
        defer { lock.unlock() }
        data[key] = value
    }
    
    func get(_ key: String) -> Any? {
        lock.lock()
        defer { lock.unlock() }
        return data[key]
    }
}

구조화된 동시성 (Structured Concurrency)

withTaskGroup 패턴:

func downloadImages(urls: [URL]) async -> [UIImage] {
    await withTaskGroup(of: UIImage?.self) { group in
        var images: [UIImage] = []
        
        for url in urls {
            group.addTask {
                return try? await downloadImage(from: url)
            }
        }
        
        for await image in group {
            if let image = image {
                images.append(image)
            }
        }
        
        return images
    }
}

withThrowingTaskGroup 패턴:

func fetchAllData() async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        var results: [Data] = []
        
        for endpoint in endpoints {
            group.addTask {
                return try await fetch(from: endpoint)
            }
        }
        
        for try await data in group {
            results.append(data)
        }
        
        return results
    }
}

성능·비교: Task 유형 선택

API용도
Task { }기본 비동기 작업. 부모 Task의 우선순위·취소를 상속(컨텍스트에 따라 다름)
Task.detached완전 분리. 꼭 필요할 때만 — 디버깅이 어려워질 수 있음
withTaskGroup병렬 청크·fan-out. 구조화된 동시성에 유리

Swift 동시성은 CPU 코어 수에 맞춘 협력 스레드 풀을 쓰므로 GCD처럼 스레드가 무한정 늘어나는 “스레드 폭주”는 일어나지 않습니다. 대신 과도한 detached는 우선순위·취소 상속이 끊겨 화면을 닫아도 작업이 계속 도는 문제를 만들고, 풀의 스레드를 막는 동기 작업을 Task 안에서 오래 돌리면 다른 작업이 모두 밀립니다.


실무 사례

  • 네트워크 레이어: URLSession의 async API는 취소가 Task에 연결되기 쉽습니다. 요청 단위 Task를 뷰와 묶으세요.
  • 이미지 디코딩: CPU 작업은 백그라운드, UIImage/NSImage 적용은 메인에서.
  • 로깅·분석: 전역 큐에 던지되, UI 상태 스냅샷은 MainActor에서 한 번에 복사해 보냅니다.

트러블슈팅

증상: 'async' call in a function that does not support concurrency 컴파일 에러
→ 호출 체인을 async로 올리거나, Task 진입점을 한 곳으로 모으세요. Expression is 'async' but is not marked with 'await'는 반대로 async 컨텍스트 안에서 await를 빠뜨린 경우입니다.

증상: 간헐적 크래시 / 데이터 레이스
→ Xcode Thread Sanitizer, Swift Concurrency 진단을 켜고, 가변 공유 상태를 액터로 옮기세요.

증상: 화면이 나갔는데도 네트워크가 돈다
→ task(id:) / Task.cancel() / 요청 취소를 연결했는지 확인하세요.

증상: MainActor에서만 써야 하는데 백그라운드에서 호출
→ await MainActor.run { } 또는 Task { @MainActor in }으로 명시적 홉을 남깁니다.


마무리

async/await는 경계 설계가 곧 품질입니다. 동기 컨텍스트에서의 await 욕구, 장수명 Task, 메인이 아닌 곳의 UI 접근을 체크리스트로 걸러면 디버깅 시간이 크게 줄어듭니다. SwiftUI와의 연동은 SwiftUI 시리즈, 에러 처리는 에러 핸들링과 함께 보면 좋습니다.


자주 묻는 질문 (FAQ)

Q. withTaskGroup 안에서 던진 에러가 사라지는 것처럼 보이는 이유는 무엇인가요?

A. withTaskGroup은 자식 작업이 에러를 던지는 것을 허용하지 않기 때문에, 컴파일 에러를 피하려고 try?를 붙이면 실패가 nil로 조용히 사라집니다. 자식 작업의 실패를 호출자에게 전파하려면 withThrowingTaskGroup을 쓰거나 각 작업이 Result를 반환하도록 감싸야 합니다. Result로 감싸면 일부 요청이 실패해도 나머지 결과를 처리하면서 실패한 항목만 따로 다룰 수 있습니다. 긴 반복 작업이라면 Task.checkCancellation()으로 취소 여부도 주기적으로 확인해야 그룹 취소가 실제로 반영됩니다.


같이 보면 좋은 글