[Swift] actor 간단한 활용법


Swift에서 actor는 동시성 문제를 해결하는 강력한 도구로, 내부 상태를 안전하게 관리할 수 있도록 도와줍니다. 하지만 모든 상황에서 만능 해결책이 되는 것은 아닙니다. actor를 제대로 이해하고 적절히 사용해야만, 성능 저하나 예기치 않은 동시성 문제를 방지할 수 있습니다. 이번 포스트에서는 actor를 올바르게 사용하는 방법과 잘못된 예시를 통해 간단한 활용법에 대해 알아보도록 하겠습니다.

actor?

actor는 클래스와 유사하지만, 차이점은 내부 상태에 대한 접근이 직렬화된다는 점입니다. 즉, 여러 스레드가 동시에 하나의 actor에 접근할 수 있지만, actor 내부의 상태 변경 작업은 한 번에 하나의 작업만 수행됩니다. 이로 인해 데이터 경합을 방지할 수 있게 해줍니다.


데이터 경합 상황 예시

비동기로 데이터 상태를 관리하면서 발생할 수 있는 간단한 예시 2가지를 보여드리겠습니다.

[1] 덧셈, 뺄셈 상태관리

다음의 예제는 class 형태로 된 CalculateManager를 이용해서 값상태를 관리하고 있습니다.
초기값이 0인 값에 +1과 -1을 각각 10만번을 비동기로 처리하는 예시입니다.

final class CalculateManager {
    var value: Int = 0
    
    func add() async { value += 1 }
    func minus() async { value -= 1 }
}

final class Tester {
    let calculateManager = CalculateManager()
    
    func test() async {
        await withTaskGroup(of: Void.self) { group in
            for _ in 0..<100000 {
                group.addTask {
                    await self.calculateManager.add()
                }
                group.addTask {
                    await self.calculateManager.minus()
                }
            }
        }
        print("최종 value 값: \(self.calculateManager.value)")
    }
}

let tester = Tester()

Task {
    await tester.test()
}
최종 value 값: -1

기대값은 0이지만 -1이 나왔습니다.

Best Practice

final actor CalculateManager {
    var value: Int = 0
    
    func add() async { value += 1 }
    func minus() async { value -= 1 }
}

[2] 데이터 저장

다음은 데이터를 비동기로 저장하는 예시입니다. 데이터의 저장순서는 중요하지 않고 단지 데이터만 정상적으로 저장이 되면 됩니다.

final class StorageManager {
    var storage: [String] = []
    
    func save(_ value: String) async -> [String] {
        storage.append(value)
        
        return storage
    }
}

final class Tester {
    
    let storage = StorageManager()
    
    func test() {
        Task { [weak self] in
            guard let self else { return }
            async let a = storage.save("a")
            async let b = storage.save("b")
            async let c = storage.save("c")
            async let d = storage.save("d")
            async let e = storage.save("e")
            async let f = storage.save("f")
            async let g = storage.save("g")
            async let h = storage.save("h")
            async let i = storage.save("i")
            async let j = storage.save("j")
            async let k = storage.save("k")
            async let l = storage.save("l")
            async let m = storage.save("m")
            
            print(await a)
            print(await b)
            print(await c)
            print(await d)
            print(await e)
            print(await f)
            print(await g)
            print(await h)
            print(await i)
            print(await j)
            print(await k)
            print(await l)
            print(await m)
        }
    }
    
}

let tester = Tester()

tester.test()
["f", "c", "m", "i", "h", "d", "j", "g"]
["f", "c", "m", "i", "h", "d", "j", "g", "e"]
["f", "c", "m", "i", "h", "d", "j", "g"]
["f", "c", "m", "i", "h", "d", "j", "g", "e"]
["f", "c", "m", "i", "h", "d", "j", "g", "e", "l"]
["f", "c", "m", "i", "h", "d", "j", "g"]
["f", "c", "m", "i", "h", "d", "j", "g", "e"]
["f", "c", "m", "i", "h", "d", "j", "g", "e"]
["f", "c", "m", "i", "h", "d", "j", "g", "e"]
["f", "c", "m", "i", "h", "d", "j", "g", "e", "l"]
[]
["f", "c", "m", "i", "h", "d", "j", "g", "e", "l"]
["f", "c", "m", "i", "h", "d", "j", "g"]

‘a’, ‘b’, ‘k’의 데이터가 제대로 저장 되지 않았음을 확인 할 수 있습니다.
테스트시 아래의 에러도 간헐적으로 발생했습니다.(Playground에서 테스트)

error async save data with class

Best Practice

final actor StorageManager {
    var storage: [String] = []
    
    func save(_ value: String) async -> [String] {
        storage.append(value)
        
        return storage
    }
}

actor 잘못된 사용 예시

데이터 경합이 발생할 수 있는 객체를 actor로 감싸면 상태 관리를 안전하게 할 수 있습니다.
그러나 actor를 잘못 사용하면 성능 저하나 동시성 문제가 발생할 수 있습니다.

[1] actor메서드 내에 비효율적인 작업이 존재

actor는 내부 상태를 안전하게 관리하기 위해 직렬화된 접근 방식을 사용합니다. 때문에, actor메서드는 상태 변경과 관련된 주요 로직만 처리하도록 하는 것이 좋습니다.

final actor StorageManager {
    var storage: [String] = []
    private var counter: Int = 0
    
    func save(_ value: String) async -> [String] {
        await preProcess(value) // 전처리 작업
        storage.append(value)
        return storage
    }
    
    func preProcess(_ value: String) async {
        ...
        print("\(value) 프로세스 끝")
    }
    
}

final class Tester {
    
    let storage = StorageManager()
    
    func test() {
        asyncSaveAndPrint(data: "a")
        asyncSaveAndPrint(data: "b")
        asyncSaveAndPrint(data: "c")
        asyncSaveAndPrint(data: "d")
        asyncSaveAndPrint(data: "e")
    }
    
    func asyncSaveAndPrint(data: String) {
        Task { [weak self] in
            guard let self else { return }
            let result = await storage.save(data)
            print(result)
        }
    }
    
}

let tester = Tester()

tester.test()

병목현상 발생

actor_bad_case

[2] 상태체크와 업데이트 설계

다음 예시는 패치를 한번만 진행되도록 하기 위해, 패치유/무 상태관리 변수를 actor에서 관리하도록 했습니다.
actor는 상태를 안전하게 보호하고 동시성을 처리할 수 있지만, 잘못된 설계로 인해 의도치 않게 패치 작업이 두 번 수행될 수 있습니다.
외부에서 actor의 메서드를 여러 번 호출할 때, 호출 간의 사이에 다른 곳에서 동일한 actor 메서드를 호출할 수 있다는 점을 주의해야 합니다. 이로 인해 데이터 경합이 발생할 수 있습니다.

final actor FetchManager {
    var isFetched: Bool = false
    
    func fetch() async -> Bool {
        // 패치 작업
        isFetched = true
        
        return true
    }
    
}

final class Tester {
    
    let manager = FetchManager()
    
    func test() {
        Task { [weak self] in
            guard let self else { return }
            if await !manager.isFetched { // 외부에서 상태 체크
                await preProcess()
                let result = await manager.fetch()
                print("패치시도: ", result)
            }
        }
        Task { [weak self] in
            guard let self else { return }
            if await !manager.isFetched {
                await preProcess()
                let result = await manager.fetch()
                print("패치시도: ", result)
            }
        }
    }
    
    func preProcess() async {
        ...
        print("프로세스 끝")
    }
    
}

let tester = Tester()

tester.test()
// 출력 결과
프로세스 끝
패치시도: true
프로세스 끝
패치시도: true

// 기대 출력
프로세스 끝
패치시도: true

Best Practice

final actor FetchManager {
    var isFetched: Bool = false
    
    func fetch() async -> Bool {
        guard !isFetched else { return false } // 내부에서 상태 체크
        // 패치 작업
        isFetched = true
        
        return true
    }
    
}

결론

actor를 사용하면 데이터 경합 문제를 효과적으로 관리할 수 있습니다. 그러나 actor를 무조건 신뢰하기보다는, 그 기능과 특성을 충분히 이해한 후 적절하게 설계하고 사용하는 것이 중요합니다. 올바른 설계를 통해 actor의 장점을 최대한 활용하여 안전하고 효율적인 동시성 처리를 하는 것이 중요합니다.




© 2021.02. by kirim

Powered by kkrim