구조적 동시성은 부모-자식 작업 계층을 명시적으로 만드는 모델이다. 부모가 만든 자식들은 일반적으로 병렬로 실행되게 되고, 부모가 끝나기 전에 자식이 모두 끝나도록 보장된다. 이 덕분에 아래와 같은 두가지 특징이 생긴다!
- 작업 수명 (Lifecycle)이 스코프로 묶여 메모리/자원 누수 가능성이 줄고
- 취소 전파와 우선순위 상속 등 런타임이 제공하는 안전장치를 자연스럽게 활용할 수 있다.
여기서 Swift에서는 구조적 동시성을 위해 두가지를 제공한다!
- async let
- TaskGroup / withThrowingTaskGroup
핵심 개념에 대해서 특징 별로 한줄로 요약하면 다음과 같다.
- 병렬 실행 : 자식 작업은 기본적으로 병렬로 동작한다.
- 스코프 보장 : 부모 스코프가 끝나기 전에 자식이 반드시 완료된다.
- 취소 전파 : 부모가 취소되면 자식도 협력적으로 취소 된다.
- 메타데이터 상속 : 자식은 부모의 우선순위, 실행 중인 엑터, Task-Local 값을 상속 한다.
- 우선순위 승격 : 자식이 더 높은 우선순위면, 부모의 우선순위가 자동으로 끌어올려질 수 있다.
그렇다면 구조적 동시성의 두가지 제공되는 내용을 더 자세하게 알아보자
async let
동시에 처리 해야할 일이 딱 정해져 있을때와 호출 개수가 작고 고정되어있을때 주로 사용한다.
struct UserSummary: Sendable {
let profile: String
let timeline: [String]
let notifications: [String]
}
func fetchProfile() async throws -> String {
print("profile start")
try await Task.sleep(nanoseconds: 120_000_000)
print("profile done")
return "profile"
}
func fetchTimeline() async throws -> [String] {
print("timeline start")
try await Task.sleep(nanoseconds: 160_000_000)
print("timeline done")
return ["post1", "post2"]
}
func fetchNotifications() async throws -> [String] {
print("noti start")
try await Task.sleep(nanoseconds: 80_000_000)
print("noti done")
return ["mention", "like"]
}
@MainActor
func loadHome() async {
do {
// 1) 여기서 동시에 3개의 "자식 작업"이 시작됨 (구조적 동시성)
async let p = fetchProfile()
async let t = fetchTimeline()
async let n = fetchNotifications()
// 2) 아래 줄이 도달할 때까지 부모(Task)는 계속 진행 가능
// 다만, 값을 쓰려면 반드시 await가 필요
print("await tuple before")
// 3) 스코프를 벗어나기 전에는 반드시 'await'로 회수해야 함
let summary = try await UserSummary(profile: p, timeline: t, notifications: n)
print("await tuple after:", summary)
// 4) 여기까지 오면 3개의 자식 작업은 모두 완료된 상태
// (한 개라도 실패하면 catch로 점프)
} catch {
// 5) 자식 중 하나라도 throw → 나머지 자식은 자동 취소(협력적)
print("loadHome error:", error)
}
}
예제 코드를 보면 async let 3개가 바로 스케줄링되어 병렬로 진행된다 (완료 순서는 보장하지 않는 형태로)
스코프 규율로 인해 async let 값은 스코프 종료 전 반드시 await로 회수해야 해서, 작업 누락/유실을 방지한다.
에러 발생 시 하나의 throw로 에러를 전파하고 즉시 나머지 자식 작업들도 취소 되며 부모는 catch로 이동하게 된다.
부모의 우선 순위 및 현재 액터 그리고 Task-Local을 상속 받아처리 된다.
결론적으로 호출 개수가 고정이고 단순 병렬화가 목표일 때 가장 쉽고 빠르게 접근 및 적용이 가능한 점이 특징이다.
withThrowingTaskGroup
withThrowingTaskGroup은 해야할 일이 리스트 길이 만큼 가변이고, 한건이라도 오류가 발생했을때 나머지를 전부 실패로 올리고 싶은 상황에 사용된다. 예를 들어 이미지 다운로드, 여러 API 병렬 조회 등등
자식 작업들을 동적으로 많이 만들고 첫 실패 시 남은 작업을 자동으로 취소하여 완료되는 대로 결과를 스트리밍 수집하는 구조적 동시성 도구인 것
enum PhotoError: Error {
case network, decoding
}
struct Photo: Sendable {
let id: Int
}
func fetchPhoto(id: Int) async throws -> Photo {
try await Task.sleep(nanoseconds: UInt64(Int.random(in: 50...150)) * 1_000_000)
if id == 3 {
throw PhotoError.network
} // 데모용 실패
return Photo(id: id)
}
func loadAlbum(ids: [Int]) async throws -> [Photo] {
try await withThrowingTaskGroup(of: Photo.self) { group in
// 1) 자식 작업을 "동적으로" 추가
for id in ids {
group.addTask {
try await fetchPhoto(id: id)
}
}
// 2) 완료되는 순서대로 수집 (입력 순서 아님)
var photos: [Photo] = []
do {
for try await p in group {
photos.append(p) // 즉시 처리/반영 가능 (스트리밍)
}
return photos
} catch {
// 3) 자식 중 하나라도 throw → 남은 작업 자동 취소 + 여기로 전파
throw error
}
} // 4) 스코프 종료 시 미완료 자식은 정리됨
}
예제를 살펴보면 먼저 그룹을 만들고 addTask로 자식 작업들을 큐에 병렬 실행되도록 올린다. 그 다음 for try await로 완료되는 대로 하나씩 소비하고 첫 실패 시 즉시 작업을 취소하고 에러를 상위로 보내게 된다.
그 이후 스코프가 끝나면 그룹과 자식 작업은 안전하게 정리
자식 작업 안에서 같은 그룹에 addTask가 금지!!
왜냐하면 TaskGroup은 그룹을 만든 그 부모 작업 에서만 안전하게 변경할 수 있는 단일 소유 핸들이기 때문!!
자식 작업이 부모의 그룹에 다시 addTask를 호출하면 동시에 같은 내부 상태를 건드려 데이터 경합 및 불변식 깨짐이 발생하기 때문에 런타임에서 크래시가 나게 된다.
예시를 보면
func bad(ids: [Int]) async throws {
try await withThrowingTaskGroup(of: Int.self) { group in
group.addTask {
// ❌ 부모 group을 자식에서 다시 변경: 금지
group.addTask { 42 } // 런타임 실패 가능
return 1
}
for try await _ in group {}
}
}
자식에서 다시 addTask 진행해 런타임 실패가 일어나는 안좋은 예시이다
올바른 사용은 다음과 같다
func goodFlat(ids: [Int]) async throws -> [Int] {
try await withThrowingTaskGroup(of: Int.self) { group in
for id in ids {
group.addTask { id * 2 }
}
var out: [Int] = []
for try await v in group { out.append(v) }
return out
}
}
이렇게 구성하는 이유는 for문을 통해 동기적으로 작업을 등록(addTask)만 하고 등록된 각 작업은 곧바로 별도의 Task로 스케줄링 되어 병렬도 돌아간다.
왜 동기 for 문으로 병렬을 만드는지 짚고 넘어가면 group.addTask { } 는 자식 Task를 생성, 스케줄하고 즉시 반환한다. 부모는 for를 빠르게 돌면서 여러 자식들을 연달아 큐에 올리고 그 자식들은 동시에 실행되는 형태인 것
그 이후 for try await v in group 에서 결과를 비동기적으로 하나씩 수거한다. 즉 동기 루프는 = 작업을 제출하고, 병렬 실행은 = 제출된 자식 Task들이 동시로 달린다.
부모(Parent)
│ for id in ids {
│ addTask(id) ──▶ 자식(id1) 실행 시작
│ addTask(id) ──▶ 자식(id2) 실행 시작
│ addTask(id) ──▶ 자식(id3) 실행 시작
│ }
│
│ for try await v in group { ◀── 자식들 중 끝난 순서대로 결과 도착
│ use(v)
│ }
└─ 스코프 종료 시 미완료 자식 정리
여기서 생기는 궁금한건 그럼 for 안에 addTask로 말고 바로 await을 하면 안되나? 했는데 알아보니 이렇게하면 직렬의 형태로 되기 때문에 병렬이 필요하면 제출과 대기 분리가 핵심이라는 것.
- 병렬로 처리할때 addTask (제출) → 루프 끝 → for await (대기/수거)
- 직렬로 처리하면 루프 본문에서 await 호출
오늘 내용은 조금 어렵다.. 개념이 살짝 스읍 다시 복습하면서 개념을 익혀야할 것 같네
'◽️ Programming > iOS' 카테고리의 다른 글
Swift Concurrency의 Continuation과 Task.sleep, sleep의 차이! (1) | 2025.09.12 |
---|---|
Swift Concurrecy에서 비동기 개념의 확장? (0) | 2025.09.02 |
WebSocket 사용 이유?와 Swift 내 URLSessionWebSocketTask (3) | 2025.08.22 |
Swift Concurrency 시리즈 (1) Task (4) | 2025.08.07 |
Swinject이요? (1/2) (5) | 2025.07.18 |