2025/07/23

20. Swift Concurrency: async/await와 Actor로 비동기 코드 간결하게 작성하기 (Swift 5.5+)

20. Swift Concurrency: async/await와 Actor로 비동기 코드 간결하게 작성하기 (Swift 5.5+)
안녕하세요! 이번 블로그에서는 Swift 5.5부터 공식 도입된, 비동기 코드를 훨씬 더 간결하고 안전하며 읽기 쉽게 만들어주는 혁신적인 기능들인 async/awaitActor에 대해 자세하게 설명하겠습니다. 🚀

오랫동안 Swift에서의 비동기 코드는 클로저 기반의 콜백(Callback) 패턴으로 인해 "콜백 지옥(Callback Hell)"이라고 불리는 복잡한 중첩 구조와 순환 참조 문제로 개발자들을 괴롭혀왔습니다. 하지만 Apple은 Swift 5.5부터 새로운 구조화된 동시성(Structured Concurrency) 모델을 도입하여 이러한 문제들을 근본적으로 해결하고, 멀티스레딩 환경에서 발생할 수 있는 데이터 경쟁(Data Race) 문제까지 효과적으로 방지할 수 있는 해법을 제시했습니다.

이번 블로그를 통해 Swift의 현대적인 비동기 프로그래밍 패러다임을 완전히 이해하고 여러분의 앱을 더욱 강력하게 만들어 보세요!

1. 비동기 프로그래밍, 왜 중요하며 무엇이 문제였나?

앱 개발에서 네트워크 요청, 이미지 처리, 데이터베이스 접근 등 시간이 오래 걸리는 작업은 사용자 인터페이스(UI)를 담당하는 메인 스레드(Main Thread)에서 직접 실행하면 안 됩니다. UI가 멈추고 앱이 '버벅이는' 현상이 발생하기 때문이죠. 그래서 이러한 작업들은 백그라운드 스레드에서 비동기적으로(asynchronously) 실행되어야 합니다.

기존 비동기 처리 방식은 주로 다음과 같은 문제점을 안고 있었습니다:

  • 콜백 지옥 (Callback Hell): 비동기 작업이 연쇄적으로 이어질 때 클로저가 계속 중첩되어 코드의 가독성이 급격히 떨어지고 유지보수가 어려워집니다.
  • 오류 처리의 복잡성: 중첩된 클로저 사이에서 오류를 전달하고 처리하는 것이 번거롭습니다.
  • 순환 참조 (Retain Cycles): 클로저와 인스턴스 간의 강력 참조로 인해 메모리 누수가 발생할 위험이 높습니다.
  • 데이터 경쟁 (Data Races): 여러 스레드가 동시에 공유 자원에 접근하여 예상치 못한 결과나 앱 충돌을 일으킬 수 있습니다.

Swift의 새로운 동시성 모델은 이러한 고질적인 문제들을 해결하기 위해 등장했습니다.

2. async/await: 비동기 코드의 가독성 혁명

async/await는 비동기적으로 실행되는 코드를 마치 동기 코드처럼 순차적으로 작성할 수 있게 해주는 문법적 설탕(Syntactic Sugar)이자 강력한 메커니즘입니다.

  • async 키워드: 함수나 메서드가 비동기적으로(asynchronously) 실행될 수 있음을 나타냅니다. 이 함수는 작업을 시작한 후 결과를 기다리지 않고 즉시 호출자에게 제어권을 반환할 수 있습니다.
  • await 키워드: async 함수 내부에서 다른 비동기 함수가 완료되기를 기다리는(await) 지점입니다. await가 있는 곳에서 현재 태스크(Task)는 잠시 중단되고, 이 시간 동안 시스템은 다른 유용한 작업을 수행할 수 있습니다. 비동기 함수가 결과를 반환하면 await 지점부터 다시 실행됩니다.

2.1. async/await 기본 사용법

// 1. 비동기 함수 정의: async 키워드 사용
func fetchWeather(for city: String) async throws -> String {
    print("날씨 정보 가져오기 시작: \(city)")
    // 실제 네트워크 요청 대신 2초 지연 시뮬레이션
    try await Task.sleep(nanoseconds: 2 * 1_000_000_000) // 2초 대기
    print("날씨 정보 가져오기 완료: \(city)")
    
    // 예시를 위해 오류 발생 가능성 추가
    if city == "ErrorCity" {
        throw NSError(domain: "WeatherApp", code: 500, userInfo: [NSLocalizedDescriptionKey: "날씨 정보를 가져올 수 없습니다."])
    }
    
    return "\(city)의 현재 날씨: 맑음, 25°C"
}

// 2. 비동기 함수 호출: await 키워드 사용 (async 컨텍스트 내에서)
func displayWeatherInfo() async {
    print("날씨 정보 표시 시작...")
    do {
        let weather = try await fetchWeather(for: "Seoul") // fetchWeather가 완료될 때까지 기다림
        print("표시할 날씨: \(weather)")
    } catch {
        print("날씨 정보 가져오기 실패: \(error.localizedDescription)")
    }
    print("날씨 정보 표시 완료.")
}

// 3. 비동기 작업 시작: Task 블록 또는 최상위 레벨 async 함수 (iOS 15+, Swift 5.5+ 모듈)
Task {
    await displayWeatherInfo()
}

// 예상 출력 순서:
// 날씨 정보 표시 시작...
// 날씨 정보 가져오기 시작: Seoul
// (2초 후)
// 날씨 정보 가져오기 완료: Seoul
// 표시할 날씨: Seoul의 현재 날씨: 맑음, 25°C
// 날씨 정보 표시 완료.

await fetchWeather(for: "Seoul") 라인에서 displayWeatherInfo 함수는 일시 중단됩니다. 이 동안 시스템은 다른 태스크를 실행하거나 UI를 업데이트하는 등 유휴 시간을 활용할 수 있습니다. fetchWeather가 결과를 반환하면 displayWeatherInfo는 await 지점부터 다시 실행됩니다.

3. Task와 TaskGroup: 구조화된 동시성

Swift의 새로운 동시성 모델은 **Task**를 기반으로 합니다. Task는 비동기적으로 실행되는 코드 블록의 단위이며, 스레드와는 다르게 경량화되어 더 효율적입니다. 구조화된 동시성은 이러한 태스크들이 계층 구조를 가지며, 부모 태스크가 종료될 때 모든 자식 태스크가 함께 종료되도록 보장하여 복잡한 비동기 작업의 생명 주기를 관리하기 쉽게 만듭니다.

3.1. Task: 독립적인 비동기 작업 단위

Task는 새로운 비동기 작업을 생성하고 실행할 때 사용됩니다.

// 메인 스레드에서 UI를 블록하지 않고 백그라운드 작업 실행
print("메인 스레드: UI 작업 진행 중...")

Task { // 새로운 비동기 Task 시작
    print("Task 내부: 복잡한 계산 시작...")
    let sum = (1...1_000_000).reduce(0, +) // 시간 소모 작업
    print("Task 내부: 계산 결과 = \(sum)")
    
    // 계산 완료 후 UI 업데이트 (메인 스레드에서)
    await MainActor.run { // MainActor를 사용하여 UI 업데이트 보장
        print("메인 스레드: UI 업데이트 완료 (계산 결과 표시)")
    }
}

print("메인 스레드: 다른 UI 작업 계속 진행 중...")
// 출력 순서는 비동기 특성상 다양할 수 있으나,
// "메인 스레드: UI 작업 진행 중..."
// "메인 스레드: 다른 UI 작업 계속 진행 중..."
// 이 먼저 출력된 후 Task 내부 작업이 나중에 출력될 가능성이 높음.

3.2. TaskGroup: 여러 비동기 작업을 병렬적으로 실행하고 관리

TaskGroup은 여러 관련 태스크를 그룹화하여 동시에 실행하고, 모든 태스크가 완료될 때까지 기다리거나 개별 결과를 수집할 때 사용합니다. 이는 GCD의 DispatchGroup과 유사하지만, async/await와 통합되어 더욱 직관적입니다.

func fetchMultipleImages(urls: [URL]) async throws -> [UIImage] {
    var images: [UIImage] = []
    
    // withTaskGroup을 사용하여 여러 작업을 병렬로 실행
    await withTaskGroup(of: UIImage?.self) { group in // UIImage?로 옵셔널 허용 (다운로드 실패 가능성)
        for url in urls {
            group.addTask { // 각 URL에 대해 새로운 Task 추가
                print("이미지 다운로드 시작: \(url.lastPathComponent)")
                try? await Task.sleep(nanoseconds: 1 * 1_000_000_000) // 1초 지연 시뮬레이션
                
                // 실제 이미지 다운로드 로직 (간단히 UIImage 반환으로 대체)
                let image = UIImage(systemName: "photo") // 예시 이미지
                print("이미지 다운로드 완료: \(url.lastPathComponent)")
                return image
            }
        }
        
        // 모든 Task의 결과를 기다리고 수집
        for await image in group {
            if let image = image {
                images.append(image)
            }
        }
    }
    return images
}

Task {
    print("\n--- 여러 이미지 다운로드 시작 ---")
    let imageUrls = [
        URL(string: "https://example.com/img1.jpg")!,
        URL(string: "https://example.com/img2.jpg")!,
        URL(string: "https://example.com/img3.jpg")!,
    ]
    
    do {
        let downloadedImages = try await fetchMultipleImages(urls: imageUrls)
        print("총 \(downloadedImages.count)개의 이미지 다운로드 완료.")
        // 다운로드된 이미지들을 UI에 표시하는 로직 (메인 액터에서)
    } catch {
        print("이미지 다운로드 중 오류 발생: \(error.localizedDescription)")
    }
    print("--- 여러 이미지 다운로드 완료 ---")
}
// 출력: img1, img2, img3 다운로드가 거의 동시에 시작/완료되며 최종 결과 수집

withTaskGroup을 사용하면 fetchImage 호출들이 병렬적으로 실행되어 전체 대기 시간을 줄여줍니다. 모든 이미지가 다운로드될 때까지 기다린 후 결과를 합칠 수 있습니다.

4. Actor: 동시성 환경에서 데이터 경쟁 방지

멀티스레드 환경에서 여러 태스크가 동시에 같은 공유 데이터에 접근하여 수정하려 할 때 데이터 경쟁(Data Race)이라는 심각한 문제가 발생할 수 있습니다. 이는 예측 불가능한 결과나 앱 충돌로 이어지며 디버깅하기 매우 어렵습니다.


Actor는 이러한 데이터 경쟁 문제를 안전하게 해결하기 위해 도입된 새로운 참조 타입입니다. 액터는 자신이 관리하는 모든 상태(프로퍼티)에 대한 접근을 직렬화(Serialization)하여, 한 번에 하나의 태스크만 액터의 상태에 접근하고 수정할 수 있도록 보장합니다.

actor BankAccount {
    var balance: Double

    init(initialBalance: Double) {
        self.balance = initialBalance
        print("계좌 초기 잔액: \(balance)")
    }

    // 잔액 변경 메서드 (async로 선언하여 비동기 호출 가능)
    func deposit(amount: Double) {
        balance += amount
        print("입금: \(amount), 현재 잔액: \(balance)")
    }

    func withdraw(amount: Double) {
        if balance >= amount {
            balance -= amount
            print("출금: \(amount), 현재 잔액: \(balance)")
        } else {
            print("잔액 부족! (시도: \(amount), 현재: \(balance))")
        }
    }
    
    // 잔액 조회 메서드 (await 필요)
    func getBalance() -> Double {
        return balance
    }
}

func simulateTransactions() async {
    let account = BankAccount(initialBalance: 1000.0)

    // 여러 Task에서 동시에 잔액에 접근 시도
    await withTaskGroup(of: Void.self) { group in
        for _ in 1...5 {
            group.addTask {
                await account.deposit(amount: 100.0) // 액터 메서드 호출 시 await 필요
            }
            group.addTask {
                await account.withdraw(amount: 50.0)
            }
        }
    }
    print("최종 잔액: \(await account.getBalance())") // 액터 프로퍼티/메서드 접근 시 await 필요
}

Task {
    print("\n--- 트랜잭션 시뮬레이션 시작 (Actor 사용) ---")
    await simulateTransactions()
    print("--- 트랜잭션 시뮬레이션 완료 ---")
}
// 출력: 입금/출금 메시지의 순서는 비결정적일 수 있지만, 최종 잔액은 항상 정확히 계산됨.
// (초기 1000 + 5*100 - 5*50 = 1000 + 500 - 250 = 1250)
// 최종 잔액: 1250.0

BankAccount를 actor로 선언함으로써, deposit과 withdraw 메서드는 동시에 여러 태스크에 의해 호출되더라도 balance 프로퍼티에 대한 접근이 자동으로 직렬화되어 데이터 경쟁이 발생하지 않습니다. 즉, await account.deposit(...)과 같이 호출할 때 Swift 런타임이 해당 액터에 대한 접근을 임시적으로 '잠그고' 작업을 수행합니다.

4.1. MainActor: UI 업데이트 안전하게 처리하기

@MainActor는 UI 업데이트와 같이 항상 메인 스레드에서 실행되어야 하는 코드 블록이나 함수, 클래스 전체에 적용할 수 있는 특수한 액터입니다. MainActor로 표시된 코드는 Swift 런타임에 의해 자동으로 메인 스레드에서 실행되도록 보장됩니다.

// 뷰 모델 예시
@MainActor // 이 클래스의 모든 메서드와 프로퍼티는 메인 스레드에서 접근됨을 보장
class MyViewModel: ObservableObject {
    @Published var message: String = "초기 메시지"

    func updateMessage(newMessage: String) async {
        // 비동기 백그라운드 작업 수행 (예: 네트워크 요청)
        let processedData = await simulateNetworkCall(input: newMessage)

        // @MainActor 클래스 내부이므로 별도의 Dispatch 작업 없이 바로 UI 관련 프로퍼티 업데이트 가능
        self.message = processedData
        print("메시지 업데이트 완료: \(message)")
    }
    
    private func simulateNetworkCall(input: String) async -> String {
        print("백그라운드 네트워크 호출 시작: \(input)")
        try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)
        print("백그라운드 네트워크 호출 완료")
        return "처리된 데이터: \(input)"
    }
}

// SwiftUI 뷰에서 사용 시 (예시)
// struct ContentView: View {
//     @StateObject private var viewModel = MyViewModel()
//
//     var body: some View {
//         VStack {
//             Text(viewModel.message)
//             Button("메시지 업데이트") {
//                 Task {
//                     await viewModel.updateMessage(newMessage: "새로운 메시지")
//                 }
//             }
//         }
//     }
// }

// @MainActor.run { ... } 를 사용하여 특정 코드 블록만 메인 스레드에서 실행
func updateUIFromBackground() {
    Task {
        // 백그라운드에서 실행될 작업
        print("백그라운드 작업 중...")
        try await Task.sleep(nanoseconds: 1_000_000_000) // 1초 대기

        await MainActor.run { // 이 클로저 내의 코드는 메인 스레드에서 실행됨
            print("UI 업데이트: 메인 스레드에서 실행됨")
            // self.myLabel.text = "업데이트 완료!" // UI 업데이트 코드
        }
    }
}

@MainActor나 await MainActor.run { ... }을 사용하면 명시적으로 DispatchQueue.main.async를 호출할 필요 없이 안전하게 UI를 업데이트할 수 있습니다.

정리하며

오늘은 Swift 5.5부터 도입된 현대적인 동시성 모델의 핵심인 async/awaitActor에 대해 심층적으로 알아보았습니다.

  • async/await: 비동기 코드를 마치 동기 코드처럼 순차적으로 작성하여 콜백 지옥을 해소하고 가독성을 높입니다.
  • Task: 비동기 작업의 기본 단위이며, 구조화된 동시성을 통해 작업 생명주기 관리를 용이하게 합니다.
  • TaskGroup: 여러 비동기 작업을 병렬적으로 실행하고 결과를 효율적으로 수집합니다.
  • Actor: 공유 가능한 상태를 안전하게 관리하여 데이터 경쟁을 방지하는 새로운 참조 타입입니다.
  • MainActor: UI 관련 코드를 메인 스레드에서 안전하게 실행하도록 보장하는 특수 액터입니다.

Swift의 새로운 동시성 기능들은 비동기 프로그래밍을 훨씬 더 안전하고, 강력하며, 즐거운 경험으로 변화시켰습니다. 이제 여러분은 복잡한 동시성 문제를 효율적으로 해결하고, 더욱 반응성이 좋고 안정적인 앱을 만들 수 있게 되었습니다.


궁금한 점이 있다면 언제든지 댓글로 남겨주세요. 감사합니다.

0 comments:

댓글 쓰기