본문 바로가기
iOS/Concurrency

class대신 actor가 요긴한 상황!

by 지금갑시다 2022. 12. 19.

해당 글은 Actor에 대한 아주 기본적인 이해만 있다면 이해하는 데 아무 문제가 없습니다!

 

Actor에 대해서 알아보고, 알아봤다는 현상만이 내 머리 속에 남았는데..

 

그중 생각나는 중요한 부분이!

Class와 거의 동일한 부분이 많았지만, Class가 thread safe 하지 않다면, Actor는 thread safe하다는 것이다.

 

그래서..! 아 그럼 Actor가 나온 배경은 Class의 thread safe문제를 해결 할 수 있어서 나왔겠구나! 라고 생각하게 되었고, 실제로 탄생 배경이 그러했다.

 

그럼, 이 글에서는 

 

1. 어떤 문제를 Actor가 풀어야 했는지!

2. 그래서 Actor가 있기 전에도 동일한 문제가 있었을텐데 어떻게 해당 문제가 풀리고 있었는지

3. 그 문제를 Actor를 활용해서는 어떻게 풀었는지!

 

에 대해서 알아보려고 한다.

 

먼저 기본 세팅을 해보자

struct Actors: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house.fill")
                }
            BrowseView()
                .tabItem {
                    Label("Browse", systemImage: "magnifyingglass")
                }
        }
    }
}

struct HomeView: View {
    @State private var text: String = ""
    let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        ZStack {
            Color.gray.opacity(0.8).ignoresSafeArea()   
            Text(text)
                .font(.headline)
        }
        .onReceive(timer) { _ in
            
        }
    }
}

struct BrowseView: View {
    @State private var text: String = ""
    let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
    
    var body: some View {
        ZStack {
            Color.yellow.opacity(0.8).ignoresSafeArea()   
            Text(text)
                .font(.headline)
        }
        .onReceive(timer) { _ in
            
        }
    }
}

위와 같이 간단한 TabView를 사용해 HomeView와 BrowseView 2개의 탭을 가지는 TabView를 구성해 주었고, 

 

 

 

HomeView의 경우 0.1 초마다 onReceive에서 어떤 동작을 해줄 것이고, BrowseView의 경우 0.01초마다 동작을 해줄 것이다.

 

이때 그냥 각 0.1초 0.01초 마다 각 뷰에 랜덤한 text를 넣어주는 Manager class를 만들어보자!

 

class MyDataManager {
    
    static let instance = MyDataManager()
    private init() { }
    
    var data: [String] = []
        
    func getRandomData() -> String? {
        self.data.append(UUID().uuidString)
        return self.data.randomElement()
    }
}

 

이제 MyDataManager의 instance를 활용해서 랜덤한 String을 Text로 넘겨주자

struct Actors: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house.fill")
                }
            BrowseView()
                .tabItem {
                    Label("Browse", systemImage: "magnifyingglass")
                }
        }
    }
}

struct HomeView: View {
    let manager = MyDataManager.instance
    @State private var text: String = ""
    let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        ZStack {
            Color.gray.opacity(0.8).ignoresSafeArea()   
            Text(text)
                .font(.headline)
        }
        .onReceive(timer) { _ in
            if let data = manager.getRandomData() {
                    DispatchQueue.main.async {
                        self.text = data
                    }
            }
        }
    }
}

struct BrowseView: View {
    let manager = MyDataManager.instance
    @State private var text: String = ""
    let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
    
    var body: some View {
        ZStack {
            Color.yellow.opacity(0.8).ignoresSafeArea()   
            Text(text)
                .font(.headline)
        }
        .onReceive(timer) { _ in
            if let data = manager.getRandomData() {
                    DispatchQueue.main.async {
                        self.text = data
                    }
            }
        }
    }
}

UI와 관련된 코드는 Main thread에서 작업되어야 해서 DispathQueue.main으로 넣은 과정이 있다.

 위와 같이 onReceive 를 구성해주면 아무 문제가 없다. background 쓰레드에서 작업이 진행되는 비동기 처리가 아니므로, 모든 작업이 main thread에서 진행되게 되고, 그 말은 곧 하나의 쓰레드에서 모든 작업을 진행함으로, 다른 간섭되는 문제로 데이터의 변화를 걱정할 필요 없는 것이다. 즉 View에 나오는 Text의 값도 아무 문제가 없다.

 

다만, 이제 백그라운드에서 작업해 데이터를 앞으로 던져주는 경우에는 문제가 될 수 있다.

실제 onReceive 안의 코드만 바꿔보자

DispatchQueue.global(qos: .background).async {
    print(Thread.current)
    if let data = manager.getRandomData {
        DispatchQueue.main.async {
            self.text = data
        }
    }
}

onRecieve안의 코드를 HomeView, BrowseView 두개다 바꾸어주게 된다면, 이때부터는 문제가 발생할 수 있다.

 

manager는 Heap에서 공유되고 있는 Class이고, HomeView와 BroseView의 각각의 쓰레드에서 manager class에 접근해버리면, 예기치 못한 Data Race 문제, Memory leak 문제가 일어날 수 있기 때문이다.

 

그러면 여기서 우리는 우리 글의 주제인 Actor를 떠올리며 이 문제를 Actor가 해결해야 하는 구나! 라고 생각할 수 있다.

그럼 '1. 어떤 문제를 Actor가 풀어야 했는지!' 는 알았다.

 

이제 '2. 그래서 Actor가 있기 전에도 동일한 문제가 있었을텐데 어떻게 해당 문제가 풀리고 있었는지'에 대해서 알아보자.

 

2를 해결하기 위해서 DispatchQueue를 만들어 각각의 접근을 Queue에 넣어 하나씩 처리하는 경우로 해결하였다. 어떤 쓰레드에서 Class에 접근하고 있으면, 다른 쓰레드는 해당 Class로 바로 접근하지 못하게 하고, 순서를 기다리고 접근하는 것이다.

 

 

이를 위해서는 MyDataManager가 변해야 하는데,

class MyDataManager {
    
    static let instance = MyDataManager()
    private init() { }
    
    var data: [String] = []
    
    // lock 이라는 DispatchQueue를 만들어주어 동시성 문제를 해결해왔음
    private let lock = DispatchQueue(label: "com.ConcurrencyActor")
    
    func getRandomData(completionHandler: @escaping(_ title: String?) -> ()) {
        // actor 이전에는 lock이라는 DispatchQueue를 만들어 lock처럼 동작하게 만들어 다른 task가 끝나기를 기다려 줌
        lock.async {
            self.data.append(UUID().uuidString)
            print(Thread.current)
            // closure안의 return을 completionhandler로 바꾸어 줌
            completionHandler(self.data.randomElement())
        }
        
    }
}

 

 위의 코드를 보면 알 수 있듯이, lock이라는 DispatchQueue를 만들어주어 해당 DispatchQueue에서 직접 async 작업을 만들어 준다. 그렇게 되면 외부 여러개의 쓰레드에서 랜덤하게 접근하여도, class의 lock으로 그 접근의 동시성처리를 해주게 되어 Data Race 와 같은 문제를 해결 할 수 있게 된다.

 

 

오~ 좋아좋아 그럼 된거 아니야? 문제도 다 해결했는데?

그러나. lock이라는 DispatchQueue를 만들어 주고 completionHandler를 만들어주어 코드를 복잡하게 만드는 단점이 있기에!

여기서는 Actor의 진가가 들어나게 된다.

'3. 그 문제를 Actor를 활용해서는 어떻게 풀었는지!' 에 대해서 보자!

 

MyDataManager를 actor로 만들어보자

 

actor MyActorManager {
    static let instance = MyActorManager()
    private init() { }
    
    var data: [String] = []     
    
    func getRandomData() -> String? {
        self.data.append(UUID().uuidString)
        return self.data.randomElement()    
    }
    
}

 

끝이다. 진짜..

DispatchQueue를 만들어줄 필요도, completionHandler를 만들어 들여쓰기를 해줄 필요도 없다.

다만, actor는 thread safe를 보장하기에 actor에 접근할때, await이라고 앞에 써주어야 한다.

 

이제 HomeView와 BrowseView에서의 instance를 MyActorManager로 바꾸어주고 HomeView 코드를 보자 (BrowseView도 동일한 코드임)

struct BrowseView: View {
    
    let actorManager = MyActorManager.instance
    @State private var text: String = ""
    let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
    
    var body: some View {
        ZStack {
            Color.yellow.opacity(0.8).ignoresSafeArea()
            
            Text(text)
                .font(.headline)
        }
        .onReceive(timer) { _ in
            // context를 비동기 환경으로 들어가게 만들어준다.
            Task {
                if let data = await actorManager.getRandomData() {
                    await MainActor.run(body: {
                        self.text = data
                    })
                }
            }
        }
    }
}

 

 정말 간단하게 thread safe를 보장하고, 이해하기에 어려운 코드도 아니다!

MainActor는 main thread에서 UI작업을 하기 위한 코드!

 

HomeView, BrowseView가 동일하게 class가 아닌 actor로 구현되어 있다면, 자동으로 어떤 뷰에서 actor에 접근하고 있다면, 다른 뷰는 기다려야 하게(thread safe하게) 구현이 되어 있어서 정말 요긴한 놈이다!!

 

actor는 thread safe하니까 위에서 걱정한 Data Race, Memory leak 문제 역시 해결할 수 있다!

 

 

끗!

 

REF: https://www.youtube.com/watch?v=UUdi137FySk&list=PLwvDm4Vfkdphr2Dl4sY4rS9PLzPdyi8PM&index=10