세션 요약 + 모든 코드 블록에 대한 상세 설명 + Structured Concurrency 실전 패턴 + Swift 6 변화
이 문서는 WWDC 2025에서 강조된 Swift 동시성의 철학과 실전 코드 패턴을 코드 + 설명 쌍(pair) 으로 정리했습니다.
특히 Swift 6의 Strict Concurrency(엄격한 동시성 검사) 변화도 함께 반영했습니다.
- Swift 6의 엄격한 동시성: 컴파일러가 데이터 레이스를 컴파일 타임에 잡도록 강화됨.
- Structured Concurrency(구조화된 동시성): async let / TaskGroup / Actor 등을 통해 안전하고 읽기 쉬운 비동기 코드를 작성.
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
)가 장점입니다. 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
은 선언 시점에 즉시 실행되며 구조화된 태스크입니다.async let
에서 await하지 않은 서브태스크는 스코프 종료 시 암묵 취소됩니다(행동 차이는 커뮤니티 논의 참고). 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
은 동적 수의 작업을 병렬 실행하기 위한 대표 패턴입니다. 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)")
}
Task.isCancelled
를 확인하거나 취소 시 throw해야 빨리 멈춥니다.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
}
}
// 콜백 기반 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)) }
}
}
}
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 }
}
}
AsyncStream
은 AsyncSequence를 직접 구현하지 않고도 이벤트 스트림을 만들게 해 줍니다. onTermination
에서 정리 로직을 넣어 자원 누수 방지가 핵심입니다.AsyncThrowingStream
을 사용합니다. 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 보장
}
nonisolated
멤버는 상태 접근이 없을 때 사용해 락 없이 호출할 수 있습니다(순수 함수/상수). 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)
}
}
}
// 값 타입은 저장 프로퍼티가 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() }
}
@unchecked Sendable
은 “내부에서 내가 보장”이라는 뜻이므로 최소화해야 합니다. // Unstructured Task — 부모/액터 격리 보장 X (상황 따라 다름)
Task {
// 현재 컨텍스트 격리를 "상속할 수도 안 할 수도" 있음 (보장 X)
}
// Detached Task — 격리/상속 절대 없음
Task.detached {
// 완전히 독립된 컨텍스트에서 실행
}
Task {}
(비구조화)는 격리 상속이 보장되지 않음(스케줄링에 의존). Task.detached {}
는 절대 상속하지 않음.