Swift의 구조적 동시성(Structured Concurrency)

Ios_Roy·2025년 9월 8일

WWDC

목록 보기
2/13
post-thumbnail

Swift의 구조적 동시성(Structured Concurrency)

들어가며

Swift 5.5에서 도입된 구조적 동시성(Structured Concurrency)은 비동기 프로그래밍의 패러다임을 근본적으로 바꾸었습니다. 기존의 completion handler 기반 코드가 가진 복잡성과 오류 가능성을 해결하고, 더 직관적이고 안전한 동시 실행 코드를 작성할 수 있게 해줍니다.

구조적 동시성의 핵심 아이디어는 구조적 프로그래밍(Structured Programming)에서 나왔습니다. 구조적 프로그래밍이 제어 흐름을 예측 가능하고 이해하기 쉽게 만든 것처럼, 구조적 동시성은 비동기 코드의 생명주기와 오류 처리를 체계적으로 관리할 수 있게 합니다.

기존 방식의 문제점과 해결책

기존 Completion Handler 방식의 한계

기존의 completion handler 기반 코드는 여러 가지 문제점을 가지고 있었습니다:

// 기존 방식: 복잡하고 오류가 발생하기 쉬운 코드
func fetchThumbnails(
    for ids: [String],
    completion handler: @escaping ([String: UIImage]?, Error?) -> Void
) {
    guard let id = ids.first else { return handler([:], nil) }
    let request = thumbnailURLRequest(for: id)
    let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in
        // 중첩된 콜백 지옥...
        guard let response = response, let data = data else {
            return handler(nil, error)
        }
        // 재귀 호출로 인한 복잡성
        fetchThumbnails(for: Array(ids.dropFirst())) { thumbnails, error in
            // ...
        }
    }
    dataTask.resume()
}

이 코드의 문제점들:

  • 콜백 지옥(Callback Hell): 중첩된 completion handler로 인한 복잡한 구조
  • 오류 처리의 어려움: 구조적 오류 처리 불가능
  • 반복문 사용 불가: 순차 처리를 위해 재귀 함수 필요
  • 메모리 관리 복잡성: 강한 참조 순환 위험
  • 취소 처리의 어려움: 작업 취소 로직 구현이 복잡

Async/Await를 사용한 개선된 코드

구조적 동시성을 적용하면 위의 문제들이 해결됩니다:

// 개선된 방식: 직관적이고 안전한 코드
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        let request = thumbnailURLRequest(for: id)
        let (data, response) = try await URLSession.shared.data(for: request)
        try validateResponse(response)
        guard let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: thumbSize) else {
            throw ThumbnailFailedError()
        }
        thumbnails[id] = image
    }
    return thumbnails
}

개선된 점들:

  • 선형적 코드 흐름: 위에서 아래로 읽기 쉬운 코드
  • 구조적 오류 처리: try-catch 블록 사용 가능
  • 자연스러운 반복문: for 루프로 순차 처리
  • 명확한 반환 타입: 함수의 결과가 명확히 정의됨
  • 컴파일 타임 검증: 오류 처리 누락을 컴파일러가 검사

Task의 종류와 활용법

Swift의 구조적 동시성은 다양한 종류의 Task를 제공하여 각기 다른 상황에 최적화된 해결책을 제공합니다.

1. Async-Let: 고정된 수의 동시 작업

async-let은 컴파일 타임에 동시 작업의 수가 정해져 있을 때 사용하는 가장 간단한 형태의 구조적 동시성입니다.

func fetchOneThumbnail(withID id: String) async throws -> UIImage {
    let imageReq = imageRequest(for: id), metadataReq = metadataRequest(for: id)
    
    // 두 개의 네트워크 요청을 동시에 시작
    async let (data, _) = URLSession.shared.data(for: imageReq)
    async let (metadata, _) = URLSession.shared.data(for: metadataReq)
    
    // 두 결과를 모두 기다림
    guard let size = parseSize(from: try await metadata),
          let image = try await UIImage(data: data)?.byPreparingThumbnail(ofSize: size) else {
        throw ThumbnailFailedError()
    }
    return image
}

async-let의 특징:

  • 변수 바인딩과 유사한 문법: 기존 let 바인딩 앞에 async만 추가
  • 자동 생명주기 관리: 스코프를 벗어나면 자동으로 취소 및 대기
  • 타입 안전성: 일반적인 변수와 동일한 타입 시스템
  • 오류 전파: 하위 작업의 오류가 자동으로 전파
  • 취소 전파: 상위 작업이 취소되면 하위 작업도 자동 취소

async-let의 동작 원리를 자세히 살펴보면, Swift는 새로운 자식 Task를 생성하고 부모 Task는 즉시 다음 코드로 진행합니다. 실제 값이 필요한 시점에 await를 통해 자식 Task의 완료를 기다리게 됩니다.

2. Task Group: 동적인 수의 동시 작업

Task Group은 런타임에 동시 작업의 수가 결정될 때 사용합니다. 대량의 데이터를 병렬로 처리할 때 매우 유용합니다.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        for id in ids {
            group.async {
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        
        // 완료 순서대로 결과 처리
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }
    }
    return thumbnails
}

Task Group의 핵심 개념:

  • 동적 동시성: 배열 크기에 따라 동시 작업 수 결정
  • 스코프 바운드: group 블록을 벗어나면 모든 자식 작업 완료 대기
  • 결과 수집: for-await-in으로 완료 순서대로 결과 처리
  • 데이터 레이스 방지: @Sendable 클로저로 안전성 보장
  • 자동 취소: 오류 발생 시 모든 자식 작업 자동 취소

중요한 것은 각 자식 Task가 값을 반환해야 한다는 점입니다. 공유 변수를 직접 수정하면 데이터 레이스가 발생할 수 있으므로, Swift 컴파일러가 이를 방지합니다.

취소(Cancellation) 메커니즘

구조적 동시성의 가장 중요한 특징 중 하나는 협조적 취소(Cooperative Cancellation) 시스템입니다.

자동 취소 전파

Task 트리에서 부모 Task가 취소되거나 오류로 인해 종료되면, 모든 자식 Task들이 자동으로 취소 마크됩니다:

func fetchOneThumbnail(withID id: String) async throws -> UIImage {
    async let (data, _) = URLSession.shared.data(for: imageReq)
    async let (metadata, _) = URLSession.shared.data(for: metadataReq)
    
    // metadata에서 오류 발생 시, data Task도 자동 취소됨
    guard let size = parseSize(from: try await metadata) else {
        throw ThumbnailFailedError() // 이 시점에서 data Task 자동 취소
    }
    
    return try await UIImage(data: data)!.byPreparingThumbnail(ofSize: size)
}

명시적 취소 확인

장시간 실행되는 작업에서는 명시적으로 취소 상태를 확인해야 합니다:

// 방법 1: 예외를 통한 취소 확인
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        try Task.checkCancellation() // 취소되었으면 예외 발생
        thumbnails[id] = try await fetchOneThumbnail(withID: id)
    }
    return thumbnails
}

// 방법 2: 불린 값을 통한 취소 확인
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        if Task.isCancelled {
            break // 부분 결과 반환
        }
        thumbnails[id] = try await fetchOneThumbnail(withID: id)
    }
    return thumbnails
}

취소 처리 시 주의사항:

  • 부분 결과 처리: 취소 시 부분 결과를 반환할지 결정해야 함
  • 리소스 정리: 취소 시 적절한 정리 작업 수행
  • API 설계: 취소 가능성을 API 문서에 명시

비구조적 Task (Unstructured Tasks)

모든 상황이 구조적 동시성에 적합한 것은 아닙니다. UI 이벤트 핸들러나 델리게이트 메서드처럼 비동기 컨텍스트가 아닌 곳에서 Task를 시작해야 할 때가 있습니다.

기본 비구조적 Task

@MainActor
class MyDelegate: UICollectionViewDelegate {
    func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        let ids = getThumbnailIDs(for: item)
        
        // 비동기 메서드에서 Task 생성
        Task {
            let thumbnails = await fetchThumbnails(for: ids)
            display(thumbnails, in: cell)
        }
    }
}

비구조적 Task의 특징:

  • Actor 상속: 생성된 Task는 원본 Actor를 상속 (여기서는 MainActor)
  • 우선순위 상속: 원본 Task의 우선순위를 상속
  • 수동 관리: 생명주기를 직접 관리해야 함
  • 스코프 독립: 생성된 스코프에 바인드되지 않음

Task 취소 관리

UI 요소가 사라질 때 실행 중인 Task를 적절히 취소하는 것이 중요합니다:

@MainActor
class MyDelegate: UICollectionViewDelegate {
    var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
    
    func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        let ids = getThumbnailIDs(for: item)
        
        thumbnailTasks[item] = Task {
            defer { thumbnailTasks[item] = nil } // 완료 시 정리
            let thumbnails = await fetchThumbnails(for: ids)
            display(thumbnails, in: cell)
        }
    }
    
    func collectionView(_ view: UICollectionView, didEndDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        thumbnailTasks[item]?.cancel() // 명시적 취소
    }
}

메모리 누수 방지를 위한 패턴:

  • Task 핸들 저장: 나중에 취소할 수 있도록 Task 저장
  • defer를 통한 정리: Task 완료 시 자동으로 딕셔너리에서 제거
  • 명시적 취소: UI 요소가 사라질 때 적절히 취소

분리된 Task (Detached Tasks)

때로는 현재 컨텍스트와 완전히 독립적인 Task가 필요할 때가 있습니다. 예를 들어, 백그라운드에서 캐시 작업을 수행하거나 로깅을 할 때입니다.

기본 분리 Task

// 썸네일을 백그라운드에서 캐시하는 예제
thumbnailTasks[item] = Task {
    defer { thumbnailTasks[item] = nil }
    let thumbnails = await fetchThumbnails(for: ids)
    
    // 메인 UI와 독립적인 백그라운드 작업
    Task.detached(priority: .background) {
        writeToLocalCache(thumbnails)
    }
    
    display(thumbnails, in: cell)
}

분리 Task 내에서 구조적 동시성 활용

분리된 Task 내에서도 구조적 동시성을 활용할 수 있습니다:

Task.detached(priority: .background) {
    await withTaskGroup(of: Void.self) { group in
        group.async { writeToLocalCache(thumbnails) }
        group.async { logThumbnailStats(thumbnails) }
        group.async { updateAnalytics(thumbnails) }
        // 필요한 만큼 백그라운드 작업 추가
    }
}

분리된 Task의 장점:

  • 완전한 독립성: 원본 컨텍스트와 완전히 분리
  • 우선순위 제어: 백그라운드, 유저 초기화 등 우선순위 설정 가능
  • 취소 전파 제어: 원본이 취소되어도 계속 실행 가능
  • 조합 가능성: 내부에서 다른 형태의 구조적 동시성 사용 가능

Task 트리와 생명주기 관리

구조적 동시성의 핵심은 Task 트리 구조입니다. 이 트리는 단순한 구현 디테일이 아니라 취소, 우선순위, Task-local 변수 등의 특성을 결정하는 중요한 구조입니다.

Task 트리 규칙

  1. 부모-자식 관계: async-let이나 Task Group으로 생성된 Task는 자식이 됨
  2. 완료 대기: 부모 Task는 모든 자식 Task가 완료될 때까지 기다림
  3. 자동 취소: 비정상 종료 시 미완료 자식 Task들이 자동 취소됨
  4. 속성 상속: 우선순위, Actor, Task-local 변수 등이 상속됨

메모리 관리와의 유사점

Task 트리의 생명주기 관리는 ARC(Automatic Reference Counting)와 유사한 자동 관리 메커니즘을 제공합니다. 개발자가 명시적으로 Task의 생명주기를 관리할 필요 없이, 스코프 기반으로 자동 관리됩니다.

실전 활용 패턴

1. 에러 핸들링 패턴

func robustFetchThumbnails(for ids: [String]) async -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    
    await withTaskGroup(of: (String, Result<UIImage, Error>).self) { group in
        for id in ids {
            group.async {
                do {
                    let thumbnail = try await fetchOneThumbnail(withID: id)
                    return (id, .success(thumbnail))
                } catch {
                    return (id, .failure(error))
                }
            }
        }
        
        for await (id, result) in group {
            switch result {
            case .success(let thumbnail):
                thumbnails[id] = thumbnail
            case .failure(let error):
                print("Failed to fetch thumbnail for \(id): \(error)")
            }
        }
    }
    
    return thumbnails
}

2. 진행 상황 추적 패턴

@MainActor
func fetchThumbnailsWithProgress(for ids: [String]) async -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    var completed = 0
    
    await withTaskGroup(of: (String, UIImage?).self) { group in
        for id in ids {
            group.async {
                let thumbnail = try? await fetchOneThumbnail(withID: id)
                return (id, thumbnail)
            }
        }
        
        for await (id, thumbnail) in group {
            completed += 1
            if let thumbnail = thumbnail {
                thumbnails[id] = thumbnail
            }
            
            // UI 업데이트 (MainActor에서 실행)
            updateProgress(completed: completed, total: ids.count)
        }
    }
    
    return thumbnails
}

3. 조건부 취소 패턴

func fetchThumbnailsWithTimeout(for ids: [String], timeout: TimeInterval) async throws -> [String: UIImage] {
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        // 타임아웃 Task 추가
        group.async {
            try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
            throw TimeoutError()
        }
        
        // 실제 작업 Task들 추가
        for id in ids {
            group.async {
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        
        var thumbnails: [String: UIImage] = [:]
        
        do {
            for try await (id, thumbnail) in group {
                thumbnails[id] = thumbnail
            }
        } catch is TimeoutError {
            group.cancelAll() // 타임아웃 시 모든 작업 취소
            throw TimeoutError()
        }
        
        return thumbnails
    }
}

성능 최적화 고려사항

1. Task 오버헤드 최소화

너무 많은 작은 Task를 생성하는 것은 오버헤드를 증가시킬 수 있습니다. 적절한 청크 사이즈로 작업을 나누는 것이 중요합니다:

func optimizedBatchProcessing<T>(items: [T], batchSize: Int = 10, processor: (T) async throws -> Void) async throws {
    for batch in items.chunked(into: batchSize) {
        try await withThrowingTaskGroup(of: Void.self) { group in
            for item in batch {
                group.async {
                    try await processor(item)
                }
            }
        }
    }
}

2. 메모리 사용량 관리

대량의 데이터를 처리할 때는 메모리 사용량을 고려해야 합니다:

func memoryEfficientProcessing(urls: [URL]) async throws -> [Data] {
    var results: [Data] = []
    let maxConcurrency = min(urls.count, ProcessInfo.processInfo.activeProcessorCount)
    
    try await withThrowingTaskGroup(of: (Int, Data).self, capacity: maxConcurrency) { group in
        var nextIndex = 0
        
        // 초기 작업들 시작
        for _ in 0..<maxConcurrency where nextIndex < urls.count {
            let index = nextIndex
            nextIndex += 1
            group.async {
                let data = try await URLSession.shared.data(from: urls[index]).0
                return (index, data)
            }
        }
        
        // 결과를 받으면서 새로운 작업 추가
        while let (index, data) = try await group.next() {
            results.append(data)
            
            if nextIndex < urls.count {
                let newIndex = nextIndex
                nextIndex += 1
                group.async {
                    let data = try await URLSession.shared.data(from: urls[newIndex]).0
                    return (newIndex, data)
                }
            }
        }
    }
    
    return results
}

마무리

Swift의 구조적 동시성은 비동기 프로그래밍을 혁신적으로 개선했습니다. 복잡한 콜백 지옥에서 벗어나 직관적이고 안전한 코드를 작성할 수 있게 되었으며, 컴파일러 수준에서 많은 동시성 버그를 방지할 수 있게 되었습니다.

핵심 원칙들:

  • 계층적 구조: Task 트리를 통한 생명주기 관리
  • 자동 취소 전파: 오류 발생 시 자동으로 관련 작업들 정리
  • 타입 안전성: 컴파일 타임에 데이터 레이스 방지
  • 조합성: 다양한 Task 형태를 자연스럽게 조합 가능

올바른 Task 선택하기:

  • async-let: 고정된 수의 병렬 작업
  • Task Group: 동적인 수의 병렬 작업
  • Unstructured Task: 스코프를 벗어나는 작업
  • Detached Task: 완전히 독립적인 작업
profile
iOS 개발자 공부하는 Roy

0개의 댓글