Swift

[Swift] Actor란?

1000JI 2024. 8. 16. 01:02

 

안녕하세요. 1000JI 입니다!

저번 Async, Await, Task에 이어서 Actor를 다루게 되었습니다.

 

Actor.... 혹시 들어 본 적 있으신가요?

ActorSwift 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) 같은 동시성 문제를 방지 할 수 있습니다.

 

특징을 두 가지로 정리해보겠습니다.

  1. 프로퍼티 및 메서드에 대한 모든 액세스를 자동으로 직렬화하기에 지정된 시간에 하나의 호출자만 액터와 직접 상호 작용 할 수 있습니다.
    따라서 모든 변화는 순차적으로 수행되고 데이터 경합을 완전히 방지 할 수 있습니다.
    1. Data Race를 피하기 위해서 잠시 동안 호출코드를 '기다리게' 할 수 있습니다.
  2. Actor는 실제로 클래스가 아니기에 서브클래싱을 지원하진 않습니다.
    1. 다만, 다른 모든 타입과 같이 프로퍼티, 메소드, 이니셜라이저 등은 가질 수 있습니다.
  3. 외부에서 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