
앞서 알아본 Concurrency는 async/await와 Task 기반의 동시성을 제공한다. 동시에 여러 일을 수행할 수 있게 되면서 중요한 문제가 생기는데 바로 바로 더 이상 필요 없는 일을 어떻게 중단하게 할건지? 에 대한 내용!!
예를 들어 사용자가 어떤 작업을 입력했는데 곧바로 해당 작업을 변경한다면 이전의 요청은 필요없어지게 된다거나, 화면을 떠났는데 여전히 해당 작업이 진행되게 된다면 불필요한 리소스를 낭비하게 된다.
이러한 점을 컨트롤하기 위해 Swift Concurrency에서는 어떤 작업 취소가 가능한지 한번 알아보도록 하자!
협력적 취소(Cooperative Cancellation)
Swift Concurrency의 취소는 강제로 멈춘다의 개념이 아닌 단순히 작업에 깃발을 꽂아두는 것!! 아주 중요한 개념이니 잊지말자
task.cancel()
이 코드를 실행하면 해당 Task의 내부 상태값 Task.isCancelled가 true로 바뀌게 되는데 이것으로 작업이 자동으로 멈추지는 않게 된다.
즉, 취소를 선언해서 isCancelled를 true로 해둘 경우, 작업이 멈출 수 있는 준비가 되었다. 라는 신호를 보내는 행위라는 것!!
Swift에서는 안전성을 가장 중요하게 생각하기 때문에 작업을 시스템이 강제로 멈추는 방식(선점형 취소)은 허용되지 않는다! 대신 직접 취소 여부를 확인하고 멈출 수 있도록 협력적 취소 방식을 채택하게 된다는 것
그렇다면 취소를 어떻게 확인하고 멈추게 해야할까!
guard !Task.isCancelled else {
print("작업이 취소되었습니다.")
return
}
첫 번째 방식은 Task.isCancelled를 파악해 해당하는 내용을 return하는 방법! 사용자에게 별도 에러를 전달하지 않고 조용히 멈추고 싶을때 이러한 단순 취소 여부 확인 후 해당 빈 리턴하는 방식을 사용하게 된다.
try Task.checkCancellation()
두번째 방법은 호출 시점에 작업이 취소 상태라면 곧바로 CancellationError를 throw 하도록 하는 메서드이다.
이렇게 하면 상위 호출자까지 취소 신호가 전파되어 전체 작업을 즉시 중단시킬 수 있게 된다. 취소를 명확하게 에러 처리하고 싶을때 적합한 방식이다!
Cancellation Point
하지만 모든 작업에 매번 Task.isCancelled를 확인하는 것은 매우 번거로운 일.. 그래서 몇몇 비동기 함수 자체가 취소를 인지하도록 내부적으로 설계되어 있다.
대표적으로 try await Task.sleep(for: .seconds(2)) 는 중간에 취소되면 CancellationError를 발생시킨다. try await URLSession.shared.data(from: url) 는 취소되면 URLError(.cancelled)를 발생시킨다.
즉, 서스펜딩(suspending)이 가능한 함수는 취소를 자동으로 감지하고 에러를 던지는 경우가 많다. 이를 활용해 취소 처리를 하면 더욱 간단하게 구현이 가능해진다는 점
구조적 동시성과 취소 전파
이전에 다뤘었던 Swift Concurrency의 구조적 동시성 원칙을 바탕으로 부모 작업이 있으면 자식 작업의 수명도 부모와 함께 관리 된다는 점은 취소에도 적용된다.
부모 Task가 취소되면 → 자식 Task도 함께 취소 된다는 점
대표적인 구조적 동시성을 만드는 async let , TaskGroup의 예시를 바탕으로 한번 보면
async let image1 = fetchImage()
async let image2 = fetchImage()
async let image3 = fetchImage()
let reuslts = try await (image1, image2, image3)
// 부모 취소 시, 세 개의 다운로드 모두 취소된다.
let images = try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask { try await fetchImage(url) }
}
return try await group.reduce(into: []) { $0.append($1) }
}
async let 또는 TaskGroup으로 만든 작업은 자식 작업이다. 부모가 취소되면 자식도 자동으로 취소됨.
async let의 경우엔 스코프를 벗어나면 아직 대기(await)하지 않은 자식들이 자동으로 취소된다. 자식들 중 하나가 throw 하면 남은 자식들이 자동으로 취소된다(대기한 시점에서 throw 전파)
withThrowingTaskGroup도 유사한 방식인데, 한 자식이 에러를 던지면 → 나머지도 자동 취소 + 빨리 종료 된다.
반대로 withTaskGroup(non-throwing)은 에러를 던지지 않으므로 자동 취소 없이 끝까지 순회한다. (원하면 group.cancelAll() 을 직접 호출하는 방식)
여기서 자식 → 부모의 취소 신호의 전파는 자식이 CancellationError 또는 다른 에러를 throw 하게 되면, 그 예외는 부모의 await 지점으로 전파되게 된다.
부모가 이 예외를 잡아서 복구할지, 그대로 상위로 넘길지 결정하게 되는 방식!!
부분 성공 vs 전체 중단
여러 작업을 동시에 수행할때, 하나의 실패가 전체를 멈춰야 하는지 아니면 가능한 결과만 모아도 되는지를 먼저 정의해야한다. 해당 전략은 각 시점마다 요구 상황에 따라 달라질듯..
먼저 부분 성공 전략을 예시로 살펴보면
- 피드/갤러리 등 보여줄 수 있는 만큼 곧바로 보여주는 UI
- 사용자 입장에선 일부분만 먼저 보이는게 더 유용함!
- withTaskGroup(of: Optional<T>.self) + try? 패턴으로 실패/취소를 nil로 흡수 한다. 결과를 순서 보장 업이 빠르게 쌓는다고 가정
func loadThumbnails(_ urls: [URL]) async -> [UIImage] {
await withTaskGroup(of: UIImage?.self) { group in
var out = [UIImage]()
for url in urls {
group.addTask { try? await fetchImage(url) } // 실패/취소 → nil
}
for await image in group {
if let image { out.append(image) }
}
return out
}
}
여기서 순서가 중요하다면 별도의 인덱스 관리를 추가해주면 될 것 같다 (index, image?) 튜플을 활용해서,,,
너무 많은 동시 작업은 리소스를 과점유하기 떄문에 배치/동시성 제한하는 설계도 필요할 것 같다.
그 다음 전체 중단 전략을 보면
- 결제, 검증 등 모든 단계가 성공해야지만 의미가 있을때!
- 부분 성공은 오히려 위험하거나 불가한 순간에 해당 전략을 활용해야할 것 같다.
- withThrowingTaskGroup(of: T.self) 사용해서 하나라도 throw → 남은 작업 자동으로 취소 + 즉시 종료된다.
func processPipeline(_ steps: [Step]) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
for step in steps { group.addTask { try await step.run() } }
for try await _ in group { /* drain */ }
}
}
여기서 이제 실패 원인을 별도로 세부적으로 분류해서 (검증 실패, 네트워크 실패 등) 상위에서 다른 UX 제공하는 방식으로 유저 경험 챙기기
추가적으로 재시도가 가능한 부분은 재시도를 할 수 있도록 설계 해야한다.
SwiftUI에서 취소, 뷰 수명에 따른 리소스 관리
SwiftUI에서 작업은 뷰의 수명에 묶어 자동으로 취소해주도록 모디파이어를 제공해준다! 이걸 잘 활용하면 리소스 낭비를 크게 줄일 수 있다.
먼저 .task의 모디파이어를 활용해 비동기 함수를 호출한다고 했을때 해당 안에서 실행되고 있는 로직은 뷰가 화면에서 사라질떄 자동으로 취소되게 된다.
여기서 더 세부적으로 .task(id: key)를 활용하면 key가 변경될 때 마다 기존 작업을 취소 하고 새 작업을 시작하게 된다. (ex: 검색 자동 완성을 구현할떄 id: query를 활용해 이전 요청 취소 후 최신 요청만 유지하는 방식)
.task(id: query) {
guard !query.isEmpty else { results = []; return }
do {
results = try await api.search(query) // 이전 query 작업은 자동 취소
} catch is CancellationError {
// 조용히 무시
}
}
이외에 .onAppear { Task {} } 로 선언된 Task는 뷰가 사라져도 자동으로 취소되지 않는 다는 점은 인지하고 있어야 한다. 수명이 독립적으로 수명이 보장되어야 한다면 해당 내용으로 구현해야하지만 그 외에는 .task를 활용해 구현하는 방식을 추천
오늘은 작업 취소에 대한 내용을 공부했다.. 구조적 동시성 부터 한번에 모든걸 이해하기엔 조금 어려운 주제인 것 같다.. 뭔가 머리속에 화아아악 다들어오지는 않는 느낌이랄까.. 정리해두고 복습하고 적용해보면서 익혀야지
'◽️ Programming > iOS' 카테고리의 다른 글
| Sendable protocol과 Actor 개념 재정리 (0) | 2025.10.11 |
|---|---|
| @TaskLocal 프로퍼티 래퍼 (0) | 2025.10.06 |
| Swift Concurrency 구조적 동시성을 정리하면서.. (0) | 2025.09.16 |
| Swift Concurrency의 Continuation과 Task.sleep, sleep의 차이! (1) | 2025.09.12 |
| Swift Concurrecy에서 비동기 개념의 확장? (0) | 2025.09.02 |