🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: 12편
앱은 항상 여러 일을 동시에 합니다. 네트워크에서 데이터를 받는 동안에도 UI는 반응해야 하고, 파일을 저장하는 동안에도 사용자 입력을 받아야 합니다. 이를 위해 오래 걸리는 작업은 백그라운드에서 실행하고 완료 시 메인 스레드에 알려주는 패턴이 필요합니다.
Swift 5.5 이전에는 이것을 콜백(completion handler)으로 처리했습니다. 동작은 했지만 중첩이 깊어지면 읽기 어려웠습니다. Swift 5.5에서 도입된 async/await는 비동기 코드를 동기 코드처럼 선형으로 쓸 수 있게 해줍니다.
콜백 방식의 문제
// 콜백 방식: 세 단계가 중첩되면 이미 읽기 힘들다
fetchUser(id: 1) { userResult in
switch userResult {
case .success(let user):
fetchPosts(for: user) { postsResult in
switch postsResult {
case .success(let posts):
fetchComments(for: posts[0]) { commentsResult in
// 여기까지 오면 들여쓰기가 4단계
switch commentsResult {
case .success(let comments):
print(comments)
case .failure(let error):
handleError(error)
}
}
case .failure(let error):
handleError(error)
}
}
case .failure(let error):
handleError(error)
}
}
에러 처리가 각 단계마다 반복되고, 실행 흐름을 추적하기 어렵습니다. 이것이 “콜백 지옥”입니다.
async/await
같은 로직을 async/await로 쓰면 이렇습니다.
func loadData() async throws {
let user = try await fetchUser(id: 1)
let posts = try await fetchPosts(for: user)
let comments = try await fetchComments(for: posts[0])
print(comments)
}
들여쓰기 없이 위에서 아래로 선형으로 읽힙니다. 에러 처리도 throws 하나로 통합됩니다.
async는 “이 함수는 비동기로 실행될 수 있다”는 선언입니다. 내부에서 await를 쓸 수 있게 됩니다.
await는 “여기서 잠시 멈추고 결과를 기다린다”는 표시입니다. 기다리는 동안 이 스레드는 다른 작업을 처리할 수 있습니다. 블로킹이 아니라 일시 중단(suspend)입니다.
func fetchUser(id: Int) async throws -> String {
// 실제로는 URLSession 등 비동기 API 호출
try await Task.sleep(for: .seconds(1)) // 1초 대기 시뮬레이션
return "철수"
}
Task — 비동기 작업 시작
async 함수는 다른 async 함수 안에서만 await로 호출할 수 있습니다. 동기 컨텍스트(예: 버튼 탭 핸들러)에서 비동기 작업을 시작하려면 Task를 씁니다.
// 버튼 탭 핸들러 — 동기 컨텍스트
func didTapButton() {
Task {
do {
let user = try await fetchUser(id: 1)
print("사용자: \(user)")
} catch {
print("에러: \(error)")
}
}
}
Task { }는 새 비동기 작업을 생성하고 즉시 반환합니다. 내부 작업은 백그라운드에서 실행됩니다.
Task에는 우선순위를 지정할 수 있습니다.
Task(priority: .background) {
await heavyWork()
}
Task(priority: .userInitiated) {
await quickResponse()
}
Task 취소
Task는 취소할 수 있습니다. 뷰가 사라질 때 진행 중인 네트워크 요청을 중단하는 패턴에 씁니다.
var loadingTask: Task<Void, Never>?
func startLoading() {
loadingTask = Task {
await loadData()
}
}
func cancelLoading() {
loadingTask?.cancel()
}
취소 신호를 받은 작업이 실제로 멈추려면 중간중간 Task.checkCancellation()이나 Task.isCancelled를 확인해야 합니다. Swift 표준 라이브러리의 비동기 API는 대부분 취소를 자동으로 처리합니다.
async let — 병렬 실행
순서가 중요하지 않은 여러 비동기 작업을 동시에 시작하려면 async let을 씁니다.
// 순차 실행 — 총 2초
func loadSequential() async throws {
let user = try await fetchUser(id: 1) // 1초
let config = try await fetchConfig() // 1초
setup(user: user, config: config)
}
// 병렬 실행 — 총 1초
func loadParallel() async throws {
async let user = fetchUser(id: 1) // 동시에 시작
async let config = fetchConfig() // 동시에 시작
setup(user: try await user, config: try await config) // 둘 다 완료 후 진행
}
TaskGroup — 동적 병렬 실행
개수가 정해지지 않은 작업을 병렬로 실행할 때 씁니다.
func fetchAllUsers(ids: [Int]) async throws -> [String] {
try await withThrowingTaskGroup(of: String.self) { group in
for id in ids {
group.addTask { try await fetchUser(id: id) }
}
var results: [String] = []
for try await user in group {
results.append(user)
}
return results
}
}
@MainActor — UI는 메인 스레드에서
UI 업데이트는 반드시 메인 스레드에서 해야 합니다. 백그라운드 Task에서 직접 UI를 변경하면 크래시나 예측 불가한 동작이 생깁니다.
@MainActor를 붙이면 해당 코드가 항상 메인 스레드에서 실행됨을 보장합니다.
@MainActor
func updateLabel(text: String) {
label.stringValue = text // 항상 메인 스레드에서 실행됨
}
// 클래스 전체에 적용
@MainActor
class ViewModel: ObservableObject {
@Published var title = ""
func load() async {
let data = await fetchData() // 백그라운드
title = data // @MainActor이므로 메인 스레드
}
}
Task 안에서 특정 코드만 메인 스레드로 보내려면 이렇게 씁니다.
Task {
let data = await fetchData() // 백그라운드 스레드
await MainActor.run {
self.label.stringValue = data // 메인 스레드
}
}
기존 콜백 API를 async로 감싸기
URLSession 같은 기존 Apple API는 이미 async 버전을 제공하지만, 직접 만든 콜백 함수를 async로 바꿔야 할 때 withCheckedContinuation을 씁니다.
func legacyFetch(completion: @escaping (String) -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
completion("결과")
}
}
// async 래퍼
func modernFetch() async -> String {
await withCheckedContinuation { continuation in
legacyFetch { result in
continuation.resume(returning: result)
}
}
}
// 이제 await로 호출 가능
let result = await modernFetch()
다른 언어와 비교
| Python | JavaScript | Swift | |
|---|---|---|---|
| 비동기 선언 | async def |
async function |
func … async |
| 대기 | await |
await |
await |
| 작업 시작 | asyncio.create_task |
Promise |
Task { } |
| 병렬 실행 | asyncio.gather |
Promise.all |
async let / TaskGroup |
| 메인 스레드 보장 | 이벤트 루프 자체가 단일 | 이벤트 루프 자체가 단일 | @MainActor |
핵심 요약
async함수는 내부에서await로 다른 비동기 작업을 기다릴 수 있다. 기다리는 동안 스레드를 블로킹하지 않는다.- 동기 컨텍스트에서 비동기 작업을 시작하려면
Task { }를 쓴다. - 순서 무관한 여러 작업을 동시에 시작하려면
async let, 개수가 동적이면TaskGroup. @MainActor는 해당 코드가 메인 스레드에서 실행됨을 컴파일 타임에 보장한다.Task는 취소 가능하다.task?.cancel()로 취소 신호를 보낸다.
다음 편은 14편 — Swift Concurrency 2부: actor와 데이터 경쟁 방지입니다. 2부의 마지막 편으로, 여러 Task가 같은 데이터를 동시에 접근할 때 생기는 문제와 actor가 이를 어떻게 해결하는지 다룹니다.
🤖 Generated with Claude Code