Sendable protocol과 Actor 개념 재정리

Swift Concurrency가 해결하고자 한 문제는 어떻게 보면 되게 간단하다 동시에 여러 스레드가 같은 데이터를 건드려도 안전하게 동작해야한다! 라는 점, 즉 Thread-Safety 문제를 해결하기 위함이다.

 

기존의 GCD를 활용했을때 문제점은

DispatchQueue.global().async {
		myData.value += 1
}

이 코드가 여러 스레드에서 동시에 실행되면 하나의 value에 여러 스레드가 동시에 접근하고, 그 결과로 데이터 경합 (Race Condition)이 발생하게 된다.

 

이런 문제를 막기위해서는 락(NSLock)을 사용하던지, 세마포어, 시리얼큐 등 직접 안정성을 관리했어야 했던점이 불필요한 작업들도 많이 들고 비효율적이었다.

 

이러한 개념을 좀 해결하기 위해서 Swift Concurrency는 각 작업은 독립적으로 동작한다는 원칙을 세우고 Task Isolation = 각 Task는 자신의 데이터만 다루게 한다.라는 개념으로 접근한다.

 

각 Task는 자신의 메모리 컨텍스트 내에서만 데이터 변경이 가능하고, 다른 Task와 공유 가능한 데이터는 오직 안전한 타입(Sendable) 만 허용한다. 이 과정에서 컴파일러가 이를 정적 분석으로 검증한다! 라는 개념으로 이제 Swfit에서 컴파일러가 Thread safe를 보장하는 계기가 된 것!!

 

여기서 Thread-Safety를 깨는 여러가지 속성 및 데이터들이 있다.

대표적으로 전역변수, 타입속성, 클래스 등 공유 변경 가능한 상태인 shared mutable state!! 전역 변수 및 타입 속성은 데이터 영역에 저장되어 모든 스레드가 접근 가능하며, 스레드 세이프하지 않는다.

 

클래스의 경우, 참조 타입이므로 var로 선언된 저장속성들은 동시에 접근 시 경합이 발생할 수 있다. 또한, 클로저에서 캡처된 변수는 클로저 주소를 캡처하기 때문에 데이터 경합이 생길 수 있다.

 

그래서 위에서 잠깐 설명했듯이 Sendable 프로토콜을 선언해서 스레드 세이프한 상태로 다른 Task에 전달할 수 있음을 나타내게 되는데, 이를 바탕으로 작업이 안전하게 배정되어 실행되게 된다.

 

Sendable 타입의 분류는 크게 5가지로 나눌 수 있다.

struct MyBook: Sendable {
    var title: String
    var page: Int
}
  • 값 타입 → 모든 값타입은 항상 값이 복사되어 저장되기때문에 안전한 상태로 거의 대부분의 값타입은 이 센더블 프로토콜을 채택하고 있다고 보면 된다!
final class Author: @unchecked Sendable {
    let name: String
    init(name: String) { self.name = name }
}
  • Immutable Class 클래스라 참조 타입이긴 하지만 모든 저장속성이 let으로 선언되어있고 final로 선언되어 상속이 불가한 상태인 클래스는 Sendable을 채택할 수 있다.
final class Cache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
    private var lock = NSLock()
    private var storage: [Key: Value] = [:]

    func getValue(forKey key: Key) -> Value? {
        lock.lock(); defer { lock.unlock() }
        return storage[key]
    }

    func setValue(_ value: Value, forKey key: Key) {
        lock.lock(); defer { lock.unlock() }
        storage[key] = value
    }
}
  • Thread-safe 클래스 → 내부적으로 위에서 말했던 NSLock , 세마포어 등 내부적으로 동기화 처리가 완료되어있는 클래스 또한 센더블을 채택할 수 있다.
actor ImageCache {
    private var storage: [String: UIImage] = [:]

    func image(for key: String) -> UIImage? {
        storage[key]
    }

    func setImage(_ image: UIImage, for key: String) {
        storage[key] = image
    }
}
  • Actor 타입 → Actor도 참조 타입이지만, Swift가 액터 내부에 동기적으로 작업이 실행될 수 있도록 해뒀기 때문에 안전한 Sendable 타입을 채택할 수 있다.
func doWork(_ operation: @escaping @Sendable () -> Void) {
    Task { operation() }
}
  • Sendable 함수 / 클로저 → 내부에서 캡쳐된 값이 모두 Sendable한 함수로 함수 내부에 모두 Sendable한 함수를 가지고 있는 함수 및 클로저도 동일하게 센더블을 채택할 수 있게 된다.

그렇다면 위에서 말한 Actor는 어떤 역할을 담당할까? 프로젝트를 진행하면서 @MainActor를 사용해 UI 업데이트를 메인스레드에서 작업될 수 있도록 보장하는 역할로 좀 사용하긴 했지만 보다 자세하게 액터의 역할에 대해서 알아보도록 하자.

 

Actor는 내부 상태에 대한 모든 접근을 자동으로 직렬화하는 참조 타입이다.

 

클래스처럼 참조 가능하지만, 항상 하나의 스레드에서만 실행되기 때문에 race conditon이 해결 될 수 있다.

actor DataStore {
    var names = ["스티브", "팀쿡", "머스크"]

    func changeName(_ name: String, at index: Int) {
        names[index] = name
    }

    func getName(at index: Int) -> String {
        names[index]
    }
}

내부의 names 배열은 오직 이 Actor 내부에서만 접근이 가능하다. 외부에서 접근하게 된다면 반드시 await을 붙여 사용해야 한다.

let store = DataStore()
Task {
    await store.changeName("뉴진스", at: 0)
}

await은 Actor 직렬 실행자(Serial Executor)에 안전하게 작업을 등록한다는 의미이다.

 

이 중에서 Actor Isolation의 개념도 따라오게 되는데 액터 내부에서는 액터 격리는 두가지의 의미가 있다.

 

내부적으로 격리되어있는 isolation(Actor 내부 속성과 메서드는 하나의 직렬 큐에서만 실행된다.) Non-isolation 이 두가지로 나뉘게 되는데 Non-isolation(비격리)의 경우, 때때로 Actor 내부에서도 격리 없이 동작해야할 때가 있다. 외부 라이브러리를 호출한다던지 등등 여러상황에서

actor Logger {
    nonisolated func printInfo() {
        print("Thread-safe한 외부 함수")
    }
}

그럴때 nonisolated를 붙여 사용하게 되면 Actor 내부에서도 다른 스레드에 작업을 실행할 수 있도록 비격리 처리를 진행할 수 있다!! 다만 Actor 내부 상태에 접근할 수는 없다.

 

완전히 독립되어 처리되는 작업의 경우에만 액터 내부에 있는 메서드를 비격리 처리를 통해 다른 스레드에서 실행될 수 있도록 처리하는 것!

지금까지 내용을 요약하자만 이렇게 표현할 수 있을 것 같다.

 

Swift Concurrency에서는 데이터를 공유하지 말고, 복사해서 안전하게 보내라! (Sendable), 만약 공유해야 한다면 Actor로 보호해라!

 

앞으로 Actor에 대해 더욱 자세하게 알아보자 들을 강의가 아직도 산더미다 ㅠㅠ 오늘은 여기까지