[iOS] 동시성 프로그래밍

z-wook·2023년 9월 3일
1

Task란?

Task비동기 작업을 나타내는 개념적인 단위로, 비동기 코드를 작성하고 실행하기 위한 핵심 요소 중 하나입니다.

비동기 코드 문제점

async/await를 사용하면 비동기 코드를 동기 코드와 유사한 방식으로 작성할 수 있어 가독성이 향상되며, 오류 처리도 간편해집니다. 또한, 비동기 작업을 효율적으로 처리할 수 있습니다.
하지만 비동기 코드에도 문제점이 있습니다. 아래의 코드와 실행 결과를 보고 어떤 문제점이 있는지 찾아보겠습니다.

  • Serial(직렬)
@objc func download() {
	Task {
    	let firstData = await donwnLoadImage(imageUrl: ImageURL.first)
		imageView1.image = UIImage(data: firstData)
            
		let secondData = await donwnLoadImage(imageUrl: ImageURL.second)
		imageView2.image = UIImage(data: secondData)
            
		let thirdData = await donwnLoadImage(imageUrl: ImageURL.third)
		imageView3.image = UIImage(data: thirdData)
	}
}
    
func donwnLoadImage(imageUrl: ImageURL) async -> Data {
	let dataTask = AF.request(imageUrl.url, method: .get)
	.downloadProgress(closure: { progress in
		switch imageUrl {
		case .first:
			self.progressView1.progress = Float(progress.fractionCompleted)
		case .second:
			self.progressView2.progress = Float(progress.fractionCompleted)
		case .third:
			self.progressView3.progress = Float(progress.fractionCompleted)
		}
	})
	.serializingData()
    
	switch await dataTask.result {
	case .success(let data):
		return data    
	case .failure(let error):
		print(error.localizedDescription)
	}
	return Data()
}

어떤 문제점이 있는지 찾으셨나요?

위의 코드는 Serial(직렬)하게 작동되는 형태입니다. Task를 사용하여 세 개의 이미지를 비동기적으로 다운로드하고 화면에 표시하고 있지만 각 이미지를 다운로드할 때 순차적으로 처리하고 있기 때문에 성능이 좋지 않습니다.
(각각의 사진을 다운로드하는데 걸리는 시간이 3초, 7초, 10초라고 할 때 Serial(직렬) 방법을 사용하면 3초 + 7초 + 10초 = 20초가 걸리는 비효율적인 코드입니다.)

이러한 성능을 개선하기 위해서 이미지 다운로드 작업을 Concurrency(동시)로 실행하면 효율적으로 이미지를 다운로드할 수 있습니다.

동시성 프로그래밍

  • Concurrency(동시)
@objc func download() {
	Task {
		async let firstData = donwnLoadImage(imageUrl: ImageURL.first)
		async let secondData = donwnLoadImage(imageUrl: ImageURL.second)
		async let thirdData = donwnLoadImage(imageUrl: ImageURL.third)
		imageView1.image = await UIImage(data: firstData)
		imageView2.image = await UIImage(data: secondData)
		imageView3.image = await UIImage(data: thirdData)
	}
}

func donwnLoadImage(imageUrl: ImageURL) async -> Data {
	// 생략
}

async let을 사용하여 이미지를 다운로드하면 세 개의 이미지를 동시에 다운로드하는 것을 볼 수 있습니다. (각각의 사진을 다운로드하는데 걸리는 시간이 3초, 7초, 10초라고 할 때 Concurrency(동시) 방법을 사용하면 이미지 최대 다운로드 시간인 10초가 걸리므로 Serial(직렬) 방법에 비해 압도적인 시간 차이가 나는 것을 볼 수 있습니다.)

하지만! 해당 코드를 자세히 보면 문제점을 또 찾을 수 있습니다.

async let을 사용해서 동시에 이미지를 다운로드하지만 imageView에 다운로드한 image를 업데이트하는 과정에서 await를 사용하기 때문에 두 번째, 세 번째 이미지를 먼저 다운로드했더라도 첫 번째 사진을 다운로드하는 중이라면 첫 번째 사진을 업데이트할 때까지 기다려야 하는 문제가 있습니다.
(실행 영상에서도 두 번째 이미지를 먼저 다운로드했음에도 불구하고 첫 번째 이미지를 다운로드할 때까지 기다리는 것을 볼 수 있습니다.)

문제 해결을 위해 어떤 방법을 사용해야 할까요? 아래에서 코드와 함께 알아보겠습니다.


문제 해결을 위해 아래와 같이 이미지를 요청하고 업데이트하는 과정을 하나의 Task로 묶어서 세 개의 Task가 병렬로 실행되도록 만들었습니다.
각각의 Task는 이미지 데이터를 비동기로 다운로드하고 이를 해당 이미지 뷰에 표시합니다.
이렇게 함으로써 애플리케이션의 성능을 향상시키고 여러 이미지를 효과적으로 처리할 수 있게 만들었습니다.

@objc func download() {
	Task {
		async let firstData = donwnLoadImage(imageUrl: ImageURL.first)
		imageView1.image = await UIImage(data: firstData)
	}

	Task {
		async let secondData = donwnLoadImage(imageUrl: ImageURL.second)
		imageView2.image = await UIImage(data: secondData)
	}

	Task {
		async let thirdData = donwnLoadImage(imageUrl: ImageURL.third)
		imageView3.image = await UIImage(data: thirdData)
	}
}

func donwnLoadImage(imageUrl: ImageURL) async -> Data {
	// 생략
}

실행 결과를 보면 다운로드 완료된 순서대로 UIImage를 업데이트해 주는 것을 볼 수 있습니다!

하지만 만약에 이미지가 100개라면??? Task를 100개 만들어서 이미지를 다운로드하기에는 비효율적이게 됩니다. 따라서 이를 해결하기 위해 아래에서 TaskGroup을 코드와 함께 설명드리겠습니다.


TaskGroup은 비동기 프로그래밍에서 여러 비동기 작업을 그룹화하고 관리하기 위한 편리한 방법을 제공합니다.
즉, Task Group을 사용하면 여러 비동기 작업을 동시에 실행하고 결과를 수집하거나 에러를 처리할 수 있습니다.

Task {
	let dataList = await getData()
	imageView1.image = UIImage(data: dataList[0])
	imageView2.image = UIImage(data: dataList[1])
	imageView3.image = UIImage(data: dataList[2])
}

func getData() async -> [Data] {
	let imageUrls: [ImageURL] = [.first, .second, .third]
        
	let datas = await withTaskGroup(of: (Data, Int).self) { group in
		for (index, imageUrl) in imageUrls.enumerated() {
			group.addTask {
				let data = await self.donwnLoadImage(imageUrl: imageUrl)
				return (data, index) // 데이터와 인덱스를 함께 반환
			}
		}
		var dataList: [Data?] = Array(repeating: nil, count: imageUrls.count)
		for await (data, index) in group {
			dataList[index] = data
		}
		return dataList.compactMap { $0 } // nil 값을 제거하여 순서대로 정렬된 배열 반환
	}
	return datas
}
    
func donwnLoadImage(imageUrl: ImageURL) async -> Data {
	// 생략
}

Task Group의 종류에는 여러 가지가 있지만 위의 코드에서는 withTaskGroup을 사용했습니다.
withTaskGroup 함수를 사용하여 이미지 데이터 다운로드 작업을 관리하기 위한 Task Group을 생성하여 이미지를 다운로드하는 비동기 작업을 그룹에 추가하는 방식입니다.

이미지를 다운로드하는 작업마다 Task를 생성하는 방식과 동작은 같지만 withTaskGroup을 사용하면
효율적인 코드를 작성할 수 있습니다.

⭐️ 에러가 날 수 있는 상황이라면 withTaskGroup대신 withThrowingTaskGroup를 사용하면 됩니다.

profile
🍎 iOS Developer

0개의 댓글