Concurrency 연작을 마무리할 마지막 질문이 남았습니다. 지금까지 async 함수(1편), actor(2편), Sendable(3편)을 봤는데요.
이전 글 Swift 심화 #3에서 이어지는 내용입니다.
정작 이 비동기 세계로 들어가는 입구인 Task는 제대로 다루지 않았어요. 그리고 Task를 다루는 순간 이 연작에서 가장 실용적인 개념이 등장합니다.
구조적 동시성(structured concurrency)입니다.
이름이 거창하지만 질문은 소박합니다. 비동기 작업을 시작한 뒤, 그 작업의 끝은 누가 책임지는가.
구조적이라는 말의 뜻 — 작업에도 스코프가 있다
구조적 프로그래밍이라는 옛 용어에서 온 이름입니다. goto가 제어 흐름을 아무 데로나 날려 보내던 시절을 지나, if와 for라는 블록 구조가 “제어는 들어간 곳으로 돌아온다”를 보장하게 됐죠.
구조적 동시성은 같은 원칙을 비동기 작업에 적용합니다. 자식 작업은 부모의 스코프를 벗어날 수 없고, 부모는 모든 자식이 끝나야 끝난다.
1편에서 본 async let이 사실 이 원칙의 첫 사례였습니다.
func loadDashboard() async throws -> Dashboard {
async let profile = fetchProfile() // 자식 작업 1
async let feed = fetchFeed() // 자식 작업 2
return try await Dashboard(profile: profile, feed: feed)
} // 이 함수는 두 자식이 정리되기 전에 반환될 수 없다
async let으로 만든 자식은 함수가 반환되기 전에 반드시 완료되거나 취소됩니다. 예외 상황도 마찬가지예요.
feed가 에러를 던지면, 아직 달리고 있는 profile은 자동으로 취소 신호를 받습니다. 함수는 정리를 마친 뒤에 에러를 전파하고요.
작업이 스코프에 묶여 있으니 “시작해놓고 잊어버린 작업”이라는 것이 구조적으로 존재할 수 없습니다.
클로저 편에서 escaping은 “함수보다 오래 사는 클로저”의 경고 라벨이었죠. 구조적 동시성은 아예 “함수보다 오래 사는 작업”을 기본에서 제거한 겁니다.
자식 수가 동적으로 정해지면 TaskGroup을 씁니다. URL 목록을 병렬로 받는 전형적인 코드는 이렇습니다.
let images = try await withThrowingTaskGroup(of: (URL, Image).self) { group in
for url in urls {
group.addTask { (url, try await download(url)) }
}
var result: [URL: Image] = [:]
for try await (url, image) in group {
result[url] = image
}
return result
}
주목할 지점은 두 개입니다. 결과가 완료 순서로 도착한다는 것(순서 보장이 필요하면 위처럼 키와 함께 담습니다), 그리고 클로저가 끝날 때 그룹의 모든 자식이 정리된다는 것.
async let이 “자식 몇 개”의 문법이라면 TaskGroup은 “자식 n개”의 문법이고, 스코프 보장은 동일합니다.
취소 — 신호는 오지만 멈추는 건 네 일이다
구조적 동시성의 두 번째 축이 취소 전파입니다. 부모가 취소되면 모든 자손에게 취소 신호가 트리를 타고 내려갑니다.
화면을 떠나면 그 화면이 시작한 네트워크 요청들이 연쇄적으로 취소되는 게 이 구조 덕분이에요.
그런데 Swift의 취소는 협조적(cooperative)입니다. 취소 신호는 작업을 강제로 죽이지 않습니다.
isCancelled 플래그를 켜줄 뿐이고, 그 플래그를 확인하고 멈추는 건 작업 자신의 일이에요.
func processLargeFile() async throws {
for chunk in chunks {
try Task.checkCancellation() // 취소됐으면 CancellationError를 던짐
await process(chunk)
}
}
왜 강제 종료가 아닐까요. 작업이 파일을 반쯤 쓰다가, 락을 잡은 채로, 트랜잭션 중간에 즉사하면 시스템이 깨진 상태로 남기 때문입니다.
“멈추기 좋은 지점은 작업 자신만 안다”는 게 협조적 취소의 논리예요.
실무 함의는 분명합니다. 오래 도는 루프에는 checkCancellation을 심어야 해요.
URLSession 같은 시스템 API는 이미 내부에서 취소를 존중하니 그냥 믿으면 됩니다.
반대로 취소를 무시하는 긴 계산 코드는 취소해도 안 멈추는 작업이 됩니다. 취소가 안 되는 게 아니라 확인을 안 한 거죠.
Task와 Task.detached — 비구조의 세계와 그 대가
여기까지가 구조의 세계라면, Task { }는 그 바깥입니다. Task로 만든 작업은 부모-자식 트리에 들어가지 않는 비구조적(unstructured) 작업이에요.
스코프에 묶이지 않고, 에러도 취소도 자동 전파되지 않습니다.
그럼 왜 존재할까요. 동기 세계에서 비동기 세계로 넘어가는 다리가 필요하기 때문입니다.
버튼 탭 핸들러는 동기 함수라 await를 못 쓰니, Task { await viewModel.refresh() }로 비동기 맥락을 새로 엽니다. 이게 Task의 정당한 자리예요.
SwiftUI의 .task 수식어는 이 다리에 수명 관리까지 얹은 것(뷰가 사라지면 자동 취소)이라, UI에서는 그쪽이 우선입니다.
문제는 Task가 습관이 될 때입니다. async 함수 안에서 또 Task를 여는 코드, fire-and-forget으로 뿌려놓은 Task들이 그렇습니다. 구조가 제공하던 보장(완료 대기, 에러 전파, 취소 연쇄)을 전부 수동으로 되사야 해요.
참조를 저장해서 직접 cancel하고 에러를 직접 로깅하고요. 그 관리 코드가 없다면 조용히 사라지는 에러와 좀비 작업이 생깁니다.
규칙으로 정리하면, async 맥락 안에서는 async let·TaskGroup이 기본이고 Task는 동기→비동기 경계에서만.
Task.detached는 한 단계 더 바깥입니다. 우선순위도, actor 격리도, task-local 값도 물려받지 않는 완전 고아 작업이에요(SE-0304).
“@MainActor 문맥에서 벗어나 무거운 일을 하고 싶다” 같은 이유로 쓰이곤 하는데, 대부분은 잘못된 처방입니다. 그 일은 nonisolated async 함수로 선언하면 협력 풀에서 알아서 돌거든요(1편).
detached가 진짜 필요한 경우는 현재 문맥과 의도적으로 무관해야 하는 백그라운드 작업 정도로 드뭅니다. 공식 문서 표현을 빌리면 최후의 수단(last resort)입니다.
연작 종합 — 네 개념이 이루는 하나의 그림
Concurrency 연작을 닫으며 전체 그림을 한 번에 정리하겠습니다.
async/await는 비동기 흐름을 컴파일러의 시야로 되돌렸습니다(1편). actor는 공유 가변 상태에 직렬 보호를 내장했고(2편), Sendable은 격리 경계를 넘는 값의 안전을 검사합니다(3편).
그리고 구조적 동시성은 작업의 수명을 스코프에 묶었습니다. 시작한 것은 반드시 끝나고, 취소는 트리를 타고 흐르게 만들었어요(이번 편).
넷을 관통하는 문장은 하나입니다. 동시성의 암묵적 규율을 언어의 명시적 구조로.
스레드 관리 규율은 협력 풀과 suspension으로, 락 규율은 actor로 바뀌었습니다. “이 객체 스레드 넘겨도 되나”라는 구전 지식은 Sendable로, “요청 정리 잊지 마”라는 리뷰 코멘트는 작업 트리로 바뀌었고요.
Swift 철학 시리즈 1편에서 본 안전 우선 원칙이 동시성이라는 가장 어려운 영역까지 확장 완료된 것. 그게 Swift Concurrency의 전체 서사입니다.
정리
- 구조적 동시성은 “자식 작업은 부모 스코프를 못 벗어난다”는 원칙입니다. async let(고정 개수)과 TaskGroup(동적 개수)이 그 문법이고, 에러 시 형제 취소와 정리가 자동입니다.
- 취소는 협조적입니다. 신호는 트리를 타고 전파되지만, 멈추는 건 checkCancellation을 심은 작업 자신입니다.
- Task { }는 동기→비동기 다리로만 씁니다. async 맥락 안의 습관적 Task는 구조의 보장을 수동 관리 부채로 바꿉니다. Task.detached는 최후의 수단입니다.
- 연작 전체의 요지: 스레드·락·수명 관리라는 암묵적 규율이 suspension·actor·Sendable·작업 트리라는 언어 구조로 이관됐습니다.
다음 편부터는 성능의 심층부로 갑니다. final이 성능 키워드인 이유, 프로토콜 호출이 늦어지는 지점, 정적 디스패치와 동적 디스패치의 실체를 다룹니다.
API와 버전 근거는 SE-0304: Structured Concurrency에서 2026-08-17에 확인했습니다.
출처 및 확인 기준
- SE-0304: Structured Concurrency — Swift Evolution · 표준·명세 원문 · 확인 2026-08-17 · 근거: Task·TaskGroup의 부모-자식 구조, 취소·우선순위·task-local·actor 문맥 상속
