저번 포스팅에서 Actor의 정의와 언제 사용하는 것인지에 대해서 알아보았습니다. 이번 포스팅에서는 Actor를 사용하기 위한 필수 개념인 actor isolation과 cross-actor reference에 대해서 알아보도록 하겠습니다.
Actor isolation은 race condition으로부터 actor를 보호해주는 핵심 원칙입니다. 이 원칙에 따르면 mutable state는 self에 의해서만 참조될 수 있습니다.
아주 간단하게 actor의 내부 원리를 비유하자면 actor의 정문마다 하나씩 문지기👨✈️가 있다고 생각하면 됩니다. 이 문지기는 actor의 state에 접근하는 쓰레드를 한줄로 세워서 동시에 두 스레드가 접근할 수 없도록 합니다. 하지만 self를 통해 참조하는 경우가 아닌 경우 이 문지기를 통하지 않고 옆문으로 들어가는 셈입니다. race condition이 발생할 수도 있는 것이죠. 따라서 self를 통해서만 참조하는 것은 한줄로 세워주는 문지기가 있는 정문으로만 state에 접근할 수 있도록 하는 것과 같습니다.
아래와 같은 actor를 예시로 설명해보겠습니다. 해당 아래 actor의 copyNum 함수는 현재 actor의 num 값을 다른 actor로 복사하는 함수입니다. otherActor의 num은 self가 아닌 외부에서 참조되고 변경되었습니다. 아래 코드는 아래와 같은 에러를 발생시킵니다.
actor SomeActor {
var num = 0
func copyNum(to otherActor: SomeActor) {
otherActor.num = num // 🚫 에러!
}
}
즉 num은 actor-isolation에 의해서 보호받는 state인데 actor-isolation의 영역 밖에서 변경될 수 없다는 내용의 에러입니다. 사실 변경 뿐만 아니라 참조만 하려고 해도 아래처럼 에러가 발생합니다
cross-actor reference는 actor isolation을 통해 보호되고 있는 state에 접근할 때 사용하는 방법입니다. 즉 self가 아닌 actor 외부에서 접근하는 방법들 입니다.
actor가 생긴 이유는 mutable shared state를 race condition으로 부터 보호하기 위함입니다. 따라서 state가 immutable인 경우는 self를 거치지 않고도 참조할 수 있습니다.
SomeActor에 let으로 선언된 UUID를 만들었습니다. 해당 property는 일단 instance가 생긴 이후에는 변경되지 않습니다. immutable state인 셈이죠. 따라서 actor isolation을 통해서 보호할 필요가 없습니다. 따라서 id는 다른 actor 내 뿐만 아니라 어디에서도 참조할 수 있습니다.
actor SomeActor {
let id = UUID()
var num = 0
func printUUID(of otherActor: SomeActor) {
print(otherActor.id)
}
}
func printUUID(of actor: SomeActor) {
print(actor.id)
}
단 immutable state라고 해도 같은 모듈 안에서만 적용됩니다. 다른 모듈에서라면 이야기가 다릅니다. 예를 들어 제가 아래와 같은 actor를 가지고 있는 라이브러리를 만들어서 배포를 했다고 칩시다. 그리고 많은 기업들이(!!!) 제 코드를 가지고 서비스를 만들어서 사용했습니다.
public actor SomeActor {
let id = UUID()
var num = 0
}
하지만 다음 업데이트에서 id를 let에서 var로 바꾸었습니다.
public actor SomeActor {
var id = UUID()
var num = 0
}
만약에 다른 모듈에서도 cross-actor reference가 가능하다면 제 라이브러리를 사용한 많은 기업들은 제 라이브러리를 사용한 부분을 모두 수정해야 합니다. 이런 부작용을 방지하기 위해서 immutable state라고 해도 cross-actor reference는 같은 모듈 내에서만 적용됩니다.
두 번째 방법은 비동기적으로 호출하는 것입니다. 즉 문지기가 세우는 줄에 서서 자기의 차례를 기다렸다가 해당 값에 접근하는 방법입니다.
읽기만 할 때는 원하는 값에 접근할 때 바로 await로 접근하면 됩니다.
func printNum(of actor: SomeActor) async {
print(await actor.num)
}
수정까지 하는 경우는 좀 더 복잡한 과정을 거쳐야 합니다. await 키워드를 붙인다고 해도 아래처럼 바로 수정을 할 수는 없습니다.
func changeNum(with num: Int, of actor: SomeActor) async {
await actor.num = num //🚫 에러
}
일단 actor안에 num을 업데이트 하는 함수를 만들어 주어야 합니다.
actor SomeActor {
let id = UUID()
var num =
func updateNum(newNum: Int) {
num = newNum
}
}
그리고 외부에서 사용할 때는 해당 함수를 아래처럼 await 키워드를 붙여서 실행하면 됩니다.
actor SomeActor {
let id = UUID()
var num = 0
func increaseNum() {
num += 1
}
func updateNum(newNum: Int) {
num = newNum
}
// 다른 actor에서 참조
func copyNum(to otherActor: SomeActor) async {
await otherActor.updateNum(newNum: num)
}
}
// 외부에서 참조
func changeNum(with num: Int, of actor: SomeActor) async {
await actor.updateNum(newNum: num)
}
그런데 updateNum은 async 키워드 없이 선언이 되었는데 사용할 때는 await를 사용해야 하는 이유는 뭘까요? 해당 함수는 actor “내부”에서는 동기적 함수입니다. 하지만 actor는 actor isolation이라는 문지기에 의해서 보호를 받습니다. 따라서 외부에서 접근할 때는 해당 문지기를 통해 “줄을 서서” 접근해야 합니다. 따라서 외부에서는 비동기적으로 호출해야 합니다. 즉 해당 함수가 비동기적인 것이 아니라 actor에 접근할 때 비동기적으로 접근하기 때문에 await를 붙여서 실행해야 하는 것입니다.