🔥 Actor

1023자
14분

Swift에서는 task를 사용하여 프로그램을 고립되고 병행적인 부분으로 분리할 수 있습니다. 그러나 때로는 task 간에 정보를 공유해야 할 필요가 있지요. 이럴 때 actor를 사용하면 병행 코드 간에 안전하게 정보를 공유할 수 있답니다.

Actor는 클래스와 마찬가지로 참조 타입이에요. 그래서 값 타입과 참조 타입의 비교는 클래스뿐만 아니라 actor에도 적용된답니다. 하지만 클래스와 달리, actor는 한 번에 하나의 task만 가변 상태에 접근할 수 있도록 허용해요. 이렇게 하면 여러 task의 코드가 동일한 actor 인스턴스와 안전하게 상호 작용할 수 있게 됩니다. 다음은 온도를 기록하는 actor의 예시입니다:

actor TemperatureLogger {
    let label: String
    var measurements: [Int]
    private(set) var max: Int
 
    init(label: String, measurement: Int) {
        self.label = label
        self.measurements = [measurement]
        self.max = measurement
    }
}
swift

actor 키워드 뒤에 중괄호 쌍으로 actor를 정의하면 됩니다. TemperatureLogger actor는 actor 외부의 다른 코드가 접근할 수 있는 속성이 있고, max 속성은 오직 actor 내부 코드만 최댓값을 업데이트할 수 있도록 제한하고 있어요.

구조체와 클래스와 동일한 초기화 구문을 사용하여 actor의 인스턴스를 생성할 수 있습니다. Actor의 속성이나 메서드에 접근할 때는 await을 사용하여 잠재적 일시 중단 지점을 표시해야 해요. 예를 들면:

let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// "25" 출력
swift

이 예제에서 logger.max에 접근하는 것은 일시 중단 지점이 될 수 있어요. Actor는 한 번에 하나의 task만 가변 상태에 접근하도록 허용하기 때문에, 다른 task의 코드가 이미 logger와 상호 작용 중이라면 이 코드는 속성에 접근하기 위해 대기하면서 일시 중단됩니다.

반면에 actor의 일부인 코드는 actor의 속성에 접근할 때 await을 작성하지 않아요. 예를 들어, 다음은 새로운 온도로 TemperatureLogger를 업데이트하는 메서드입니다:

extension TemperatureLogger {
    func update(with measurement: Int) {
        measurements.append(measurement)
        if measurement > max {
            max = measurement
        }
    }
}
swift

update(with:) 메서드는 이미 actor에서 실행 중이므로 max와 같은 속성에 접근할 때 await으로 표시하지 않습니다. 이 메서드는 actor가 한 번에 하나의 task만 가변 상태와 상호 작용하도록 허용하는 이유 중 하나를 보여주기도 해요. Actor의 상태에 대한 일부 업데이트는 일시적으로 불변성을 깨뜨리거든요.

TemperatureLogger actor는 온도 목록과 최고 온도를 추적하고, 새 측정값을 기록할 때 최고 온도를 업데이트합니다. 업데이트 중간에, 새 측정값을 추가한 후 max를 업데이트하기 전에, 온도 로거는 일시적으로 불일치 상태에 있게 되죠. 여러 task가 동시에 동일한 인스턴스와 상호 작용하는 것을 방지하면 다음과 같은 이벤트 시퀀스로 인한 문제를 예방할 수 있습니다:

  1. 여러분의 코드가 update(with:) 메서드를 호출합니다. 먼저 measurements 배열을 업데이트해요.
  2. max를 업데이트하기 전에, 다른 곳의 코드가 최댓값과 온도 배열을 읽습니다.
  3. 여러분의 코드가 max를 변경하여 업데이트를 마칩니다.

이 경우, update(with:)를 호출하는 도중에 데이터가 일시적으로 유효하지 않은 상태에서 다른 곳에서 실행 중인 코드가 actor에 접근하여 잘못된 정보를 읽게 됩니다. Swift actor를 사용할 때는 이런 문제를 방지할 수 있어요. Actor는 한 번에 상태에 대해 하나의 작업만 허용하고, 그 코드는 await이 일시 중단 지점을 표시하는 곳에서만 중단될 수 있기 때문이죠. update(with:)에는 일시 중단 지점이 없으므로 다른 코드가 업데이트 중간에 데이터에 접근할 수 없습니다.

만약 actor 외부의 코드가 구조체나 클래스의 속성에 접근하는 것처럼 이러한 속성에 직접 접근하려고 하면, 컴파일 타임 오류가 발생해요. 예를 들면:

print(logger.max)  // 오류
swift

await을 작성하지 않고 logger.max에 접근하면 실패합니다. Actor의 속성은 해당 actor의 격리된 로컬 상태의 일부이기 때문이에요. 이 속성에 접근하는 코드는 actor의 일부로 실행되어야 하며, 이는 비동기 작업이므로 await을 작성해야 합니다. Swift는 actor에서 실행되는 코드만 해당 actor의 로컬 상태에 접근할 수 있다는 것을 보장하는데, 이러한 보장을 actor isolation이라고 해요.

Swift 동시성 모델의 다음 측면들이 함께 작용하여 공유 가변 상태에 대해 더 쉽게 추론할 수 있게 만들어 줍니다:

  • 잠재적 일시 중단 지점 사이의 코드는 다른 병행 코드의 중단 가능성 없이 순차적으로 실행됩니다.
  • actor의 로컬 상태와 상호 작용하는 코드는 해당 actor에서만 실행됩니다.
  • actor는 한 번에 하나의 코드 조각만 실행합니다.

이러한 보장 덕분에 actor 내부에 있고 await를 포함하지 않는 코드는 프로그램의 다른 곳에서 일시적으로 유효하지 않은 상태를 관찰할 위험 없이 업데이트를 수행할 수 있어요. 예를 들어, 아래 코드는 측정된 온도를 화씨에서 섭씨로 변환합니다:

extension TemperatureLogger {
    func convertFahrenheitToCelsius() {
        measurements = measurements.map { measurement in
            (measurement - 32) * 5 / 9
        }
    }
}
swift

위의 코드는 측정값 배열을 한 번에 하나씩 변환해요. map 작업이 진행되는 동안 일부 온도는 화씨이고 다른 온도는 섭씨입니다. 그러나 코드에 await이 포함되어 있지 않아서 이 메서드에는 잠재적 일시 중단 지점이 없어요. 이 메서드가 수정하는 상태는 actor에 속하므로, 해당 코드가 actor에서 실행될 때를 제외하고는 코드가 읽거나 수정하는 것으로부터 보호됩니다. 즉, 단위 변환이 진행되는 동안 다른 코드가 부분적으로 변환된 온도 목록을 읽을 방법이 없다는 뜻이에요.

일시 중단 지점을 생략하여 일시적으로 유효하지 않은 상태를 보호하는 actor 내에서 코드를 작성하는 것 외에도, 해당 코드를 동기 메서드로 이동할 수 있습니다. 위의 convertFahrenheitToCelsius() 메서드는 동기 메서드이므로 잠재적 일시 중단 지점을 포함하지 않는다는 것이 보장되죠. 이 함수는 데이터 모델을 일시적으로 불일치하게 만드는 코드를 캡슐화하고, 작업을 완료하여 데이터 일관성을 복원하기 전에는 다른 코드가 실행될 수 없다는 것을 코드를 읽는 사람이 쉽게 인식할 수 있게 해줍니다. 나중에 이 함수에 병행 코드를 추가하여 잠재적 일시 중단 지점을 도입하려고 하면 버그를 도입하는 대신 컴파일 타임 오류가 발생할 거예요.

Global Actor와 Main Actor

앞서 살펴본 actor는 새로운 actor를 정의하여 사용하는 방식이었어요. 하지만 Swift는 미리 정의된 actor도 제공하고 있습니다. 바로 @globalActor@MainActor인데요, 이들은 actor isolation을 위한 특별한 속성들이에요.

@globalActor 속성을 사용하면 전역 actor를 정의할 수 있어요. 전역 actor는 앱 전체에서 공유되는 상태를 안전하게 관리하는 데 사용됩니다. 다음은 @globalActor를 사용하여 전역 actor를 정의하는 예시입니다:

@globalActor
struct DatabaseActor {
    actor ActorType { }
 
    static let shared = ActorType()
}
swift

위 코드에서는 DatabaseActor라는 전역 actor를 정의하고 있어요. 이 actor는 데이터베이스 관련 작업을 수행할 때 사용될 수 있습니다. @DatabaseActor 속성을 사용하여 해당 actor에서 실행되어야 하는 코드를 표시할 수 있죠:

@DatabaseActor func fetchData() -> [String] {
    // 데이터베이스에서 데이터를 가져오는 코드
}
swift

@DatabaseActor 속성이 붙은 fetchData() 함수는 DatabaseActor에서 실행됩니다. 이렇게 하면 여러 task에서 동시에 데이터베이스에 접근하는 것을 방지할 수 있어요.

한편, @MainActor는 앱의 메인 스레드에서 실행되는 코드를 나타내는 특별한 전역 actor예요. UI 업데이트와 관련된 작업은 대부분 메인 스레드에서 실행되어야 하므로, @MainActor를 사용하여 해당 코드를 표시할 수 있습니다:

@MainActor func updateUI() {
    // UI를 업데이트하는 코드
}
swift

@MainActor 속성이 붙은 updateUI() 함수는 메인 스레드에서 실행되므로 UI를 안전하게 업데이트할 수 있어요.

@globalActor@MainActor를 사용하면 actor 기반 코드를 더욱 간결하고 명확하게 작성할 수 있답니다. 전역 actor를 통해 앱 전체에서 공유되는 상태를 관리하고, 메인 actor를 통해 UI 관련 작업을 처리할 수 있죠. 이를 통해 데이터 경쟁을 방지하고 안전한 병행 프로그래밍을 실현할 수 있습니다.

이처럼 Swift의 actor와 관련 기능들을 잘 활용한다면 복잡한 병행 프로그래밍에서도 안전하게 데이터를 공유하고 불변성을 유지할 수 있을 거예요. Actor의 개념과 사용법을 익히고, @globalActor@MainActor를 상황에 맞게 활용해 보세요. 더욱 강력하고 안정적인 병행 프로그램을 만들 수 있습니다!