Swift 동시성 — async/await와 Actor

Swift 동시성이란?

Swift 5.5에서 도입된 구조화된 동시성(Structured Concurrency)은 async/await 키워드로 비동기 코드를 순차적으로 작성하고, Actor로 데이터 경쟁을 컴파일 타임에 방지합니다. 콜백 지옥이나 completion handler의 복잡성을 해결하는 현대적인 비동기 프로그래밍 모델입니다.

이 글에서는 async/await의 기본부터 Task, TaskGroup, Actor까지 핵심 개념을 정리합니다.

async/await 기본

async 키워드는 비동기 함수를 선언하고, await 키워드는 비동기 함수의 결과를 대기합니다.

import Foundation

// 비동기 함수 선언
func fetchUser(id: Int) async -> String {
    // 비동기 대기 — 스레드를 차단하지 않음
    try? await Task.sleep(for: .seconds(1))
    return "사용자_\(id)"
}

func fetchOrders(for user: String) async -> [String] {
    try? await Task.sleep(for: .milliseconds(800))
    return ["주문A", "주문B", "주문C"]
}

// 에러를 던질 수 있는 비동기 함수
enum NetworkError: Error {
    case invalidURL
    case serverError(code: Int)
    case timeout
}

func fetchData(from urlString: String) async throws -> Data {
    guard let url = URL(string: urlString) else {
        throw NetworkError.invalidURL
    }
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw NetworkError.serverError(code: 500)
    }
    return data
}

// 메인 진입점
@main
struct App {
    static func main() async {
        // 순차 실행
        let user = await fetchUser(id: 1)
        print("사용자: \(user)")
        // 실행 결과: 사용자: 사용자_1

        let orders = await fetchOrders(for: user)
        print("주문: \(orders)")
        // 실행 결과: 주문: ["주문A", "주문B", "주문C"]

        // 병렬 실행 — async let
        let startTime = CFAbsoluteTimeGetCurrent()

        async let user1 = fetchUser(id: 1)
        async let user2 = fetchUser(id: 2)
        async let user3 = fetchUser(id: 3)

        // 모든 결과를 동시에 대기
        let users = await [user1, user2, user3]
        let elapsed = CFAbsoluteTimeGetCurrent() - startTime

        print("사용자들: \(users)")
        print("소요 시간: \(String(format: "%.1f", elapsed))초 (병렬이므로 약 1초)")
        // 실행 결과:
        // 사용자들: ["사용자_1", "사용자_2", "사용자_3"]
        // 소요 시간: 1.0초 (병렬이므로 약 1초)
    }
}

async let으로 여러 비동기 작업을 동시에 시작하면, 순차 실행 대비 실행 시간이 크게 줄어듭니다.

Task와 TaskGroup

Task는 비동기 작업의 단위이고, TaskGroup은 동적인 수의 작업을 관리합니다.

import Foundation

struct UserProfile {
    let name: String
    let score: Int
}

func fetchProfile(id: Int) async -> UserProfile {
    try? await Task.sleep(for: .milliseconds(500))
    return UserProfile(name: "사용자_\(id)", score: Int.random(in: 60...100))
}

@main
struct App {
    static func main() async {
        // Task — 독립적인 비동기 작업
        let task = Task {
            await fetchProfile(id: 1)
        }
        let profile = await task.value
        print("\(profile.name): \(profile.score)점")
        // 실행 결과: 사용자_1: 87점 (랜덤 값)

        // Task 취소
        let longTask = Task {
            for i in 1...10 {
                // 취소 확인 — 협조적 취소
                guard !Task.isCancelled else {
                    print("작업 취소됨 (i=\(i))")
                    return
                }
                try? await Task.sleep(for: .milliseconds(200))
                print("진행: \(i)/10")
            }
        }
        try? await Task.sleep(for: .milliseconds(500))
        longTask.cancel()  // 취소 요청
        await longTask.value
        // 실행 결과:
        // 진행: 1/10
        // 진행: 2/10
        // 작업 취소됨 (i=3)

        // TaskGroup — 동적 수의 병렬 작업
        let userIds = [1, 2, 3, 4, 5]

        let profiles = await withTaskGroup(
            of: UserProfile.self,
            returning: [UserProfile].self
        ) { group in
            // 모든 ID에 대해 병렬로 프로필 조회
            for id in userIds {
                group.addTask {
                    await fetchProfile(id: id)
                }
            }

            // 결과 수집
            var results: [UserProfile] = []
            for await profile in group {
                results.append(profile)
            }
            return results
        }

        print("\n전체 프로필:")
        for p in profiles.sorted(by: { $0.score > $1.score }) {
            print("  \(p.name): \(p.score)점")
        }
        // 실행 결과 (랜덤 값, 점수 내림차순):
        // 전체 프로필:
        //   사용자_3: 95점
        //   사용자_1: 88점
        //   사용자_5: 82점
        //   사용자_2: 76점
        //   사용자_4: 71점
    }
}

TaskGroup은 동적인 수의 작업을 병렬로 실행하고 결과를 모을 때 유용합니다. async let은 정적인 수의 작업에 적합합니다.

Actor — 데이터 경쟁 방지

Actor는 자신의 상태에 대한 접근을 직렬화하여 데이터 경쟁을 컴파일 타임에 방지합니다. 클래스와 유사하지만, 외부에서 프로퍼티나 메서드에 접근할 때 반드시 await를 사용해야 합니다.

import Foundation

// Actor — 동시 접근으로부터 상태 보호
actor BankAccount {
    let owner: String
    private(set) var balance: Int

    init(owner: String, balance: Int) {
        self.owner = owner
        self.balance = balance
    }

    func deposit(_ amount: Int) {
        balance += amount
        print("\(owner): +\(amount)원 (잔액: \(balance)원)")
    }

    func withdraw(_ amount: Int) -> Bool {
        guard balance >= amount else {
            print("\(owner): 잔액 부족 (잔액: \(balance)원, 요청: \(amount)원)")
            return false
        }
        balance -= amount
        print("\(owner): -\(amount)원 (잔액: \(balance)원)")
        return true
    }

    // nonisolated — actor 격리 없이 접근 가능 (불변 데이터만)
    nonisolated var accountInfo: String {
        return "계좌 소유자: \(owner)"
    }
}

// @MainActor — 메인 스레드에서 실행 보장
@MainActor
class ViewModel {
    var statusMessage = ""

    func updateStatus(_ message: String) {
        statusMessage = message
        print("[UI 업데이트] \(message)")
    }
}

@main
struct App {
    static func main() async {
        let account = BankAccount(owner: "홍길동", balance: 100000)

        // Actor의 메서드는 await 필요
        await account.deposit(50000)
        // 실행 결과: 홍길동: +50000원 (잔액: 150000원)

        let success = await account.withdraw(30000)
        print("출금 성공: \(success)")
        // 실행 결과:
        // 홍길동: -30000원 (잔액: 120000원)
        // 출금 성공: true

        // 동시에 여러 작업이 접근해도 안전
        await withTaskGroup(of: Void.self) { group in
            for i in 1...5 {
                group.addTask {
                    await account.deposit(i * 1000)
                }
            }
        }

        let finalBalance = await account.balance
        print("최종 잔액: \(finalBalance)원")
        // 실행 결과: 최종 잔액: 135000원 (120000 + 1000+2000+3000+4000+5000)

        // nonisolated 프로퍼티는 await 불필요
        print(account.accountInfo)
        // 실행 결과: 계좌 소유자: 홍길동
    }
}

Actor는 뮤텍스나 잠금 없이도 스레드 안전한 코드를 작성할 수 있게 합니다. 컴파일러가 격리 규칙을 강제하므로, 데이터 경쟁이 발생할 수 있는 코드는 컴파일 시점에 오류로 잡힙니다.

실전 팁

  • async let vs TaskGroup: 작업 수가 컴파일 타임에 정해지면 async let, 동적이면 TaskGroup을 사용합니다.
  • Task 취소는 협조적: Task.isCancelled를 확인하거나 try Task.checkCancellation()을 호출하여 취소에 협조합니다.
  • Actor는 참조 타입: Actor는 클래스처럼 참조로 전달됩니다. 값 타입이 필요하면 구조체를 사용합니다.
  • @MainActor로 UI 보호: UI 관련 코드는 @MainActor로 표시하여 메인 스레드에서 실행되도록 보장합니다.
  • Sendable 프로토콜: Actor 경계를 넘어가는 데이터는 Sendable을 채택해야 합니다. 구조체와 열거형은 자동으로 Sendable입니다.
  • 에러 처리: async throws 함수는 do-catch 블록에서 try await로 호출합니다.

이 글이 도움이 되었나요?