본문 바로가기

스위프트

[Swift] Concurrency

예전부터 비동기 처리에 대한 연구는 많이 이뤄졌다. 스위프트에서 대표적으로 비동기 처리 후에 이벤트를 받는 방법에는 딜리게이트, 클로저, 옵저버 패턴 등이 있을 것이다. 그러던 중, iOS 13.0부터 Swift에 Concurrency라는 이름하에 비동기로직을 동기형태로 처리하는 문법이 개발되었다. 이에 대해서 알아보자. 나는 Swift Apprentice 교재를 참고하였다.

Task

iOS 13.0부터 지원되는 구조체 타입인 Task는 비동기 로직을 수행하는 한 단위라고 보면된다. 마치 클로저가 Trailing Closure일 경우, 메서드의 우측을 대괄호로 묶어 비동기 로직 이후에 로직을 처리하듯이, "비동기 로직"이 수행되는 공간을 Task라고 보면 되겠다. Task는 구조체로써 두개의 제너릭 타입을 사용하는데, Swift문법의 Result<Success, Failure>와 같이 사용된다. 이는 Task의 내부 프로퍼티인 value와 result를 통해 Task라는 비동기 로직이 실행된 후의 결과의 자료형을 정하기위해 사용된다.

 

다음의 예제는 간단히 숫자를 비교하는 비동기 로직이 들어가고, 그 결과로 String을 반환하거나 정의된 Error를 반환하는 예제이다. 

 

enum TaskError: Error {
    case numberError
}

let number = 5

let task1: Task<String, Error> = Task { () -> String in
    do {
        if number >= 10 {
            return "Number is over 10"
        }
        else {
            throw TaskError.numberError
        }
    }
}

Task {
    let result = await task1.result
    switch result {
    case .success(let text):
        print(text)
    case .failure(let error):
        print(error)
    }
}

위 예제에서 볼 수 있듯이, Task는 Trailing Closure로 비동기 로직을 실행하며, Task자체의 자료형을 Task<String, Error>로 하여 비동기 로직의 반환형을 String으로 하고, Throw를 통해 에러 핸들링을 진행한다. 위에서 언급했듯이 Task에는 내부 프로퍼티로 value, result가 있는데, value는 Success케이스에서의 반환형 값을 바로 받는 형태이면서 try를 통해 에러핸들링을 할 수 있고, result는 위 예제처럼 Result<Success, Failure>형태처럼 switch문을 통해 분기를 할 수도 있다.

 

근데 하단의 Task의 클로저 안을 보면 await라는 예약어가 선언되어있는데, Swift의 Concurrency에서는 async와 await라는 예약어를 통해 특정 메서드가 비동기 로직을 실행하는 동안 멈춤상태로 있다가 재개되면 다음로직이 실행되는 것을 명시하고 받는 쪽에서 또한 다음 코드 라인이 실행되지 않고 대기할 수 있다는 선언을 통해 코드자체가 비동기라는 것을 이해할 수 있다.

Async와 Await

Async는 특정 메서드, 혹은 계산 프로퍼티가 비동기로 실행될 수 있음을 의미하며 Await는 코드의 특정 라인에서 비동기 실행이 끝나고나면 다음라인이 실행되도록 명시할 수 있다.

 

다음은 iOS15.0버전이상에서 async함수를 통해 특정 URL로부터 데이터를 받아오는 메서드 및 메서드를 호출하여 대기하는 로직이다.

// Async와 Await 예제

// URLSession.shared.data(from: URL)은 iOS 15.0 이상 버전에서 추가된 Concurrency 메서드이다.
func fetchData(firstURL: URL, secondURL: URL) async throws -> [(Data, URLResponse)] {
    let firstResponse = try await URLSession.shared.data(from: firstURL)
    let secondResponse = try await URLSession.shared.data(from: secondURL)

    return [firstResponse, secondResponse]
}

let firstURL = URL(string: "https://www.naver.com")!
let secondURL = URL(string: "https://www.google.com")!
let task2 = Task { () -> [(Data, URLResponse)] in
    try await fetchData(firstURL: firstURL, secondURL: secondURL)
}

Task {
    let responses = try await task2.value

    print(responses)
}

 

 

특정 함수가 비동기로 돌아서 결과를 리턴할 때는 함수의 파라미터 뒤에 async를 명시한다. 추가적으로 특정 함수가 에러 핸들링을 위해 throws가 추가될 수 있는데 이 때 순서는 async throws가 된다. 순서는 개발할 때 반드시 지킬필요가 없는게 컴파일 시점에 위치가 반대로 되어있을 때 에러가 발생한다. 함수 해석을 할 때 이렇게 하면 편할 것 같다. 위 함수를 예를 들면 "URL 두개를 파라미터로 받아서 Data와 URLResponse 튜플 배열을 반환하는데 비동기로 처리되서 반환되고 에러를 반환할 수도 있다." 

 

위 같이 작성하면 firstResponse를 반환받기위해 URLSession.shared.data의 비동기 로직의 실행이 끝날 때 까지 대기할 것이고, secondResponse를 반환받는 로직이 실행될 것 이다. secondResponse를 반환받기위해 다시 한 번 URLSession.shared.data의 비동기 로직의 실행이 되고, 두번째 리스폰스를 정상적으로 받고나서야 해당 함수는 리턴될 것이다. 근데, 위와 같은 로직의 문제는 비동기 로직은 완료시간이 언제가 될지 모르기 때문에, 비동기 로직은 병렬적으로 실행되는 게 프로그램 실행속도가 빠를 수 있다. 이와 같이 await 비동기를 하나의 라인에서 대기하는 게 아닌, async로 도는 비동기로직을 동시에 실행하여 await하게 바꿀수도 있다. 아래는 비동기 로직 실행을 병렬적으로 하고, 최종에 결과를 리턴하도록 수정한 로직이다.

func fetchDataParellel(firstURL: URL, secondURL: URL) async throws -> [(Data, URLResponse)] {
    async let firstResponse = URLSession.shared.data(from: firstURL)
    async let secondResponse = URLSession.shared.data(from: secondURL)

    return try await [firstResponse, secondResponse]
}

let task3 = Task { () -> [(Data, URLResponse)] in
    try await fetchDataParellel(firstURL: firstURL, secondURL: secondURL)
}

Task {
    let responses = try await task3.value

    print(responses)
}

아까와 다르게 async 메서드를 호출하는 부분에서 await하는 것이 아닌, 변수에 async가 명시되어있다. 해당 변수는 async로 반환되는 함수 자체를 의미하는 것이므로, 실제 return 되는 부분의 await에서 각 async함수가 실행되며 전부 반환되면 리턴이 될 것이다. 이런 형태로 구현하면 병렬적으로 반복되는 비동기 로직을 실행할 수 있다.

 

결론은 async는 특정 함수가 비동기로 실행될 것을 의미하며, await는 비동기 로직의 실행이 완료될 때까지 대기함을 의미한다.

Actor

actor는 클래스 구조체와 마찬가지로 특정 자료구조를 선언할 때 쓰는 예약어이다. 단, actor로 선언된 자료구조의 모든 프로퍼티 및 메서드는 디폴트 값이 async이다. Actor는 특정 자료구조의 값을 변경하는 데 있어 무결성을 항상 유지해야만 할 때 쓰기 좋다. 이유는 변경 가능한 상태 값을 내부적으로 컨트롤하여 async메서드에 접근은 한 타임에 한 쓰레드만 가능하도록 하기 때문이다.

 

actor는 모든 프로퍼티 및 메서드가 기본적으로 비동기로 실행됨을 의미하지만, 프로토콜 준수라던지 특정 메서드나 프로퍼티는 비동기가 아닌 동기로 실행될 수 있다면 앞에 nonisolated를 선언하여 async하지 않게 접근할 수 있다.

 

다음 예제는 특정 클래스를 actor로 바꾼 예제이다.

// Concurrency를 사용하는 데 있어 가장 큰 리스크는 한 객체를 다수의 Task(멀티 스레드)에서 접근하여 객체의 변경이 일어날 때,
// 접근에 대한 제약이 없다면 객체의 참조의 무결성이 지켜지지 않아 예기치 못한 결과를 만들어낼 수 있다는 점이다.
// 아래 클래스의 예제를 확인해보자.
// 아래의 4가지 메서드는 클래스 내부의 songs라는 변수의 값을 변경시킨다.
// 동일한 인스턴스에 멀티 스레드에서 접근하여 메서드를 통해 songs를 변경한다면 기대하는 결과대로 나오지 않을 수 있다.
// 예를들어 A와 B 스레드에서 동시에 "Titanium"이라는 song을 add한다고 해보자.
// 해당 메서드는 guard문에서 동일한 이름의 song은 추가하지 않지만, 비동기적으로 A와 B에서 동시에 실행된다면
// 해당 인스턴스의 songs에는 두개의 "Titanium"이 추가될 수 있다.
// 이러한 문제를 해결하기 위해서 나온 Swift Concurrency에서 제공된 방법이 Actor이다.

class Playlist {
    let title: String
    let author: String
    private(set) var songs: [String]

    // MARK: - Initializers

    init(title: String, author: String, songs: [String]) {
        self.title = title
        self.author = author
        self.songs = songs
    }

    // MARK: - Internal Methods

    func add(song: String) {
        guard songs.contains(where: { $0 == song }) == false else {
            return
        }

        songs.append(song)
    }

    func remove(song: String) {
        songs.removeAll { $0 == song }
    }

    func move(song: String, from playlist: Playlist) {
        playlist.remove(song: song)
        add(song: song)
    }

    func move(song: String, to playlist: Playlist) {
        remove(song: song)
        playlist.add(song: song)
    }
}

// Actor
// Actor는 전역 수정가능한 상태를 관리하며 해당 상태에 동시에 접근하는 것을 예방한다.
// 멀티 스레드에서 Actor로의 접근은 한 번의 시간에 단 한번의 메서드만 실행하도록 허용한다.
// 모든 Actor 메서드들은 암시적으로 비동기로 되어있지만, 여기에 명시적으로 await를 강제하도록 구현할 수 있다.
// 그래서, move메서드들을 보면 두번째 파라미터로 전달되는 ActorPlaylist 메서드 호출앞에 await를 적어도, 적지않아도
// 컴파일 상에서 에러는 발생하지 않는다.

actor ActorPlaylist {
    let title: String
    let author: String
    private(set) var songs: [String]

    // MARK: - Initializers

    init(title: String, author: String, songs: [String]) {
        self.title = title
        self.author = author
        self.songs = songs
    }

    // MARK: - Internal Methods

    func add(song: String) {
        guard songs.contains(where: { $0 == song }) == false else {
            return
        }

        songs.append(song)
    }

    func remove(song: String) {
        songs.removeAll { $0 == song }
    }

    func move(song: String, from playlist: ActorPlaylist) async {
        await playlist.remove(song: song)
        add(song: song)
    }

    func move(song: String, to playlist: ActorPlaylist) async {
        await playlist.add(song: song)
        remove(song: song)
    }
}

// nonisolated는 Actor의 모든 메서드와 프로퍼티가 async가 암시적으로 깔려있을 때
// 준수할 프로토콜의 메서드나 프로퍼티가 동기를 요구할 때 해당 프로퍼티나 메서드에 한해서
// Actor의 async 선언을 없애는 예약어이다.

extension ActorPlaylist: CustomStringConvertible {
    nonisolated var description: String {
        "\(title) by \(author)"
    }
}

Sendable

sendable은 프로토콜이지만 내부적으로 프로퍼티 및 메서드의 구현을 강제하진 않는다. sendable을 준수한 자료구조는 비동기 로직을 실행하는 데 있어 안전성이 보장되는 자료구조라는 것을 의미한다. 다음은 Task의 초기화 함수 중 하나이다.

extension Task where Failure == Never {

    /// Runs the given nonthrowing operation asynchronously
    /// as part of a new top-level task on behalf of the current actor.
    ///
    /// Use this function when creating asynchronous work
    /// that operates on behalf of the synchronous function that calls it.
    /// Like `Task.detached(priority:operation:)`,
    /// this function creates a separate, top-level task.
    /// Unlike `Task.detached(priority:operation:)`,
    /// the task created by `Task.init(priority:operation:)`
    /// inherits the priority and actor context of the caller,
    /// so the operation is treated more like an asynchronous extension
    /// to the synchronous operation.
    ///
    /// You need to keep a reference to the task
    /// if you want to cancel it by calling the `Task.cancel()` method.
    /// Discarding your reference to a detached task
    /// doesn't implicitly cancel that task,
    /// it only makes it impossible for you to explicitly cancel the task.
    ///
    /// - Parameters:
    ///   - priority: The priority of the task.
    ///     Pass `nil` to use the priority from `Task.currentPriority`.
    ///   - operation: The operation to perform.
    public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)
}

초기화 함수의 두 번째 파라미터를 보면 @escaping말고도 @Sendable이 선언되어있다. Task를 만들기위해서라도 Sendable을 준수할 것을 의미한다. 비동기 로직을 실행함에 있어서 안정성을 보장하는 자료구조 형태란 교재에서는 해당 자료구조의 모든 저장된 프로퍼티들이 수정 불가능할 때가 예라고 하였다.

 

Concurrency는 iOS13.0부터 사용가능한 비동기 로직 처리방법 중에 하나이지만, 아직은 애플 네이티브 프레임워크에서도 해당 문법을 사용하여 호출하는 메서드가 모든 곳에 존재하지는 않는다. 다만, 클로저나 딜리게이트, 옵저빙과 다르게 비동기 로직을 실행하고 대기한 뒤에 이후에 호출하고 다음 로직이 실행되는 그런 로직이라면 위처럼 동기적으로 짜놓을 수 있으므로 이해하기 좋아보인다.