← Study Hub

03 · 구조적 vs 비구조적 동시성

async let의 수명, Task {}가 상속하는 것과 상속하지 않는 것
진행률
0 / 0 완료

← 문항 인덱스

면접 질문
  1. 부모 스코프가 끝나면 async let 자식 태스크는 어떻게 되나요?
  2. Task {}가 상속하는 것 3가지는?

답변 ① — 자식은 부모 스코프를 넘어 살아남지 못한다 🔥 핵심

async let으로 만든 자식은 부모 스코프에 수명이 묶입니다(구조적 동시성). 부모가 스코프를 벗어나기 전에 결과를 await로 거둬야 하고, await하지 않은 채 벗어나면 그 자식은 자동으로 취소되고 암시적으로 await됩니다. 즉 부모는 자식이 끝날 때까지 기다린 뒤 나갑니다.

결과적으로 자식이 부모보다 오래 살아남는 일이 없습니다. 이것이 구조적 동시성의 핵심 보장이고, 태스크 누수가 원천적으로 생기지 않는 이유입니다. 덤으로 구조적 태스크는 취소뿐 아니라 에러도 부모로 자동 전파됩니다.

기본 형태 — 스코프 안에서 수거한다 (Swift)
func loadProfile() async throws -> Profile {
    async let user  = fetchUser()     // 자식 태스크 생성(동시 진행)
    async let posts = fetchPosts()

    return Profile(user: try await user, posts: try await posts)   // 여기서 수거
}

말로만 하면 와닿지 않으니, await 하지 않고 스코프를 벗어나면 실제로 무슨 일이 벌어지는지 직접 돌려 봅시다. 자식은 취소되면 그 사실을 출력하도록 만들었습니다.

실행 예제 ① — await 없이 조기 return 하면? (Swift 6.2)
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하지 않으면 컴파일러가 먼저 알려준다는 뜻이라, 실수로 자식을 버리는 코드는 대개 경고 단계에서 걸립니다.

에러도 같은 원리로 처리됩니다. 한 자식이 실패하면 에러는 부모로 전파되고, 남은 자식은 자동으로 정리됩니다.

실행 예제 ② — 에러 전파 + 남은 자식 자동 정리 (Swift 6.2)
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 {}(비구조적)가 생성 시점에 상속하는 것은 정확히 셋입니다.

⚠️ 주의

여기서 취소는 상속하지 않습니다. Task {}는 구조적 트리의 자식이 아니라 독립적이라, 주변 task를 cancel()해도 안쪽 Task {}는 자동으로 취소되지 않습니다 — 직접 참조를 들고 .cancel() 해야 합니다. 이 문항에서 가장 자주 틀리는 지점입니다. 그리고 Task.detached는 위 3가지를 하나도 상속하지 않습니다.

상속 항목 async let (구조적) Task {} Task.detached
우선순위상속상속안 함
task-local 값상속상속(복사)안 함
actor 격리상속상속(선언 기준)안 함
취소 전파받음안 받음안 받음
부모 스코프에 수명 묶임묶임독립독립
Task {} — 취소는 상속되지 않는다 (Swift)
func demo() {
    let parent = Task {
        Task {                   // 비구조적: 부모 트리 '밖'에 있다
            await longWork()     // parent.cancel() 해도 이 Task는 자동 취소 X
        }
    }
    parent.cancel()              // 안쪽 Task는 계속 돎!
}

// detached: 우선순위·task-local·격리를 아무것도 상속하지 않음
Task.detached { await cleanup() }

답변 ①의 실험을 Task {}로 바꿔 보면 차이가 한눈에 드러납니다. 같은 상황, 한 줄만 다른 코드입니다.

대조 실험 — async let 자리에 Task {} 를 놓으면 (Swift 6.2)
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.