🔥 속성에 대한 접근 충돌

811자
11분

구조체, 튜플, 열거형과 같은 타입들은 개별적인 구성 값들로 이루어져 있습니다. 예를 들면 구조체의 속성이나 튜플의 요소들이 그렇죠. 이들은 값 타입이기 때문에, 값의 어떤 부분을 변경하면 전체 값이 변경됩니다. 즉, 속성 중 하나에 대한 읽기 또는 쓰기 접근은 전체 값에 대한 읽기 또는 쓰기 접근을 필요로 한다는 뜻이에요. 예를 들어, 튜플 요소에 대한 쓰기 접근이 겹치면 충돌이 발생합니다:

var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// 오류: playerInformation의 속성에 대한 접근이 충돌합니다
swift

위 예제에서 튜플의 요소에 balance(_:_:)를 호출하면 충돌이 발생하는데, 이는 playerInformation에 대한 쓰기 접근이 겹치기 때문이에요. playerInformation.healthplayerInformation.energy 모두 in-out 매개변수로 전달되는데, 이는 balance(_:_:)가 함수 호출 기간 동안 이들에 대한 쓰기 접근을 필요로 한다는 것을 의미합니다. 두 경우 모두, 튜플 요소에 대한 쓰기 접근은 전체 튜플에 대한 쓰기 접근을 필요로 해요. 이는 playerInformation에 대한 두 개의 쓰기 접근이 기간이 겹치면서 충돌을 일으킨다는 뜻이죠.

아래 코드는 전역 변수에 저장된 구조체의 속성에 대한 쓰기 접근이 겹칠 때도 같은 오류가 나타남을 보여줍니다:

var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy)  // 오류
swift

실제로는 구조체의 속성에 대한 대부분의 접근은 안전하게 겹칠 수 있어요. 예를 들어, 위 예제에서 변수 holly를 전역 변수 대신 지역 변수로 변경하면, 컴파일러는 구조체의 저장 속성에 대한 겹치는 접근이 안전하다는 것을 증명할 수 있습니다:

func someFunction() {
    var oscar = Player(name: "Oscar", health: 10, energy: 10)
    balance(&oscar.health, &oscar.energy)  // OK
}
swift

위 예제에서 Oscar의 health와 energy는 balance(_:_:)의 두 in-out 매개변수로 전달됩니다. 컴파일러는 두 저장 속성이 어떤 식으로도 상호작용하지 않기 때문에 메모리 안전성이 보장된다는 것을 증명할 수 있어요.

구조체 속성에 동시에 접근하는 것을 제한한다고 해서 항상 메모리를 안전하게 사용할 수 있는 건 아닙니다. 우리가 원하는 건 메모리를 안전하게 사용하는 거지만, 한 번에 하나의 접근만 허용하는 건 메모리 안전성보다 더 까다로운 조건이에요. 그래서 어떤 코드는 동시 접근을 허용하더라도 메모리를 안전하게 사용할 수 있습니다. Swift는 컴파일러가 동시 접근이 안전하다고 판단할 수 있으면 이런 코드를 허용해줍니다. 좀 더 구체적으로 말하면, 다음 조건을 만족하면 구조체 속성에 동시 접근해도 안전하다고 볼 수 있어요:

  • 인스턴스의 저장 속성에만 접근하고, 계산 속성이나 클래스 속성에는 접근하지 않습니다.
  • 구조체는 지역 변수의 값이며, 전역 변수의 값이 아닙니다.
  • 구조체는 클로저에 캡처되지 않거나, 탈출하지 않는(nonescaping) 클로저에만 캡처됩니다.

컴파일러가 접근이 안전하다는 것을 증명할 수 없다면, 그 접근을 허용하지 않아요.

이렇게 속성에 대한 접근 충돌은 값 타입의 특성과 직접 연관되어 있습니다. 구조체나 튜플 같은 값 타입은 전체가 하나의 값으로 취급되기 때문에, 속성에 대한 접근이 전체 값에 대한 접근으로 확장되죠. 이로 인해 발생할 수 있는 접근 충돌을 이해하고, 컴파일러가 안전하다고 보장할 수 있는 상황을 파악하는 것이 중요합니다.

다음은 이를 도식화한 그림입니다:

lecture image

이 그림은 구조체의 개별 속성에 대한 접근이 어떻게 전체 값에 대한 접근으로 이어지는지 보여줍니다. 속성에 대한 접근 충돌을 피하려면 이런 특성을 염두에 두어야 해요.

가능하다면 전역 변수보다는 지역 변수를 사용하고, 구조체를 탈출 클로저에 캡처하지 않는 것이 좋습니다. 탈출 클로저는 클로저가 함수에서 반환된 후에도 실행될 수 있기 때문에, 클로저 밖의 컨텍스트에서 캡처된 변수에 접근할 수 있어요. 이는 원래의 구조체와 클로저 내의 구조체 사이에 접근 충돌을 일으킬 수 있습니다.

예를 들어볼게요:

var globalStruct = SomeStruct()
 
func someFunction() -> () -> Void {
    return {
        globalStruct.someProperty = newValue
    }
}
 
let escapingClosure = someFunction()
globalStruct.someProperty = anotherValue // 접근 충돌 가능성 있음
escapingClosure()
 
swift

위 코드에서 someFunctionglobalStruct를 캡처하는 탈출 클로저를 반환합니다. 이 클로저는 someFunction이 반환된 후에도 globalStruct에 접근할 수 있어요. 만약 escapingClosure가 호출되는 동시에 globalStruct의 속성에 접근하려고 하면, 접근 충돌이 발생할 수 있습니다.

반면, 구조체를 탈출 클로저에 캡처하지 않으면 이런 문제를 피할 수 있어요. 비탈출 클로저(non-escaping closure)는 함수 내에서만 존재하므로, 함수 밖의 컨텍스트와 상호작용할 일이 없습니다. 따라서 비탈출 클로저에 캡처된 구조체는 접근 충돌의 위험이 훨씬 적죠.

이런 이유로, 컴파일러는 탈출 클로저에 캡처된 구조체에 대해 더 엄격한 접근 제한을 적용합니다. 이렇게 하면 접근의 안전성을 더 쉽게 보장할 수 있고, 우리는 접근 충돌에 대해 덜 걱정할 수 있게 되겠죠.

하지만 때로는 탈출 클로저에서 구조체를 캡처해야 하는 상황이 있을 수 있어요. 이럴 때는 다음과 같은 방법으로 접근 충돌을 피할 수 있습니다:

  1. 구조체의 복사본을 사용하기: 클로저 내에서 구조체의 복사본을 만들어 사용하면, 원래의 구조체와 별개의 인스턴스가 되므로 접근 충돌이 발생하지 않아요.
var globalStruct = SomeStruct()
 
func someFunction() -> () -> Void {
    var localCopy = globalStruct
    return {
        localCopy.someProperty = newValue
    }
}
 
swift
  1. 접근을 동기화하기: 디스패치 큐(dispatch queue)나 오퍼레이션 큐(operation queue)를 사용하여 구조체에 대한 접근을 동기화할 수 있어요. 이렇게 하면 한 번에 하나의 접근만 허용되므로 충돌을 피할 수 있습니다.
let queue = DispatchQueue(label: "serialQueue")
var globalStruct = SomeStruct()
 
func someFunction() -> () -> Void {
    return {
        queue.async {
            globalStruct.someProperty = newValue
        }
    }
}
swift
  1. 액터(Actor)를 사용하기 (Swift 5.5 이상): Swift 5.5부터 도입된 액터는 동시성을 더 안전하게 관리할 수 있는 방법을 제공해요. 액터 내부의 상태는 한 번에 하나의 작업만 접근할 수 있으므로, 구조체를 액터 내부의 속성으로 정의하면 접근 충돌을 피할 수 있습니다.
actor SomeActor {
    var state: SomeStruct
 
    func updateState() {
        state.someProperty = newValue
    }
}
swift

이런 방법들을 상황에 맞게 활용하면, 탈출 클로저에서 구조체를 캡처해야 할 때도 안전하게 접근할 수 있어요. 물론 가능하다면 탈출 클로저에서의 캡처를 피하는 게 가장 좋지만, 꼭 필요한 경우에는 위의 방법들을 고려해보는 것이 좋겠죠.