← 홈으로 돌아가기
Swift Concurrency Deep Dive

Task.yield()와 Cancellation 완전 정리

Task.yield()의 동작 원리, Cooperative Cancellation, Structured vs Unstructured Concurrency를 Q&A 형식으로 정리

1️⃣ Task.yield() 기초

Task.yield()는 무엇인가?

현재 실행 중인 Task를 자발적으로 일시 중단(suspend)하고, Cooperative Thread Pool에 스레드를 반환하여 다른 Task들이 실행될 기회를 주는 함수입니다.

await Task.yield()를 앞에 해야 하나, 뒤에 해야 하나?

상황에 따라 다르지만, 앞에 하는 게 더 안전하고 공정한 패턴입니다.

// 뒤에 하는 경우 - 첫 작업 즉시 시작 for item in items { processItem(item) await Task.yield() } // 앞에 하는 경우 - 더 공정하고 취소 확인 먼저 (권장) for item in items { await Task.yield() // 공정성 + 취소 확인 try Task.checkCancellation() // 명시적 취소 확인 processItem(item) }

yield한 다음에 try Task.checkCancellation()을 하는 이유는?

네, 맞습니다! yield() 중에 (suspend 되어 있는 동안) 다른 곳에서 Task가 취소될 수 있기 때문입니다.

시간 0ms: await Task.yield() → suspend 🔵
시간 12ms: 사용자가 뒤로가기 → Task 취소됨 ❌
시간 13ms: Task resume
시간 14ms: try Task.checkCancellation() → CancellationError throw → 작업 중단 ✅
핵심: yield() 직후 = 취소 상태가 변경되었을 가능성이 가장 높은 시점

yield는 동기 코드를 비동기로 변환하는 것인가?

아니요! yield는 스레드 양보일 뿐, 동기 코드는 여전히 동기 코드로 실행됩니다.

// ❌ yield는 여전히 메인 스레드 블로킹 @MainActor func badExample() async { heavySyncWork() // 메인 스레드 블로킹 🔴 await Task.yield() heavySyncWork() // 또 블로킹 🔴 } // ✅ 진짜 비동기로 변환 @MainActor func goodExample() async { let result = await Task.detached { heavySyncWork() // 백그라운드 스레드에서 실행 ✅ }.value }

2️⃣ Cooperative Cancellation (협력적 취소)

뒤로가기 버튼 누르면 for문이 자동으로 멈추나?

아니요. Swift의 Cooperative Cancellation이라서 명시적으로 체크해야 합니다.

// ❌ 취소 체크 없으면 끝까지 실행 .task { for i in 1...1000 { Thread.sleep(forTimeInterval: 0.01) print("처리 중: \(i)") } // View 사라져도 끝까지 실행됨 🔴 } // ✅ 취소 체크로 중단 .task { for i in 1...1000 { try Task.checkCancellation() // ✅ 필수 Thread.sleep(forTimeInterval: 0.01) print("처리 중: \(i)") } }
중요: Task가 취소되어도 자동으로 멈추지 않습니다! 명시적 체크 필수!

예외: 일부 API는 내부적으로 취소 체크함

// URLSession은 내부적으로 취소 체크 for url in urls { let (data, _) = try await URLSession.shared.data(from: url) // Task 취소되면 여기서 자동으로 throw ✅ } // 동기 방식은 취소 체크 안 함 for url in urls { let data = try Data(contentsOf: url) // 취소되어도 계속 🔴 try Task.checkCancellation() // ✅ 명시적 체크 필요 }

3️⃣ Task.detached의 Suspend와 Cancel

Task.detached할 때도 suspend, cancel 체크하나?

네, 해야 합니다! Task.detached도 Task의 일종이므로 suspend/cancel 체크가 필요합니다.

부모 Task가 취소되면 detached도 자동으로 취소되나?

아니요! Unstructured Concurrency이므로 부모 취소를 자동으로 상속받지 않습니다.

// Task (일반) - 자동 취소 전파 .task { let childTask = Task { // 부모 취소되면 자식도 자동 취소 ✅ for i in 1...1000 { try Task.checkCancellation() process(i) } } } // Task.detached - 자동 취소 안 됨 .task { let detached = Task.detached { // 부모 취소되어도 계속 실행 🔴 for i in 1...1000 { try Task.checkCancellation() process(i) } } }
Best Practice: defer로 생명주기 보장
func processData() async throws { let detached = Task.detached { for item in items { try Task.checkCancellation() // detached 안에서 취소 체크 await Task.yield() process(item) } } defer { detached.cancel() // 함수 종료 시 detached도 취소 ✅ } try await detached.value }

4️⃣ Structured vs Unstructured Concurrency

VC가 deinit되면 withTaskGroup의 작업도 취소되나?

네, 맞습니다! Structured Concurrency의 핵심입니다.

class MyViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { Task { // ← Parent Task await structuredWay() } } func structuredWay() async { await withTaskGroup(of: Void.self) { group in group.addTask { await heavyWork() // 10분 걸리는 작업 } } } }
0초: VC 나타남
0초: Task 시작
0초: withTaskGroup 시작
0초: heavyWork() 시작 (10분 예정)
3초: 사용자가 뒤로가기 버튼 탭
3초: VC deinit ✅
3초: Parent Task 취소 ✅
3초: withTaskGroup 취소 ✅
3초: group의 모든 자식 Task 자동 취소 ✅
3초: heavyWork() 안에서 await하는 부분이 throw
3초: 종료 (10분 기다리지 않음)

defer에 cancel을 넣는 건 unstructured라서 수동 관리?

정확합니다!

// ✅ Structured - 자동 관리 func structuredWay() async { await withTaskGroup(of: Void.self) { group in group.addTask { await heavyWork() } // 함수 취소되면 자식도 자동 취소 ✅ // defer 불필요 } } // ❌ Unstructured - 수동 관리 필요 func unstructuredWay() async { let detached = Task.detached { await heavyWork() } defer { detached.cancel() // 수동으로 취소해야 함 ✅ } try? await detached.value }
⚠️ 위 unstructuredWay 예시의 함정 (보강 2026-06-23)

이 코드는 "structured(자동 취소) vs unstructured(수동 취소)" 대비를 보여주려는 예시다. 의도는 "Task.detached는 부모와 분리되니 defer로 수동 취소하자"는 것. 하지만 실제로는 defer cancel이 의도대로 동작하지 않는다:

  • await detached.value(와 .result)는 현재 task의 취소에 반응하지 않는다 — detached가 끝날 때까지 그냥 기다림(CancellationError를 던지지 않음).
  • 그래서 부모가 취소돼도 이 await는 안 멈추고, defer { detached.cancel() }detached가 이미 끝난 뒤에 불려 사실상 무효.
  • 정상 완료 시에도 마찬가지 — value를 받은 뒤 함수가 끝나며 cancel하니 이미 끝난 task라 무의미.
  • 즉 "부모 취소 시 detached도 취소" 의도를 이 패턴으로는 달성 못 한다.

그럼 detached의 취소를 제대로 전파하려면?

withTaskCancellationHandleronCancel을 쓴다 — 부모 취소되는 즉시 호출된다(defer처럼 함수 끝까지 기다리지 않음).

// ✅ 부모 취소를 detached에 즉시 전파 func unstructuredWay() async { let detached = Task.detached { await heavyWork() } await withTaskCancellationHandler { try? await detached.value } onCancel: { detached.cancel() // 부모 취소되는 즉시 호출됨 } }
결론: Task.detached는 부모와 완전 분리(취소·actor·priority·task-local 미상속). 취소 전파를 원하면 withTaskCancellationHandler를 쓰거나, 애초에 structured(withTaskGroup / async let)를 써라 — 부모 취소가 자식에 자동 전파된다. defer cancel은 "함수가 끝날 때만" 불려서 취소 전파엔 부적합하다.

5️⃣ defer 호출 타이밍

VC가 닫히면 defer가 바로 호출되나?

아니요! Task가 취소되어도 defer가 즉시 호출되지 않습니다. 함수가 return 또는 throw해야 defer가 호출됩니다.

func processData() async throws { let detached = Task.detached { ... } defer { print("defer 실행") detached.cancel() } try await Task.sleep(for: .seconds(5)) // 여기서 대기 중 try Task.checkCancellation() await doWork() }
0초: detached 시작
3초: [VC 닫힘 → Task 취소됨]
3초: defer 호출 안 됨! ← Task만 취소됨, 함수는 아직 안 끝남
5초: Task.sleep 완료 → CancellationError throw! ✅
5초: 함수 종료! ← 여기서 defer 호출됨 ✅
5초: "defer 실행"
핵심: Task 취소 = 플래그만 설정, 함수는 계속 실행됨. await 지점에서만 취소를 감지할 수 있음.

defer 호출 조건

func example() async throws { defer { print("defer") } // 경우 1: return return // defer 호출 ✅ // 경우 2: throw throw MyError() // defer 호출 ✅ // 경우 3: 함수 끝 print("끝") // defer 호출 ✅ } // Task 취소만으로는 defer 호출 안 됨! ❌ // await 지점에서 throw해야 함수가 종료되고 그때 defer 호출 ✅

6️⃣ Parent Task vs detached Task

Parent Task가 Task { await processData() }에 있는 Task인가?

정확합니다!

class MyViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { Task { // ← 이게 Parent Task await processData() } } } func processData() async { // 이 함수는 Parent Task의 컨텍스트에서 실행됨 }

Task 계층 구조

.task { // ← View.task modifier가 만든 Task = Parent Task await processData() // ← Parent Task 안에서 실행 } func processData() async { // 여기서 Task.isCancelled 체크하면 // Parent Task의 취소 상태를 확인하는 것 let task = Task { // ← Unstructured Task (Task{}는 structured 아님!) // actor/priority/task-local은 상속하지만 // Parent 취소는 자동 전파 안 됨 → 수동 cancel 필요 } let detached = Task.detached { // ← Detached Task (unstructured, 완전 독립) // actor 등도 상속 안 함, Parent 취소도 안 됨 } async let childResult = work() // ← 이게 진짜 structured (자동 취소 전파) }
⚠️ 교정 (2026-06-23): Task { }는 structured가 아니다
생성 방식종류부모 취소 자동 전파actor 상속
async let / withTaskGroupstructured
Task { }unstructured✅(현재 actor·task-local)
Task.detached { }unstructured(detached)❌ 완전 독립

Task { }는 현재 actor/priority/task-local은 상속하지만 부모 취소가 자동 전파되지 않는다(unstructured). structured는 async let / withTaskGroup뿐 — 이것만 스코프 기반으로 완료 보장 + 취소 자동 전파.

try Task.checkCancellation()은 detached가 아니라 processData()의 Parent Task 취소를 보는 거지?

정확합니다!

func processData() async throws { // ← Parent Task의 컨텍스트 let detached = Task.detached { // 여기서 Task.checkCancellation()을 하면 // detached 자신의 취소 상태를 체크 ✅ try Task.checkCancellation() } defer { detached.cancel() } // 여기서 Task.checkCancellation()을 하면 // processData()의 Parent Task 취소 상태를 체크 ✅ try Task.checkCancellation() let result = try await detached.value return result }
중요: try Task.checkCancellation()은 detached와 무관! Parent Task의 취소 상태만 확인합니다.

7️⃣ 실전 문제와 해결

캔슬 체크를 하지 않으면 무작정 기다리는 거 아닌가?

정확합니다! Parent Task가 취소되어도 detached가 끝날 때까지 기다립니다.

// ❌ 문제: Parent Task가 취소되어도 여기서 계속 기다림! func processImages(_ images: [UIImage]) async throws -> [UIImage] { let tasks = images.map { image in Task.detached { return await processImage(image) } } defer { tasks.forEach { $0.cancel() } } var results: [UIImage] = [] for task in tasks { let result = try await task.value // 무작정 기다림 🔴 results.append(result) } return results }
0초: 100개 이미지 처리 시작
5초: [사용자가 뒤로가기 → Parent Task 취소]
5초: 하지만 for 루프는 계속 진행 🔴
5초: try await task.value ← 각 task가 끝날 때까지 기다림
60초: 모든 task 완료
60초: 함수 종료
60초: defer 실행 → tasks.forEach { $0.cancel() } (이미 끝났는데?)

해결 방법 1: 주기적으로 Parent 취소 체크

func processImages(_ images: [UIImage]) async throws -> [UIImage] { let tasks = images.map { image in Task.detached { return await processImage(image) } } defer { tasks.forEach { $0.cancel() } } var results: [UIImage] = [] for task in tasks { // ✅ Parent Task 취소 체크 try Task.checkCancellation() let result = try await task.value results.append(result) } return results }

해결 방법 2: withThrowingTaskGroup 사용 (권장)

func processImages(_ images: [UIImage]) async throws -> [UIImage] { // ✅ Structured Concurrency 사용 try await withThrowingTaskGroup(of: UIImage.self) { group in for image in images { group.addTask { return await processImage(image) } } var results: [UIImage] = [] for try await result in group { results.append(result) } return results } // Parent Task가 취소되면 자동으로 모든 자식 취소 ✅ }

tasks[0].value만 하면 0번째만 나오면 바로 사용할 수 있나?

정확합니다! 0번째만 완료되면 바로 진행됩니다. 나머지는 백그라운드에서 계속 실행됩니다 (고아 Task).

let tasks = images.map { image in Task.detached { await Task.sleep(for: .seconds(10)) return await processImage(image) } } // 첫 번째 task만 기다림 let result = try await tasks[0].value // 0번이 끝나면 바로 진행 ✅ print("여기 도달") // 10초 후 출력 (나머지 안 기다림) // 나머지 tasks는? → 백그라운드에서 계속 실행 중 🔴
문제: defer로 cancel하지 않으면 나머지 99개 Task가 고아 Task가 되어 계속 실행됨!

defer + checkCancellation 둘 다 필요한 이유

// defer: 플래그만 설정 defer { detached.cancel() // detached.isCancelled = true } // checkCancellation: 플래그 확인하고 throw let detached = Task.detached { for i in 1...100 { try Task.checkCancellation() // isCancelled == true면 throw process(i) } }
3초: 함수 종료
3초: defer { detached.cancel() } → 플래그 true로 설정 ✅
3초: detached 루프 계속 진행
3초: try Task.checkCancellation() → isCancelled == true → throw ✅
3초: detached 종료
결론: defer는 플래그 설정, checkCancellation은 플래그 확인 → 둘 다 필요!

📌 핵심 정리

1. Task.yield()

2. Cooperative Cancellation

3. Task.detached

4. Structured vs Unstructured

5. defer 호출 타이밍

6. Parent Task vs detached

7. 실전 주의사항

8️⃣ 함수 vs Task vs Parent Task

함수 안에 Task를 만들면, 그 함수가 Parent Task인가?

아니요! 함수는 Task가 아닙니다. 함수는 어떤 Task의 컨텍스트 안에서 실행되는 코드일 뿐입니다.

func doWork() async { // 이 함수 자체는 Task가 아님! // 이 함수는 어떤 Task의 컨텍스트에서 실행되는 코드 print(Task.isCancelled) // Parent Task의 취소 상태 확인 }

Parent Task의 진짜 의미

class MyViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { Task { // ← 이게 Parent Task! await doWork() } } func doWork() async { // 여기는 Parent Task의 일부로 실행됨 // doWork() 함수 자체는 Task가 아님! } }
계층 구조:
VC
└─ Task { } ← Parent Task (진짜 Task)
    └─ await doWork() ← 함수 (Parent Task 안에서 실행되는 코드)

함수 안에서 Task 생성하면?

func doWork() async { // 여기는 Parent Task의 컨텍스트 let newTask = Task { // ← 새로운 독립 Task! // 이건 doWork() 함수와 별개 // 이건 Parent Task와도 별개 await heavyWork() } print("함수 끝") // 즉시 출력 // newTask는 백그라운드에서 계속 실행 중 }
계층 구조:
Parent Task
└─ doWork() 함수 (Parent Task 안에서 실행)
    └─ Task { } ← 새로운 독립 Task (Parent와 관계없음!)
핵심: Task { }는 함수 안에 있어도 독립적입니다! 함수의 생명주기와 무관하게 실행됩니다.

9️⃣ Parent Task 취소 시 Unstructured Task는?

Parent Task가 취소되면 그 안의 Unstructured Task도 취소되나?

아니요! Parent Task 취소되어도 Unstructured Task는 영향 받지 않습니다.

.task { // ← Parent Task print("1. Parent 시작") let newTask = Task { // ← Unstructured Task print("2. 새 Task 시작") try await Task.sleep(for: .seconds(10)) print("3. 새 Task 완료") } print("4. Parent 계속 실행") try await Task.sleep(for: .seconds(5)) // ← await 지점 print("5. Parent 완료") }

타임라인 (3초 후 View 사라짐)

0초: "1. Parent 시작"
0초: "2. 새 Task 시작"
0초: "4. Parent 계속 실행"
3초: [View 사라짐 → Parent Task.isCancelled = true]
3초: 새 Task는 영향 없음 ✅ 계속 실행
5초: Parent의 Task.sleep 완료 → CancellationError throw
5초: Parent Task 중단 (종료)
5초: "5. Parent 완료" 출력 안 됨
10초: "3. 새 Task 완료" 출력 ✅ (계속 살아있었음)
핵심 포인트:
1. Parent Task 취소 = await 지점에서 throw할 때 중단됨
2. Unstructured Task는 영향 없이 계속 실행
3. Structured Task는 자동으로 취소됨

Structured vs Unstructured 비교

.task { // Parent Task // Unstructured - 영향 없음 let task1 = Task { await work1() } let task2 = Task { await work2() } // Structured - 영향 받음 async let result1 = work3() await withTaskGroup(of: Void.self) { group in group.addTask { await work4() } } let _ = await result1 } // View 사라지면: // → Parent Task 취소 // → task1, task2 계속 실행 🔴 (Unstructured) // → async let 자동 취소 ✅ (Structured) // → withTaskGroup 자동 취소 ✅ (Structured)

🔟 일반 Task { } vs SwiftUI .task { } modifier

Task는 생명주기 영향이 없다면서, 왜 VC 닫히면 Parent Task가 취소되나?

SwiftUI의 .task { } modifier는 특별합니다! 일반 Task { }와 다르게 View 생명주기와 연동됩니다.

일반 Task { } - 생명주기 독립

class MyViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { Task { // ← 일반 Task await work() } print("viewDidAppear 끝") // Task는 백그라운드에서 계속 실행 중 } } // VC 닫혀도 Task 계속 실행됨 🔴

SwiftUI .task modifier - View 생명주기와 연동

struct MyView: View { var body: some View { Text("Hello") .task { // ← SwiftUI의 특별한 modifier await work() } } } // View 사라지면 Task 자동 취소됨 ✅

SwiftUI .task modifier 내부 동작 (개념)

// SwiftUI가 내부적으로 하는 일 struct MyView: View { @State private var task: Task<Void, Never>? var body: some View { Text("Hello") .onAppear { // View 나타나면 Task 시작 task = Task { await work() } } .onDisappear { // View 사라지면 Task 취소 ✅ task?.cancel() task = nil } } }
핵심:
- 일반 Task { }는 생명주기 독립 ✅
- SwiftUI .task { } modifier는 View와 생명주기 연동 ✅
- .task { }는 SwiftUI가 자동으로 .cancel() 호출해줌

UIKit에서는 수동 관리 필요

class MyViewController: UIViewController { private var task: Task<Void, Never>? override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) task = Task { // ← 일반 Task (생명주기 독립) await work() } } deinit { // 수동으로 취소해야 함 ✅ task?.cancel() } }

1️⃣1️⃣ Task vs Task.detached 상세 비교

부모 Task를 취소해도 그 안의 Task가 취소 안 된다면, Task와 Task.detached의 차이는 context와 priority뿐인가?

정확합니다! Task와 Task.detached는 "무엇을 상속받느냐"만 다르고, 생명주기 관리는 완전히 동일합니다.

차이점 1: Actor Context 상속

@MainActor class ViewModel { func example() { // Task - MainActor context 상속 ✅ Task { print(Thread.isMainThread) // true // MainActor에서 실행됨 } // Task.detached - MainActor context 상속 안 함 ❌ Task.detached { print(Thread.isMainThread) // false // 백그라운드 스레드에서 실행됨 } } }

차이점 2: Priority 상속

Task(priority: .high) { // 이 Task는 .high priority // Task - priority 상속 ✅ Task { print(Task.currentPriority) // .high } // Task.detached - priority 상속 안 함 ❌ Task.detached { print(Task.currentPriority) // .medium (기본값) } }

차이점 3: TaskLocal 상속

@TaskLocal static var userId: String? Task { Self.userId = "user123" // Task - TaskLocal 상속 ✅ Task { print(Self.userId) // "user123" } // Task.detached - TaskLocal 상속 안 함 ❌ Task.detached { print(Self.userId) // nil } }

🚨 둘 다 동일한 것 (중요!)

생명주기 독립 (Unstructured)
.task { // Parent Task // Task - 부모 취소 전파 안 됨 ❌ let task = Task { await work() } // Task.detached - 부모 취소 전파 안 됨 ❌ let detached = Task.detached { await work() } // 둘 다 동일: Parent 취소되어도 계속 실행 🔴 }
defer 필요
func example() async { // Task - defer 필요 ✅ let task = Task { await work() } // Task.detached - defer 필요 ✅ let detached = Task.detached { await work() } defer { task.cancel() // 수동 관리 필요 detached.cancel() // 수동 관리 필요 } }

실무에서 언제 뭘 쓰나?

Task { } - 대부분의 경우 (95%)
@MainActor class ViewModel { func fetchData() { Task { // ← 보통 이거 사용 // MainActor context 그대로 유지 let data = await api.fetch() // MainActor에서 실행되므로 안전 self.data = data // UI 업데이트 } } }
Task.detached { } - 특수한 경우 (5%)
@MainActor class ViewModel { func processHeavyData() { Task.detached { // ← 백그라운드에서 완전히 독립 실행 // MainActor 아님 (백그라운드 스레드) let result = heavySyncWork() // 무거운 작업 // UI 업데이트는 MainActor로 명시적 전환 await MainActor.run { self.data = result } } } }

비교표

Task { } Task.detached { }
Actor context 상속
Priority 상속
TaskLocal 상속
부모 취소 전파
생명주기 독립
defer 필요
Unstructured
결론:
- 차이점: Context 상속, Priority 상속, TaskLocal 상속
- 공통점: 둘 다 Unstructured, 부모 취소 전파 안 됨, defer 필요
- 실무: 95% → Task { } 사용 (context 유지), 5% → Task.detached { } 사용 (완전 독립)

📌 핵심 정리

1. Task.yield()

2. Cooperative Cancellation

3. Task.detached

4. Structured vs Unstructured

5. defer 호출 타이밍

6. Parent Task vs detached

7. 실전 주의사항

8. 함수 vs Task

9. Parent Task 취소

10. 일반 Task vs .task modifier

11. Task vs Task.detached