<@Sendable은 무엇인가?> 5. nonisolated keyword

SteadySlower·2023년 7월 17일
0

iOS Development

목록 보기
27/38

이번 포스팅에서도 Actor에 대해 배워봅니다. 이번 포스팅에서는 비동기적으로 접근하는 property를 동기적으로 접근할 수 있도록 해주는 nonisolated keyword에 대해서 알아봅니다.

immutable은 동기적으로, mutable은 비동기적으로

actor SomeActor {
    let id = UUID()
    var num = 0
    
    func increaseNum() {
        num += 1
    }
}

위 actor에서 id와 num은 각각 상수와 변수입니다. 각각 property에 접근할 때는 상수는 동기적으로 변수는 비동기적으로 접근해야 합니다.

print(actor.id)
print(await actor.num)

그렇다면 computed property는?

위 두개의 property는 stored property인데요. actor에는 stored 말고도 computed property도 존재합니다. 그렇다면 computed property는 동기적으로 접근할까요? 아니면 비동기적으로 접근할까요?

computed property는 기본적으로 가변성(mutable)을 띕니다. 즉 호출할 때마다 달라질 수 있다는 것이지요. 따라서 변수와 동일하게 비동기적으로 접근해야 합니다.

actor SomeActor {
    let id = UUID()
    var num = 0
    
    var description: String {
        "actor(id: \(id.uuidString)) has num of \(num)"
    }
    
    func increaseNum() {
        num += 1
    }
}
print(await actor.description)

하지만 절대 변할 일 없는 computed property의 경우?

하지만 불변하는 computed property가 있을 수도 있습니다. 아래 uuidString의 경우 computed property이지만 let으로 선언된 id만으로 구해진 값입니다. 따라서 절대로 변할 일이 없습니다.

actor SomeActor {
    let id = UUID()
    var num = 0
    
    var uuidString: String {
        id.uuidString
    }
    
}

하지만 이런 경우에도 호출할 때는 무조건 비동기적으로 해야 합니다.

print(await actor.uuidString)

nonisolated keyword

이렇게 모든 것을 비동기적으로 호출하는 것은 불편합니다… 위 uuidString의 경우에는 동기적으로 호출해도 아무런 문제가 없는데도 말이죠. 이런 경우 앞에 nonisolated를 붙여서 동기적으로 호출되도록 할 수 있습니다. 해당 property를 actor-isolated 하지 않게 만들어주는 것이죠.

actor SomeActor {
    let id = UUID()
    var num = 0

    nonisolated var uuidString: String {
        id.uuidString
    }
}

print(actor.uuidString)

참고로 nonisolated는 computed property 뿐만 아니라 func에도 붙일 수 있습니다. 아래 method들은 동일한 코드를 가진 함수인데 nonisolated를 붙이면 동기적으로 호출이 가능해집니다.

actor SomeActor {
    let id = UUID()
    var num = 0

    nonisolated var uuidString: String {
        id.uuidString
    }

    func printActor1() {
        print("actor")
    }
    
    nonisolated func printActor2() {
        print("actor")
    }

}

await actor.printActor1()
actor.printActor2() //👉 nonisolated는 동기적으로 호출 가능

아무데나 붙일 수는 없다 🚫

무조건 nonisolated 키워드를 붙이면 동기적으로 호출이 가능해지는 것은 아닙니다. 컴파일러가 판단했을 때 동기적으로 호출할 경우 문제가 될 수 있는 computed property 혹은 method는 에러가 납니다.

아래 예시의 경우 computed property와 method 모두 mutable state인 num을 포함하고 있으므로 actor-isolated하게 사용해야만 합니다.

actor SomeActor {
    let id = UUID()
    var num = 0

    //🚫 Actor-isolated property 'num' can not be referenced from a non-isolated context
    nonisolated var description: String {
        "actor(id: \(id.uuidString)) has num of \(num)"
    }

    //🚫 Actor-isolated property 'num' can not be referenced from a non-isolated context
    nonisolated func increaseNum() {
        num += 1
    }
}

응용: Identifiable, Hashable 준수 하기

nonisolated는 각종 Protocol들을 채택할 때 편리하게 사용할 수 있습니다. 예를 들어 Identifiable은 id 값에 Hashable의 경우는 hash 함수에 동기적으로 접근할 수 있어야 준수할 수 있습니다.

각각 어떤 예시가 있는지 보도록 하겠습니다.

Identifiable

아래와 같은 actor를 만들었습니다. 그리고 Identifiable을 채택하기 위해서 id를 computed property로 만들었는데요. 아시다시피 해당 computed property는 actor-isolated입니다. 따라서 아래와 같은 에러가 뜹니다.

actor Product: Identifiable {
    let name: String
    let category: String
    var price: Int
    
		//🚫 Actor-isolated property 'id' cannot be used to satisfy nonisolated protocol requirement
    var id: String {
        "\(name)_\(category)"
    }
    
    init(name: String, category: String, price: Int) {
        self.name = name
        self.category = category
        self.price = price
    }
}

해석해보면 actor-isolated 속성인 id는 nonisolated인 프로토콜의 요구사항을 만족시키는데 사용될 수 없다는 것입니다. 따라서 id를 nonisolated로 만들 필요가 있습니다.

actor Product: Identifiable {
    let name: String
    let category: String
    var price: Int
    
    nonisolated var id: String {
        "\(name)_\(category)"
    }
    
    init(name: String, category: String, price: Int) {
        self.name = name
        self.category = category
        self.price = price
    }
}

자 이렇게 하면 아래처럼 Identifiable을 요구하는 ForEach문에서 actor를 사용할 수 있습니다.

struct ContentView: View {
    
    let products = [
        Product(name: "iphone", category: "smart phone", price: 100),
        Product(name: "mac", category: "desktop", price: 200),
        Product(name: "mac", category: "laptop", price: 300)]
    
    var body: some View {
        ForEach(products) { product in
            Text("Product: \(product.name)")
        }
    }
}

Hashable

Hashable을 채택하기 위해서는 hash라는 함수를 구현해야 합니다. 아래처럼 구현해보도록 하겠습니다. (참고로 ==를 구현한 이유는 Hashable을 채택하기 위해서는 Equtable을 채택해야 하기 때문입니다.)

actor Product: Hashable {
    
    let name: String
    let category: String
    var price: Int
    
    func hash(into hasher: inout Hasher) {
        hasher.combine("\(name)_\(category)")
    }
    
    static func == (lhs: Product, rhs: Product) -> Bool {
        "\(lhs.name)_\(lhs.category)" == "\(rhs.name)_\(rhs.category)"
    }
    
    init(name: String, category: String, price: Int) {
        self.name = name
        self.category = category
        self.price = price
    }
}

역시 에러가 발생하는데요. 마찬가지로 내용은 actor-isolated한 hash 함수는 nonisolated인 프로토콜의 요구사항을 만족시키는데 사용할 수 없다는 것입니다. 마찬가지로 nonisolated 키워드를 활용합니다.

actor Product: Hashable {
    
    let name: String
    let category: String
    var price: Int
    
    nonisolated func hash(into hasher: inout Hasher) {
        hasher.combine("\(name)_\(category)")
    }
    
    static func == (lhs: Product, rhs: Product) -> Bool {
        "\(lhs.name)_\(lhs.category)" == "\(rhs.name)_\(rhs.category)"
    }
    
    init(name: String, category: String, price: Int) {
        self.name = name
        self.category = category
        self.price = price
    }
}

에러는 사라지고 아래처럼 Hashable을 요구하는 Set에서 사용할 수 있습니다

let productSet = Set<Product>()

마치며…

actor는 thread-safe를 위해서 actor-isolation이라는 안전 장치를 가지고 있는데요. 개발자 입장에서 해당 안전 장치는 유용하기도 하지만 때문에 불편한 상황도 자주 발생합니다.

오늘은 그 안전 장치를 우회하는 nonisolated 키워드에 대해서 알아봤습니다. actor 포스팅도 거의 끝나가네요. 잘 마무리해보도록 하겠습니다.

profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

2개의 댓글

comment-user-thumbnail
2023년 7월 17일

좋은 글 감사합니다!

1개의 답글