https://developer.apple.com/videos/play/wwdc2021/10134/
Explore structured concurrency in Swift - WWDC21 - Videos - Apple Developer
When you have code that needs to run at the same time as other code, it's important to choose the right tool for the job. We'll take you...
developer.apple.com
구조적 동시성
초창기 프로그래밍 (0:34)
- 초창기 프로그램은 점프/분기가 많아 읽기 어려움

오늘날의 구조화된 프로그래밍
- if, for, while 같은 블록 제어 흐름 도입 → 가독성 향상, 수명/스코프 명확해짐
- 전체 흐름을 위에서 아래로 읽을 수 있음 (구조화된 프로그래밍의 기본)
- 직관적이나, 비동기 및 동시 코드엔 여전히 어려움

기존 비동기 코드 문제 (2:00)
이미지 다운로드 후 썸네일 생성 작업 예시

이 코드는 이미지를 식별하는 문자열 배열을 가져와 비동기적으로 작동하여 호출될 떄 바로 값을 반환하지 않음 (Completion Handler)
- 에러 처리 불편 (구조화된 제어 흐름(throw) 불가)
- 루프 불가 (콜백 중첩 필요)
- 재귀/중첩이 증가하여 가독성이 떨어짐
async/await 도입 (2:58)

- 함수 시그니처에 async throws
- completion handler 제거 후 await으로 중단점 표시
- 루프 작성 가능, throws 지원, 컴파일러 체크
문제점:
순차 실행만 가능하므로, 이미지를 하나씩 받아오고 있어 비효율적 → 병렬 처리 필요
Task in Swift (4:00)

Task: 비동기 코드를 실행하는 실행 컨텍스트
- 각 Task는 다른 실행 컨텍스트와 동시에 실행됨
- 병렬로 실행되도록 자동으로 스케줄링
- 컴파일러가 버그를 방지하는 데 도움을 줌
- 비동기 함수를 호출한다고 해서 새로운 Task가 생기는 것은 아님 → 명시적으로 Task 생성
- Swift의 Task는 작업 트리(Task Tree)로 연결되어 취소, 우선순위, 지역 변수를 전파함
Async let (4:54)
async let이라는 새로운 형식 설명 이전에 일반 let 바인딩에 대해 알아보자
Sequential bindings

- Swift가 let 바인딩에 도달하면 이니셜라이저가 평가되어 값을 생성함
- 이 예제에서는 데이터 다운로드에 시간이 걸릴 수 있음
- 데이터가 다운로드된 후 다음 명령문으로 진행하기 전, 해당 값을 변수 이름에 바인딩 함
- 이 과정에는 화살표로 표시한 실행 흐름이 하나만 있음
하지만 위와같이 다운로드에 시간이 걸리는 작업은 프로그램이 데이터 다운로드를 시작하고, 데이터가 실제로 필요할 떄까지 다른 작업을 진행하기를 원함
Concurrent bindings(07:23)

- 기존 let 바인딩 앞에 async라는 키워드를 추가하면 동시 바인딩으로 바뀜

- 동시 바인딩을 평가하기 위해 먼저 새 하위 작업을 생성함
- 모든 작업은 프로그램의 실행 컨텍스트를 나타내기 때문에 화살표가 동시에 두 개가 됨
- 첫 번째 화살표(초록색)는 데이터를 다운로드하는 하위 작업
- 두 번째 확살표(흰색)는 변수 결과를 바인딩하는 상위 작업
- 자식이 데이터를 다운로드 하는 동안 부모 작업은 동시 바인딩 뒤에 오는 작업을 계속 실행
- 결과의 실제 값이 필요한 표현식에 도달하면 부모는 결과에 대한 자리 표시자를 충족하는 자식 작업의 완료를 기다림
→ 하위 작업이 데이터를 다운로드 하는 동안 부모 작업은 계속 실행되고, 실제 값을 기다리는 건 try await 키워드가 붙은 실제 필요한 자리에서 하위 작업의 완료를 기다림
썸네일 가져오기 예제를 살펴보자

→ 두 개의 이미지(이미지 전체, 메타 데이터) 순처 처리 (직렬)
이러한 직렬적인 순차 처리를 병렬적으로 바꾸기 위해 async let을 사용한다

- 두 다운로드가 동시에 발생하도록 하려면 두 let 앞에 async 키워드를 씀
- 이제 다운로드가 하위 작업에서 발생하므로 try await을 바로 작성하지 않음
- try await 키워드는 실제로 데이터가 필요한 곳에서 사용
- 부모 작업은 하위 작업에 일을 넘기고 다음 코드가 실행되므로 imageReq에 대한 다운로드 일을 바로 하위 작업에 넘기고, metadataReq에 대한 다운로드 일도 하위 작업을 만들어 넘김
아래는 Task Tree 구조이다

→ fetchOneThumbnail 함수가 부모 작업이 되고 data, metadata를 가져오는 작업이 자식 작업이 됨
- 이 트리는 구조화된 동시성의 중요한 부분임
- 취소, 우선순위, Task-Local 변수를 상속하기 때문
- 따라서, 하위 작업들은 fetchOneThumbnail 함수의 모든 속성을 상속함
- 또한, 트리는 각 부모와 하위 작업 간의 링크로 구성되어 부모 작업은 모든 자식 작업이 완료된 후에 작업이 완료됨
- 부모 작업은 자식 작업이 모두 끝날 때까지 기다림
예를 들어 첫 번째 작업이 오류를 throw하여 완료되면 fetchOneThumbnail 함수는 해당 오류를 throw하여 즉시 종료해야 한다
그렇게 된다면, 이미 실행 중인 두 번째 작업은 어떻게 될까?

- Swift는 하위 작업을 자동으로 취소된 것으로 표시한 다음, 작업이 완료될 때까지 기다렸다가, 모두 완료되면 부모 작업 종료
- 작업이 취소되었다고 표시는 하지만, 곧바로 중단하지 않음
- 단순히 작업의 결과가 더이상 필요하지 않다는 것을 알림
취소(cancelled)를 표시한다고 해서 Task가 바로 종료되는 것은 아니라 결과가 필요 없다고 알림
→ 취소를 명시적으로 확인하고 적절한 방식으로 실행을 종료해야 함
비동기 여부와 간계없이 모든 함수에서 현재 작업의 취소 상태를 확인할 수 있으므로,
특시, 장기 실행 게산이 포함된 작업인 경우, 취소를 염두에 두고 API를 구현해야 함
협력적 취소(Cooperative Cancellation) (11:45)

- 각 루프의 반복에서 checkCancellation 호출하여 작업이 취소된 경우에 오류를 throw 함
- 코드에 따라 아래와 같이 현재 작업의 취소 상태를 Bool 값으로 가져올 수 있음

→ 위와 같이 Bool 값으로 확인하여 작성하면 완료된 썸네일은 부분적으로 반환할 수 있음
- 취소는 강제 중단이 아님 → 협력적 모델
- Task.isCancelled 또는 try Task.checkCancellation() 사용
- 장기 연산/네트워크 작업 시 반드시 반영해야 함
- API도 부분 결과 반환 고려 필요
Task Group (12:50)
async let 보다 더 많은 유연성을 제공
async let은 고정된 양의 동시성을 사용할 수 있을 때 유리함

→ ids 배열에 따라 for 문을 돌려 fetchOneThumbnail 메서드 내 이미지 데이터와 메타이터 다운은 병렬로 되지만, fetchOneThumbnail 메서드는 여전히 직렬적으로 동작함
- for 문에서 try await fetchOneThumbnail(withID: id) 는 두 개의 자식 작업(데이터, 메타데이터 다운)의 완료를 기다려야 함
- 따라서, 이 루프가 모든 썸네일을 동시에 다운로드하는 작업이 필요함 → Task Group 적합
Task Group은 동적 개수의 동시성을 제공하도록 설게된 구조화된 동시성의 한 형태

- withThrowingTaskGroup 함수는 오류를 발생시킬 수 있는 자식 작업을 만드는 범위가 지정된 그룹 개체를 제공함
- 그룹에 추가된 작업은 그룹이 정의된 블록보다 오래 지속될 수 없음
- 그룹의 비동기 메서드(group.async)를 호출하여 자식 작업을 만들고 그룹에 추가된 하위 작업은 즉시 순서에 관계없이 실행되기 시작함
- 그룹 개체가 범위를 벗어나면 그 안의 모든 작업이 암시적으로 완료 됨
하지만 위와 같은 코드는 컴파일러가 Data Race에 대한 오류를 발생함
Data Race 방지 (14:56)

- 여러 자식이 동시에 Dictionary를 수정 → Data Race 발생
- Swift 컴파일러가 @Sendable로 사전 캡처 경고
- thumnails는 dictionary로, thread safe하지 않음. (not sendable)
Task를 만들면 이는 새로운 closure type인 @Sendable closure가 된다. @Sendable closure는 외부에 영향을 주어 변경을 가할 수 있는 변수를 캡처하는 것이 제한된다. (not sendable)

- @Sendable 클로저의 본문은 작업이 시작된 후 해당 변수를 수정할 수 있기 때문에 어휘 컨텍스트에서 변경 가능한 변수를 캡처하는 것이 제한됨
- 작업에서 캡처한 값은 안전하게 공유할 수 있어야 함
→ 비동기 컨텍스트 내부에서 컨텍스트 외부 (thumbnails 변수)에 접근하게 되면 Data Race 발생 가능하기 때문
https://developer.apple.com/videos/play/wwdc2021/10133/
Protect mutable state with Swift actors - WWDC21 - Videos - Apple Developer
Data races occur when two separate threads concurrently access the same mutable state. They are trivial to construct, but are notoriously...
developer.apple.com
해결책: 자식이 튜플 값으로 반환 후 부모가 순차적으로 합침

- 자식 작업이 튜플로 반환하여 결과 처리는 부모가 책임
- 부모 작업은 for-await 루프를 이용하여 각 자식 작업의 결과를 반복
- for-await 루프는 완료 순서대로 하위 작업의 결과를 가져옴 (자식 작업 추가 순서 X)
https://developer.apple.com/videos/play/wwdc2021/10058/
Task Group의 에러 / 취소 (17:45)
- 그룹 내 자식이 에러 throw → 나머지 Task 자동 취소 + 완료 대기 (async let과 동일)
- async let과 차이점
- 블록 정상 종료 시엔 Task 자동 취소 없음 (fork-join 패턴 가능)..
- fork-join pattern? 여러 개의 작업을 기반으로 하나의 결과를 생성하는 패턴.
- 명시적으로 group.cancelAll()로 전체 취소 가능
Unstructured Task - 구조화되지 않은 작업 (18:43)
프로그램에 작업을 추가할 땐 항상 계층 구조가 있는 것은 아님
구조화되지 않은 작업은 수동 관리가 필요하지만, 더 많은 유연성을 제공함

- 동기 코드 중 Task를 생성했을 때
- 작업 수명이 단일 범위나 단일 함수의 범위에 맞지 않을 수 있음
UIDelegate 예시
UI 작업은 메인 스레드에서 이루어져야 하기 때문에 메인 액터를 사용함

fetchThumbnails 함수를 사용하여 컬렉션 뷰의 항목이 표시될 때 네트워크에서 썸네일을 가져오려고 한다

→ fetchThumbnails 함수를 호출하고 시지만 delegate 메서드는 비동기가 아니므로 비동기 메소드의 호출을 기다릴 수 없어 아래와 같이 오류가 남

이때 Unstructured Task를 사용함

일반 메서드 실행 중 Task를 만나면 Swift는 원래 속한 scope와 동일한 actor에서 실행되도록 예약됨
위 코드는 상위 scope가 메인 액터를 사용하므로 메인스레드에서 실행되지만 제어권은 호출자에게 바로 반한됨
fetchThumnails는 메인 스레드를 즉시 차단하지 않고 여유가 있을 때, 메인 스레드에서 실행됨
직접 생성된 Task는 실행된 컨텍스트의 actor, priority 등 기타 특성을 상속 받음
그러나, 생성된 Task는 실행된 컨텍스트의 스코프에 구속되지 않음
구조화된 동시성과 달리 취소 및 오류는 자동으로 전파되지 않으며 명시적인 조치를 취하지 않는 한 작업의 결과를 암시적으로 기다리지 않음
Unstructured Task 취소 관리 (21:55)
- 썸넨일이 준비되기 전에 항목이 뷰 밖으로 스크롤되는 경우, 해당 작업을 취소해야 함
- 하지만 구조화되지 않은 작업은 취소가 자동으로 이루어지지 않음

- 작업을 만들 때 indexPath를 키로 딕셔너리에 Task를 값으로 넣어 취소할 때 사용함
- 작업이 완료되면 Dictionary에서 제거
- 해당 함수가 내부적으로 메인 스레드에서 동작하므로 Data Race가 발생하지 않아 컴파일 오류가 안 나는 것

- 딜리게이트를 통해 디스플레이가 제거되면 cancel 메서드를 호출하여 작업 취소
지금까지는 호출 컨텍스트의 특성을 상속하여 메인 액터를 상속한 Task에 대해 살펴보았지만, 아예 독립적으로 실행되는 구조를 알아보자
Detached Task
- 호출 컨텍스트에서 아무것도 상속하고 싶지 않을 때, 유연성을 극대화하기 위해 사용
- 구조화되지 않은 작업
- 수명도 원래 범위에 구속되지 않음
- 동일한 액터로 제한되지 않고 우선순위도 상속되지 않음
예를 들어 썸네일을 가져온 후 디스크 캐시에 저장한다고 가정해보자
캐싱은 메인 액터에서 발생할 필요가 없으며 모든 썸네일 가져오기를 취소하더라도 가져온 썸네일을 캐시하는 것이 좋음

- Task.detached로 분리된 작업은 훨씬 더 많은 유연성을 가질 수 있음
- 캐싱은 기본 UI를 방해하지 않도록 낮은 우선순위를 가져야 함
썸네일로 수행하는 백그라운드 작업이 여러 개 있는 경우 어떻게 해야 할까?
백그라운드 작업을 분리할 수 있지만, 분리된 작업에서 구조화된 동시성을 사용할 수 있음


- 나중에 백그라운드 작업을 취소해야 하는 경우, 작업 그룹을 사용하면 최상위 분리 작업을 취소해도 자식 작업을 모두 취소 가능함
- 또한, 하위 작업은 자동으로 부모 작업의 우선순위를 상속하므로, 우선 순위 설정을 잊어도 자동으로 적용됨
정리

- async/await: 가독성↑, 순차 코드처럼 작성
- Task: 동시 실행 단위, 트리 구조로 연결
- async-let: 고정 개수의 자식 Task, 간단히 병렬 처리
- TaskGroup: 동적 개수 처리, for await로 결과 수집
- 에러/취소: 자동 전파, 협력적 취소 모델
- Unstructured Task: 스코프 독립, 취소/에러 수동 관리 필요
- Detached Task: 컨텍스트 상속 없이 완전히 독립 실행
'WWDC' 카테고리의 다른 글
| WWDC 2021 - Protect mutable state with Swift actors 정리 (0) | 2025.09.09 |
|---|---|
| WWDC 2021 - Meet AsyncSequence 정리 (0) | 2025.09.08 |
| WWDC 2021 - Use async/await with URLSession 정리 (0) | 2025.09.05 |
| WWDC 2021 - Discover concurrency in SwiftUI 정리 (0) | 2025.09.04 |
| WWDC 2021 - Meet async/await in Swift 정리 (2) | 2025.09.01 |