[Swift] Actor란?
안녕하세요. 1000JI 입니다!
저번 Async, Await, Task에 이어서 Actor를 다루게 되었습니다.
Actor.... 혹시 들어 본 적 있으신가요?
Actor도 Swift Concurrency가 도입되면서 짠하고 나타난 하나의 타입입니다!
왜 나타나게 되었을까요?
왜 Actor를 써야 할까?
먼저, Actor를 보기 전에 이전에 발생하고 있었던 주요 이슈가 뭐가 있었는지를 살펴보아야 합니다!
비동기 처리를 하게 되면 문제가 되었던 점 중 하나가 무엇일까요?
바로바로 동시성 문제가 많이 거론 됩니다.
Data Race, 즉 데이터 경합이라는 것이 발생 할 수 있습니다.
데이터 경합이 무엇일까요?
데이터 경합(Data Race)
두 개의 개별 스레드가 동시에 동일한 데이터에 액세스하거나 변경하려고 할 때 발생 할 수 있는 문제입니다.
만약 숫자 '10'든 변수가 있는 상황에서 A 스레드는 '5'를, B 스레드는 '10'을 더해서 저장하고 싶어 합니다.
유저는 '10 + 5 + 10' -> '25'가 저장 될 줄 알았는데, 정작 저장은 '15'가 되어있을 수 있습니다.
왜냐하면 동시에 접근 했을 때 A, B 스레드는 각각 변수에 '10'이 있다는 것을 인지했고,
거기에 각 스레드에 있는 값을 하나의 변수에 동시 저장하려고 하다 보니 발생 할 수 있다는 것입니다.
정리하면
- A 스레드와 B 스레드가 거의 동시에 value 변수에 접근합니다.
- A 스레드가 value 값을 읽습니다. 이때 value는 10입니다.
- B 스레드도 거의 동시에 value 값을 읽습니다. 이때도 value는 10입니다.
- A 스레드는 10에 5를 더해 15를 계산합니다.
- B 스레드는 10에 10을 더해 20을 계산합니다.
- A 스레드가 value에 15를 저장합니다.
- B 스레드가 value에 20을 저장합니다. 하지만 이 순간 A 스레드가 계산한 15는 덮어쓰여집니다.
위 처럼 정리 할 수 있겠습니다.
해결 방법은?
사실 해결 방법은 여러 가지 방법이 있습니다.
Foundation Framework에서 사용하는 NSLock이라던지, DispatchQueue를 이용한 순차적 접근이라던지요 :)
하지만 저희는 Actor라는 타입을 사용해보려고 합니다!
Actor란?
데이터 경합(Data Race)을 방지 목적으로 Shared Mutable State에 대한 동기화 매커니즘 입니다.
또, 클래스와 유사한 참조 타입(Reference Type)으로, 내부 상태를 안전하게 보호합니다.
한 번에 하나의 작업만 실행(Actor Isolation) 할 수 있기 때문에 데이터 경합(Data Race) 같은 동시성 문제를 방지 할 수 있습니다.
특징을 두 가지로 정리해보겠습니다.
- 프로퍼티 및 메서드에 대한 모든 액세스를 자동으로 직렬화하기에 지정된 시간에 하나의 호출자만 액터와 직접 상호 작용 할 수 있습니다.
따라서 모든 변화는 순차적으로 수행되고 데이터 경합을 완전히 방지 할 수 있습니다.- Data Race를 피하기 위해서 잠시 동안 호출코드를 '기다리게' 할 수 있습니다.
- Actor는 실제로 클래스가 아니기에 서브클래싱을 지원하진 않습니다.
- 다만, 다른 모든 타입과 같이 프로퍼티, 메소드, 이니셜라이저 등은 가질 수 있습니다.
- 외부에서 Actor와 상호작용 할 때 마다 비동기식으로 수행하며, 이는 Swift가 보장합니다.
Actor 예제
먼저, 문제가 될 만한 예제 코드부터 살펴 볼까요?
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]
}
}
만약 서로 다른 스레드에서 동시에 접근해서 동일한 user 정보에 저장 된다거나...
또는 어느 스레드는 user 정보를 저장하고, 어느 스레드는 해당 user 정보를 찾고..
말만 들어도 순서를 보장하기 어렵고 내가 원하는 데이터를 가져 올 수 있다는 확신을 내리기 어렵습니다.
그러면 순차적으로 동작하게 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]
}
}
}
DispatchQueue를 이용하여 처리 하였습니다만..
하나의 호출이 완료 될 때 까지는 실행이 차단되게 됩니다.
이는 많은 동시 읽기/쓰기 작업이 발생한다면 문제가 발생 할 수 있겠죠?
또한 하나가 완료 될 때 까지 다른 작업이 차단 되기에 성능 저하와 메모리의 과도한 낭비가 생길 수 있습니다.
이것이 바로 데이터 경합(Data Race)라고 합니다.
그러면 비동기로 처리하면 괜찮지 않을까요?
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])
}
}
}
자자자... escaping handler를 통해 비동기 처리를 했습니다.
하지만 우리는 Async/Await를 배웠던 이유가 뭐였을까요?
여러 이유가 있지만 대표적으로 나오는 말은 콜백 지옥을 벗어나기 위해... 였죠?
그런데 지금 상황보면 뭔가 또 콜백 지옥에 발을 딛는 느낌 입니다.
그럼 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에 있는 users 프로퍼티는 순차적으로 접근 할 수 밖에 없게 됩니다.
정말 간단하죠?
조금 더 딥한 코드로 살펴보겠습니다.
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, async를 통해서 상호 작용을 하게 됩니다.
이로써 actor도 같이 곁들여서 더 간단한 코드로 구현 할 수 있게 되었습니다.
하지만....... 이렇게 까지 했지만 데이터 경합에서 자유로울 순 없습니다.
정말 간단한 조회, 등록 같은 로직 같은 경우는 큰 문제가 없을 수 있으나,
실제 API 통신을 해서 많은 곳에서 사용자를 로드한다던지 해서 중복 네트워크 API 호출이 발생한다면 경쟁 상태에 빠질 수 있습니다.
사용자 정보를 이미 조회해서 기다리고 있는 상황인데 다른 곳에서 중복해서 요청 할 필요가 없다는거죠.
그렇다면 어떻게 해결 할 수 있을까요?
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
// 이미 테스크가 돌고 있다면? value 기다리기.
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
}
}
위와 같이 한다면 activeTasks가 작업이 가능한 경우 적절하게 재사용 되도록 보장하고,
Actor의 직렬화 매커니즘이 모든 변화를 예측 가능한 직렬 순서로 activeTasks에 전달합니다.
이해가 되셨을까요? :)
참고 사이트
https://green1229.tistory.com/341
Swift Concurrency - Actor
안녕하세요. 그린입니다🍏 이번 포스팅에서는 Actor가 무엇인지 간단히 살펴보고 Swift Concurrency에서 어떻게 활용되는지 학습해보겠습니다🙋🏻 우선 Swift에서는 다들 아시다시피 다양한 유형을
green1229.tistory.com
https://zeddios.tistory.com/1290
Swift ) Actor (1)
안녕하세요 :) Zedd입니다. WWDC 21 ) What‘s new in Swift 에서도 잠깐 본 내용인데, Actor에 대해서 공부. # 다중 쓰레드 시스템에서 제대로 작동하지 않는 코드 WWDC 21 ) What‘s new in Swift 에서 본 예제. class
zeddios.tistory.com
https://zeddios.tistory.com/1303 / https://zeddios.tistory.com/1304