일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
- 스레드 점유권
- SwiftUI
- 순환참조
- ObservedObject
- git 명령어
- 앱실행
- Access Control
- async/await
- task 취소
- restful api
- 작업 취소
- MainActor
- environment value
- unowned
- rest api
- Swift
- assosiated type
- NavigationLink
- 동시성 프로그래밍
- environment object
- MVVM
- weak
- StateObject
- Swift Concurrency
- swfitui
- actor
- 격리 시스템
- Git
- navigationview
- REDRAW
- Today
- Total
Develup
[Concurrency] Swift async/await와 CPS(Continuation-Passing Style)의 완벽 이해 본문
[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 컴파일러는 최적화를 통해 이를 최소화합니다. 대부분의 경우, 코드 가독성과 유지보수성 향상이 미미한 성능 차이보다 더 큰 이점을 제공합니다.
'Swift > Concurrency' 카테고리의 다른 글
[Concurrency] Task 취소 완벽 이해하기: 코드 예제와 모범 사례 (0) | 2025.03.15 |
---|---|
[Concurrency] Swift의 async/await와 Combine 프레임워크: 주요 차이점 완벽 비교 (0) | 2025.03.06 |
[Concurrency] Swift async/await 완벽 가이드: 스레드 점유 이해하기 (0) | 2025.03.06 |