💻

async let vs TaskGroup vs 연속 await

joho2022 2025. 8. 28. 23:00

 

 

Swift Concurrency는 async/await 문법을 통해 비동기 작업을 직관적으로 표현할 수 있게 해준다.

하지만 비동기 작업을 병렬로 실행하는 방법은 여러 가지가 있고,

이를 잘못 사용하면 성능 이점을 살리지 못하거나, 관리되지 않는 Task를 남발하는 실수를 저지르기 쉽다.

( 내가 실수했었다 )

 

다음과 같은 방식을 비교하여 정리하고자 한다.

1. Task가 연속 있을 때

2. 연속 await 사용할 때

3. async let 통한 정적 병렬 처리

4. TaskGroup을 통한 동적 병렬 처리

 

 


 

공통 테스트 환경

실제 네트워크 대신, 서로 다른 지연 시간을 갖는 비동기 함수를 준비했다.

 

enum Work {
    static func fetchItemA() async throws -> String {
        print("🟢 Start A")
        try await Task.sleep(for: .seconds(2.0))
        print("🔴 End A")
        return "Item A"
    }
    static func fetchItemB() async throws -> String {
        print("🟢 Start B")
        try await Task.sleep(for: .seconds(1.0))
        print("🔴 End B")
        return "Item B"
    }
    static func fetchItemC() async throws -> String {
        print("🟢 Start C")
        try await Task.sleep(for: .seconds(4.0))
        print("🔴 End C")
        return "Item C"
    }
}

 

 


Task 연속있을 때

해당 테스트를 통해 알 수 있는 사실은

Task를 호출한 부모 함수(test_wrongWay)가 자식 Task의 완료 여부와 상관없이 즉시 실행을 마친다.

fetchItemC는 4초가 걸리는 작업임에도 불구하고, 해당 함수가 걸리는 시간은 거의 0초로 측정이 된다.

 

이를 통해 부모함수는 Task들의 시작만 지시하고, 완료는 기다리지 않은 채 종료되는 것을 의미한다.

 

로그결과

매번 결과가 달라지는 것을 확인할 수 있는데,

코드상으로는 ABC순서로 작성했지만, 위와 같이 ACB로 찍힌다.

Task는 즉시 어느 스레드에서 실행할지 스케줄링만 하고, 어떤 순서로 실행될지는 런타임 스케줄러가 정하기 때문에

동시성만 보장되고, 실행순서가 보장되지 않는다.

 

Swift에서는 권장하지 않는 방식이라고 말한다.

 

그리고 여기서 중요한 것은

부모의 cancel이 자식 Task에게 전파되는지 알아야 한다.

 

부모의 취소가 자식에게 전파되지 않으면 이미 불필요한 작업이 계속 실행돼 자원을 낭비하게 된다.

또한 자식이 캡처한 객체를 붙잡고 있으면 메모리 정리 지연 문제가 생길 수 있다.

그리고 데이터 일관성에 대한 문제 가능성도 생길 수 있다.

 


 

https://developer.apple.com/videos/play/wwdc2023/10170/

 

사실 Task { } 로 만든 자식들은 unstructured task라서 부모와 생명주기가 끊어져 있다.

 

 

따라서 부모 Task를 취소해도, 자식 Task는 계속 실행된다.

 

반면, structured task로 분류되는 async let이나 taskGroup들은 부모 취소가 자신에게 전파되는 특징이 있다.

 

그래서 정리하면 Task를 연속으로 사용한 패턴은

- 시작만 하고,

- 완료를 기다리지 않고,

- 취소도 전파되지 않는 

예측이 힘든 패턴이라는 알 수 있다.

 

 


연속 await 사용할 때

사실 해당 케이스가 내가 실수를 경험했었다.

그냥 생각없이 연속으로 await를 했을 때, 단순히 병렬로 될거라고 생각했지만

직렬로 진행되는 것을 확인할 수 있었다. 

 

로그결과

부모 함수는 순서대로 끝까지 기다리는 것을 확인할 수 있다.

그리고 실행순서를 보장하여서 ABC 순서대로 로그가 찍히는 것을 확인이 가능하다.

 

그래서 직렬로 실행이 되어서, 2 + 1 + 4 와 비슷한 7초로 측정되는것이 확인가능하다

 


 

직렬 awiat흐름에서 부모 취소를 전파했다.

A 작업은 2초가 걸리지만, 부모 Task를 실행한 뒤 1초 만에 부모를 취소하도록 설정했다.

 

그 결과, 보이는 로그처럼 A 작업이 끝나기 전에 즉시 취소 에러가 발생했고, 이후 B와 C 작업은 전혀 실행되지 않았다.

즉, 직렬 await 구조에서는 부모의 취소가 자식 작업에도 그대로 전파된다는 사실을 확인할 수 있다.

 


 

async let 통한 정적 병렬 처리

정적으로 병렬처리가 된다. 즉, 컴파일 시점에 비동기작업의 수가 고정되어 있음

코드가 실행되는 순간, async let로 선언된 모든 비동기 작업이 즉시 시작이 된다.

 

그리고 await 에서 결과를 모을 때까지 기다리게 되고,

실제로 런타임 스케줄러가 async let 으로 만든 비동기 작업을 한번에 테스크 큐에 집어 넣어서 

병렬로 처리가 되어진다.

 

로그결과

병렬작업 되었기에,

예상되는 로그결과와 4초 근처로 나타내는 것을 확인할 수 있다.

 


 

위 테스트는 3개의 비동기 작업을 시작한 뒤, 1초 만에 부모 Task를 취소하는 상황을 연출했다.

 

async let은 구조화된 동시성이므로 부모가 취소되면 자식 Task에도 취소가 전파된다.

 

그 결과 자식 작업들은 모두 중단되어 await 구문까지 도달하지 못했다.

즉, async let은 부모와 생명주기를 공유하며 안전하게 취소 전파가 보장된다.

 


 

TaskGroup을 통한 동적 병렬 처리

withTaskGroup 블록 안에서 group.addTask 할 때마다 새로운 자식 Task가 즉시 시작된다.

 

 

로그결과

async let과 똑같이 병렬로 처리되고 로그결과도 비슷한걸 확인할 수 있다.

 

TaskGroup은 async let으로는 처리할 수 없는 상황에 TaskGroup이 필요하다.

 

특히 런타임 중에 작업 개수가 달라지거나 예측하기 어려운 Concurrency 상황이나,

먼저 끝난 작업부터 차례대로 처리하고 싶을때 TaskGroup이 적합하다.

 


 

TaskGroup도 동일하게 부모가 취소되면 자식 Task에도 취소가 전파된다.

 

 


부모 Context 상속

Task 관련 학습을 하다 보면 부모 Context 상속 이라는 표현을 종종 볼 수 있다.

 

이는 자식 Task가 생성되는 순간,

부모의 Context ( 액터, 우선순위, Task-local, 부모 취소상태 등 ) 를 그대로 복사해서 그 상태로 시작한다는 의미이다.

 

예를들어, 부모가 이미 취소된 상태였다면, 자식도 취소 상태로 태어난다.

 

이는 단순히 시작 시점의 스냅샷일 뿐이고,

이후 부모의 상태 변화까지 실시간으로 따라가는 것은 아니다. 

실시간으로 따라가는 역할은 취소 전파가 담당함. 위에서 테스트했던 과정들!

 

그래서 정리하면,

 

Task {} (Unstructured) → 상속 O, 전파 X

(생성 당시 취소면 취소 상태로 시작하지만, 나중 취소는 안 따라감)

---

async let / TaskGroup (Structured) → 상속 O, 전파 O

(부모가 언제 취소돼도 자식이 즉시 취소)

 

 


 정리

병렬 성능 이점을 살리려면 구조화된 동시성( async let / TaskGroup )을 쓰자.

Task {} 남발은 시작만 하고 기다리지도/취소도 전파하지도 않는 위험한 패턴이다.

마지막으로, 부모 Context 상속은 생성 순간의 스냅샷이고, 취소 전파는 그 이후의 변화를 실시간으로 연결하는 개념이다