새벽의 기록

[iOS] Swift Concurrency 박살내기 #2 - 구조화된 동시성 (Structured Concurrency) vs 구조화되지 않은 동시성 (Unstructured Concurrency) (Task, TaskGroup, async let) 본문

[iOS]

[iOS] Swift Concurrency 박살내기 #2 - 구조화된 동시성 (Structured Concurrency) vs 구조화되지 않은 동시성 (Unstructured Concurrency) (Task, TaskGroup, async let)

OneTen 2025. 10. 20. 16:59

Task

https://developer.apple.com/documentation/swift/task

 

Task | Apple Developer Documentation

A unit of asynchronous work.

developer.apple.com

 

 

”A unit of asynchronous work.”

비동기적으로 실행되는 작업의 단위

 

Task는 Swift Concurrency에서 비동기 작업을 실행하는 가장 기본적인 단위이다.

async 함수가 아닌 곳(예: 버튼 클릭 이벤트)에서 async 세계로 진입하는 '다리' 역할을 한다.

구조화되지 않은 동시성 (Unstructured Concurrency)

  • Task { ... } 이니셜라이저로 생성된 작업은 구조화되지 않았다. 즉, Unstructured Concurrency 인데, 이것에 관한 설명은 뒤에서 하겠다.
  • 이는 Task를 생성한 부모 스코프(예: 함수)가 종료되더라도, Task는 자신의 작업이 완료되거나 취소될 때까지 독립적으로 계속 실행됨을 의미한다.
  • 이러한 특성 때문에 "Fire-and-forget (실행하고 잊어버리기)" 작업에 자주 사용된다.

컨텍스트 상속 (Context Inheritance)

  • Task는 생성되는 시점의 컨텍스트(Context)를 상속받는다.
  • 만약 @MainActor 함수 내에서 Task { ... }를 생성하면, 그 Task의 코드도 기본적으로 @MainActor에서 실행된다.
  • 또한, 부모 작업의 우선순위(QoS)를 상속받는다.

하지만 Task.detached { ... }는 부모로부터 어떤 컨텍스트도 상속받지 않는 완전히 독립된 작업을 생성한다.

Task는 async 함수가 아닌 곳에서 비동기 작업을 시작할 때 사용한다.

// SwiftUI 버튼
Button("Upload Data") {
    
    // Task { ... }로 비동기 진입
    Task {
        do {
            // 이 Task는 버튼 핸들러가 리턴된 후에도
            // 백그라운드에서 계속 실행됨 (Unstructured)
            let result = try await networkService.uploadData(mydata)
            
            // UI 업데이트는 MainActor에서 수행
            await MainActor.run {
                self.statusText = "\\(result.id) 업로드 성공!"
            }
        } catch {
            // 에러 처리
            await MainActor.run {
                self.statusText = "업로드 실패: \\(error.localizedDescription)"
            }
        }
    }
}

 

TaskGroup

https://developer.apple.com/documentation/swift/taskgroup

 

TaskGroup | Apple Developer Documentation

A group that contains dynamically created child tasks.

developer.apple.com

 

 

“A group of tasks that are created dynamically”

동적으로 생성되는 작업들의 그룹.

 

TaskGroup은 구조화된 동시성(Structured Concurrency)을 구현하는 핵심 도구로, 여러 개의 자식 작업(child task)을 병렬로 실행하고, 그 작업들이 모두 완료될 때까지 대기하며, 결과를 수집하는 데 사용된다.

구조화된 동시성 (Structured Concurrency)

  • TaskGroup은 withTaskGroup (또는 withThrowingTaskGroup)이라는 스코프(Scope) 안에서 생성된다.
  •  스코프는 그룹에 추가된 모든 자식 작업이 완료되기 전까지 절대 리턴하지 않는다.
  • 이는 작업 누수(Task Leak)를 원천적으로 방지한다.

구조화된 동시성과 구조화 되지 않은 동시성이 대체 뭐가 다른데??

이건 조금 뒤에서 다루겠다.

동적 작업 추가 (Dynamic Creation)

  • async let은 컴파일 시점에 몇 개의 작업을 실행할지 정해져 있지만(정적), TaskGroup은 런타임에 동적으로(예: for-in 루프) 작업을 추가할 수 있다.
    → 즉, 이미 어떤 작업을 할 것인지 혹은 개수가 정해져 있는 상황에서는 async let을 써도 무방하다. async let 도 역시 뒤에서 다룰 예정
  • group.addTask { ... }를 호출하면 자식 작업이 즉시 병렬로 실행된다.

결과 수집 (Result Collection)

  • 그룹의 모든 자식 작업은 withTaskGroup(of: ...)에 명시된 동일한 타입의 결과를 반환해야 한다.
  • for await result in group { ... } 구문을 사용하여 결과를 수집한다.
  • 결과는 작업이 추가된 순서가 아니라, 완료되는 순서대로 도착한다.

자동 취소 전파 (Automatic Cancellation)

  • 자식 작업 중 하나라도 에러를 throw하면, 그 즉시 그룹 내의 다른 모든 실행 중인 자식 작업들이 자동으로 취소된다.
  • 혹은 withTaskGroup 스코프가 (예: return이나 throw로) 조기에 종료되면, 모든 자식 작업이 자동으로 취소된다.
  • group.cancelAll()을 통해 수동으로 그룹 전체를 취소할 수도 있다.

이는 taskgroup의 특징이라기 보다는 구조화된 동시성의 특징인데, 이것 역시 뒤에서…

func downloadAllImages(urls: [URL]) async throws -> [UIImage] {
    var images: [UIImage] = []

    // 1. TaskGroup 생성
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        
        // 2. URL 개수만큼 동적으로 작업 추가
        for url in urls {
            group.addTask {
                // 이 작업들은 병렬로 실행됨
                // 즉, 각각 스레드를 사용함
                return try await downloadImage(from: url) 
            }
        }
        
        // 3. 작업이 "끝나는 순서대로" 결과를 받아서 처리
        // (순서 보장 안 됨)
        for try await image in group {
            images.append(image)
        }
        
        // 4. 이 블록이 끝나는 시점엔 모든 group 작업이 완료됨을 보장
    }

    print("모든 이미지 다운로드 완료!")
    return images
}

taskgroup은 Structured Concurrency이기에, 만약 downloadImage 하나라도 throw를 하면, 그룹 내 다른 모든 작업이 즉시 자동으로 취소된다.

async let

서로 다른 타입의, 미리 정해진 개수의 비동기 작업을 동시에 실행할 때 사용한다.

  • async let으로 작업을 선언하는 순간, 즉시 병렬로 실행이 시작된다.
  • 코드는 await를 만날 때까지 멈추지 않고 아래로 계속 진행한다.
  • 나중에 await 키워드로 해당 작업의 결과를 요청할 때, 작업이 끝나지 않았으면 그제서야 **일시 중단(suspend)**된다. (Block 아님!!)

async let으로 수정한 예시

기존에는 이렇게 쓰고 있었어서 하나의 호출이 완료되면 다음 호출이 시작되고 있었다. 그래서 View에서는 순서대로 로딩이 되는 것처럼 보이고 있었고, 이는 인터넷 속도가 느린 것처럼 보이게 만들어 UX적인 부분에서 부정적인 영향을 주고 있다고 느껴졌다.

        .onAppear {
            Task {
                await viewModel.getUserInfo()
                await viewModel.getRecommendCertificationList()
                await viewModel.fetchPreCertification()
                await viewModel.getFavoriteCertificationList()
            }
        }

await으로 호출해서 순서가 보장됐던 기존의 방식에서 async let으로 비동기 함수들을 병렬 처리 하고 마지막에 await을 통해 한 번에 결과를 불러오는 방식으로 바꿨다. 이렇게 처리하니 순서대로 로딩이 되는 것처럼 보이던 뷰가 한 번에 모두 패치되는 것처럼 보여 UX적인 측면에서 긍정적인 영향을 줄 수 있었다.

        .onAppear {
            Task {
                async let userInfo: () = viewModel.getUserInfo()
                async let recommendList: () = viewModel.getRecommendCertificationList()
                async let preCertifications: () = viewModel.fetchPreCertification()
                async let favoriteList: () = viewModel.getFavoriteCertificationList()

                _ = await (userInfo, recommendList, preCertifications, favoriteList)
            }
        }

구조화된 동시성 vs 구조화되지 않은 동시성

정말 간단하게 말하면 부모 작업이 어떤 이유로 인해 취소되거나 에러가 발생하면 자식 작업들도 전부 취소되는 게 구조화된 동시성이고 부모 작업과 별개로 작동하기에 부모 작업이 취소되든 에러가 발생하든 상관없이 실행되는 게 구조화 되지 않은 동시성이다.

구조화된 동시성 (Structured Concurrency):

  • 정식 프로젝트 팀과 같다.
  • 특징: 매니저(부모 작업)가 팀원(자식 작업)들을 관리한다.
  • 생명주기: 팀원은 매니저보다 오래 일할 수 없다. (부모 작업이 끝나면 자식 작업도 끝나야 함)
  • 취소: 매니저가 프로젝트를 취소하면, 모든 팀원에게 자동으로 취소 신호가 간다.
  • 에러: 팀원 한 명이 **실패(Error)**하면, 즉시 매니저에게 보고되고 프로젝트 전체가 중단될 수 있다.
  • 도구: async let, TaskGroup

구조화되지 않은 동시성 (Unstructured Concurrency):

  • 외주 프리랜서와 같다.
  • 특징: 회사(현재 스코프)가 프리랜서(새 Task)에게 일을 맡깁니다.
  • 생명주기: 프리랜서는 회사의 프로젝트가 끝나도 자기 일을 계속할 수 있다. (현재 스코프를 벗어나서도 실행됨)
  • 취소: 프로젝트가 취소되어도 프리랜서는 모른다. 즉, 수동으로 따로 연락해서 취소해야 한다.
  • 에러: 프리랜서가 실패해도 회사는 모른다. 수동으로 결과물을 확인해야 알 수 있다.
  • 도구: Task { ... }, Task.detached { ... }

이 세 경우의 사용 예시와 그 결과를 자세히 확인해보고 싶다면 아래 블로그를 추천한다.

https://joho.tistory.com/61

 

async let vs TaskGroup vs 연속 await

Swift Concurrency는 async/await 문법을 통해 비동기 작업을 직관적으로 표현할 수 있게 해준다.하지만 비동기 작업을 병렬로 실행하는 방법은 여러 가지가 있고,이를 잘못 사용하면 성능 이점을 살리

joho.tistory.com

 

 

 

 


참고

https://joho.tistory.com/34 
https://joho.tistory.com/61 
https://jimmy-ios.tistory.com/47 
https://yudonlee.tistory.com/43

 

 

Comments