Swift Concurrency · Learning Path

Swift Concurrency 마스터 로드맵

async/await부터 Custom Executor까지, Swift Concurrency를 완전히 이해하기 위한 학습 경로

✅ 학습 완료 🔄 학습 중 📚 예정

전체 진행률: 약 45% (9개 주제 중 4개 완료)

✅ 학습 완료

async/await 기본

  • 콜백 → async/await 변환
  • suspension point 개념
  • 에러 전파 (throws)

Actor 기본

  • actor vs lock (serial executor)
  • data isolation
  • await로 접근

CheckedContinuation

  • 콜백 ↔ async 브릿지
  • resume 정확히 1번
  • withCheckedContinuation / withCheckedThrowingContinuation

Sendable / nonisolated

  • Sendable 프로토콜
  • @unchecked Sendable
  • nonisolated / nonisolated(unsafe)

Task Isolation (SE-0461)

  • async let → global executor
  • withTaskGroup → global executor
  • Task { } → caller 상속
  • Task.detached → global executor
  • Swift 6.2 기본 동작 변경

📚 핵심 학습 주제

1. Structured vs Unstructured Concurrency ⭐⭐

핵심: 수명 관리와 취소 전파의 차이

// Structured (수명 명확, 자동 취소)
async let x = work()           // 스코프 끝나면 자동 대기/취소
await withTaskGroup { group in
    group.addTask { work() }   // group 끝나면 모든 child 완료 보장
}

// Unstructured (수동 관리)
let task = Task { work() }     // 명시적 cancel/await 필요
task.cancel()
await task.value

Task.detached { work() }       // 완전 독립, 추적 어려움
언제 뭘 쓰나?
  • Structured: 대부분의 경우 (취소 자동 전파, 에러 자동 전파)
  • Task { }: UI 이벤트에서 async 작업 시작, actor isolation 상속 필요 시
  • Task.detached: 완전히 독립된 백그라운드 작업 (드묾)

2. Task Cancellation 심화 ⭐⭐

핵심: 취소는 협조적(cooperative) — 자동 중단 아님, 직접 체크 필요

func processItems(_ items: [Item]) async throws {
    for item in items {
        // 방법 1: 에러 던지기
        try Task.checkCancellation()

        // 방법 2: 조건 체크
        guard !Task.isCancelled else {
            cleanup()
            return
        }

        await process(item)
    }
}

// 취소 핸들러 (정리 작업)
await withTaskCancellationHandler {
    await longRunningOperation()
} onCancel: {
    // ⚠️ 동기적으로, 다른 스레드에서 실행될 수 있음
    connection.cancel()
}
⚠️ 주의: onCancel 클로저는 동기적으로 실행되며, 작업과 동시에 실행될 수 있음. Sendable 필수.

3. Actor Reentrancy (재진입) ⭐⭐⭐

핵심: await 지점에서 다른 호출이 끼어들 수 있음

actor BankAccount {
    var balance = 1000

    // ❌ 버그: await 사이에 다른 호출이 끼어듦
    func transfer(amount: Int, to other: BankAccount) async {
        guard balance >= amount else { return }

        balance -= amount           // 1000 → 500
        await other.deposit(amount) // ⚠️ 여기서 또 다른 transfer 호출 가능!
        // balance가 이미 바뀌어 있을 수 있음
    }

    // ✅ 해결: await 전에 상태 변경 완료
    func transferSafe(amount: Int, to other: BankAccount) async {
        guard balance >= amount else { return }
        balance -= amount           // 상태 변경 먼저
        await other.deposit(amount) // 이후 외부 호출
    }
}
원칙: actor 내 await 전후로 상태가 달라질 수 있음을 항상 인지. 가능하면 await 전에 상태 변경 완료.

4. AsyncSequence / AsyncStream ⭐⭐

핵심: 시간에 따라 여러 값을 비동기로 방출하는 프로토콜과 구현체

AsyncSequence vs Sequence

// Sequence: 동기적 - 모든 값이 즉시 사용 가능
let array = [1, 2, 3]
for value in array { print(value) }  // 즉시 전체 반복

// AsyncSequence: 비동기적 - 값이 시간에 따라 도착
// 네트워크 응답, 파일 읽기, 타이머 등
for await line in fileHandle.bytes.lines {
    print(line)  // 각 줄이 도착할 때마다 처리
}

AsyncStream - 콜백 API를 async/await으로 변환

// 타이머를 AsyncStream으로 래핑
let timerStream = AsyncStream<Int> { continuation in
    var count = 0
    Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
        continuation.yield(count)  // 값 방출
        count += 1
        if count >= 5 {
            continuation.finish()  // 스트림 종료
            timer.invalidate()
        }
    }

    // 소비자가 취소했을 때 정리
    continuation.onTermination = { _ in
        print("Stream terminated")
    }
}

// 사용
for await tick in timerStream {
    print("Tick: \(tick)")  // 0, 1, 2, 3, 4 (1초 간격)
}

실전 예제: NotificationCenter

// NotificationCenter를 AsyncSequence로 사용
let notifications = NotificationCenter.default.notifications(
    named: UIApplication.didBecomeActiveNotification
)

// 앱이 활성화될 때마다 처리
Task {
    for await notification in notifications {
        print("App became active: \(notification)")
    }
}

AsyncThrowingStream - 에러 처리 포함

// 에러가 발생할 수 있는 스트림
let locationStream = AsyncThrowingStream<CLLocation, Error> { continuation in
    let manager = CLLocationManager()
    let delegate = LocationDelegate(
        onLocation: { location in
            continuation.yield(location)
        },
        onError: { error in
            continuation.finish(throwing: error)  // 에러로 종료
        }
    )
    manager.delegate = delegate
    manager.startUpdatingLocation()

    continuation.onTermination = { _ in
        manager.stopUpdatingLocation()
    }
}

// 사용 - try 필요
for try await location in locationStream {
    print("위치: \(location)")
}

AsyncSequence 변환 연산자

// map, filter 등 지원 (Combine과 유사)
for await line in fileHandle.bytes.lines
    .filter { !$0.isEmpty }
    .map { $0.uppercased() }
{
    print(line)
}

// first, contains 등
let hasError = await stream.contains { $0.isError }
let firstValid = await stream.first { $0.isValid }
💡 언제 AsyncStream을 쓰나?
- 콜백 기반 API를 async/await로 변환할 때
- delegate 패턴을 스트림으로 변환할 때
- Timer, NotificationCenter, WebSocket 등 이벤트를 스트림으로 처리할 때
⚠️ 주의: for await 루프가 종료되면 자동으로 iteration이 취소됨. onTermination에서 리소스 정리 필수.

5. withThrowingTaskGroup 에러 처리 ⭐⭐

핵심: 하나가 throw → 나머지 취소 요청 → 모두 완료 대기 → 에러 전파 (Cooperative Cancellation)

func fetchAllData() async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        group.addTask { try await fetchA() }  // 1초 후 에러 throw
        group.addTask { try await fetchB() }  // 10초 걸리는 작업

        var results: [Data] = []

        // fetchA가 throw하면:
        // 1. fetchB에 취소 신호 전송 (Task.isCancelled = true)
        // 2. fetchB가 취소를 확인하고 종료할 때까지 대기
        // 3. 모두 완료된 후 에러 전파
        for try await data in group {
            results.append(data)
        }

        return results
    }
}

// ⚠️ 취소를 무시하면 계속 실행됨
func fetchB_bad() async throws -> Data {
    for i in 0..<100 {
        // Task.isCancelled 체크 안 함 → 취소 신호 무시
        await heavyWork()  // 끝까지 실행됨
    }
    return data
}

// ✅ 올바른 취소 처리
func fetchB_good() async throws -> Data {
    for i in 0..<100 {
        try Task.checkCancellation()  // 취소 시 즉시 throw
        await heavyWork()
    }
    return data
}
⚠️ "즉시 끝남"이 아님: Cooperative cancellation이라 취소 신호만 보내고, 자식 Task가 취소를 확인하고 종료할 때까지 그룹이 대기함. Task.checkCancellation()이나 Task.isCancelled로 취소를 처리해야 빠르게 종료됨.

withTaskGroup (non-throwing) - 부분 성공 허용

// non-throwing 그룹은 자동 취소 없음
func fetchWithPartialSuccess() async -> [Data] {
    await withTaskGroup(of: Data?.self) { group in
        group.addTask {
            do { return try await fetchA() }
            catch { return nil }  // 실패해도 nil 반환
        }
        group.addTask {
            do { return try await fetchB() }
            catch { return nil }
        }

        var results: [Data] = []
        for await result in group {
            if let data = result {
                results.append(data)  // 성공한 것만 수집
            }
            // nil이면 무시 (부분 실패 허용)
        }
        return results
    }
}
💡 throwing vs non-throwing 차이:
- withThrowingTaskGroup: 하나 실패 → 나머지 자동 취소 → 에러 전파
- withTaskGroup: 자동 취소 없음 → Result/Optional로 부분 성공 처리

6. Sendable 심화 ⭐⭐⭐

핵심: isolation boundary를 넘어갈 수 있는 타입

// 자동 Sendable
struct Point: Sendable {  // 모든 프로퍼티가 Sendable이면 자동
    let x: Int
    let y: Int
}

// @unchecked Sendable - 수동으로 thread-safety 보장
final class ThreadSafeCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Data] = [:]

    func get(_ key: String) -> Data? {
        lock.lock()
        defer { lock.unlock() }
        return storage[key]
    }

    func set(_ key: String, data: Data) {
        lock.lock()
        defer { lock.unlock() }
        storage[key] = data
    }
}

// @Sendable 클로저 - 다른 isolation domain으로 보내기 위한 표시
var counter = 0  // non-Sendable (mutable captured variable)

// ❌ 컴파일 에러: non-Sendable 값 캡처
let badClosure: @Sendable () -> Void = {
    counter += 1  // data race 위험!
}

// ✅ Sendable 값만 캡처
let value = 42  // let은 Sendable
let goodClosure: @Sendable () -> Void = {
    print(value)  // 안전
}
💡 @Sendable 클로저의 의미:
클로저를 다른 isolation domain(다른 Task, 다른 Actor)으로 보내려면 @Sendable 표시가 필요하다. 이때 캡처하는 값이 Sendable이 아니면, 외부에서 해당 값을 수정할 때 data race를 보장받지 못한다. 그래서 컴파일러가 non-Sendable 캡처를 금지한다.
⚠️ @unchecked Sendable: 컴파일러 체크 우회. 반드시 직접 thread-safety 보장 필요.

7. GlobalActor / @MainActor 심화 ⭐⭐

핵심: 전역적으로 공유되는 actor isolation

// 커스텀 GlobalActor
@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

// 클래스/함수에 적용
@DatabaseActor
class DatabaseManager {
    var cache: [String: Data] = [:]  // DatabaseActor에서만 접근

    func save(_ data: Data) {
        // 자동으로 DatabaseActor에서 실행
    }
}

// @MainActor 패턴
@MainActor
class ViewModel: ObservableObject {
    @Published var items: [Item] = []

    func load() async {
        let data = await fetchFromNetwork()  // 백그라운드
        items = data  // MainActor로 자동 복귀
    }
}

// nonisolated로 탈출
@MainActor
class MyClass {
    nonisolated func pureComputation() -> Int {
        // MainActor 아닌 곳에서도 호출 가능
        return 42
    }
}

8. Task Local ⭐⭐⭐

핵심: 자식 task에 값을 암묵적으로 전달

enum Trace {
    @TaskLocal static var requestID: String = "unknown"
}

func handleRequest(id: String) async {
    await Trace.$requestID.withValue(id) {
        await processOrder()  // requestID 자동 상속

        async let a = fetchUser()  // ✅ 상속
        Task { print(Trace.requestID) }  // ✅ 상속
        Task.detached { print(Trace.requestID) }  // ❌ 상속 안 함
    }
}

func processOrder() async {
    print("Processing order: \(Trace.requestID)")  // "req-123"
}
용도: 로깅 requestID, 인증 컨텍스트, Feature flags, 테스트 Mock 주입

9. Custom Executor (고급) ⭐⭐⭐⭐

핵심: actor가 실행될 스레드/큐 커스터마이징

// 특정 DispatchQueue에서 실행되는 actor
actor SerialQueueActor {
    private let queue = DispatchQueue(label: "my.queue")

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        queue.asUnownedSerialExecutor()
    }

    func doWork() {
        // 항상 my.queue에서 실행
    }
}

// MainActor도 내부적으로 main queue executor 사용
용도: 기존 GCD 코드 마이그레이션, 특정 스레드 요구사항 (Core Data, Metal 등)

🎯 추천 학습 순서

순서 주제 난이도 선수 지식
1 Structured vs Unstructured ⭐⭐ async/await 기본
2 Task Cancellation ⭐⭐ Task 기본
3 withThrowingTaskGroup 에러 ⭐⭐ withTaskGroup 기본
4 AsyncSequence / AsyncStream ⭐⭐ for await 기본
5 Actor Reentrancy ⭐⭐⭐ Actor 기본
6 GlobalActor ⭐⭐ @MainActor 기본
7 Sendable 심화 ⭐⭐⭐ Sendable 기본, Actor
8 Task Local ⭐⭐⭐ Task 상속 개념
9 Custom Executor ⭐⭐⭐⭐ Actor, GCD 이해

📖 참고 자료

❓ Q&A

Q: async let과 Task { }의 차이?

A: async let은 structured (자동 취소/대기), global executor에서 실행. Task { }는 unstructured (수동 관리), caller의 actor 상속.

Q: Task.detached는 언제 쓰나?

A: actor isolation, priority, task local 모두 상속하고 싶지 않을 때. 대부분의 경우 Task { }로 충분.

Q: Actor가 lock보다 나은 이유?

A: 블로킹 대신 suspend, 컴파일 타임 data race 방지, 재진입 허용 (deadlock 방지).

Q: Swift 6.2에서 뭐가 바뀌었나?

A: SE-0461로 nonisolated async 함수가 caller의 actor에서 실행 (이전: global executor). @concurrent로 이전 동작 가능.