[13편] Swift Concurrency 1부 — async/await와 구조화된 동시성

🤖 이 글은 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

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다