- 부모 스코프가 끝나면
async let자식 태스크는 어떻게 되나요? Task {}가 상속하는 것 3가지는?
답변 ① — 자식은 부모 스코프를 넘어 살아남지 못한다 🔥 핵심
async let으로 만든 자식은 부모 스코프에 수명이 묶입니다(구조적 동시성). 부모가 스코프를 벗어나기 전에 결과를 await로 거둬야 하고, await하지 않은 채 벗어나면 그 자식은 자동으로 취소되고 암시적으로 await됩니다. 즉 부모는 자식이 끝날 때까지 기다린 뒤 나갑니다.
결과적으로 자식이 부모보다 오래 살아남는 일이 없습니다. 이것이 구조적 동시성의 핵심 보장이고, 태스크 누수가 원천적으로 생기지 않는 이유입니다. 덤으로 구조적 태스크는 취소뿐 아니라 에러도 부모로 자동 전파됩니다.
func loadProfile() async throws -> Profile {
async let user = fetchUser() // 자식 태스크 생성(동시 진행)
async let posts = fetchPosts()
return Profile(user: try await user, posts: try await posts) // 여기서 수거
}말로만 하면 와닿지 않으니, await 하지 않고 스코프를 벗어나면 실제로 무슨 일이 벌어지는지 직접 돌려 봅시다. 자식은 취소되면 그 사실을 출력하도록 만들었습니다.
func child(_ name: String, seconds: Double) async -> String {
print(" [\(name)] 시작")
do {
try await Task.sleep(for: .seconds(seconds))
print(" [\(name)] 정상 완료")
return name
} catch {
print(" [\(name)] 취소 감지 → 정리하고 종료") // 취소되면 sleep이 throw
return "\(name)(취소)"
}
}
func earlyReturn() async -> String {
async let a = child("A", seconds: 2)
async let b = child("B", seconds: 3)
print("부모: a·b를 await 하지 않고 조기 return")
return "조기종료" // ← 여기서 스코프를 벗어난다
}
let r = await earlyReturn()
print("=== 부모 반환: \(r) ===")부모: a·b를 await 하지 않고 조기 return
[A] 시작
[B] 시작
[B] 취소 감지 → 정리하고 종료 ← ① 자동으로 취소됐다
[A] 취소 감지 → 정리하고 종료
=== 부모 반환: 조기종료 === ← ② 자식이 다 끝난 '뒤에' 부모가 반환됐다
두 가지가 동시에 증명됩니다. ① 아무도 cancel()을 부르지 않았는데 자식이 스스로 취소됐습니다. ② 더 중요한 건 출력 순서입니다. return은 이미 실행됐는데도 "부모 반환"이 자식들의 종료 메시지 뒤에 찍혔습니다 — 부모가 스코프를 벗어나며 자식을 암시적으로 await했다는 뜻입니다. 자식이 부모보다 오래 살아남을 수 없다는 말이 바로 이겁니다.
위 코드를 컴파일하면 a·b에 "never used" 경고가 뜹니다. async let을 만들고 await하지 않으면 컴파일러가 먼저 알려준다는 뜻이라, 실수로 자식을 버리는 코드는 대개 경고 단계에서 걸립니다.
에러도 같은 원리로 처리됩니다. 한 자식이 실패하면 에러는 부모로 전파되고, 남은 자식은 자동으로 정리됩니다.
struct Boom: Error {}
func failing() async throws -> String {
print(" [F] 시작")
try await Task.sleep(for: .seconds(1))
print(" [F] 에러 발생")
throw Boom()
}
func errorPropagation() async throws -> String {
async let f = failing() // 1초 뒤 실패
async let s = child("S", seconds: 5) // 5초 걸리는 작업
return try await f + (await s) // f가 throw → 스코프 탈출
}
do { _ = try await errorPropagation() }
catch { print("=== 부모가 에러를 받음: \(error) ===") } [F] 시작
[S] 시작
[F] 에러 발생
[S] 취소 감지 → 정리하고 종료 ← 5초를 다 기다리지 않고 정리됐다
=== 부모가 에러를 받음: Boom() ===
s는 5초짜리였지만 1초 만에 정리됐습니다. f가 던진 에러로 스코프를 벗어나는 순간 남은 자식이 취소된 것입니다. try/catch 외에 정리 코드를 한 줄도 쓰지 않았는데 누수도 없고 에러도 전달됐다는 점이 구조적 동시성의 핵심입니다.
답변 ② — 우선순위 · task-local · actor 격리 (취소는 아님)
Task {}(비구조적)가 생성 시점에 상속하는 것은 정확히 셋입니다.
- 우선순위(priority)
- task-local 값 — 생성 시점의 값을 복사
- actor 격리(실행 컨텍스트) — 단, 렉시컬하게(정적으로) 선언된 격리
여기서 취소는 상속하지 않습니다. Task {}는 구조적 트리의 자식이 아니라 독립적이라, 주변 task를 cancel()해도 안쪽 Task {}는 자동으로 취소되지 않습니다 — 직접 참조를 들고 .cancel() 해야 합니다. 이 문항에서 가장 자주 틀리는 지점입니다. 그리고 Task.detached는 위 3가지를 하나도 상속하지 않습니다.
| 상속 항목 | async let (구조적) | Task {} | Task.detached |
|---|---|---|---|
| 우선순위 | 상속 | 상속 | 안 함 |
| task-local 값 | 상속 | 상속(복사) | 안 함 |
| actor 격리 | 상속 | 상속(선언 기준) | 안 함 |
| 취소 전파 | 받음 | 안 받음 | 안 받음 |
| 부모 스코프에 수명 묶임 | 묶임 | 독립 | 독립 |
func demo() {
let parent = Task {
Task { // 비구조적: 부모 트리 '밖'에 있다
await longWork() // parent.cancel() 해도 이 Task는 자동 취소 X
}
}
parent.cancel() // 안쪽 Task는 계속 돎!
}
// detached: 우선순위·task-local·격리를 아무것도 상속하지 않음
Task.detached { await cleanup() }
답변 ①의 실험을 Task {}로 바꿔 보면 차이가 한눈에 드러납니다. 같은 상황, 한 줄만 다른 코드입니다.
func unstructured() async -> String {
Task { _ = await child("C", seconds: 2) } // async let 이 아니라 Task {}
print("부모(비구조적): 바로 return")
return "조기종료"
}
let r = await unstructured()
print("=== 부모 반환: \(r) ===")
try? await Task.sleep(for: .seconds(2.5)) // 부모가 끝난 뒤를 지켜본다부모(비구조적): 바로 return
[C] 시작
=== 부모 반환: 조기종료 === ← ① 자식을 기다리지 않고 먼저 반환됐다
[C] 정상 완료 ← ② 취소되지도 않고 끝까지 실행됐다
async let이었다면 "취소 감지"가 찍히고 부모가 나중에 반환됐을 자리인데, Task {}는 부모가 먼저 반환되고 자식은 취소도 안 된 채 끝까지 돌았습니다. 이것이 "취소를 상속하지 않는다"의 실제 모습이고, 화면을 닫았는데 네트워크 요청이 계속 도는 버그가 여기서 나옵니다.
심화: Swift 6.2의 NonisolatedNonsendingByDefault에서는 '선언된 격리'가 실제 실행 executor와 달라질 수 있습니다. nonisolated async 함수가 MainActor 위에서 실행 중이더라도, 그 안에서 만든 Task {}는 선언대로 nonisolated를 상속합니다.
쉽게 이해하기
(질문 ①) async let은 부모가 외출할 때 자식 손을 꼭 잡고 같이 나가는 방식이에요. 자식만 집에 두고 가는 법이 없습니다. 자식이 아직 안 끝났으면 문 앞에서 기다렸다가 같이 나가고, 급하면 "그만하고 와"라고 부른 다음(취소) 그래도 올 때까지는 기다립니다. 그래서 부모가 사라졌는데 자식만 떠도는 상황이 생기지 않아요.
(질문 ②) Task {}는 심부름 보낸 아이예요. 심부름 갈 때 용돈(우선순위), 주소록(task-local), 지금 있는 방(actor 격리) 이 세 가지는 부모한테서 물려받아요. 그런데 부모가 "이제 그만 와!"라고 외쳐도(취소) 그 소리는 자동으로 전달되지 않습니다. 부모가 따로 전화를 걸어야(.cancel()) 해요. detached는 아무것도 안 물려받고 완전히 혼자 나가는 아이입니다.
Task {}는 물건은 물려받고 신호는 못 받는다 — 우선순위·task-local·격리는 O, 취소는 X.