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

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

이 글의 핵심

Swift async await 실수—동기 함수에서의 await, Task 누수, MainActor, 데이터 레이스를 피하고 Xcode 진단으로 잡는 요약입니다.

들어가며

Swift의 async/await는 비동기 코드를 읽기 쉽게 만들지만, 동기 함수와의 경계·Task 생명 주기·액터 격리를 잘못 다루면 컴파일 경고가 아니라 런타임 데이터 레이스로 이어질 수 있습니다. Swift async·await 실수는 대부분 “경계 한 줄”에서 생깁니다. 특히 Swift 6의 엄격한 동시성 검사는 “예전에 돌아가던 코드”를 다시 드러냅니다.

이 글은 실무에서 반복되는 실수 패턴디버깅 순서를 정리했습니다. 기본 문법은 Swift 비동기 가이드를 먼저 보는 것을 권합니다.


목차

  1. 개념 설명
  2. 실전 구현 (단계별 코드)
  3. 고급 활용
  4. 성능·비교
  5. 실무 사례
  6. 트러블슈팅
  7. 마무리

개념 설명

  • 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

실수 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 {
            while !Task.isCancelled {
                await syncData()
                
                do {
                    try await Task.sleep(for: .seconds(60))
                } catch {
                    break  // 취소 시 종료
                }
            }
        }
    }
    
    func stopBackgroundSync() {
        syncTask?.cancel()
        syncTask = nil
    }
    
    deinit {
        syncTask?.cancel()
    }
}

실수 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  // ✅ 명시적으로 메인에서 실행
        }
    }
}

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

❌ 잘못된 코드:

func loadMultipleResources() async {
    await withTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask {
                return try await fetchData(from: url)  // ❌ 에러 전파 안됨
            }
        }
        
        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)
            }
        }
    }
}

실수 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()
        }
    }
}

실수 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)
    }
}

실수 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
    }
}


고급 활용: 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
    }
}

부분 격리 해제 (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. 구조화된 동시성에 유리

과도한 detached스레드 폭주취소 누락을 부릅니다.


실무 사례

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

트러블슈팅

증상: Expression is 'async' but... 컴파일 에러
→ 호출 체인을 async로 올리거나, Task 진입점을 한 곳으로 모으세요.

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

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

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


마무리

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