본문 바로가기

iOS

[iOS] Actor, Task, Async&Await

Actor, Task, Async/Await

 

프로젝트 작업으로 바빳어서 오랜만에 포스팅을 하네요!

 

최근 프로젝트에서 async/await을 사용하게 되어 다시 학습을 하며 정리한 내용을 기재했습니다.

 

Swift 5.5 이전까지 우리는 GCD를 이용해 비동기 로직을 처리하고, completion Handler와 같은 방식을 활용해 비동기 로직이 끝나는 시점에 필요한 작업을 수행 했습니다.

 

기존에도 Swift에서는 Concurrency를 위한 방법들이 존재하고 있었으나 여러 아쉬운 점들이 있었습니다.

 

그 아쉬운 점들을 예시 코드를 통해 확인해보도록 할게요!

 

func fetchImage(completion: @escaping (Result<UIImage, Error>) -> Void) {
    let request = imageURLRequest()
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        if let error = error {
            completion(.failure(error))
        } else if (response as? HTTPURLResponse)?.statusCode != 200 {
            completion(.failure(Error.badResponse))
        } else {
            guard let image = UIImage(data: data!) else {
                completion(.failure(Error.badImageData))
                return
            }

            image.prepare(of: CGSize(width: CusotmWidth, height: CustomHeight)) { image in
                guard let image = image else {
                    completion(.failure(Error.failPrepare))
                    return
                }
                completion(.success(image))
            }
        }
    }
    task.resume()
}

 

네트워킹을 통해 이미지를 다운로드하여 completionHandler 방식으로 전달하는 함수입니다.

 

총 23라인의 코드를 작성했으나 버그가 발생할 수 있는 지점이 대략적으로만 봐도 4곳이 있으며 추가적인 조건이 붙는다면 더 늘어날 수 있습니다.

 

함수 내부에서의 상황에 따라 에러 처리도 매우 까다로우며 실패, 성공에 따른 코드 분기가 꽤나 복잡합니다.

 

뿐 만 아니라 비동기 함수 호출이 중첩될 경우 이러한 코드 depth는 더욱 깊어져 흔히 얘기하는 콜백지옥에 빠지게 됩니다.

 

이러한 여러 단점들을 극복하고자 Swift 5.5에서는 async/await 이 탄생했습니다.

 

위 예시 코드를 async/await 을 활용해 작성해보도록 할게요!

 

func fetchImage() async throws -> UIImage {
    let request = imageURLRequest()
    let (data, response) = try await URLSession.shared.data(for: request)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
        throw Error.badResponse
    }
    let image = UIImage(data: data)
    guard let preparedImage = await image?.prepare else {
        throw Error.failPrepare
    }
    return preparedImage
}

 

기존 코드에 존재하던 클로저와 depth가 사라져 깔끔한 코드가 되었습니다.

 

async/await은 기존 비동기 로직을 처리하던 방식에 비해 간편하게 코드 작성이 가능하며 훨씬 가독성이 좋은 형태로 표현이 가능합니다.

 

 

async/await


async

async 는 함수를 비동기로 처리한다는 것을 의미합니다.

 

메소드 시그니처 중 함수명 오른쪽에 키워드가 위치하며 throws를 같이 사용할때는 async throws 로 기재합니다.

 

// 비동기 동작, 에러 발생 가능, 스트링 반환
func someMethod() async throws -> String {
    ...
}

 

await

기존에 async 로 정의한 함수를 호출하기 위해서는 await 키워드가 필요합니다.

 

throwstry 가 함께 쓰이듯, asyncawait이 함께 사용됩니다.

 

let string: String = try await someMethod()

 

await 키워드가 사용된 지점은 잠재적 일시 중단 지점(Potential suspension point)로 지정이 되며 async 함수가 완료될 때 까지 일시 중지되는 지점입니다.

 

이 시점에는 해당 스레드가 다른 작업을 수행할 수 있게 제어권으 놓아주는것을 의미하며 이를 통해 스레드 제어권을 시스템에게 넘겨주게 되고 이후 비동기 동작이 끝나갈떄 쯤 해당 작업의 우선순위가 높아지면 스레드 제어권을 다시 할당받아 이후 작업들을 수행하게 됩니다.

 

이 때, await를 호출하기 이전의 스레드와 다시 할당받은 스레드가 다를 수 있으며 그 사이 앱의 상태가 변경될 수 있습니다. 이 부분에 대해서는 추후 다루도록 할게요!

 

async 키워드를 사용한 함수는 await 키워드와 함께 호출하게 된다고 얘기했는데요!

 

await 만 명시한다고 사용이 가능한건 아닙니다.

 

async 함수를 호출하는 caller 또한 async 키워드를 활용하여 작성해야 합니다.

 

func someMethod() async -> String {
    ...
}

func callAsyncFunction() async {
    let string = await someMethod()
}

 

이렇게요!

 

그럼 결국 async 를 호출하는 곳도 또 다시 async 로 만들어줘야 하고 그렇게 만든 함수를 호출하는 부분도 async 로 만들어 줘야 하고... 무한 굴레에 빠지게 됩니다.

 

이를 위해 Task 라는 개념이 등장합니다.

 

Task


Task 는 비동기 작업 단위를 의미합니다.

 

image

 

Task 라는 비동기 context 안에서 우리는 async 함수를 호출 할 수 있습니다.

 

아래처럼요!

 

func someMethod() async -> String {
    ...
}

func callAsyncFunction() async {
    let string = await someMethod()
}

override viewDidLoad() {
    Task {
        await callAsyncFunction()
    }
}

 

Task 는 임의의 스레드에서 다른 context와 함께 동시에 실행됩니다.

 

즉, 여러 Task 가 동시에 독립적으로 각각의 작업을 실행할 수 있습니다.

 

각각의 Task 들은 서로가 독립적으로 격리되어 비동기 작업을 수행합니다.

 

이렇게 독립적으로 동작하는 Task 단위들이 서로의 value Type이 아닌 referecen Type의 데이터를 공유하고 싶다면 어떻게 할까요?

 

여러 Task 는 동시에 독립적으로 자신의 일을 하고 있지만 동일한 객체를 참조한다면 두 개 이상의 스레드가 동일한 데이터에 접근하며 발생하는 Data Race 상황이 발생할 수 있습니다.

 

이러한 문제를 위해 Swift 에서는 Data Race 상황을 회피하면서도 Task 간 안전하게 가변 데이터를 공유할 수 있는 방법 Actor 를 제공합니다.

 

Actor


imageimage

 

Actor 는 여러 Task 들을 조정하는 역할을 수행하며 외부로부터 데이터를 격리하여 한 번에 하나의 Task 만 내부 상태를 조작하도록 허용함으로써 동시 변경으로 인한 Data Race를 회피합니다.

 

actor 타입에는 동시에 하나의 Task 만 접근이 가능하기 때문에 나머지 부분에 대한 접근을 격리하고, 해당 데이터에 대한 동기화된 접근을 보장합니다.

 

이를 가능하게 하는 중요한 성질중 하나가 Actor isloated 입니다.

 

actor 의 내부 값들은 기본적으로 모두 actor isolated 상태이며 이 값들은 오직 self 참조를 통해서만 접근이 가능합니다.

 

actor 내부에서만 접근이 자유롭습니다.

 

외부에서 actor isloated 한 값들에 접근하고자 할 떄는 반드시 await 을 통해 접근해야 되며 그렇게 접근한다 하더라도 바로 실행이 되는 것이 아니라 접근이 가능할 떄 실행 처리하도록 요청하기 위해 await 을 통해 접근을 수행하게 됩니다.

 

actor 는 이러한 요청들을 직렬화하여 보유하고 있다가 작업이 끝나면 하나씩 처리하게 됩니다. 이를 통해 data race가 발생하지 않음을 보장하게 됩니다.

 

다만 Task의 접근을 직렬화하기 때문에 actor의 접근이 가능해질때 까지 모든 Task가 대기하는 방식은 동시성 컴퓨팅의 이점을 챙길 수 없습니다.

 

그래서 actor에는 특정 함수의 파라미터를 isolated 하게 만들거나, 특정 값만 actor isloated 하지 않도록 설정할 수 있는 기능이 존재합니다.

 

파라미터에 isolated 키워드를 붙여주면 함수가 isolated context를 가지게 되며 actor 상태에 직접적인 접근이 가능하게 됩니다.

 

또한 nonisolated 키워드를 actor 내 프로퍼티 혹은 함수에 사용하게 되면 해당 프로퍼티 혹은 함수는 isolated 상태에서 벗어나게 됩니다.

 

오늘 이렇게 간단히 Swift Concurrency에 대해 알아보았습니다.

대략적으로 개념은 이해했으나 좀 더 익숙하게 사용할 수 있도록 열심히 적용해봐야겠습니당!

이상으로 Actor, Task, async/await 에 대한 포스팅을 마치겠습니다.