⚠️ 해당 내용은 Swift 공식문서인 동시성을 한글로 번역되어있는 내용을 바탕으로 작성한 글입니다. (말이 바탕이지 사실상 복사 붙혀넣기 입니다.)
이전에 작성한 글도 확인해보세요!
Swift Concurrency 1 - 동시성, 비동기 시퀀스
Swift Concurrency 2 - Task, Task Group, Task Cancellation
프로그램을 동시성 조각으로 분리하기 위해 Task를 사용할 수 있었죠.
작업은 서로 분리되어 있어서 같은 시간에 안전하게 실행은 될 수 있지만, 간혹 작업 간에 일부 정보들을 공유해야 할 수도 있습니다. 이런 상황에서 액터(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
}
}
위 코드는 온도를 기록하는 액터입니다.
actor
키워드를 사용하여 액터를 도입하고, 중괄호로 정의하게 됩니다. TemperatureLogger
액터는 액터 외부의 다른 코드가 접근할 수 있는 프로퍼티가 있고, 액터 내부의 코드만 최대값을 업데이트 할 수 있게 max
프로퍼티를 제한합니다.
구조체, 클래스와 같은 초기화 구문으로 액터의 인스턴스를 생성합니다. 액터의 프로퍼티 또는 메서드에 접근할 때 일시 중단 지점을 나타내기 위해 await
을 사용합니다.
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
이렇게요!
이와는 다르게 액터 내부에서 사용할 때 프로퍼티에 접근할 경우에는 await
키워드를 사용하지 않고 접근할 수 있습니다.
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
요렇게요! 왜 이렇게 가능한걸까요?
액터 내부의 메서드는 다른 코드와 동시에 실행되지 않기 때문입니다.
이는 동시성 문제를 방지하기 위한 것이죠. 액터는 한 번에 하나의 작업만 수행할 수 있도록 설계되어, 여러 작업이 동시에 액터의 상태를 변경하지 못하게 합니다.
작동되는 순서는 다음과 같습니다.
update
메서드를 먼저 호출합니다. measurements
배열을 업데이트 합니다.max
를 업데이트 하기 전에 다른 코드에서 최대값과 온도 배열을 읽습니다.max
를 변경하여 업데이트를 완료합니다.Swift 액터는 한 번에 해당 상태에 하나의 작업만 허용하고 해당 코드는 await
가 일시 중단 지점으로 표시되는 위치에서만 중단될 수 있기 때문에, Swift 행위자를 사용하여 이 문제를 방지할 수 있습니다.
print(logger.max)
이렇게 접근이 가능할까요?
await
작성 없이 logger.max
에 접근하는 것은 액터의 프로퍼티가 액터 내부의 프로퍼티이기 때문에 애초에 컴파일 단계에서 실패하게 됩니다.
이 프로퍼티는 무조건적으로 비동기 작업인 액터의 일부분으로 수행되어야 하고, await
키워드를 사용하여 접근해야 합니다.
Swift는 액터에서 수행하는 코드만 액터의 로컬 상태의 접근할 수 있도록 보장합니다.
이 보장을 액터 분리 (actor isolation) 이라고 합니다.
중단 가능 시점(또는 일시 중단 시점) 사이의 코드는 다른 비동기 코드의 간섭 없이 순차적으로 진행됩니다.
즉, await
키워드가 없는 코드 블록은 중단되지 않고 끝까지 실행됩니다.
예시로 update
메서드 내부에 await
키워드가 없기 때문에, 이 메서드는 다른 코드의 간섭 없이 순차적으로 실행됩니다.
actor TemperatureLogger {
private var measurements: [Int] = []
private var max: Int = Int.min
func update(with newMeasurement: Int) {
measurements.append(newMeasurement) // 중단 가능 지점 없음
if newMeasurement > max {
max = newMeasurement // 중단 가능 지점 없음
}
}
}
액터의 로컬 상태와 상호작용하는 코드는 해당 액터 내부에서만 수행됩니다. 이는 다른 스레드나 코드가 액터의 상태에 직접 접근할 수 없음을 의미하게 되죠.
예시로, 액터 내의 measurements
배열과 max
값은 오직 TemperatureLogger
액터 내에서만 수정되고 접근됩니다.
actor TemperatureLogger {
private var measurements: [Int] = []
private var max: Int = Int.min
// 액터 내부에서 수정
func update(with newMeasurement: Int) {
measurements.append(newMeasurement)
if newMeasurement > max {
max = newMeasurement
}
}
// 액터 내부에서 호출
func getMax() -> Int {
return max
}
// 액터 내부에서 호출
func getMeasurements() -> [Int] {
return measurements
}
}
액터는 한 번에 하나의 작업만 수행할 수 있습니다. 이는 액터 내의 코드가 다른 작업과의 동시 실행 없이 독립적으로 실행되는 것을 보장합니다.
예시로, update(with:)
메서드가 실행되는 동안 다른 코드가 실행되지 않습니다. 이는 액터가 한 번에 하나의 작업만 수행하도록 설계되었기 때문입니다.
위에 언급된 세 가지 항목은 Swift의 액터 모델에서 액터 분리(Actor Isolation)를 설명하는 중요한 개념들입니다.
이를 통해 액터는 동시성 문제를 안전하게 처리하고, 상태의 일관성을 유지할 수 있게됩니다.
액터 분리는 동시성 프로그래밍에서 매우매우(호우) 중요한 개념이며,
Swift에서 안전하고 효율적인 동시성 처리를 가능하게 합니다.
Task와 Actor는 프로그램을 동시에 안전하게 실행할 수 있는 조각으로 나눌 수가 있습니다.
Task 또는 Actor의 인스턴스 내부에서 변수와 프로퍼티와 같은 특정 코드 블록이나 데이터가 동시적으로 접근이 되고 변경이 가능한 상태를 포함하는 프로그램의 일부분을 동시성 도메인 (concurrency domain) 이라고 부릅니다. 어떤 데이터는 데이터가 변경 가능한 상태를 포함하지만, 동시로 접근하는 상황에 대해 보호가 되지 않으므로 동시성 도메인 간에 공유가 될 수 없습니다.
이게 무슨말인지 잘 이해가 안갑니다. 그래서 더 찾아봤습니다.
동시성 도메인 간 데이터 공유의 문제
동시성 도메인 간에 데이터를 공유하는것은 문제가 될 수 있다고 합니다. 여러 작업이 동시에 동일한 데이터에 접근하려고 하면, 데이터가 일관성을 잃거나 손상이 될 수 있죠.
이런 문제를 보통 Race Condition(데이터 경쟁) 이라고 합니다.
이러한 문제를 방지하려면 어떤 작업이 들어가야 할까요?
아마 수정이 불가능하거나, 어디서나 같은 값을 제공받을 수 있어야 한다고 생각이 드는데요.
이를 가능하게 해주는 것이 전송 가능 타입 입니다. 전송 가능 타입은 동시성 도메인 간에 안전하게 전달될 수 있는 데이터 타입을 의미힙니다.
Sendable
프로토콜을 준수하는 타입만이 동시성 도메인 간에 안전하게 전달될 수가 있습니다.
전송 가능 타입으로 만드는 방법
값 타입(Value Type) : 변경 가능한 상태가 없고, 모든 프로퍼티가 전송 가능한 타입으로 구성
struct Point: Sendable {
let x: Int
let y: Int
}
// 값 타입은 복사될 때마다 새로운 인스턴스를 생성하기 때문에
// 동시에 접근할 상황에 문제가 발생하지 않습니다.
읽기 전용(Read-Only) 타입: 변경 가능한 상태가 없고, 모든 상태가 전송 가능한 타입으로 구성
struct ImmutableData: Sendable {
let data: String
}
// 읽기 전용 프로퍼티만 있는 구조체 또는 클래스가 이에 해당합니다.
// 이런 타입은 상태가 불변이므로 동시 접근으로 인한 문제가 발생하지 않습니다.
특정 스레드나 큐에서 안전하게 접근되는 타입: @MainActor
로 표시된 클래스나 특정 큐에 안전하게 접근되는 클래스
@MainActor class MainActorClass: Sendable {
var counter: Int = 0
}
// 클래스가 특정 스레드나 큐에서만 접근이 되고,
// 그로 인해 변경 가능한 상태가 안전하게 관리되는 경우
// @MainActor 속성을 사용하여 메인 스레드에서만 접근 가능한 클래스를 정의할 수 있습니다.
다른 곳에서도 사용이 가능한데,
let sendableClosure = { @Sendable (number: Int) -> String in
return number > 12 ? "More than a dozen." : "Less than a dozen"
}
이와 같이 클로저 내부에서도 사용이 가능합니다.
@Sendable
어트리뷰트는 클로저가 동시성 도메인 간에 안전하게 전달될 수 있음으로 나타내며
클로저 내부의 값이 안전하게 변하지 않도록 보장하게 됩니다. 즉, 클로저가 참조하는 값들이 전송 가능 타입임을 보장하여 동시성 환경에서 안전하게 사용할 수 있도록 합니다.
위 내용이 어떻게 보장을 하는지 궁금해서 조금 더 찾아봤습니다.
@Sendable
어트리뷰트를 사용하면 클로저가 동시성 도메인 간에 안전하게 전달될 수 있다는 것을 컴파일러가 보장하게 됩니다. 동작 방식을 조금 더 살펴보죠.
@Sendable
어트리뷰트는 컴파일 타임에 클로저가 동시성 안전성을 보장하는지 검증하게 됩니다.Sendable
을 준수하지 않는(혹은 전송 가능한 상태가 아닌) 동시성 도메인이라면 컴파일 단계에서 에러가 나옵니다.Sendable
을 준수하는지 확인합니다.이렇게 3개의 게시물로 Swift Concurrency에 대해 알아보았습니다.
이 글을 작성하면서 새로 알아낸 것도 있고, 더 자세히 알아낸 것들도 존재했습니다.
Swift Concurrency에 대해 더 열심히 공부하며 사용해야겠습니다.