ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Swift] Actor란?
    Swift 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

    'Swift' 카테고리의 다른 글

    [Swift] @MainActor란?  (4) 2024.08.18
    [Swift] Async, Await, Task란?  (0) 2024.08.14
Designed by Tistory.