스위프트 async await, 콜백에서 순차 코드로
핵심 요약 async/await는 콜백 중첩 대신 비동기 작업을 순차 코드로 쓰게 해 주는 스위프트 문법입니다. 함수에 async, 호출부에 await를 붙이는 기본 문법부터 async let 병렬 실행, 콜백 API 전환, 취소와 액터까지 실무 관점으로 정리했습니다.
async/await는 스위프트에서 네트워크 요청이나 파일 읽기처럼 시간이 걸리는 비동기 작업을 위에서 아래로 읽히는 순차 코드로 쓰게 해 주는 문법입니다. 콜백 함수를 여러 겹 중첩하던 방식을 값을 그대로 반환받는 형태로 바꿔, 실행 흐름과 오류 처리를 눈으로 따라가기 쉽게 만듭니다. 2026년 7월 기준 이 방식은 스위프트 동시성(Swift Concurrency)의 표준으로 자리 잡은 것으로 안내되며, 문법과 동작 규칙은 스위프트 공식 문서에 정리되어 있습니다. async/await는 Swift 5.5에서 언어에 추가된 것으로 알려져 있습니다.
이 글은 Swift 5.5 이상을 기준으로 async/await의 기본 문법, 콜백 API 전환, 실무에서 챙길 점을 다룹니다. 액터 격리 규칙의 세부나 Combine 프레임워크와의 비교는 범위를 넘어서므로 여기서는 다루지 않습니다.
async await는 어떤 문제를 푸는가
콜백이 겹겹이 쌓이는 문제와 흩어진 오류 처리를 정리합니다. 예전에는 비동기 함수의 결과를 completion handler(작업이 끝난 뒤 호출되는 콜백 함수)로 받았습니다. 요청이 하나둘 이어질수록 클로저가 안쪽으로 계속 들어가 코드가 오른쪽으로 밀렸고, 성공과 실패 분기를 각 단계마다 따로 챙겨야 했습니다.
async/await를 쓰면 이 흐름이 평평해집니다. 결과를 반환값으로 받고, 오류는 스위프트의 일반 오류 처리(try/catch)로 한곳에서 잡습니다. 콜백 안에서 self를 약하게 참조하거나 completion 호출을 빠뜨리는 실수도 줄어듭니다.
async와 await는 어떻게 쓰나
함수 선언에 async를 붙이고, 그 함수를 부르는 자리에 await를 씁니다. await는 "여기서 결과가 올 때까지 기다린다"는 표시이며, 기다리는 동안 스레드를 막지 않고 다른 작업에 양보합니다.
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}async 함수는 다른 async 문맥에서만 부를 수 있습니다. 버튼 탭 핸들러처럼 동기 코드에서 시작할 때는 Task로 비동기 문맥을 엽니다.
Task {
do {
let user = try await fetchUser(id: 1)
updateUI(with: user)
} catch {
showError(error)
}
}여러 작업을 동시에 실행하려면
서로 의존하지 않는 두 요청은 async let으로 동시에 띄운 뒤 한 번에 기다립니다. 아래처럼 쓰면 두 요청이 병렬로 나가고 await 시점에서 둘 다 모이므로, 순서대로 await하는 것보다 전체 시간이 짧아집니다.
async let profile = fetchProfile(id: id)
async let posts = fetchPosts(id: id)
let (loadedProfile, loadedPosts) = try await (profile, posts)작업 개수가 가변이거나 결과를 모아야 하면 TaskGroup을 씁니다. 이렇게 부모 작업이 끝나면 자식 작업도 함께 정리되는 구조를 구조적 동시성(structured concurrency)이라고 부릅니다. 작업이 새어 나가 계속 도는 상황을 언어 차원에서 막아 준다는 점이 장점으로 안내됩니다.
콜백 기반 API를 async로 바꾸기
completion handler를 쓰는 옛 API는 withCheckedContinuation으로 감싸 async 함수로 만들 수 있습니다. continuation의 resume를 정확히 한 번 호출하는 것이 규칙이며, 두 번 부르거나 빠뜨리면 문제가 됩니다.
func currentLocation() async throws -> Location {
try await withCheckedThrowingContinuation { continuation in
locationManager.request { result in
switch result {
case .success(let location):
continuation.resume(returning: location)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}기존 콜백 코드를 한꺼번에 걷어내기 어렵다면, 이렇게 경계 함수만 async로 감싸고 호출부부터 조금씩 옮기는 방식이 무난합니다.
실무에서 챙길 점
UI 갱신은 메인 스레드에서 일어나야 합니다. 뷰나 뷰모델을 @MainActor로 표시하면 그 안의 코드가 메인에서 실행되도록 컴파일러가 도와주는 것으로 안내됩니다. await 이후에는 실행 스레드가 바뀔 수 있으니, 화면을 건드리는 코드가 메인에서 도는지 확인하는 편이 안전합니다.
공유 상태를 여러 작업이 동시에 건드릴 때, 예전에는 세마포어나 뮤텍스로 직접 잠금을 걸었지만, 스위프트 동시성에서는 액터(actor)가 상태 접근을 한 줄로 세워 데이터 경쟁을 막는 역할을 맡는 것으로 알려져 있습니다.
취소도 함께 설계해야 합니다. Task는 취소 신호를 전달하지만 실행을 강제로 멈추지는 않으므로, 긴 루프 안에 Task.checkCancellation 같은 확인 지점을 두어 스스로 빠져나오게 만드는 방식이 권장되는 것으로 안내됩니다. 서버 쪽도 다르지 않습니다. Vapor로 스위프트 서버를 만들 때도 라우트 핸들러가 async로 동작해, 같은 문법이 앱과 서버 양쪽에 그대로 쓰입니다.