Reference
들어가며
Swift Concurrency를 공부하면서
Task.cancel()을 호출하면 모든 작업이 즉시 멈춘다고 막연히 생각하고 있었다.
그런데 WWDC 세션 “Beyond the basics of structured concurrency”를 보면서
내가 이해하고 있던 cancellation 모델이 꽤 단순했다는 걸 알게 됐다.
이번 글에서는
Task cancellation이 실제로 어떻게 전파되고, 왜 ‘cooperative’하다고 말하는지,
그리고 그로 인해 등장한 withTaskCancellationHandler까지
헷갈렸던 포인트 위주로 정리해보려고 한다.
Task hierarchy와 cancellation 전파
Structured concurrency에서 Task는 트리 구조(task hierarchy)를 가진다.
func makeSoup(order: Order) async throws -> Soup {
async let pot = stove.boilBroth()
async let choppedIngredients = chopIngredients(order.ingredients)
async let meat = marinate(meat: .chicken)
let soup = try await Soup(meat: meat, ingredients: choppedIngredients)
return try await stove.cook(
pot: pot,
soup: soup,
duration: .minutes(10)
)
}
makeSoup가 부모 Task라면, async let으로 생성된 작업들은 모두 child task가 된다.
여기서 중요한 점은,
부모 Task가 cancel되면, 그 아래의 모든 child task에도 cancellation이 전파된다.
다만, Task cancellation는 협동적이기(cooperative) 때문에
이 cancellation은 즉시 실행 중단을 의미하지 않는다.
Task cancellation은 왜 cooperative일까?
Task cancellation은 cooperative하다.
즉, “강제로 멈춘다”가 아니라
“취소되었다는 표시(cancel flag)를 남긴다”
에 가깝다.
func makeSoup(order: Order) async throws -> Soup {
async let pot = stove.boilBroth()
guard !Task.isCancelled else {
throw SoupCancellationError()
}
async let choppedIngredients = chopIngredients(order.ingredients)
async let meat = marinate(meat: .chicken)
let soup = try await Soup(meat: meat, ingredients: choppedIngredients)
return try await stove.cook(pot: pot, soup: soup, duration: .minutes(10))
}
- cancel flag가 이 guard 이전에 설정되면 → throw를 통해 이후 작업은 실행되지 않는다
- guard를 통과한 뒤에 cancel flag가 설정되면 → 아래 비동기 작업이 모두 실행된다.
이 부분을 이해하면서 이런 생각이 들었다.
expensive한 작업마다 isCancelled 나 checkCancellation() 를 계속 체크해야 하나?
이건 너무 번거롭지 않나?
이 지점에서 등장하는 게 바로 withTaskCancellationHandler다.
withTaskCancellationHandler가 필요한 이유
Swift는 cancellation을 감지하는 두 가지 방법을 제공한다.
Task.isCancelled // 1
try Task.checkCancellation() // 2
직접 체크하지 않으면 아무 일도 일어나지 않는다.
그래서 나온 게:
withTaskCancellationHandler(operation:onCancel:)
이 API는 Task가 취소되는 순간 onCancel 클로저를 실행해준다.
예를 들어 AsyncSequence의 next() 구현을 보면:
public func next() async -> Order? {
return await withTaskCancellationHandler {
let result = await kitchen.generateOrder()
guard state.isRunning else { return nil }
return result
} onCancel: {
state.cancel()
}
}
여기서 중요한 포인트는:
- Task가 suspend 상태(= cancel flag 는 세팅되었으나 실제로는 비동기작업이 종료되지 않은 상태)여도
- cancellation이 발생하면, onCancel이 즉시 실행된다는 점이다
단순 polling으로는 해결되지 않던 문제를 이 방식으로 처리할 수 있다.
polling vs cancellation handler, 언제 써야 할까?
그냥 withTaskCancellationHandler만 쓰는 게 더 좋은 거 아닌가?
이 부분에서 나도 가장 헷갈렸다.
추가로 찾아보면서 이렇게 이해했다.
- polling (isCancelled)
- 잘못 사용하면, 반응성 지연 및 배터리 소모.
취소 신호를 받기 위해 계속 취소여부를 반복적으로 확인해야한다. - 하지만
대량의 데이터를 가공하거나 반복문을 실행할 때나
비동기 함수 사이사이에 로직이 길게 늘어져 있을 때 중단시키기 유용하다.
- 잘못 사용하면, 반응성 지연 및 배터리 소모.
- withTaskCancellationHandler
- 잘못 사용하면, 실행 제어 불가.
신호는 받았지만 실제 돌고 있는 무거운 코드를 강제로 끌 방법이 없음. - 따라서
Task를 모르는 외부 라이브러리나 Delegate 기반 API를 중단시켜야 할 때 유용하다. - 단, onCancel 핸들러는 별도의 컨텍스트에서 실행되므로 Thread-safety를 고려해야 한다.
- 잘못 사용하면, 실행 제어 불가.
우선순위 전파와 inversion
또한 structured concurrency에서 인상 깊었던 부분은 우선순위 전파였다.
child task는 parent task의 priority를 상속한다.
그리고
높은 우선순위의 작업이 낮은 우선순위 작업을 기다리는 상황이 생기면,
시스템은 기다리는 child task의 priority를 부모 수준으로 올린다.
이걸 보면서 든 생각:
“이거 GCD 시절부터 priority inversion 막으려고 하던 그거 아닌가?”
맞다.
다만 차이는 이거다.
- 이상적인 해결: inversion 자체가 발생하지 않게 설계
- Swift Concurrency의 방식: 이미 발생한 inversion을 runtime에서 완화
Task group을 이용한 패턴
1. 동시성 개수 제한 패턴 (Limiting Concurrency)
여러 Task를 동시에 실행할 때 무작정 task를 늘리는 게 아니라 제한을 두는 방식.
이 아래 예시는 메모리를 아끼면서(최대 3개), 속도는 최대한 뽑아내는(빈자리 즉시 채우기) 최적의 병렬 처리가 가능하다.
func chopIngredients(_ ingredients: [any Ingredient]) async -> [any ChoppedIngredient] {
return await withTaskGroup(of: (ChoppedIngredient?).self,
returning: [any ChoppedIngredient].self) { group in
// Concurrently chop ingredients
let maxChopTasks = min(3, ingredients.count)
for ingredientIndex in 0..<maxChopTasks {
group.addTask { await chop(ingredients[ingredientIndex]) }
}
// Collect chopped vegetables
var choppedIngredients: [any ChoppedIngredient] = []
var nextIngredientIndex = maxChopTasks
for await choppedIngredient in group {
if nextIngredientIndex < ingredients.count {
group.addTask { await chop(ingredients[nextIngredientIndex]) }
nextIngredientIndex += 1
}
if choppedIngredient != nil {
choppedIngredients.append(choppedIngredient!)
}
}
return choppedIngredients
}
}
func run async throws {
try await withThrowingDiscardingTaskGroup { group in
for cook in staff.keys {
group.addTask { try await cook.handleShift() }
}
group.addTask { // keep the restaurant going until closing time
try await Task.sleep(for: shiftDuration)
throw TimeToCloseError()
}
}
}
- 상황: 요리사들의 근무(Shift)를 관리하며, 퇴근 시간이 되면 모든 작업을 중단해야 한다.
- 특징:
- 자식 작업이 완료되면 결과를 유지하지 않고 리소스를 즉시 방출하여 메모리 소비를 줄입니다.
- 퇴근 시간 오류(TimeToCloseError)가 발생하면 형제 작업들이 자동으로 취소되므로 명시적인 정리가 필요 없습니다.
withTaskgroup 를 쓰는거랑 뭐가 다른거지?
withTaskGroup을 사용하면, 메모리 부담이 있을 수 있다.
일반적인 TaskGroup은 "누군가 나중에 결과를 물어볼 수도 있어"라는 가정하에 동작하는 것이다.
- addTask 클로저가 Void를 반환하더라도, 시스템 내부적으로는 "이 작업이 성공적으로 끝났다"는 상태값을 메모리에 쌓아둔다.
- 이 정보들은 group.next()를 호출해 하나씩 꺼내 가거나, 전체 그룹 블록이 완전히 종료될 때까지 메모리에 계속 남아 있는다.
- 만약 만 단위의 아주 많은 작업을 추가했는데 next()를 호출하지 않는다면, 완료된 작업들의 "성공 기록"이 메모리에 계속 누적되어 성능에 영향을 줄 수 있다.
따라서
withDiscardingTaskGroup 는 return 이 필요없는 상황(= addTask 클로저가 Void를 반환하는 상황)에서
많은 Task 를 돌리는 작업에서 사용하면 좋다.
Task-local values와 context propagation
@TaskLocal은
처음엔 단순히 global(static) 변수처럼 보이지만, 실제로는
현재 Task tree 안에서만 공유되는 값
이다.
@TaskLocal static var cook: String
부모 Task에서 값을 설정하면 자식 Task에서도 접근 가능하지만, 다른 Task tree에는 전혀 영향이 없다.
Task traces 와 span metadata propagation
우선 Tracing API 의 withSpan 함수에 대한 이해에 앞어 trace 와 span 에 대한 개념이 있어야 하는데,
trace는 여러개의 span 으로 구성되어있고, 아래의 예시에서는 "수프 만들기"가 하나의 span 이 된다.
또한 span 에는 `span.attributes["kitchen.order.id"] = order.id` 와 같이 metadata 가 저장된다.
withSpan(_:context:ofKind:function:file:line:_:)
import Tracing
func makeSoup(order: Order) async throws -> Soup {
try await withSpan(#function) { span in
span.attributes["kitchen.order.id"] = order.id
async let pot = stove.boilBroth()
async let choppedIngredients = chopIngredients(order.ingredients)
async let meat = marinate(meat: .chicken)
let soup = try await Soup(meat: meat, ingredients: choppedIngredients)
return try await stove.cook(pot: pot, soup: soup, duration: .minutes(10))
}
}
위 예시에서는 async let을 사용하면 3개의 자식 Task가 생성된다.
- boilBroth()
- chopIngredients()
- marinate()
이때 Task Tree에서의 Task Local Storage를 활용해 Span 메타데이터를 자식들에게 자동으로 넘겨준다.
즉, 각 자식 Task 에서도 order ID 값을 자동으로 알 수 있게 된다는 의미이다.
정리하면서 추가로 알게 된 점은
structured 와 unstructured 에서의 상속/propagation 개념이 항상 헷갈렸는데,
아래와 같은 차이가 있었다.
우선
structured task 를 생성하는 방법에는 async let 과 TaskGroup 가 있고,
unstructured task 를 생성하는 방법에는 Task {} 와 Task.detached {} 가 있다.
그리고 각 방식의 차이는 아래와 같다:
structured task 는 상위 Task 로 부터 아래를 상속받는다.
- Task Cancellation
- Error Propagation
: 자식 작업에서 에러가 발생하면, 그 에러가 부모에게 전달(전파)되고, 부모는 나머지 모든 자식 작업을 자동으로 취소된다. - Priority Progatation
- Context Progatation (ex: @TaskLocal, Actor)
unstructured task 에서는 Task {} 인지, Task.detached {} 인지에 따라 상위 Task 로 부터 다르게 상속받는다.
- Task {}
- Priority Progatation
- Context Progatation (ex: @TaskLocal, Actor)
- Task.detached {}
- (아무것도 상속되지 않음)
오늘은 Task Cancellation 방식에 대해 더 알아보고,
Structured 와 Unstructured 를 활용하는 방법에 대해 더 자세히 알아봤다.
유익했다면 댓글/공감 남겨주세요~~ 작성자에게 큰 힘이 됩니다 ☺️
(이전 글) 👉 Swift Concurrency - Structured Concurrency & Task (2)
(다음 글) 👉 TO BE CONTINUE...
'Swift' 카테고리의 다른 글
| Swift Concurrency - Structured Concurrency & Task (2) (0) | 2025.10.01 |
|---|---|
| Swift Concurrency - Async Await (1) (0) | 2025.09.27 |
| Swift Concurrency - 시작하기 전에 (0) (0) | 2025.09.27 |
| Attribute Wrapper (0) | 2024.08.01 |
| [Swift Standard Library] (0) | 2024.08.01 |