📚 핵심 학습 주제
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 등)