[Swift] async & await

이정훈·2023년 7월 23일
0

Swift 파헤치기

목록 보기
7/10
post-thumbnail

이전 포스트를 통해 GCD 즉, DispatchQueue를 이용하여 Swift의 동시성 프로그래밍에 대해 알아 보았고 이번에는 Swift 5.5 버전부터 추가된 Swift Concurrency와 관련된 asyncawait 문법에 대해 알아 보려고 한다.


기존 callback 함수의 문제점

어떤 Task를 비동기적으로 수행하면 기존 스레드와 다른 백그라운드 스레드에서 기존 스레드와 비동기적으로 수행 되고 있기 때문에 비동기 Task가 완료된 시점이 언제인지, 완료된 Task를 어떻게 처리하고 사용할 것인지가 가장 큰 문제이다.

RxSwiftCombine과 같은 Reactive Programming 라이브러리를 사용하지 않는다면 @escaping 속성과 함께 일명 탈출 클로저, 다시 말해 Completion Handler를 사용하여 비동기 Task 완료 이후에 수행할 Task를 Completion Handler 내부에 작성하였고 그 형태는 다음과 같다.

func someAsyncFunction<T>(completion: @escaping (T) -> ()) where T: Numeric {
    //Async Task
}

someAsyncFunction() { (result: Int) in
    print(result)
}

코드의 가독성이 그리 나빠 보이지 않는다.

아직까진..

하지만 아래 사진과 같이 중첩된 형태의 callback 함수(Completion Handler)를 사용하는 순간 일명 callback 지옥이 시작된다.

이러한 중첩된 형태의 callback 함수는 코드의 가독성을 해칠 뿐만 아니라 Error Handling의 복잡성과 개발자의 실수 등 여러 문제를 야기할 수 있다.

이러한 복합적인 문제를 해결하기 위해 Swift 5.5부터 Swift Concurrency가 등장하였다.


기존 GCD 코드

아래 코드를 통해 다시 한번 비동기로 수행되는 함수에 @escaping 속성으로 탈출 클로저를 전달하여 비동기 Task 이후에 수행할 작업을 Completion Handler로 처리하는 구체적인 코드를 살펴보자

func addNumWithGCD(completion: @escaping (Int) -> ()) {
    DispatchQueue.global().async {
        var result: Int = 0
        
        for i in 1...10 {
            result += i
        }
        
        completion(result)
    }
}

func callGCDFunction() {
    addNumWithGCD(completion: { result in
        print(result)
    })
}

callGCDFunction()    //55

addNumWithGCD(completion:) 함수의 내부에는 1부터 10까지 수를 더하는 과정이 DispatchQueue를 통해 비동기로 수행되고 callGCDFunction() 함수에서는 addNumWithGCD(completion:)함수를 호출하고 매개변수 completion으로 탈출 클로저를 전달하여 비동기 Task가 종료된 이후에 수행할 작업을 수행하고 있다.


async / await

위의 과정을 DispatchQueue를 사용하지 않고 asyncawait 만을 이용하여 코드를 작성하면 다음과 같다.

func addNumWithAsync() async -> Int {
    var result: Int = 0
    
    for i in 1...10 {
        result += i
    }
    
    return result
}

func callAsyncFunction() async {
    print(await addNumWithAsync())
}

Task {
    await callAsyncFunction()    //55
}

DispatchQueue로 인한 들여 쓰기가 없어지고 매개변수로 탈출 클로저를 전달하지 않아도 되기 때문에 코드가 간결해지고 가독성도 향상된 모습을 볼 수 있다. 특히 중첩된 형태의 Completion Handler를 asyncawait로 구현한다면 그 효과는 극대화 될것이다.

asyncawait를 사용한다면 Completion Halder를 사용하지 않아도 비동기 Task가 완료된 시점에 비동기 함수가 호출된 위치(Suspension Point)로 돌아와 그 다음 코드를 이어서 진행할 수 있도록 보장하고 개발자 입장에서는 코드를 마치 동기 코드처럼 작성할 수 있어 개발에 편리함을 줄 수 있다.

지금부터 async & await 문법을 어떻게 사용하는지 코드 라인을 따라 하나씩 살펴 보려고 한다.

async

func addNumWithAsync() async -> Int

함수 이름 뒤에 async라는 키워드를 사용하면 해당 함수를 비동기로 실행하겠다는 의미이다.

만약 함수 수행 중에 Error가 발생할 가능성이 있어 throws 키워드를 사용해야 한다면 아래의 코드와 같이 async 키워드 뒤에 throws 키워드를 사용하도록 한다.

func addNumWithAsync() async throws -> Int

추가적으로 async로 선언된 함수는 다른 async로 선언된 함수 내부, 혹은 Task 구조체의 이니셜라이저로 전달되는 클로저 내부와 같은 asynchronous context에서만 호출될 수 있다. Task에 대한 설명은 아래에서 추가적으로 다루려고 한다.

await

async로 선언한 함수를 호출하기 위해서는 아래의 코드와 같이 await 키워드와 함께 호출한다.

func callAsyncFunction() async {
    print(await addNumWithAsync())
}

참고로 await 뒤에 호출되는 함수는 반드시 비동기로 수행되는 함수가 와야한다.

위에서 언급한 것과 같이 async 함수를 호출하기 위해서는 asynchronous context 내부에서만 호출할 수 있고 결국 await 또한 asynchronous context 내부에서만 사용될 수 있다.

만약 await로 호출하는 함수가 Error 발생의 가능성이 있다면 아래의 코드와 같이 await try 키워드를 사용한다.

func callAsyncFunction() async {
	do {
		try await addNumWithAsync()
	} catch {
		print(error)
	}
}

await 키워드를 사용하여 async 함수를 호출하게 되면 해당 지점을 Suspension Point라고 한다.

Suspension Point

Apple에서는 Suspension Point에 대해 다음과 같이 설명한다.

A suspension point is a point in the execution of an asynchronous function where it has to give up its thread

비동기 함수의 실행 지점인건 알겠는데, 해당 스레드를 포기해야 한다는 것은 무슨 의미일까?

이것을 이해하기 위해서는 동기비동기 함수에서 thread 제어권이 어떻게 이동하는지 이해할 필요가 있다.

동기 함수의 thread 제어권

먼저 callAsyncFunction() 함수에서 addNum()이라는 동기 함수를 호출 한다고 가정했을때 thread 제어권의 흐름은 다음과 같다.

callAsyncFunction() 함수에서 시작된 thread는 동기 함수인 addNum() 함수의 호출과 함께 thread 제어권을 해당 함수로 넘겨 주게되고 addNum() 함수가 종료 되었을때 다시 callAsyncFunction() 함수로 thread 제어권을 넘겨주게 된다.

이런 경우 동기 함수에 thread 제어권을 넘겨 주었기 때문에 동기 함수가 종료 될때까지 thread는 다른 작업을 수행할 수 없게 된다.

비동기 함수의 thread 제어권

다음으로 callAsyncFunction() 함수에서 비동기 함수인 addNumWithAsync()를 호출했을때 thread 제어권의 흐름은 다음과 같다.

위에서 설명한 것과 같이 await를 통해 비동기 함수를 호출하게 되면 그 지점이 Suspension Point가 되고 해당 함수를 호출한 thread의 thread 제어권을 System에게 넘긴다.

System은 해당 thread 제어권을 가져와 다른 Task에 할당하여 다른 Task를 수행하다가 비동기 함수가 완료 되었을때 다시 thread 제어권을 돌려 줌으로서 비동기 함수 호출 이후의 작업을 이어서 수행할 수 있다.

Suspension Point의 내용을 보면 마치 GCD를 이용한 비동기 함수의 탈출 클로저와 역할이 비슷해 보이지 않는가?

Swift Concurrency를 사용하면 탈출 클로저 없이 asyncawait만으로 탈출 클로저와 같은 기능을 수행할 수 있다.

위에서 등장한 코드를 조금 수정하여 아래와 같은 코드가 있을때 결과는 어떻게 될까?

func callAsyncFunction() async {
    print(await addNumWithAsync())    //Suspension Point 잠시 대기
    print("Hello World!")
}

awaitasync 함수를 호출하여 그 지점부터 Suspension Point가 되어 그 이후의 코드는
addNumWithAsync() 함수가 종료 될때까지 잠시 대기하고 System은 해당 함수가 종료 될때까지 다른 Task를 먼저 수행한다.

그리고 addNumWithAsync() 함수가 종료 되면 System으로부터 thread 제어권을 부여 받아 그 다음 print 문이 실행 되기 때문에 출력되는 순서는 아래와 같을 것이다.

//55
//Hello World!

Task

위에서 언급 했듯이 async 함수는 다른 async 함수 내부 혹은 Task 구조체 이니셜라이저로 전달되는 클로저 내부와 같은 asynchronous context에서만 호출 가능하다고 했다.

그렇다면 Task가 무엇인지 알아보자

Apple 개발자 문서에 소개 되어있는 Task의 정의는 다음과 같다.

Task

A unit of asynchronous work.

Task비동기로 수행할 작업을 클로저를 통해 전달하여 asynchronous context를 생성한다.

전달된 작업들은 Task 인스턴스 생성과 동시에 수행하게 된다.

이해를 위해 위의 코드를 다시 가져오자면 아래와 같다.

Task {
    await callAsyncFunction()    //55
}

또 다른 예로 SwiftUI에서 View의 instance method로 task라는 modifier를 사용할 수 있는데 해당 View가 생성될때 수행할 비동기 작업을 수행할 수 있다.

Image(systemName: "info.circle")
	.task {
    	await callAsyncFunction()
    }

Reference

사진 출처 - https://medium.com/dsc-srm/javascript-callback-hell-or-pyramid-of-doom-4f786d14b997

https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md

https://sujinnaljin.medium.com/swift-async-await-concurrency-bd7bcf34e26f

profile
새롭게 알게된 것을 기록하는 공간

0개의 댓글