-
Swift Concurrency - ActorConcurrency 2023. 3. 20. 11:16
안녕하세요. 그린입니다🍏
이번 포스팅에서는 Actor가 무엇인지 간단히 살펴보고 Swift Concurrency에서 어떻게 활용되는지 학습해보겠습니다🙋🏻
우선 Swift에서는 다들 아시다시피 다양한 유형을 클래스, 구조체, 열거 타입등으로 정의할 수 있습니다.
거기다 Swift 5.5에서부터는 Swift Concurrency가 도입되면서 actor라는 새로운 유형이 짠하고 나타났어요⭐️
그럼 우선 actor 탄생 전에 어떻게 기존 유형을 가지고 데이터를 처리했을까요?
데이터 경합 방지
Swift의 새로운 유형인 actor의 가장 핵심적인 장점 중 하나는 race condition, 즉 데이터 경합이라는것을 방지해줄 수 있습니다.
두 개의 개별 스레드가 동시에 동일한 데이터에 엑세스하거나 변경하려고 할 때 발생할 수 있는 메모리 손상 문제를 방지해줄 수 있죠.
예를들어 우리가 아래와 같은 actor가 아닌 class 타입의 객체가 있다고 가정해보겠습니다.
class UserStorage { private var users = [User.ID: User]() func store(_ user: User) { users[user.id] = user } func user(withID id: User.ID) -> User? { users[id] } }
해당 구현에는 우선 문제가 없죠.
그러나 멀티 스레드 환경에서 UserStorage 클래스를 사용할 때 구현이 현재 요청된 스레드 혹은 DispatchQueue에 대해 내부적인 변화를 수행하기에 다양한 종류의 데이터 경합인 race condition에 부딪히게 됩니다.
결과적으로 해당 클래스는 현재 스레드로부터 안전하지 않습니다.
이를 해결하기 위한 방법으로 모든 읽기/쓰기를 특정 DispatchQueue에 수동으로 디스패치하여 일감을 주는 것입니다.
이를 통해 UserStorage 클래스의 메서드가 사용되는 스레드 혹은 큐에 관계없이 항상 직렬적으로 작업이 수행되죠.
바로 아래처럼 sync하게 만들 수 있습니다.
class UserStorage { private var users = [User.ID: User]() private let queue = DispatchQueue(label: "UserStorage.sync") func store(_ user: User) { queue.sync { self.users[user.id] = user } } func user(withID id: User.ID) -> User? { queue.sync { self.users[id] } } }
이 보완된 코드는 이제 데이터 경합으로부터는 방지되었지만 여전히 문제가 있습니다.
해당 API를 사용해 사전 엑세스 코드를 발송하기에 하나의 호출이 완료될 때까지는 현재 실행이 완전히 차단되게 됩니다.
이는 많은 동시적인 읽기/쓰기 기능을 수행하면 문제가 발생합니다.
또한 하나가 완료될 때까지 다른 작업이 차단되기에 성능 저하와 메모리의 과도한 낭비가 생길 수 있습니다.
이런걸 우리는 데이터 경합이라고 합니다😭
그럼 이 문제를 해결해봐야겠죠?
하나의 방법으로 메서드를 완전한 비동기로 만드는 것입니다.
즉 아래와 같은 코드로 개선될 수 있습니다.
class UserStorage { private var users = [User.ID: User]() private let queue = DispatchQueue(label: "UserStorage.sync") func store(_ user: User) { queue.async { self.users[user.id] = user } } func loadUser( withID id: User.ID, handler: @escaping (User?) -> Void) { queue.async { handler(self.users[id]) } } }
보시면 이제 async로 변화하고 또한 handler 클로저를 통해 사용할 수 있습니다.
이런 처리가 아주 익숙하신 분들도 많을거에요.
Swift 5.5 이전에서 많이 사용되고 선호되는 방법 중 하나였습니다.
클로저라는 개념이 아주 좋지만 클로저로 모든 코드를 감싸야 하는 단점이 코드를 더 복잡하고 어렵게 만들기도 합니다.
특히 loadUser 메서드의 클로저 인자를 이스케이핑 처리해야함으로 사용부가 더 복잡해지죠.
그래서 이런것들의 장점을 가져오고 단점을 보완한 actor를 보시죠!
Actor
actor는 클래스와 두 가지 주요 예외사항을 빼고는 거의 유사하게 동작합니다.
즉, 참조로 전달되는 핵심은 마찬가지이죠.
그 두 가지 다른 사항은 아래와 같아요.
1️⃣ actor는 해당 프로퍼티 및 메서드에 대한 모든 액세스를 자동으로 직렬화하기에 지정된 시간에 하나의 호출자만 액터와 직접 상호 작용할 수 있습니다.
그렇다면 모든 변화가 차례로 순차적으로 수행되고 데이터 경합을 완전히 방지할 수 있습니다.
2️⃣ actor는 실제로 클래스가 아니기에 서브클래싱을 지원하진 않습니다.
따라서 클래스를 액터로 변환하기 위해 할일은 딱 하나🙌
class 키워드를 actor로 바꾸는거 그 이상 그 이하도 없습니다.
actor UserStorage { private var users = [User.ID: User]() func store(_ user: User) { users[user.id] = user } func user(withID id: User.ID) -> User? { users[id] } }
이제 아주 조그만 변화로 우리는 UserStorage 타입이 어떤 종류의 사용자 지정 디스패치를 가질지 구현할 필요 없이 완전히 스레드로부터 안전하게 되었습니다!
액터라는 키워드를 사용하여 다른 코드에서 메서드를 호출할때 await 키워드를 통해 호출하도록 강제하게 됩니다.
그럼 예시를 볼까요?
예시로, 우리는 actor로 만들어진 UserStorage를 통해 값을 로드하고 저장하게 됩니다.
class UserLoader { private let storage: UserStorage private let urlSession: URLSession private let decoder = JSONDecoder() init(storage: UserStorage, urlSession: URLSession = .shared) { self.storage = storage self.urlSession = urlSession } func loadUser(withID id: User.ID) async throws -> User { if let storedUser = await storage.user(withID: id) { return storedUser } let url = URL.forLoadingUser(withID: id) let (data, _) = try await urlSession.data(from: url) let user = try decoder.decode(User.self, from: data) await storage.store(user) return user } }
이제 액터와 상호작용이 필요한 메서드에서는 await를 사용하게 되는것이죠.
이전 class를 사용해 비동기 처리를 해주면서 클로저로 이뤄진 복잡한 부분들을 actor와 await의 단 하나의 키워드로 해결할 수 있게 되었어습니다⭐️
여전히 발생할 수 있는 Race Condition
위의 학습을 통해 우리는 데이터 경합을 해결할 수 있었습니다.
그러나 반드시 데이터 경합에서 자유롭지 않을 수 있습니다.
낮은 수준의 데이터 경합에서는 괜찮지만 말이죠ㅠ
여러 작업이 예측할 수 없는 순서로 발생할 수 있습니다.
실제로 UserLoader를 사용해 여러 곳에서 동시에 동일한 사용자를 로드하면 여러번의 중복 네트워크 API 호출이 수행되기에 경쟁 상태에 빠질 가능성이 농후합니다.
저장된 사용자 값은 해당 사용자가 완전히 로드된 후에만 존재하기 때문이죠.
모든 호출을 직렬화 한다쳐도, 액터 내에서 대기가 발생할 때 여전히 중단될 수 있습니다.
즉, 액터는 차단이 해제되고 다른 코드로부터 새로운 요청을 받을 준비가 됩니다.
그럼 일반적으로는 논블로킹 코드를 작성할 수 있기에 이러한 동작이 UserLoader가 클래스로 구현되었을 때와 마찬가지로 계속 중복 네트워크 호출을 수행하게 합니다.
이걸 해결하기 위해 actor가 주어진 네트워크 통신을 수행하고 있는 시점을 추적해야 합니다.
즉 다음과 같이 참조를 딕셔너리로 저장할 수 있죠.
actor UserLoader { private let storage: UserStorage private let urlSession: URLSession private let decoder = JSONDecoder() private var activeTasks = [User.ID: Task<User, Error>]() ... func loadUser(withID id: User.ID) async throws -> User { if let existingTask = activeTasks[id] { return try await existingTask.value } let task = Task<User, Error> { if let storedUser = await storage.user(withID: id) { activeTasks[id] = nil return storedUser } let url = URL.forLoadingUser(withID: id) do { let (data, _) = try await urlSession.data(from: url) let user = try decoder.decode(User.self, from: data) await storage.store(user) activeTasks[id] = nil return user } catch { activeTasks[id] = nil throw error } } activeTasks[id] = task return try await task.value } }
activeTask를 두어 참조를 저장하여 활용할 수 있습니다.
이제는 UserLoader가 완전히 스레드로부터 안전하며 원하는 만큼의 많은 스레드 혹은 큐에서 여러번 호출 할 수 있습니다.
activeTask가 작업이 가능한 경우 적절하게 재사용되도록 보장하고 액터의 직렬화 메커니즘이 모든 변화를 예측 가능한 직렬 순서로 activeTask에 전달합니다.
결론
이제 액터를 사용해 안전하게 상태에 액세스할 수 있게 되었습니다.
그러나 유형을 액터로 전환하려면 비동기 방식으로 상호 작용을 해야 한다는 점은 분명히 기억해야 합니다.
이는 일반적으로 async/await 같은것을 사용하는 경우 호출을 수행하는데 다소 복잡하고 느리게 만들 수 있기 때문이죠.
[참고 자료]
'Concurrency' 카테고리의 다른 글
Swift Concurrency - @MainActor 사용하기 (8) 2023.03.27 Swift Concurrency - 이전 버전에서 비동기 시스템 API 사용하기 (3) 2023.03.16 Swift Concurrency - Throwing & 비동기 Swift 프로퍼티 (5) 2023.03.14 Swift Concurrency - Async sequence & stream (9) 2023.03.09 Swift Concurrency - map & forEach (14) 2023.03.09