Swift Concurrency 맛보기 & 맛 평가
안녕하세요! 미니입니다. 오늘은 프로젝트에서 Swift Concurrency에 대해서 정리해보고, 적용 후기를 작성해보려고 합니다. 첫번째로 비동기 프로그래밍에 대해서 먼저 생각해보려고 합니다.
비동기 프로그래밍?
동기
동기 프로그래밍은 하나의 작업이 종료될 때까지 프로그램이 기다리는 것을 의미합니다. 즉, 하나의 작업이 종료되어야지만 다음 작업을 할 수 있다는 의미입니다.
비동기
하지만, 비동기는 하나의 작업이 실행되고 나면, 다른 작업을 바로 수행하는 것을 의미합니다. 즉, 진행중인 작업에 대해서 기다리지 않습니다.
이때, 저희는 작업이 종료되는 시점을 알기 위해서 클로저를 활용하게 됩니다. 실제로, URLSession의 dataTask 메서드는 (error, response, data) 타입을 반환하는 클로저를 발생시킵니다.
Closure의 문제점!!
Completion Handler를 사용하다 보면 다양한 문제들을 발견할 수 있습니다.
(참고 : https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md)
1.콜백 지옥
func processImageData1(completionBlock: (_ result: Image) -> Void) {
loadWebResource("dataprofile.txt") { dataResource in
loadWebResource("imagedata.dat") { imageResource in
decodeImage(dataResource, imageResource) { imageTmp in
dewarpAndCleanupImage(imageTmp) { imageResult in
completionBlock(imageResult)
}
}
}
}
}
processImageData1 { image in
display(image)
}
다음 함수를 보게 되면, completion의 completion의 completion과 같은 중첩적인 클로저를 구성하고 있습니다. 이런 경우에는 굉장히 코드의 Depth가 깊어지게 되고, 코드의 실행을 추적하고 파악하는 것을 힘들게 만듭니다. (디버깅하면 화면이 휙휙휙!! ㅋㅋㅋ)
2.복잡한 에러 처리
// (2a) Using a `guard` statement for each callback:
func processImageData2a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
loadWebResource("dataprofile.txt") { dataResource, error in
guard let dataResource = dataResource else {
completionBlock(nil, error)
return
}
loadWebResource("imagedata.dat") { imageResource, error in
guard let imageResource = imageResource else {
completionBlock(nil, error)
return
}
decodeImage(dataResource, imageResource) { imageTmp, error in
guard let imageTmp = imageTmp else {
completionBlock(nil, error)
return
}
dewarpAndCleanupImage(imageTmp) { imageResult, error in
guard let imageResult = imageResult else {
completionBlock(nil, error)
return
}
completionBlock(imageResult)
}
}
}
}
}
processImageData2a { image, error in
guard let image = image else {
display("No image today", error)
return
}
display(image)
}
해당 함수 구문을 보게 되면, 에러를 처리하기 위해서 guard let else
구문을 많이 사용하고 있습니다. 하지만, 이런 문제들을 해결하기 위해서 Result
타입을 사용하라고 하지만, 문제는 여전히 해결되지 않습니다. 당연히 Switch 구문을 활용하게 되기 때문입니다.
3.조건부 실행 및 함수의 하향식 패러다임 해체
func processImageData3(recipient: Person, completionBlock: (_ result: Image) -> Void) {
let swizzle: (_ contents: Image) -> Void = {
// ... continuation closure that calls completionBlock eventually
}
if recipient.hasProfilePicture {
swizzle(recipient.profilePicture)
} else {
decodeImage { image in
swizzle(image)
}
}
}
비동기 함수를 수행한 후 결과값을 통해서 새로운 함수를 실행시키는 경우에 문제가 발생하게 됩니다. 이런 방식의 함수는 자연스러운 하향식 구성을 반전시키게 됩니다. 또한, 내부 생성된 Closure가 completionHandler를 활용하기 때문에 capture에 신중해야 합니다.
4.실수가 쉬운 구문
func processImageData4a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
loadWebResource("dataprofile.txt") { dataResource, error in
guard let dataResource = dataResource else {
return // <- forgot to call the block
}
loadWebResource("imagedata.dat") { imageResource, error in
guard let imageResource = imageResource else {
return // <- forgot to call the block
}
...
}
}
}
구문이 복잡해지게 되면, completionHandler를 호출하는 것을 까먹을 가능성이 높아지게 됩니다.
이렇게 보면 굉장히 많은 부분에서 클로저가 미워지게 되는 것 같죠? (우리 closure 미워하면 안됩니다! ㅋㅋㅋ) 이런 문제들을 해결하기 위해서 Swift 5.5 버전 부터 Concurrency가 도입되었습니다. 비동기 코드를 동기적인 코드 처럼 작성할 수 있도록 해줍니다.
async & await
실제 프로젝트를 async & await으로 변경하면서 설명을 해보려고 합니다. 우선은 데모 앱으로 구현한 코드를 먼저 설명드릴게요! 해당 앱은 버튼에 따라서 이미지를 불러오는 코드를 다르게 작성하였습니다!
struct ContentView: View {
@State private var image: Image = Image(systemName: "photo")
var body: some View {
VStack {
image
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: 200)
Button(action: {
self.showImage { urls, error in
if let _ = error {
print("error in load Image URLS")
return
}
guard let imagePath = urls?.raw else { return }
loadImage(with: imagePath) { image in
if let image = image {
self.image = Image(uiImage: image)
}
}
}
}, label: {
Text("클로저로 호출하기")
})
Button(action: {
Task {
let imagePath = try await showImageConcurrency().raw
self.image = try await loadImageConcurrency(with: imagePath)
}
}, label: {
Text("Concurrency로 호출하기")
})
}
.padding()
}
각 버튼에 따라서 동작하는 로직을 다르게 구현하였습니다. CompletionHandler로 작성한 코드를 먼저 살펴볼게요! 해당 API는 바로 이미지에 대한 데이터를 주는 것이 아니라 URL을 넘겨주도록 구현이 되어 있습니다. 그래서 저희는 URL을 받아온 후에, URL을 다시 이미지로 로드 하는 과정을 구현하게 될 것입니다.
func showImage(with completionHandler: @escaping (ImageURLs?, Error?) -> Void) {
guard let request = HTTPRequest().generate() else {
return
}
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completionHandler(nil, error)
return
}
guard let response = response as? HTTPURLResponse,
(200...300) ~= response.statusCode else {
completionHandler(nil, ResponseError.invalidCode)
return
}
guard let data = data else {
completionHandler(nil, ResponseError.notData)
return
}
guard let decodeData = try? JSONDecoder().decode(ImageURLs.self, from: data) else {
completionHandler(nil, ResponseError.decodeError)
return
}
completionHandler(decodeData, nil)
return
}
task.resume()
}
위의 코드를 보게 되면, 저희가 위에서 문제가 되는 부분에 대해서 확실하게 알 수 있게 됩니다. 굉장히 많은 부분에서 CompletionHandler를 호출하고 있고, return을 많이 작성한 것을 볼 수 있습니다. 다음에는 이미지를 로드하는 부분의 코드입니다.
func loadImage(with path: String, completionHandler: @escaping (UIImage?) -> Void) {
guard let url = URL(string: path) else { return }
URLSession.shared.dataTask(with: url) { data, response, error in
if let _ = error {
completionHandler(nil)
return
}
guard let response = response as? HTTPURLResponse,
(200...300) ~= response.statusCode else {
completionHandler(nil)
return
}
guard let data = data else {
completionHandler(nil)
return
}
let image = UIImage(data: data)
completionHandler(image)
}.resume()
}
위의 로직도 동일하게 CompletionHandler를 활용한 부분들이 많게 됩니다. 또한, View에서 함수를 호출하는 과정을 보면 더욱 알 수 있습니다.
self.showImage { urls, error in
if let _ = error {
print("error in load Image URLS")
return
}
guard let imagePath = urls?.raw else { return }
loadImage(with: imagePath) { image in
if let image = image {
self.image = Image(uiImage: image)
}
}
}
해당 부분은 이미지 URL을 호출하고, 이미지를 직접 로드하기까지에 코드입니다. Image URL을 불러온 후, 내부적으로 다시 CompletionHandler의 결과를 통해서 다른 비동기 함수를 호출하게 됩니다. 이로 인해서 코드가 복잡해지고, 읽기가 매우 불편하게 됩니다. 이제 문제들은 이해가 된 것 같으니 Concurrency로 변경을 해보죠!
func loadImageConcurrency(with path: String) async throws -> Image {
guard let url = URL(string: path) else {
throw ResponseError.convertRequestError
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let baseImage = UIImage(data: data) else {
throw ResponseError.notData
}
return Image(uiImage: baseImage)
}
func showImageConcurrency() async throws -> ImageURLs {
guard let request = HTTPRequest().generate() else {
throw ResponseError.convertRequestError
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse,
(200...300) ~= response.statusCode else {
throw ResponseError.invalidCode
}
let decodeData = try JSONDecoder().decode(ImageURLs.self, from: data)
return decodeData
}
CompletionHandler를 활용하는 것보다 더 적은 코드로 수행할 수 있게 됩니다. 이를 활용하는 방법을 뜯어보겠습니다.
Async
func showImageConcurrency() async throws -> ImageURLs
비동기로 동작하는 함수를 정의할 때에는 함수 선언부 뒤에 async
키워드를 붙여주면 됩니다. 또한, 에러를 반환할 수 있는 함수는 throws
를 함깨 붙여주면 됩니다.
Await
let urls = try await showImageConcurrency()
async
키워드와 짝꿍인 await
을 보겠습니다. await 키워드는 비동기를 시스템이 바라보았을 때 의미가 명확하다고 느꼈습니다. 시스템은 비동기 함수를 만나게 되면, 다른 작업을 수행할 수 있기 때문에 함수의 결과가 반환될 때까지 기다린다 라는 의미를 가지는 것입니다. 실질적으로 await 키워드를 만나게 되면, 함수가 쓰레드에 대한 제어권을 포기한다고 하는데 이 부분은 다음 포스팅에서 더욱 자세하게 다루겠습니다.
Concurrency를 사용한 함수들을 호출하는 곳을 살펴보죠!
let imagePath = try await showImageConcurrency().raw
self.image = try await loadImageConcurrency(with: imagePath)
이런 아름다운 코드를 작성할 수 있습니다. 개인적으로 이렇게 작성하게 된다면, 누군가에게 설명하기 위해서 이곳 저곳을 왔다갔다 하지 않다도 되는 것 같았습니다. 이를 좀 어렵게 이야기하면, 함수의 자연스러운 흐름을 해치지 않고 비동기 프로그래밍을 구현했다고 할 수 있겠네요!
실제 프로젝트에서는?
위에서 async & await의 기본적인 사용법을 알았으니, 제가 실제 프로젝트에서 활용한 방법을 설명드리려고 합니다.
protocol Networking {
// 중략
}
extension Networking {
func execute(to generator: RequestGenerator) async throws -> Data {
let request = try generator.generate()
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
try handleStatusCode(with: response.statusCode)
return data
}
func request<T: Decodable>(to generator: RequestGenerator) async throws -> T {
let data = try await execute(to: generator)
let decodedData = try JSONDecoder().decode(T.self, from: data)
return decodedData
}
}
실제 프로젝트에서 네트워크를 수행한 후에 결과로 아무것도 넘겨주지 않는 경우가 있어서 함수를 2개로 쪼개서 구현했습니다. 실제 네트워크를 수행하는 함수를 통해서 에러를 핸들링하고, 난 후에 Data 타입만 반환할 수 있도록 하였으며, decode 해서 실제 인스턴스를 생성해야 하는 경우에는 내부적으로 네트워크 함수를 수행한 후에 decode를 할 수 있도록 구현하였습니다.