티스토리 뷰
안녕하세요 Diana 입니다
오늘은 Structured Concurrency에 대해 알아보려고 합니다.
✅ Structured Concurrency(구조적 동시성)이란?
Structured Concurrency는 비동기, 병렬 작업을 부모-자식의 계층적 범위(Scope)로 묶어 관리하는 프로그래밍 패러다임 입니다.
핵심 아이디어는 "동시성에도 명확한 시작과 끝이 있어야 한다"는 것으로 GCD 시절, escaping closure 기반 비동기 코드가 가졌던 문제들(작업 수명을 추적할 수 없고 취소를 수동 관리 해야하며 에러가 클로저 안에서 사라지던 문제) 를 언어 차원에서 해결하고자 하는 시도입니다.
작업 트리(Task Tree)
구조적 동시성은 부모 Task는 자식 Task가 모두 끝나기 전에는 완료될 수 없다는 보장에서부터 시작합니다.
모든 async 함수는 어떤 Task의 일부로 시작되고 그 안에서 생성된 자식 Task들은 트리 구조를 이룹니다. 자식은 부모로부터 우선순위(priority), task-local 값 그리고 취소 상태를 상속 받습니다. 이 트리 구조 덕분에 수명, 취소, 에러 전파가 자동으로 따라오는 것이죠.
부모 Scope를 벗어나는 순간 떠도는 자식 Task가 남지 않는 다는 것이 GCD와의 결정적인 차이입니다.
async let - 정적인 동시 바인딩
func loadUserData() async throws -> UserData {
async let profile = fetchProfile() // 여기서 자식 task 생성, 즉시 실행 시작
async let posts = fetchPosts() // 또 다른 자식 task
// await 지점에서 비로소 결과를 기다림 — 두 작업은 병렬로 진행됨
return try await UserData(profile: profile, posts: posts)
}
async let 으로 바인딩한 값은 사용하는 시점(await)에서 결과를 받아옵니다.
만약 어떤 경로에서 async let을 await하지 않고 Scope를 벗어나면 컴파일러가 암묵적으로 자식을 취소하고 await를 해줍니다. 자식이 부모보다 오래 살아 남는 일을 구조적으로 막아주는 것이죠.
TaskGroup - 동적인 자식 작업
TaskGroup은 자식 작업 수가 런타임에 결정될 때 사용합니다.
func fetchImages(ids: [String]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for id in ids {
group.addTask { try await fetchImage(id: id) }
}
var images: [UIImage] = []
for try await image in group { // 완료되는 순서대로 받음
images.append(image)
}
return images
}
}
여기서 중요한 점은 두 가지 입니다.
첫째, for try await 로 받는 결과는 완료 순서이지 추가한 순서가 아닙니다.
순서가 필요하면 인덱스를 함께 반환하도록 설계해야합니다.
둘째, withThrowingTaskGroup의 클로저가 반환되는 시점에서 그룹 내 모든 자식 작업이 끝났음이 보장됩니다.
그룹 Scope가 곧 생명주기의 결계가 되는 것이죠.
취소 전파(Cancellation Propagation)
취소는 트리를 따라 아래로 전파되고 협력적(Cooperative)입니다. 부모 Task를 취소하면 자식에게 취소 플래그가 전달이 되지만 Swift는 현재 실행중인 작업을 강제로 멈추지 않습니다. 각 작업이 스스로 취소 여부를 확인하고 중단해야 합니다.
for id in ids {
try Task.checkCancellation() // 취소됐으면 CancellationError throw
// 또는
if Task.isCancelled { return [] }
await processImage(id)
}
TaskGroup에서 자식 Task 하나가 에러를 던지면, 그룹은 나머지 자식들을 자동으로 취소하고 에러를 클로저 밖으로 전파합니다.
이게 withThrowingTaskGroup와 일반 withTaskGroup의 차이점이죠.
여기까지 우리가 알아본 async let, TaskGroup은 구조적입니다. 수명, 취소, 에러 전파가 자동이죠.
반면 Task{}는 비구조적입니다. 현재 Context는 상속하지만 Scope에 묶이지 않습니다. 핸들(Task)을 받아서 직접 .value를 await 하거나 .cancel()을 호출해 수동으로 관리해야 합니다. SwiftUI에서 .task modifier가 아닌 버튼 핸들러 안에서 비동기 작업을 할때 흔히 쓰이죠.
let handle = Task {
await doSomething() // 부모 컨텍스트는 상속, 수명은 독립
}
// 나중에
handle.cancel()
Structured Concurrency의 진짜 가치는 "지역적 추론(Local reasoning)" 이 가능해진다는 점입니다. 함수 하나를 봤을 때, 그 함수가 반환되면 그 안에서의 모든 Task가 끝나거나 취소됫다고 확신할 수 있는 것이죠.
GCD에서는 클로저가 어디론가 escape해서 언제 끝날지, 끝난다 해도 추적하기 여간 쉽지 않았는데 Structured Concurrency는 그 불확실성을 언어차원에서 제거합니다.
TaskGroup vs Async let
그럼 이번엔 TaskGroup과 async let을 성능 관점에서 비교해보겠습니다.
이 두개는 Structured라는 점에서는 같지만 메모리 배치와 비용 구조가 다릅니다.
async let의 경우 자식 Task의 수가 컴파일 타임에 고정되어 있습니다. 컴파일러가 정확히 몇개의 자식이 생기는지 알기 때문에 각 자식 작업의 저장 공간을 부모 스택 프레임에 인접하게 효율적으로 배치할 수 있죠. 동적 컬렉션이 필요가 없습니다. 그래서 "두세 개의 독립적인 비동기 호출을 병렬로"의 상황에선 async let 이 오버헤드가 가장 낮습니다.
반대로 TaskGroup은 런타임에 자식 수가 결정되므로 그룹이 자식 작업들과 그 결과를 관리할 동적 저장소(힙 할당)을 필요로 합니다. 이 유연성에는 비용이 따르죠.
여기서 함정이 있습니다. TaskGroup에서 addTask로 작업을 추가하면 즉시 Task가 실행되며 결과는 소비될 때까지 그룹 내부에 버퍼링됩니다.
// 위험한 패턴: 1만 개를 한꺼번에 띄움
try await withThrowingTaskGroup(of: Data.self) { group in
for url in tenThousandURLs {
group.addTask { try await download(url) } // 전부 동시 시작
}
// 결과를 소비하기 전까지 1만 개 작업 + 그 결과가 메모리에 쌓임
for try await data in group { save(data) }
}
이건 메모리 폭발과 리소스 고갈(소켓, 파일 핸들)을 일으킵니다.
func downloadAll(_ urls: [URL], maxConcurrent: Int) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
var results: [Data] = []
var iterator = urls.makeIterator()
// 먼저 maxConcurrent개만 띄운다
for _ in 0..<maxConcurrent {
if let url = iterator.next() {
group.addTask { try await download(url) }
}
}
// 하나 끝날 때마다 하나 더 추가 — 동시 실행 수를 일정하게 유지
while let data = try await group.next() {
results.append(data)
if let url = iterator.next() {
group.addTask { try await download(url) }
}
}
return results
}
}
즉 두개의 선택 기준은 "자식 수를 컴파일 타임에 알 수 있는가" 입니다. 안다면 가볍고 빠른 async let, 모르면 유연하지만 동시성 제어를 직접 설계해야하는 TaskGroup을 선택하면 됩니다. 물론 TaskGroup을 사용할때는 백프레셔(Backpressure)을 항상 의식해야 합니다.
또 하나는 결과 순서입니다.
async let은 바인딩 변수로 받으니 결과의 순서가 자연스럽게 보존됩니다. 하지만 TaskGroup의 for try await은 완료 순서라 순서가 깨집니다. 순서가 필요하면 인덱스를 함께 반환해 정렬하거나 튜플로 받아 재배치 해야 합니다.
Task-local 값과 트리 상속
@TaskLocal 은 작업 트리를 따라 암묵적으로 전파되는 값 입니다. 명시적으로 파라미터를 넘기지 않고도 "현재 실행 컨텍스트"에 데이터를 실어보내는 메커니즘 입니다. 로깅 컨텍스트, 요청 ID, 트레이싱, 테스트용 의존성 주입에 주로 사용됩니다.
규칙이 몇가지 있습니다. 반드시 static 이어야 하며 직접 대입할 수 없으며 오직 Scope 기반 withValue로만 바인딩 해야 합니다.
enum RequestContext {
@TaskLocal static var requestID: String?
@TaskLocal static var logger: Logger = .default
}
func handleRequest() async {
await RequestContext.$requestID.withValue("req-42") {
await processStep() // 이 스코프 안의 모든 호출이 "req-42"를 봄
}
// 스코프를 벗어나면 다시 nil
}
func processStep() async {
print(RequestContext.requestID ?? "none") // "req-42"
}
Task-local 값은 작업 생성 시점에 스냅샷으로 자식에게 전달됩니다.
내부적으로는 바인딩들이 연결리스트(Linked List) 형태로 쌓여있고 값을 읽을 땐 체인을 따라 올라가며 가장 가까운 바인딩을 찾고 자식 Task는 생성된 순간 부모의 바인딩 체인을 가리킵니다.
여기서 중요한 점은 자식이 생성된 이후에 부모가 새로운 withValue Scope에서 값을 바꿔도 이미 지나간 자식 Task는 그걸 인지하지 못합니다. 자식은 자신이 태어난 시점의 뷰를 가지며 값 자체를 복사하는게 아닌, 그 시점의 바인딩 위치를 참조합니다.
그리고 상속 여부가 작업 종류마다 다릅니다.
구조적 작업(async let, TaskGroup)과 비 구조적 Task{} 는 상속하고 Task.detached{}는 상속하지 않습니다.
await RequestContext.$requestID.withValue("req-42") {
async let a = childWork() // "req-42" 봄 (구조적)
Task { await childWork() } // "req-42" 봄 (비구조적이지만 상속)
Task.detached { await childWork() } // nil (상속 끊김)
_ = await a
}
우선순위와 우선순위 상승(Escalation)
TaskPriority는 high(=userInitiated), medium, low(=utility), background로 네 단계 입니다. 자식 Task는 기본적으로 부모의 우선 순위를 상속 받습니다.
여기서 흥미로운 부분은 우선순위 상승(Priority escalation) 입니다. 이건 전통적인 우선순위 역전 문제를 Swift 런타임이 자동으로 해소해주는 매커니즘 입니다.
예시를 보면 아래와 같습니다.
Task(priority: .high) { // 부모의 Priority는 high
print("부모 Task - \(Task.currentPriority)")
await Task(priority: .low) { // 자식의 Priority는 low
print("자식 Task - \(Task.currentPriority)")
}.value
}
// 결과 -> 부모, 자식 모두 high
우선순위가 높은 부모 Task에서 자식 Task를 기다린다(await)고 해봅시다.
만약 우선순위가 설정된대로 실행이 되면 부모 Task는 우선순위가 작은 자식 Task로 인해 발이 묶입니다. 여기서 우선순위가 높은 작업이 낮은 작업을 기다리며 느려지는 것이 바로 우선순위 역전이죠. 이따 Swift는 자식 Task의 우선순위를 부모 Task의 우선순위로 끌어올립니다. 부모 Task가 기다리고 있는 동안 만큼은 자식 Task도 high로 만들어 부모 Task가 빨리 풀려나도록 한 것이죠.
핵심 규칙은 우선순위는 올라갈 수만 있고 내려가지 않는다는 점 입니다. 한번 상승되면 작업이 끝날때까지 해당 우선순위가 유지됩니다. 그리고 이 상승은 트리를 따라 전파됩니다. 부모가 상승하면 그 부모를 기다리고 있는 작업들도 모두 함께 상승하는 것이죠.
이 상승 매커니즘은 우선순위 역전을 막기 위한 자동 보정이고 트리를 따라 전파하며 올라가기만 한다 라는게 핵심입니다.
출처
https://github.com/swiftlang/swift-evolution/blob/main/proposals/0304-structured-concurrency.md
https://github.com/swiftlang/swift-evolution/blob/main/proposals/0317-async-let.md
https://developer.apple.com/videos/play/wwdc2021/10254/
https://developer.apple.com/videos/play/wwdc2023/10170/
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
'Swift' 카테고리의 다른 글
| Swift - nonisolated 란 (0) | 2026.06.30 |
|---|---|
| Swift - 캐시 지역성(Cache Locality)이란 (0) | 2026.06.29 |
| Swift - Swift Concurrency (0) | 2026.06.23 |
| Swift - GCD vs Swift Concurrency (0) | 2026.06.08 |
| Swift - Class와 Struct, 성능 관점에서 다시 보기 (1) | 2026.04.24 |
- Total
- Today
- Yesterday
- 역전파
- SocialLogin
- 인공신경망
- Xcode
- swift
- ios
- ViewBuilder
- Tuist
- 동시성
- 알고리즘
- BoxedType
- AI
- webrtc
- opaquetype
- iphone
- 스위프트
- Algorithm
- coredata
- nonisolated
- CacheLocality
- SwiftUI
- kakaomapssdk
- CD/CI
- opaque
- Concurrency
- GradientDescent
- Actor
- 경사하강법
- 코어데이터
- tuist v4
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
