Swift Concurrency Deep Dive

Ios_Roy·2025년 8월 22일
0

swift 문법

목록 보기
30/30
post-thumbnail

🚀 WWDC 2025 — Swift Concurrency 대폭 확장 정리

세션 요약 + 모든 코드 블록에 대한 상세 설명 + Structured Concurrency 실전 패턴 + Swift 6 변화

이 문서는 WWDC 2025에서 강조된 Swift 동시성의 철학과 실전 코드 패턴을 코드 + 설명 쌍(pair) 으로 정리했습니다.
특히 Swift 6의 Strict Concurrency(엄격한 동시성 검사) 변화도 함께 반영했습니다.

  • Swift 6의 엄격한 동시성: 컴파일러가 데이터 레이스를 컴파일 타임에 잡도록 강화됨.
  • Structured Concurrency(구조화된 동시성): async let / TaskGroup / Actor 등을 통해 안전하고 읽기 쉬운 비동기 코드를 작성.

0. 용어 한눈 요약

  • async/await: 비동기 코드를 동기처럼 작성. 일시 중단(suspend) 후 재개(resume).
  • Task / Task.detached: 비동기 작업 단위(구조화/비구조화).
  • async let: 간단한 병렬 실행. Structured Task.
  • TaskGroup: 동적 개수의 병렬 작업. 오류/취소 전파를 구조적으로 관리.
  • Actor: 상태 격리로 데이터 레이스를 언어 차원에서 차단.
  • withChecked(Throwing)Continuation: 콜백 기반 API ↔ async/await 브릿지.
  • AsyncStream / AsyncThrowingStream: 비동기 스트림을 쉽게 구성.
  • Sendable (Swift 6 강화): 태스크 경계를 넘는 타입은 스레드-세이프해야 함.

1. async/await — 가장 먼저 적용하는 기본기

코드

import Foundation
import UIKit

func loadImage(url: URL) async throws -> UIImage {
    let (data, _) = try await URLSession.shared.data(from: url) // suspend → resume
    guard let image = UIImage(data: data) else {
        throw URLError(.cannotDecodeContentData)
    }
    return image
}

Task { // 비구조화(unstructured) 태스크 컨텍스트 생성
    do {
        let img = try await loadImage(url: URL(string: "https://example.com/image.png")!)
        print("이미지 로드 성공: \(img.size)")
    } catch {
        print("이미지 로드 실패: \(error)")
    }
}

설명

  • URLSession.shared.data(from:)async API이므로 try await로 호출합니다.
  • Task { ... }비구조화 태스크로, 호출자 스코프와 수명이 분리될 수 있습니다.
  • 콜백 지옥 없이 동기처럼 읽히는 에러 처리(do-catch)가 장점입니다.
  • 참고: Swift Concurrency 문서는 async/await의 서스펜션 모델을 설명합니다.

2. async let — 가장 쉬운 병렬화

코드

func fetchTitle(_ id: Int) async throws -> String { "Title \(id)" }
func fetchBody(_ id: Int) async throws -> String { "Body \(id)" }

// 여러 비동기 작업을 병렬로 시작
async let title = fetchTitle(1)
async let body  = fetchBody(1)

// 모든 값이 준비될 때까지 대기
let article = try await (title, body)
// article = ("Title 1", "Body 1")

설명

  • async let선언 시점에 즉시 실행되며 구조화된 태스크입니다.
  • 작성은 간단하지만, 개수/흐름 제어가 복잡해지면 TaskGroup이 더 적합합니다.
  • (비교 지식) async let에서 await하지 않은 서브태스크는 스코프 종료 시 암묵 취소됩니다(행동 차이는 커뮤니티 논의 참고).

3. TaskGroup — 동적 개수 병렬 처리 + 오류/취소 전파

코드

import Foundation

struct Article: Decodable { let id: Int; let title: String }

func fetchArticle(_ id: Int) async throws -> Article {
    let url = URL(string: "https://example.com/articles/\(id).json")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(Article.self, from: data)
}

func fetchAll(_ ids: [Int]) async throws -> [Article] {
    try await withThrowingTaskGroup(of: Article.self) { group in
        for id in ids {
            group.addTask { try await fetchArticle(id) } // 자식 태스크 생성
        }
        var results: [Article] = []
        // 완료된 순서대로 값이 도착 (요청 순서와 다를 수 있음)
        for try await article in group { results.append(article) }
        return results
    }
}

설명

  • withThrowingTaskGroup동적 수의 작업을 병렬 실행하기 위한 대표 패턴입니다.
  • 자식 중 하나라도 오류가 나면 즉시 전파되어 그룹이 중단됩니다.
  • 그룹 스코프가 끝나면 자식 태스크가 자동 정리됩니다(구조화된 수명).

4. 취소(Cancellation) — 협조적(cancel-cooperative)으로 처리

코드

enum WorkError: Error { case cancelled }

func longWork() async throws -> Int {
    var acc = 0
    for i in 0..<10_000 {
        if Task.isCancelled { throw WorkError.cancelled } // 협조적 취소 체크
        acc += i
        try await Task.sleep(nanoseconds: 500_000) // 0.5ms 휴지
    }
    return acc
}

let handle = Task {
    try await longWork()
}

// 10ms 뒤 취소
Task { try await Task.sleep(nanoseconds: 10_000_000); handle.cancel() }

do {
    _ = try await handle.value
} catch {
    print("취소됨: \(error)")
}

설명

  • Swift의 취소는 협조적입니다. 작업 내부에서 Task.isCancelled를 확인하거나 취소 시 throw해야 빨리 멈춥니다.
  • 취소는 전파됩니다. 부모가 취소되면 자식들도 함께 취소됩니다(Structured).

5. 타임아웃(Timeout) — TaskGroup으로 간단히 구현

코드

enum Timeout: Error { case exceeded }

func withTimeout<T>(_ seconds: Double, _ op: @escaping () async throws -> T) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await op() }
        group.addTask {
            try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
            throw Timeout.exceeded
        }
        let first = try await group.next()! // 먼저 끝난 쪽
        group.cancelAll()                   // 나머지 취소
        return first
    }
}

설명

  • 경쟁 레이스(operation vs timer)를 걸어 먼저 끝난 쪽 결과를 채택합니다.
  • 그룹 스코프 종료 시 남은 태스크는 취소되어 자원 누수를 막습니다.

6. 콜백 API 브릿지 — withChecked(Throwing)Continuation

코드

// 콜백 기반 API (예시)
func legacyFetch(_ completion: @escaping (Data?, Error?) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { completion(Data("OK".utf8), nil) }
}

// async/await로 감싸기
func modernFetch() async throws -> Data {
    try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
        legacyFetch { data, error in
            if let error { cont.resume(throwing: error) }
            else if let data { cont.resume(returning: data) }
            else { cont.resume(throwing: URLError(.badServerResponse)) }
        }
    }
}

설명

  • 단 한 번만 resume해야 하며, 놓치면 런타임 경고가 납니다(CheckedContinuation의 오남용 감지).
  • 이 패턴으로 기존 콜백 API를 점진적으로 async/await로 마이그레이션할 수 있습니다.

7. 스트림(AsyncStream) — 이벤트를 비동기 시퀀스로

코드

import Foundation

// 타이머를 AsyncStream 으로 래핑
func ticking(every seconds: TimeInterval) -> AsyncStream<Date> {
    AsyncStream(Date.self) { continuation in
        let timer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: true) { _ in
            continuation.yield(Date())
        }
        continuation.onTermination = { _ in timer.invalidate() }
    }
}

func consumeTicks() async {
    for await now in ticking(every: 1.0) {
        print("tick:", now)
        if now.timeIntervalSince1970.truncatingRemainder(dividingBy: 5) == 0 { break }
    }
}

설명

  • AsyncStreamAsyncSequence를 직접 구현하지 않고도 이벤트 스트림을 만들게 해 줍니다.
  • onTermination에서 정리 로직을 넣어 자원 누수 방지가 핵심입니다.
  • 던질 수 있는 스트림은 AsyncThrowingStream을 사용합니다.

8. Actor — 상태 격리로 데이터 레이스 원천 차단

코드

actor Counter {
    private var value = 0
    func increment() { value += 1 }
    func current() -> Int { value }

    nonisolated var version: String { "1.0.0" } // 상태 접근 없음 → 비격리 허용
}

func raceFree() async {
    let c = Counter()
    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<10_000 { group.addTask { await c.increment() } }
    }
    print(await c.current()) // 10000 보장
}

설명

  • Actor는 내부 상태를 독점적으로 보유(격리)하므로, 여러 태스크가 동시에 접근해도 레이스가 발생하지 않습니다.
  • nonisolated 멤버는 상태 접근이 없을 때 사용해 락 없이 호출할 수 있습니다(순수 함수/상수).
  • Swift 6 모드에서는 격리 위반을 컴파일 타임에 에러로 잡습니다.

9. @MainActor — UI 스레드 안전성 보장(SwiftUI 등)

코드

import SwiftUI

@MainActor
final class ImageVM: ObservableObject {
    @Published var image: UIImage?

    func load(_ url: URL) {
        Task { [weak self] in
            let data = try await withCheckedThrowingContinuation { cont in
                DispatchQueue.global().async { // I/O 는 백그라운드
                    do { cont.resume(returning: try Data(contentsOf: url)) }
                    catch { cont.resume(throwing: error) }
                }
            }
            self?.image = UIImage(data: data) // UI 업데이트 (MainActor)
        }
    }
}

설명

  • UI 업데이트는 MainActor에서만 수행되어야 합니다(SwiftUI는 기본적으로 MainActor 격리).
  • 백그라운드 I/O → MainActor 전환이라는 역할 분리가 자연스럽게 드러납니다.

10. Sendable(스레드-세이프) — Swift 6에서 더 엄격

코드

// 값 타입은 저장 프로퍼티가 Sendable 이면 자동 준수
struct Payload: Sendable {
    let id: Int
    let bytes: [UInt8] // Array<UInt8> 자체가 Sendable
}

// 참조 타입은 기본적으로 Sendable 아님 → 보장 시에만
final class Cache: @unchecked Sendable {
    private var dict: [String: Data] = [:]
    // 내부적으로 락 등으로 스레드 안전을 "개발자가" 직접 보장해야 함
}

// @Sendable 클로저는 캡처 값도 Sendable 이어야 함
func runOnBackground(_ work: @Sendable @escaping () -> Void) {
    Task.detached(priority: .background) { work() }
}

설명

  • Swift 6 Strict Concurrency에서 Sendable 위반은 경고가 아닌 오류가 될 수 있습니다.
  • @unchecked Sendable은 “내부에서 내가 보장”이라는 뜻이므로 최소화해야 합니다.

11. Unstructured vs Structured Task 차이

코드

// Unstructured Task — 부모/액터 격리 보장 X (상황 따라 다름)
Task {
    // 현재 컨텍스트 격리를 "상속할 수도 안 할 수도" 있음 (보장 X)
}

// Detached Task — 격리/상속 절대 없음
Task.detached {
    // 완전히 독립된 컨텍스트에서 실행
}

설명

  • Structured(async let, TaskGroup)는 부모의 액터 격리상속합니다.
  • Task {}(비구조화)는 격리 상속이 보장되지 않음(스케줄링에 의존).
  • Task.detached {}절대 상속하지 않음.

12. 참고 자료(공식)

  • Adopting strict concurrency in Swift 6 — Swift 6 엄격 동시성 개요와 마이그레이션 가이드.
  • Updating an app to use strict concurrency — 기존 코드 이관 예시.
  • Swift Book — Concurrency — 언어 차원의 구조화된 동시성 기초.
  • TaskGroup / AsyncStream / Continuation API 문서.
  • App Dev Tutorial — Managing structured concurrency — 실습형 튜토리얼.

✅ 마무리

  • 코드는 동기처럼 읽히고, 실행은 병렬/비동기적으로 돌아가며, 오류/취소 전파는 구조적으로 해결됩니다.
  • Swift 6에서는 Sendable/Actor/격리 위반을 컴파일 타임에 잡아 주므로,
    설계 단계에서부터 타입 안전성과 격리를 의식하는 습관이 중요합니다.
profile
iOS 개발자 공부하는 Roy

0개의 댓글