[Swift] Concurrency - Collections & Parallelization (1)
안녕하세요, 루피입니다.
이번 포스팅은 지난 Task에 대한 포스팅을 마무리하고 새롭게 시작하는 Parallelization을 정리하려합니다. 벙렬 작업은 주로 컬렉션 내에서 이루어지기에 제목을 위와 같이 만들었습니다.
바로 시작합니다.
Swift의 동시성 시스템을 사용하여 여러 작업을 병렬로 실행하기
Swift의 내장 동시성 시스템의 이점 중 하나는 여러 비동기 작업을 병렬로 수행하는 것을 훨씬 쉽게 만들어준다는 것입니다. 이는 별도의 부분으로 나눌 수 있는 작업의 속도를 크게 향상시킬 수 있습니다.
이 글에서는 이를 수행하는 몇 가지 다른 방법과 각 기법이 특히 유용한 경우를 살펴보겠습니다.
동시성(Concurrency) vs 비동기(Parallelism)
우선 동시성과 비동기의 차이를 먼저 확인하고 넘어가겠습니다. 많이들 햇갈려 하시는 부분이더라고요!!
일단 간단하게 말하자면, 동시성은 동시에 실행되는 것 같이 보이게 만드는 것, 병렬성은 실제로 동시에 여러 작업을 처리하는 것.
이렇게 생각하시면 편할거 같습니다.
| 동시성 | 병렬성 |
| 동시에 실행되는 것 같이 보이는 것 | 실제로 동시에 여러 작업이 처리되는 것 |
| 싱글 코어에서 멀티 쓰레드를 동작 시키는 방식 | 멀티 코어에서 멀티 쓰레드를 동작시키는 방식 |
| 한번에 많은 것을 처리 | 한번에 많은 일을 처리 |
| 논리적인 개념 | 물리적인 개념 |
조금 더 쉽게 그림으로 확인 한다면 아래와 같이 이해 하시면 됩니다.

비동기에서 동시성으로
시작하기 위해, 다양한 제품을 표시하는 어떤 형태의 쇼핑 앱을 작업하고 있고, 다음과 같은 일련의 비동기 API를 사용하여 다양한 제품 컬렉션을 로드할 수 있는 ProductLoader를 구현했다고 가정해봅시다.
class ProductLoader {
...
func loadFeatured() async throws -> [Product] {
...
}
func loadFavorites() async throws -> [Product] {
...
}
func loadLatest() async throws -> [Product] {
...
}
}
위의 메서드들은 대부분의 경우 별도로 호출될 가능성이 높지만, 앱의 특정 부분에서는 이 세 가지 ProductLoader 메서드의 모든 결과를 포함하는 결합된 Recommendations 모델을 형성하고 싶다고 가정해봅시다:
extension Product {
struct Recommendations {
var featured: [Product]
var favorites: [Product]
var latest: [Product]
}
}
이를 수행하는 한 가지 방법은 await 키워드를 사용하여 각 로딩 메서드를 호출하고, 그 호출의 결과를 사용하여 Recommendations 모델의 인스턴스를 생성하는 것입니다.
extension ProductLoader {
func loadRecommendations() async throws -> Product.Recommendations {
let featured = try await loadFeatured()
let favorites = try await loadFavorites()
let latest = try await loadLatest()
return Product.Recommendations(
featured: featured,
favorites: favorites,
latest: latest
)
}
}
하지만 위 코드는 세 개의 로딩 작업이 모두 완전히 비동기적임에도 불구하고, 현재는 순차적으로, 하나씩 차례대로 수행되고 있습니다. 따라서 최상위 loadRecommendations 메서드가 앱의 다른 코드와 관련하여 동시에 수행되고 있지만, 실제로는 아직 동시성을 활용하여 내부 작업 집합을 수행하지 않고 있습니다.
우리의 제품 로딩 메서드들은 어떤 방식으로든 서로 의존하지 않으므로, 순차적으로 수행할 실제 이유가 없습니다. 따라서 대신 완전히 동시에 실행되도록 만드는 방법을 살펴보겠습니다.
이를 수행하는 방법에 대한 초기 아이디어는 위의 코드를 단일 표현식으로 줄이는 것일 수 있습니다.
이는 단일 await 키워드를 사용하여 각 작업이 완료되기를 기다릴 수 있게 해줍니다.
extension ProductLoader {
func loadRecommendations() async throws -> Product.Recommendations {
try await Product.Recommendations(
featured: loadFeatured(),
favorites: loadFavorites(),
latest: loadLatest()
)
}
}
하지만 우리의 코드가 이제 동시적으로 보일 수 있지만, 실제로는 이전과 마찬가지로 여전히 완전히 순차적으로 실행될 것입니다.
async let
동시성 시스템에 각 로딩 작업을 병렬로 수행하도록 지시하기 위해 Swift의 async let 바인딩을 활용해야 합니다. 이 구문을 사용하면 즉시 완료를 기다릴 필요 없이 백그라운드에서 비동기 작업을 시작할 수 있습니다. (정확히 얘기하지면, 병렬로 수행한다는 것이 CPU 코어를 여러개 사용하는 것을 확정하는 것은 아닙니다. 왜냐하면 그부분은 알아서 시스템이 알아서 해주기 때문입니다. 우리가 하는건 병렬 수행이 가능한 구조로 만든다는 것입니다.)
그런 다음 로드된 데이터를 실제로 사용하는 지점(즉, Recommendations 모델을 형성할 때)에서 단일 await 키워드와 결합하면, 상태 관리나 데이터 경합과 같은 것들을 걱정할 필요 없이 로딩 작업을 병렬로 실행하는 모든 이점을 얻을 수 있습니다.
extension ProductLoader {
func loadRecommendations() async throws -> Product.Recommendations {
async let featured = loadFeatured()
async let favorites = loadFavorites()
async let latest = loadLatest()
return try await Product.Recommendations(
featured: featured,
favorites: favorites,
latest: latest
)
}
}
매우 깔끔합니다! 따라서 async let은 수행하려는 작업의 알려진 유한 집합이 있을 때 여러 작업을 동시에 실행하는 내장 방법을 제공합니다. 하지만 그렇지 않은 경우는 어떨까요?
작업 그룹
이제 네트워크를 통해 이미지를 로드할 수 있는 ImageLoader를 작업한다고 가정해봅시다.
주어진 URL에서 단일 이미지를 로드하기 위해, 다음과 같은 메서드를 사용할 수 있습니다.
class ImageLoader {
...
func loadImage(from url: URL) async throws -> UIImage {
...
}
}
일련의 이미지를 한 번에 모두 로드하는 것을 간단하게 만들기 위해, URL 배열을 받아 다운로드된 URL을 키로 하는 이미지 딕셔너리를 비동기적으로 반환하는 편의 API도 만들었습니다.
extension ImageLoader {
func loadImages(from urls: [URL]) async throws -> [URL: UIImage] {
var images = [URL: UIImage]()
for url in urls {
images[url] = try await loadImage(from: url)
}
return images
}
}
이제 이전에 ProductLoader에서 작업했을 때와 마찬가지로, 위의 loadImages 메서드가 각 이미지를 순차적으로 다운로드하는 것이 아니라 동시에 실행되도록 만들고 싶다고 가정해봅시다.
하지만 이번에는 컴파일 타임에 수행해야 할 작업의 수를 알 수 없으므로 async let을 사용할 수 없습니다. 다행히 Swift 동시성 도구 상자에는 동적인 수의 작업을 병렬로 실행할 수 있는 도구도 있습니다 바로 TaskGroup 입니다.
작업 그룹을 형성하기 위해, 작업 내에서 오류를 던질 옵션을 원하는지에 따라 withTaskGroup 또는 withThrowingTaskGroup을 호출합니다. 이 경우, 기본 loadImage 메서드가 throws 키워드로 표시되어 있으므로 후자를 선택하겠습니다.
그런 다음 이전과 마찬가지로 각 URL을 반복하지만, 이번에는 직접 완료를 기다리는 대신 각 이미지 로딩 작업을 그룹에 추가합니다. 대신, 각 작업을 추가한 후 그룹 결과를 별도로 await할 것입니다. 이렇게 하면 이미지 로딩 작업이 완전히 동시에 실행될 수 있습니다:
extension ImageLoader {
func loadImages(from urls: [URL]) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
for url in urls {
group.addTask {
let image = try await self.loadImage(from: url)
return (url, image)
}
}
var images = [URL: UIImage]()
for try await (url, image) in group {
images[url] = image
}
return images
}
}
}
async let을 사용할 때와 마찬가지로, 작업이 어떤 종류의 상태를 직접 변경하지 않는 방식으로 동시 코드를 작성하는 것의 큰 이점은 어떤 종류의 데이터 경합 문제도 완전히 피할 수 있으면서도 혼합에 잠금이나 직렬화 코드를 도입할 필요가 없다는 것입니다.
따라서 가능할 때는 각 동시 작업이 완전히 별도의 결과를 반환하도록 하고, 최종 데이터 집합을 형성하기 위해 그 결과들을 순차적으로 await하는 것이 가장 좋은 접근 방식인 경우가 많습니다.
데이터 경합을 피하는 다른 방법들(예: Swift의 새로운 actor 타입 사용)에 대해서는 향후 글에서 자세히 살펴보겠습니다.
https://www.swiftbysundell.com/discover/concurrency/#collections-and-parallelization
Discover Concurrency on Swift by Sundell
Explore Swift’s built-in concurrency system, and how to use tools like async/await and actors to write concurrent code in robust and efficient ways.
www.swiftbysundell.com
오늘도 화이팅입니다!