Swift

Swift Concurrency - Structured Concurrency & Task (2)

빨간체리반지 2025. 10. 1. 23:14

Reference

WWDC21: Explore structured concurrency in Swift | Apple

 

Swift Concurrency 를 공부하다보면 async-await 외에도 자주 접하는게 Task 라는 녀석이다. 그런데 이게 익숙하지 않던 개념이어서 그런지 확실히 머릿속에 정리하기가 쉽지 않았다.

 

Task { } ?

아마 한번이라도 Swift Concurrency 를 시도했던 사람이라면 Task {} 를 본적이 있을 것이다.

이 initializer의 용도는 "비동기 코드(async 함수)를 실행하기 위한 새로운 실행 컨텍스트를 제공한다." 라고 한다.

 

원래 작성되어있던 프로젝트 코드에서 async 함수를 호출하고 싶을 때 냅다 그냥 async 함수를 호출하면 오류가 나는데, async 함수를 Task {} 로 한번 감싸주면 async 함수를 실행시킬 수 있는 컨텍스가 생성되기 때문에 오류가 사라진다. 보통 우리는 동기 코드 혹은 비동기 closure 내에서 async 함수를 호출하기 위해 Task {} 사용하곤 한다. 하지만 오류가 쉽게 해결된다고해서 막무가내로 사용하면... 안된다... (나는 몰랐지 이렇게 복잡하고 알아야할 게 많은 줄은...)

 

우선 Task 는 structured 와 unstructured 로 나뉜다.

오늘은 structured 와 unstructured 의 차이점을 알아보고 각각을 어떻게 생성해서 사용할 수 있는지 알아보려고 한다.

 

Structured Task 와 Unstructured Task

우선 Task는 아래와 같다.

- 비동기 코드를 실행하며 Combine 과 마찬가지로 cancel 기능을 제공한다

- Task 는 여러개 생성될 수 있다.

 

'특정 비동기 로직이 끝나면 연결해서 진행되어야하는 비동기 로직이 있고, 또 연결되어있는 비동기로직이 있다.' 라고 가정해보자.

첫번째 비동기 로직이 취소되면 연결되어있는 하위 비동기 로직들은 필요가 없기 때문에 같이 취소되는게 성능상 효율적이다. 이를 위해 Structured Task(Task hierarchy) 라는 개념이 있는 것이다. Task hierarchy 가 형성되면 취소 전파(cancellation propagation)가 가능하고, 이는 연계되어있는 여러 Task 를 관리하기에 용이하다.

(* 참고: Task Hierarchy 를 도식화 한 표현으로 Task Tree 라는 용어로 쓰이기도 한다.)

 

unstructured Task는 structured Task가 아닌 나머지를 unstructured 라고 생각하면 된다.

 

 

 

structured Task 생성하려면,

async let을 쓰거나 withThrowingTaskGroup을 사용하면 되고,

unstructured Task 를 생성하려면, Task {}Task.detached {} 로 생성하면 된다.

 

Structured Task

async let

원래 비동기 코드가 아래 코드 예시와 같다면,

첫번째 비동기 호출인 await URLSession.shared.data(for: imageReq) 가 return 되어야만 await URLSession.shared.data(for: metadataReq) 를 요청할 수 있고, await URLSession.shared.data(for: metadataReq) 가 return 되어야만 guard 문에 도달할 수 있다.

즉 아래 코드는 sequential 하게 처리된다.

let (data, _) = try await URLSession.shared.data(for: imageReq)
let (metadata, _) = try await URLSession.shared.data(for: metadataReq)

guard let size = parseSize(from: metadata),
	  let image = UIImage(Data: data)?.byPreparingThubnail(ofSize: size) else {
	  throw SomError()
}

이 방법은 두 비동기 로직을 동시에 실행시킬 수 없다는 단점이 있다.

 

하지만 async let을 사용하면,

async let (data, _) = URLSession.shared.data(for: imageReq)
async let (metadata, _) = URLSession.shared.data(for: metadataReq)

guard let size = parseSize(from: try await metadata),
	  let image = try await UIImage(Data: data)?.byPreparingThubnail(ofSize: size) else {
	  throw SomError()
}

 

아래처럼 Task Hierarchy 를 구성하며 두 비동기 로직을 동시에 실행시킬 수 있다.

Parent Task // 현재 함수 실행 컨텍스트
│
├─ Child Task // URLSession.shared.data(for: imageReq)
│
└─ Child Task // URLSession.shared.data(for: metadataReq)

 

자세한 설명은 아래와 같다

  1. async-let 을 만나면, URLSession.shared.data(for: imageReq) 비동기 로직을 위한 child task를 생성한다.
    (task 는 concurrent하게 비동기 로직을 실행할 수 있다)
  2. return 값에는 placeholder 를 할당해두고 다음 코드로 넘어간다.
  3. URLSession.shared.data(for: metadataReq) 비동기 로직도 1~2번과 동일하게 반복된다.
  4. await metadata 에서 metadata 값을 리턴하는 child task 가 완료될 때까지 suspend 한다.
  5. await UIImage(Data: data)?.byPreparingThubnail(ofSize: size) 에서 data 값을 리턴하는 child task 가 완료될 때까지 suspend 한다.

 

 

Group Task

async let 보다 유연하게 사용할 수 있으면서, Task Hierarchy 를 생성하는 방법으로는 withThrowingTaskGroup가 있다.

 

async let 만 사용한 아래코드를 보면, data와 metadata 모두 반환되어야지만 다음 for문을 돌 수 있게 된다.

func fetchThumbnail(for ids: [String]) async throws -> [String: UIImage] {
	var thumbnails: [String: UIImage] = [:]
	for id in ids {
		thumbnails[id] = try await fetchOneThumbnail(withID: id)
	}
	return thumbnails
}

func fetchOneThumbnail(withID id: String) async throws -> UIImage {
	// ...
	async let (data, _) = URLSession.shared.data(for: imageReq)
	async let (metadata, _) = URLSession.shared.data(for: metadataReq)
	// ...
}

 

 

하지만 withThrowingTaskGroup 을 사용하면 모든 비동기 로직을 한번에 실행시킬 수 있다.

func fetchThumbnail(for ids: [String]) async throws -> [String: UIImage] {
	var thumbnails: [String: UIImage] = [:]
	try await withThrowingTaskGroup(of: Void.self) { group in
		for id in ids {
			group.async {
				return(id, try await fetchOneThumbnail(withID: id))
			}
		}
		
		for try await (id, thumbnail) in group {
			thumbnails[id] = thumbnail
		}
	}
	return thumbnails
}

func fetchOneThumbnail(withID id: String) async throws -> UIImage {
	// ...
	async let (data, _) = URLSession.shared.data(for: imageReq)
	async let (metadata, _) = URLSession.shared.data(for: metadataReq)
	// ...
}

 

자세히 설명하자면 아래와 같이 동작하는 것이다.

  1. withThrowingTaskGroup 내부에서 group.async {} 를 호출해 child task를 생성한다.
    (task 는 concurrent하게 비동기 로직을 실행할 수 있다)
  2. 생성된 child task 는 withThrowingTaskGroup scope 내에서만 유효하며, withThrowingTaskGroup 내의 모든 child task 가 완료되면, await withThrowingTaskGroup 이 resume 된다.

 

(참고)

위 코드에서 주의할 점은 group.async 안에서 thumbnails 를 직접 수정하지 않고, 튜플을 return 한 이유는 아래와 같다.

  • group.async 내부(= child task)에서는 mutual variable 에 직접 접근하지 말고, return 만 한다. 왜냐하면 2개 child task 들이 동시에 Dictionary 를 수정하려고 하면 크래시가 발생하거나 data race 가 발생할 수 있다.
  • group.async 외부(= parent task)에서 for await 을 사용해 결과값들은 iterate 할 수 있다.
    (호출된 순이 아닌, 완료된 child task 순으로 iterate 된다)
    • for await 은 하나씩 await 했다가 호출되므로, 안전하게 Dictionary 에 값을 수정할 수 있다.
      (AsyncSequence 프로토콜을 차용한다면 언제든지 for await 을 사용할 수 있다)
 

 

 

자 이제 structured Task 를 어떻게 생성하는지 알게 됐으니 아래 예시 코드들을 보면서 Task Tree(Task Hierarchy)에서 cancel 과 에러처리가 실제로 어떻게 동작하는지 자세히 알아보도록 하자.

Task Tree 에서의 Cancellation Propagation 과 Error 처리

Task Tree 에서 부모 Task 가 취소되는 경우, 자식 Task(하위 비동기 로직)들에게 취소되었음을 전파(cancellation propagation)시키기 때문에, 부모 Task를 취소하는 것만으로 모든 자식 Task들을 취소를 시킬 수 있다.

또한 Task Tree 내에서 에러 throwing 이 발생하면, Tree 내의 모든 task가 영향을 받으며 최종적으로 parent task 에 error가 throw 된다.

 

cancellation propagation 에 대해 좀 더 구체적으로 알아보자.

func doSomething() async throws {
    async let (data, _) = URLSession.shared.data(for: imageReq)
    async let (metadata, _) = URLSession.shared.data(for: metadataReq)

    guard let size = parseSize(from: try await metadata),
          let image = try await UIImage(Data: data)?.byPreparingThubnail(ofSize: size) else {
          throw SomError()
    }
}

여기에서 parent task 는 data, metadata 데이터를 비동기로 가져오기위한 child task 두개를 갖는다.
이 두개의 child task 가 모두 완료 되어야 parent task 도 종료될 수 있다.

 

만약 위 코드에서 doSomething() 을 수행하던 Task 가 취소된다면 어떻게 될까?

  1. doSomething 내에서 생성된 두개의 child task 로 parent task 가 취소되었음이 전달될 것이다.
  2. 그러면 각각의 child task 는 cancelled 로 표기된다. 하지만 cancelled 로 표기되었다고 해서 실제로 task 가 바로 멈추는건 아니며, 해당 비동기 로직의 return 을 체크할 필요가 없음을 명시하는 것이라고 이해하면 된다.
  3. 모든 child task 가 cancelled 되면 parent task scope 를 빠져나올 수 있다.

 

그런데... task 가 cancelled 표기되었는지 어떻게 알 수 있을까?

(* 참고 : Task cancellation 은 async context 인지 아닌지와 상관없이 어디서나 체크 가능하다)

  • try Task.checkCancellation() : error throwing 으로 cancel되었음을 알 수 있다.
  • Task.isCancelled : cancel 여부를 Boolean 값으로 알 수 있다.
  • (withTaskCancellationHandler 를 사용하는 방법도 있는데 이건 다음 시간에...ㅎㅎ)
func fetchThumbnail(for ids: [String]) async throws -> [String: UIImage] {
	var thumbnails: [String: UIImage] = [:]
	for id in ids {
		try Task.checkCancellation() // or `if Task.isCancelled { break }`
		thumbnails[id] = try await fetchOneThumbnail(withID: id)
	}
	return thumbnails
}

 

 

흠.. 그렇다면 이번엔 취소가 아닌 child task 중 하나에서 에러가 발생한다면 어떻게 될까?

위 예시 코드의 URLSession.shared.data(for: metadataReq) 비동기 로직에서 에러가 발생했다고 가정하자.

  1. URLSession.shared.data(for: metadataReq) 를 수행하던 child task 는 error 를 throw 하면서 종료되고,
  2. 아직 resume 되지 않은 URLSession.shared.data(for: imageReq) 를 처리하던 child task 는 cancelled task 로 표기된다.
    (cancelled 로 표기했다고 바로 종료되는건 아님. 단지 결과값이 더이상 필요없다고 표기하는것 뿐)
  3. 2번에서 cancelled task 로 표기되면 Cancellation Propagation 에 의해 그 하위 task 들도 자동으로 cancelled 표기된다.
  4. 계층 내의 모든 하위 tasks 들이 종료되면, 1번에서 발생했던 error 가 parent task 로 throw 되면서 parent task 도 종료된다.

즉, 같은 Task Tree 내에 있다면 (즉, structured task 라면) cancellation, error handling 모두 같이 관리 된다는 의미이다.

 
Structured Task 가 무엇인지 어떻게 사용하는 것인지를 알아봤다. 마지막으로 async let 과 group task 을 비교하면서 마무리하겠다.

 

 

async let 과 Group Task 비교하기

  • 공통점
    • async-let 과 group task 모두 child task 에서 에러가 발생했다면, parent task 로 에러가 throw 되고, 모든 child task 들은 묵시적으로 cancelled 로 표기된다.
  • 차이점
    • group task 는 cancelAll() 메서드 를 사용해 모든 child task 들을 수동으로 취소할 수 있다.
      (정상적인 종료 상황에서 모든 태스크를 즉시 취소하고 싶을 때 사용)
    • async-let 은 이런 기능이 제공되지 않는다.

 

Unstructured Task

그렇지만 모든 상황에서 structured task 를 사용할 수 있는건 아니다.

  • non-async 콘텍스트에서 async 함수를 호출할 땐, parent task 자체가 없을 수 있다.
  • task 의 범위가 scope 외에 존재하길 바랄 수도 있다.

이럴때 사용하는게 Unstructured Task 이다!

위에서 잠깐 언급했듯이 unstructured Task 를 생성하려면, Task {}  Task.detached {} 로 생성하면 된다.

 

Task { }

우선 Task{} 에 대한 설명을 하자면 아래와 같다.

  • 시작 컨텍스트의 actor와 동일한 actor 에서 동작한다(= actor context 를 상속). 또한 상위 task 의 priority 및 기타 특성도 상속한다.
  • Task {} 로 생성된 task 는 task 가 생성된 scope 밖의 범위에서도 수명이 유지된다. (= unscoped lifetime)
  • Task {} 는 non-async context 에서 호출할 수 있다.
  • 자유도가 높은 대신 cancellation/error propagation 은 지원되지 않으며, 개발자가 직접 관리해주어야 한다.

따라서 올바른 Task 사용 예시는 아래와 같다.

@MainActor
class MyDelegate: UICollectionViewDelegate {
	var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
	
	func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
		let ids = getThumbnailIDs(for: item)
		thumbnailTasks[item] = Task {
			defer { thumbnailTasks[item] = nil }
            do {
                let thumbnails = try await fetchThumbnails(for: ids)
                display(thumbnails, in: cell)
            } catch {
            	// handle error...
			}
		}
	}
	
	func collectionView(_ view: UICollectionView, didEndDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
		thumbnailTasks[item]?.cancel()
	}
}



이때, Task 는 actor 속성을 그래도 상속받으므로, 메인스레드에서만 실행된다.
따라서 Task {} 내에서 thumbnailTasks 에 접근해도 data race 가 발생하지 않는다.

 

Task.detached { }

위에서 봤듯이 Task {} 는 시작 컨텍스트의 actor, priority 등을 상속받는다. 하지만 이 중에서 아무것도 상속받지 않길 바랄때 사용하는게 detached task 이다!
다만 얘도 Task {} 와 마찬가지로 unscoped lifetime 을 가지고, 직접 cancelled, error, awaited 를 관리해줘야한다.


background priority 를 갖는 여러 작업이 수행되어야할 땐?
detached task 안에 structured task 를 구성하면 된다.

@MainActor
class MyDelegate: UICollectionViewDelegate {

	func collectionView(_ view: UICollectionView,
						willDisplay cell: UICollectionViewCell,
						forItemAt item: IndexPath) {
		let ids = getThumbnailIDs(for: item)
		thumbnailTasks[item] = Task {
			defer { thumbnailTasks[item] = nil }
			let thumbnails = await fetchThumbnails(for: ids)
			
			Task.detached(priority: .background) {
				// unstructured 안에 structured 구성 가능!
				withTaskGroup(of: Void.self) { g in
					g.async { writeToLocalCache(thumbnails) }
					g.async { log(thumbnails) }
					g.async { ... }
				}
			}
			
			display(thumbnails, in: cell)
		}
	}
}

 

Task.detached 자체는 unstructured task 이지만 detached task 내부에서는 child task 들이 생성되었으므로 detached task 를 cancel 하면, 그 안의 모든 child task 다 cancel 이 가능하다.

 

 

 

 

 

오늘은 Task 를 생성하는 법과 그 차이점에 대해 모두 살펴보았다.

모든 Task 생성하는 방법을 정리하고, 비교한 표를 첨부하면서 마무리하려고 한다.

- structured task 는 cancellation, error handling 이 연관되어 동작하며, priority 와 actor context 를 상속한다.

- unstructured task 인 Task {} 는 priority 와 actor 를 상속할 뿐이다.

- unstructured task 인 Task.detached {} 는 아무것도 상속하지 않는다.


 

유익했다면 댓글/공감 남겨주세요~~ 작성자에게 큰 힘이 됩니다 ☺️

 

(이전 글) 👉 Swift Concurrency - Async Await (1)

(다음 글) 👉 TO BE CONTINUE...

 

'Swift' 카테고리의 다른 글

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