Develup

[Concurrency] Swift async/await와 CPS(Continuation-Passing Style)의 완벽 이해 본문

Swift/Concurrency

[Concurrency] Swift async/await와 CPS(Continuation-Passing Style)의 완벽 이해

Develup 2025. 3. 6. 17:39
반응형

소개

Swift 5.5에서 도입된 async/await는 비동기 프로그래밍 패러다임을 혁신적으로 변화시켰습니다. 이전에는 복잡한 콜백 구조나 Combine 같은 반응형 프레임워크를 사용해야 했던 비동기 작업이 이제는 마치 동기 코드처럼 작성할 수 있게 되었습니다. 그러나 async/await의 내부 작동 원리를 이해하려면 CPS(Continuation-Passing Style)라는 프로그래밍 개념을 알아야 합니다. 이 글에서는 async/await의 기본 개념부터 CPS와의 관계, 그리고 실제 사용 사례까지 살펴보겠습니다.

async/await란 무엇인가?

async/await의 기본 개념

async/await는 비동기 코드를 동기 코드처럼 작성할 수 있게 해주는 Swift의 언어적 기능입니다. 이전의 콜백 기반 비동기 프로그래밍의 복잡함을 크게 줄여주는 방식입니다.

// 콜백 기반 비동기 코드
func fetchUserData(completion: @escaping (User?, Error?) -> Void) {
    // 비동기 작업 수행 후 완료 시 콜백 호출
    networkService.fetchUser { user, error in
        completion(user, error)
    }
}

// async/await 기반 비동기 코드
func fetchUserData() async throws -> User {
    // 비동기 작업을 수행하고 결과 반환
    return try await networkService.fetchUser()
}

async/await의 주요 이점은 다음과 같습니다:

  • 코드 가독성 향상
  • "콜백 지옥" 방지
  • 오류 처리의 일관성
  • 동기 코드와 유사한 사고 방식 적용 가능

async 키워드의 역할

함수나 메서드에 async 키워드를 사용하면 해당 함수가 비동기적으로 동작함을 명시합니다. 이는 함수의 실행이 일시 중단될 수 있으며, 나중에 재개될 수 있음을 의미합니다.

func processImage() async -> UIImage {
    // 이미지 처리 로직
    let data = await downloadImageData()
    let image = await processData(data)
    return image
}

await 키워드의 역할

await 키워드는 비동기 함수를 호출하는 지점을 표시합니다. 이 지점에서 함수의 실행이 일시 중단될 수 있으며, 비동기 작업이 완료되면 해당 지점부터 실행이 재개됩니다.

func loadProfileView() async {
    let user = try? await userService.fetchCurrentUser()
    let image = try? await imageService.fetchProfileImage(for: user.id)
    
    // 위의 비동기 작업들이 완료된 후에 실행됨
    updateUI(with: user, image: image)
}

CPS(Continuation-Passing Style)란 무엇인가?

CPS의 기본 개념

CPS(Continuation-Passing Style)는 함수의 반환 값을 직접 반환하지 않고, 후속 작업(continuation)을 처리하는 함수를 인자로 받아 그 함수에 결과를 전달하는 프로그래밍 스타일입니다. Swift에서는 이를 클로저(콜백)를 통해 구현합니다.

// 일반적인 함수 스타일
func add(_ a: Int, _ b: Int) -> Int {
    return a + b
}

// CPS 스타일
func addCPS(_ a: Int, _ b: Int, continuation: (Int) -> Void) {
    let result = a + b
    continuation(result)
}

CPS는 다음과 같은 특징이 있습니다:

  • 함수가 결과를 직접 반환하지 않음
  • 대신 결과를 처리할 함수(continuation)를 전달받음
  • 비동기 작업에 자연스럽게 적용 가능

Swift에서의 CPS 활용

Swift에서 CPS는 전통적으로 콜백 기반 API에서 많이 사용되었습니다:

func fetchData(completion: @escaping (Data?, Error?) -> Void) {
    // 비동기 작업 수행
    URLSession.shared.dataTask(with: url) { data, response, error in
        completion(data, error)
    }.resume()
}

이러한 패턴은 Swift의 많은 비동기 API에서 사용되었으며, 특히 iOS 앱 개발에서는 매우 일반적이었습니다.

async/await와 CPS의 관계

async/await의 내부 구현과 CPS

Swift의 async/await는 내부적으로 CPS 변환(transformation)을 사용하여 구현됩니다. 컴파일러는 async 함수를 CPS 스타일로 변환하여, 함수가 일시 중단되고 재개될 수 있는 지점을 관리합니다.

간단히 말해, 컴파일러는 async 함수를 여러 부분으로 분할하고, 각 await 지점에서 함수의 상태를 저장한 다음, continuation을 통해 비동기 작업이 완료되면 해당 지점부터 다시 실행할 수 있도록 합니다.

// 원래 async 함수
func processImages() async -> [UIImage] {
    let image1 = await downloadImage(url1)
    let image2 = await downloadImage(url2)
    return [image1, image2]
}

// 컴파일러에 의해 내부적으로 변환된 형태 (개념적 표현)
func processImages(continuation: @escaping ([UIImage]) -> Void) {
    downloadImage(url1) { image1 in
        downloadImage(url2) { image2 in
            continuation([image1, image2])
        }
    }
}
반응형

withCheckedContinuation을 사용한 명시적 CPS 변환

Swift에서는 withCheckedContinuation 및 withCheckedThrowingContinuation 함수를 통해 콜백 기반 API를 async/await 패턴으로 변환할 수 있습니다:

func fetchData(from url: URL) async throws -> Data {
    return try await withCheckedThrowingContinuation { continuation in
        URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let data = data {
                continuation.resume(returning: data)
            } else {
                continuation.resume(throwing: URLError(.badServerResponse))
            }
        }.resume()
    }
}

이 함수는 기존의 콜백 기반 URLSession API를 async/await 패턴으로 래핑합니다. withCheckedThrowingContinuation은 CPS를 명시적으로 사용하여 이를 가능하게 합니다.

async/await와 CPS의 실제 응용

레거시 콜백 API를 async/await로 변환하기

많은 iOS 개발자들은 레거시 콜백 기반 API를 새로운 async/await 패턴으로 변환해야 할 필요가 있습니다. 이를 위한 일반적인 패턴은 다음과 같습니다:

// 기존 콜백 기반 API
func fetchUserData(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    // 구현...
}

// async/await로 변환
extension UserService {
    func fetchUserData(id: String) async throws -> User {
        return try await withCheckedThrowingContinuation { continuation in
            fetchUserData(id: id) { result in
                continuation.resume(with: result)
            }
        }
    }
}

이 패턴을 사용하면 기존 코드베이스를 점진적으로 마이그레이션할 수 있습니다.

Task와 TaskGroup을 활용한 병렬 처리

CPS와 async/await의 결합을 통해 병렬 처리를 구현할 수 있습니다:

func loadDashboard() async throws -> Dashboard {
    async let userData = fetchUserData()
    async let recentPosts = fetchRecentPosts()
    async let notifications = fetchNotifications()
    
    // 모든 비동기 작업이 완료될 때까지 기다린 후 결과 조합
    return try await Dashboard(
        user: userData,
        posts: recentPosts,
        notifications: notifications
    )
}

이 코드는 세 개의 비동기 작업을 동시에 시작하고, 모든 작업이 완료될 때까지 기다린 후 결과를 조합합니다. 내부적으로 이는 CPS를 사용하여 각 작업의 완료를 추적합니다.

에러 처리와 취소 관리

async/await와 CPS를 함께 사용할 때 에러 처리와 취소 관리는 중요한 부분입니다:

func performComplexOperation() async throws -> Result {
    do {
        try Task.checkCancellation() // 취소 여부 확인
        let firstResult = try await firstOperation()
        
        try Task.checkCancellation() // 중간에 취소 여부 확인
        let secondResult = try await secondOperation(with: firstResult)
        
        return secondResult
    } catch is CancellationError {
        // 작업이 취소된 경우 처리
        throw OperationError.cancelled
    } catch {
        // 기타 오류 처리
        throw error
    }
}

CPS 기반의 continuation에서도 취소 처리를 적절히 구현해야 합니다:

func legacyOperationWithCancellation() async throws -> Data {
    return try await withCheckedThrowingContinuation { continuation in
        let task = legacyAPI.performOperation { result in
            switch result {
            case .success(let data):
                continuation.resume(returning: data)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
        
        // 취소 처리 설정
        continuation.onTermination = { _ in
            task.cancel()
        }
    }
}

고급 주제: Swift Concurrency 모델과 CPS

구조적 동시성(Structured Concurrency)과 CPS

Swift의 구조적 동시성은 CPS의 원칙을 바탕으로 설계되었습니다. Task와 TaskGroup은 continuation을 관리하는 고급 추상화입니다:

func processDocuments(urls: [URL]) async throws -> [Document] {
    return try await withThrowingTaskGroup(of: (Int, Document).self) { group in
        for (index, url) in urls.enumerated() {
            group.addTask {
                let document = try await downloadAndProcessDocument(url)
                return (index, document)
            }
        }
        
        var documents = [Document?](repeating: nil, count: urls.count)
        for try await (index, document) in group {
            documents[index] = document
        }
        
        return documents.compactMap { $0 }
    }
}

이 코드는 여러 문서를 병렬로 처리하면서도 그 결과를 원래 순서대로 정렬합니다. 내부적으로 이는 CPS를 사용하여 각 작업의 상태와 결과를 관리합니다.

Actor 모델과 CPS

Swift의 actor 모델도 CPS와 밀접한 관련이 있습니다. Actor는 상태 격리를 제공하면서 비동기 메시지 전달을 사용합니다:

actor UserManager {
    private var users = [String: User]()
    
    func getUser(id: String) async throws -> User {
        if let user = users[id] {
            return user
        }
        
        // 데이터베이스에서 사용자 정보 가져오기
        let user = try await database.fetchUser(id: id)
        users[id] = user
        return user
    }
}

Actor의 메서드를 호출할 때, 실제로는 내부적으로 CPS 변환이 일어나 메시지 큐를 통해 요청을 전달합니다.

결론

async/await와 CPS는 Swift의 현대적 비동기 프로그래밍의 핵심 개념입니다. async/await는 개발자에게 더 직관적이고 가독성 높은 API를 제공하며, 내부적으로는 CPS의 원리를 활용하여 구현됩니다.

비동기 코드를 작성할 때, 단순히 async/await 구문을 사용하는 것을 넘어 그 내부 작동 원리인 CPS를 이해하면 더 효과적으로 코드를 작성하고 디버깅할 수 있습니다. 또한 레거시 코드를 새로운 concurrency 모델로 마이그레이션할 때도 이러한 이해가 큰 도움이 됩니다.

Swift의 concurrency 모델은 계속 발전하고 있으며, CPS에 대한 깊은 이해는 앞으로도 새로운 비동기 패턴과 기능을 활용하는 데 중요한 기반이 될 것입니다.

자주 묻는 질문 (FAQ)

Q: async/await와 GCD(Grand Central Dispatch)의 차이점은 무엇인가요?

A: GCD는 저수준 동시성 API로, 작업을 큐에 제출하고 관리하는 방식입니다. async/await는 언어 수준의 고수준 추상화로, 컴파일러가 내부적으로 상태 관리와 일시 중단/재개를 처리합니다. async/await는 코드 가독성을 크게 향상시키며, 구조적 동시성을 제공합니다.

Q: withCheckedContinuation과 withUnsafeContinuation의 차이점은 무엇인가요?

A: withCheckedContinuation은 continuation이 정확히 한 번만 재개되는지 확인하는 안전장치가 있습니다. 반면 withUnsafeContinuation은 이러한 검사를 수행하지 않아 성능이 약간 더 좋지만, 잘못 사용하면 메모리 누수나 예기치 않은 동작을 일으킬 수 있습니다.

Q: 모든 콜백 기반 API를 async/await로 변환해야 하나요?

A: 반드시 모든 API를 변환할 필요는 없지만, 코드베이스의 일관성과 가독성을 위해 점진적으로 마이그레이션하는 것이 좋습니다. 특히 복잡한 비동기 흐름이 있는 부분부터 시작하는 것이 효과적입니다.

Q: CPS 변환은 성능에 영향을 미치나요?

A: CPS 변환은 약간의 오버헤드를 발생시킬 수 있지만, Swift 컴파일러는 최적화를 통해 이를 최소화합니다. 대부분의 경우, 코드 가독성과 유지보수성 향상이 미미한 성능 차이보다 더 큰 이점을 제공합니다.

반응형