Swift/ETC

[Swift] Actor 격리 시스템: 동시성 환경에서 안전한 데이터 접근을 위한 핵심 메커니즘

Develup 2025. 3. 11. 16:56
반응형

Swift의 액터 시스템은 동시성 프로그래밍에서 가장 혁신적인 기능 중 하나입니다. 특히 액터의 격리 메커니즘은 데이터 레이스와 같은 동시성 문제를 방지하는 핵심 요소입니다. 이 글에서는 액터의 격리 시스템이 어떻게 작동하는지, 이것이 왜 중요한지, 그리고 실제 코드에서 어떻게 효과적으로、최적화된 방식으로 활용할 수 있는지 살펴보겠습니다.

액터 격리의 개념과 원리

액터 격리(Actor isolation)는 액터 내부의 가변 상태에 대한 접근을 동기화하여 데이터 레이스를 방지하는 메커니즘입니다. 이는 Swift의 타입 시스템과 컴파일러가 강제하는 규칙을 통해 구현됩니다.

액터 격리의 기본 원칙은 다음과 같습니다:

  1. 배타적 접근: 한 번에 하나의 태스크만 액터의 가변 상태에 접근할 수 있습니다.
  2. 경계 보호: 액터 외부에서 액터의 가변 상태에 직접 접근할 수 없습니다.
  3. 진입점 제어: 액터의 상태에 접근하려면 액터의 메서드나 프로퍼티를 통해야 합니다.
  4. 비동기 경계: 외부에서 액터의 메서드를 호출할 때는 비동기적으로(await 키워드 사용) 접근해야 합니다.

다음은 액터 격리의 기본적인 예입니다:

actor BankAccount {
    private var balance: Double = 0
    
    func deposit(amount: Double) {
        balance += amount
    }
    
    func withdraw(amount: Double) throws -> Double {
        guard balance >= amount else {
            throw BankError.insufficientFunds
        }
        balance -= amount
        return amount
    }
    
    func checkBalance() -> Double {
        return balance
    }
}

이 코드에서 balance 변수는 액터 내부에 격리되어 있습니다. 외부에서는 deposit, withdraw, 또는 checkBalance 메서드를 통해서만 접근할 수 있으며, 이러한 메서드 호출은 자동으로 직렬화됩니다.

 

액터 격리의 종류와 적용 범위

액터 격리는 여러 수준에서 적용될 수 있습니다. Swift에서는 다음과 같은 방식으로 격리를 표현할 수 있습니다:

1. 액터 선언을 통한 격리

가장 기본적인 형태의 격리는 액터 타입을 선언하는 것입니다:

actor DataManager {
    private var data: [String: Any] = [:]
    
    func setValue(_ value: Any, forKey key: String) {
        data[key] = value
    }
    
    func getValue(forKey key: String) -> Any? {
        return data[key]
    }
}

액터 내부의 모든 가변 상태는 자동으로 격리되며, 외부에서는 비동기적으로 접근해야 합니다:

let manager = DataManager()
await manager.setValue("hello", forKey: "greeting")
let greeting = await manager.getValue(forKey: "greeting")

2. 명시적 격리 표현: nonisolated와 isolated

때로는 액터 내에서도 격리를 더 세밀하게 제어해야 할 필요가 있습니다. Swift는 이를 위해 nonisolated와 isolated 키워드를 제공합니다:

actor ImageProcessor {
    private var images: [String: UIImage] = [:]
    
    func processImage(named name: String) async -> UIImage? {
        guard let image = images[name] else { return nil }
        
        // 시간이 오래 걸리는 이미지 처리 작업
        let processedImage = await heavyImageProcessing(image)
        return processedImage
    }
    
    nonisolated func getImageSize(for image: UIImage) -> CGSize {
        // 이 메서드는 액터 상태에 접근하지 않으므로 격리가 필요 없음
        return image.size
    }
    
    nonisolated var processingDescription: String {
        // 이 프로퍼티는 액터 상태에 접근하지 않음
        return "Standard image processing"
    }
}

nonisolated 키워드는 해당 메서드나 프로퍼티가 액터의 가변 상태에 접근하지 않음을 나타냅니다. 이를 통해 외부에서 비동기적 호출 없이 직접 접근할 수 있어 성능이 향상됩니다.

반면, isolated 키워드는 함수 파라미터나 컨텍스트에서 격리 상태를 명시적으로 표현할 수 있게 해줍니다:

func processUserData(isolated user: User) {
    // 이 블록 내에서는 user 액터의 상태에 동기적으로 접근 가능
    let name = user.name  // await 필요 없음
    let email = user.email
}
반응형

액터 격리와, 참조 타입과의 상호작용

액터 격리의 중요한 측면 중 하나는 참조 타입(클래스)과의 상호작용입니다. 격리된 상태에 클래스 인스턴스가 포함되어 있으면, 해당 객체의 가변성에 주의해야 합니다.

actor DocumentManager {
    private var documents: [Document] = []  // Document가 클래스인 경우
    
    func addDocument(_ document: Document) {
        documents.append(document)
    }
    
    func getDocument(at index: Int) -> Document? {
        guard index < documents.count else { return nil }
        return documents[index]  // 주의: 참조가 반환됨
    }
}

이 경우, getDocument(at:) 메서드는 Document 객체에 대한 참조를 반환합니다. 이 객체가 클래스라면, 반환된 참조를 통해 여러 스레드에서 동시에 수정할 수 있게 되어 데이터 레이스가 발생할 수 있습니다.

이러한 문제를 해결하기 위한 몇 가지 접근 방식이 있습니다

 

1. Sendable 프로토콜 활용: 액터 경계를 넘어 전달되는 타입은 Sendable 프로토콜을 준수해야 합니다.

class Document: Sendable {
    let title: String
    let content: String
    
    init(title: String, content: String) {
        self.title = title
        self.content = content
    }
    
    // Sendable을 준수하기 위해 불변(immutable) 상태만 가져야 함
}

2. 깊은 복사본 전달: 가변 객체의 복사본을 만들어 전달합니다.

actor DocumentManager {
    private var documents: [Document] = []
    
    func getDocumentCopy(at index: Int) -> Document? {
        guard index < documents.count else { return nil }
        // 복사본 생성 후 반환
        return documents[index].copy() as? Document
    }
}

3.  값 타입 사용: 가능한 경우 클래스 대신 구조체와 같은 값 타입을 사용합니다.

actor DocumentManager {
    private var documents: [DocumentStruct] = []  // 구조체 사용
    
    func getDocument(at index: Int) -> DocumentStruct? {
        guard index < documents.count else { return nil }
        return documents[index]  // 값이 복사되어 반환됨
    }
}

액터 격리의 실제 패턴 및 최적화

실제 애플리케이션에서 액터 격리를 효과적으로 활용하기 위한 몇 가지 패턴과 최적화 기법을 살펴보겠습니다.

1. 유한 상태 기계(Finite State Machine) 패턴

액터는 상태 관리가 필요한 시스템을 모델링하는 데 아주 적합합니다:

actor StateMachine<State> {
    private var currentState: State
    private var transitionRules: [State: Set<State>] = [:]
    
    init(initialState: State) {
        self.currentState = initialState
    }
    
    func addTransition(from source: State, to destination: State) {
        if transitionRules[source] == nil {
            transitionRules[source] = []
        }
        transitionRules[source]?.insert(destination)
    }
    
    func transition(to newState: State) throws {
        guard let allowedStates = transitionRules[currentState],
              allowedStates.contains(newState) else {
            throw StateMachineError.invalidTransition
        }
        
        currentState = newState
    }
    
    func getCurrentState() -> State {
        return currentState
    }
}

이 패턴은 상태 변화가 항상 정의된 규칙에 따라 일어나도록 보장합니다.

2. 배치 처리(Batch Processing) 최적화

액터 호출에는 일정한 오버헤드가 있기 때문에, 여러 개의 작은 호출보다는 배치로 처리하는 것이 효율적입니다:

actor DataStore {
    private var values: [String: Int] = [:]
    
    // 비효율적: 여러 번의 액터 호출 필요
    func setValue(_ value: Int, forKey key: String) {
        values[key] = value
    }
    
    // 효율적: 배치 처리
    func setValues(_ newValues: [String: Int]) {
        for (key, value) in newValues {
            values[key] = value
        }
    }
}

다음과 같이 사용할 수 있습니다:

// 비효율적 방식
await dataStore.setValue(1, forKey: "a")
await dataStore.setValue(2, forKey: "b")
await dataStore.setValue(3, forKey: "c")

// 효율적 방식
await dataStore.setValues(["a": 1, "b": 2, "c": 3])

3. 읽기-쓰기 최적화

읽기 작업이 많고 쓰기 작업이 적은 경우, 다음과 같은 최적화가 가능합니다:

actor ReadOptimizedCache<Key: Hashable, Value> {
    private var storage: [Key: Value] = [:]
    private var snapshotForReading: [Key: Value] = [:]
    
    func set(_ value: Value, forKey key: Key) {
        storage[key] = value
        // 쓰기 발생 시 읽기용 스냅샷 업데이트
        snapshotForReading = storage
    }
    
    // 읽기 전용 작업은 nonisolated로 선언하여 동기적 접근 허용
    nonisolated func get(forKey key: Key) -> Value? {
        return snapshotForReading[key]
    }
}

이 패턴은 읽기 작업이 비용이 많이 드는 비동기 액터 호출 없이 동기적으로 수행될 수 있게 합니다.

액터 격리와 데드락 방지

액터를 사용할 때 주의해야 할 중요한 문제 중 하나는 데드락입니다. 특히 여러 액터가 서로를 참조할 때 발생할 수 있습니다.

데드락 발생 시나리오

actor ResourceA {
    private var data: String = "Resource A"
    
    func accessResourceB(_ resourceB: ResourceB) async -> String {
        let dataFromB = await resourceB.getData()
        return "\(data) + \(dataFromB)"
    }
    
    func getData() -> String {
        return data
    }
}

actor ResourceB {
    private var data: String = "Resource B"
    
    func accessResourceA(_ resourceA: ResourceA) async -> String {
        let dataFromA = await resourceA.getData()
        return "\(data) + \(dataFromA)"
    }
    
    func getData() -> String {
        return data
    }
}

다음과 같은 코드는 데드락이 발생할 가능성이 있습니다:

let resourceA = ResourceA()
let resourceB = ResourceB()

// 두 태스크가 동시에 실행되면 데드락 가능성 있음
Task {
    let result = await resourceA.accessResourceB(resourceB)
}

Task {
    let result = await resourceB.accessResourceA(resourceA)
}

데드락 방지 전략

  1. 비동기 작업 분리: 상호 의존적인 액터 간 호출을 최소화합니다.
actor ResourceA {
    private var data: String = "Resource A"
    
    func combineWithResourceB(dataFromB: String) -> String {
        return "\(data) + \(dataFromB)"
    }
    
    func getData() -> String {
        return data
    }
}
  1. 시간 제한 설정: 작업에 시간 제한을 두어 영원히 기다리지 않도록 합니다.
try await withTimeout(.seconds(5)) {
    let result = await resourceA.accessResourceB(resourceB)
    return result
}
  1. 의존성 방향 단일화: 액터 간 의존성을 한 방향으로만 설계합니다.
// ResourceA는 ResourceB에 의존하지만, ResourceB는 ResourceA에 의존하지 않음
actor ResourceA {
    func accessResourceB(_ resourceB: ResourceB) async -> String {
        // ...
    }
}

actor ResourceB {
    // ResourceA에 의존하는 메서드 없음
}

식별자 기반 액터(Identity-Based Actor)와 격리

식별자 기반 액터는 특정 식별자에 따라 인스턴스가 생성되고 관리되는 패턴입니다. 이 패턴은 분산 시스템에서 유용합니다:

distributed actor User {
    let id: UUID
    private var name: String
    private var email: String
    
    init(id: UUID, name: String, email: String) {
        self.id = id
        self.name = name
        self.email = email
    }
    
    distributed func updateProfile(name: String? = nil, email: String? = nil) {
        if let name = name {
            self.name = name
        }
        
        if let email = email {
            self.email = email
        }
    }
}

distributed 키워드는 액터가 분산 시스템에서 작동할 수 있음을 나타냅니다. 이러한 액터는 네트워크를 통해 다른 프로세스나 기기에서도 접근할 수 있으며, 격리 메커니즘은 이러한 분산 환경에서도 작동합니다.

결론

Swift의 액터 격리 시스템은 동시성 프로그래밍의 복잡성을 크게 줄여주는 강력한 도구입니다. 적절하게 사용하면 데이터 레이스를 방지하고, 코드의 안정성을 높이며, 동시에 성능도 유지할 수 있습니다.

액터 격리를 효과적으로 활용하기 위한 핵심 사항은 다음과 같습니다:

  1. 격리 범위 이해하기: 액터 내부의 어떤 상태가 격리되는지 명확히 이해하세요.
  2. 적절한 경계 설정하기: nonisolated와 isolated 키워드를 활용하여 격리 경계를 최적화하세요.
  3. 참조 타입 주의하기: 액터 경계를 넘어 전달되는 참조 타입은 Sendable을 준수하거나 불변 상태여야 합니다.
  4. 성능 최적화하기: 배치 처리, 읽기-쓰기 최적화 등의 패턴을 적용하세요.
  5. 데드락 방지하기: 액터 간 상호 의존성을 주의 깊게 설계하세요.

액터 격리는 단순히 동시성 문제를 해결하는 도구를 넘어, 코드의 구조와 설계에 영향을 미치는 중요한 개념입니다. 적절한 격리 전략을 통해 동시성 프로그래밍의 이점을 최대한 활용하면서도 안전성을 보장할 수 있습니다.

반응형