메모리 안전성

메모리 안전성 (Memory Safety)

메모리에 접근할 때 충돌이 생기지 않도록 코드를 구성하는 방법을 알아봐요. Swift는 기본적으로 우리 코드에서 안전하지 않은 동작이 발생하지 않도록 막아 주는데요, 그 원리를 이해하면 왜 Swift가 특정 코드를 허용하지 않는지 자연스럽게 알 수 있어요.

출처: The Swift Programming Language — Memory Safety

본문

Swift는 기본적으로 여러분의 코드에서 안전하지 않은 동작이 일어나지 않도록 막아 줘요. 예를 들어 Swift는 변수가 사용되기 전에 초기화되는지, 메모리가 해제된 뒤에 접근되지는 않는지, 배열의 인덱스가 범위를 벗어나지는 않는지 확인해 줍니다.

Swift는 또 같은 메모리 영역에 대한 여러 접근이 충돌하지 않도록 해요. 메모리의 어떤 위치를 수정하는 코드가 그 메모리에 대한 **독점적 접근(exclusive access)**을 요구하도록 만들기 때문이죠. Swift가 메모리를 자동으로 관리해 주기 때문에, 대부분의 경우 메모리 접근에 대해 생각할 필요가 없어요. 하지만 충돌이 어디에서 발생할 수 있는지는 이해해 두는 게 중요합니다. 그래야 충돌하는 접근을 일으키는 코드를 쓰지 않을 수 있으니까요. 만약 코드에 충돌이 있다면 컴파일 타임이나 런타임에 오류가 발생해요.

메모리에 대한 충돌 접근 이해하기

메모리 접근은 변수의 값을 설정하거나 함수에 인자를 전달하는 등의 동작에서 일어나요. 예를 들어 아래 코드에는 읽기 접근과 쓰기 접근이 모두 들어 있어요.

// A write access to the memory where one is stored.
var one = 1

// A read access from the memory where one is stored.
print("We're number \(one)!")

메모리에 대한 충돌 접근은 코드의 서로 다른 부분이 동시에 메모리의 같은 위치에 접근하려고 할 때 일어날 수 있어요. 같은 시간에 메모리의 한 위치에 여러 번 접근하면 예측할 수 없거나 일관되지 않은 동작이 생길 수 있죠. Swift에서는 값을 수정하는 방법 중에는 여러 줄에 걸쳐 이어지는 것도 있어서, 값이 수정되는 도중에 그 값에 접근하려는 시도가 가능해질 수 있어요.

이와 비슷한 문제를 종이에 적힌 예산을 갱신하는 상황으로 생각해 볼게요. 예산을 갱신하는 일은 두 단계로 진행돼요. 먼저 항목들의 이름과 가격을 적고, 그다음 전체 금액을 목록에 있는 항목들에 맞게 바꾸는 거죠. 갱신 전과 후에는 예산에서 어떤 정보든 읽어도 올바른 답을 얻을 수 있어요.

항목을 예산에 추가하는 동안에는, 전체 금액이 새로 추가된 항목을 반영하도록 갱신되지 않았기 때문에 예산은 임시로 유효하지 않은 상태가 돼요. 항목을 추가하는 과정에서 전체 금액을 읽으면 잘못된 정보를 얻게 되죠.

이 예제는 메모리에 대한 충돌 접근을 고칠 때 만날 수 있는 어려움도 보여 줘요. 충돌을 고치는 방법이 여러 가지 있고, 각각 서로 다른 결과를 만들어 내는데 어느 답이 맞는지가 항상 명확하지는 않은 경우가 있거든요. 이 예제에서도 원래의 전체 금액을 원했는지 갱신된 전체 금액을 원했는지에 따라 $5 또는 $320이 올바른 답일 수 있어요. 충돌 접근을 고치기 전에, 그 접근이 의도한 것이 무엇인지 먼저 정해야 합니다.

Note: 동시성(concurrent) 또는 멀티스레드 코드를 작성해 본 적이 있다면 메모리에 대한 충돌 접근이 익숙한 문제일 거예요. 하지만 여기서 다루는 충돌 접근은 단일 스레드 안에서도 일어날 수 있고, 동시성 또는 멀티스레드 코드와는 관련이 없어요.

단일 스레드 안에서 메모리에 대한 충돌 접근이 있다면, Swift는 컴파일 타임이나 런타임에 오류가 발생한다는 것을 보장해 줘요. 멀티스레드 코드에서는 Thread Sanitizer를 사용해서 스레드 간 충돌 접근을 감지하는 데 도움을 받을 수 있어요.

메모리 접근의 특성

충돌 접근과 관련해 살펴볼 메모리 접근의 특성은 세 가지예요. 접근이 읽기인지 쓰기인지, 접근의 지속 시간(duration), 그리고 접근하는 메모리의 위치죠. 구체적으로 말하면, 다음 조건을 모두 만족하는 접근이 두 개 있으면 충돌이 발생해요.

  • 두 접근이 모두 읽기가 아니면서 모두 원자적(atomic)이지 않다.
  • 같은 메모리 위치에 접근한다.
  • 지속 시간이 겹친다(overlap).

읽기 접근과 쓰기 접근의 차이는 보통 분명해요. 쓰기 접근은 메모리의 위치를 바꾸지만 읽기 접근은 바꾸지 않죠. 메모리의 위치란 접근 대상이 되는 것, 예를 들어 변수, 상수, 프로퍼티를 가리켜요. 메모리 접근의 지속 시간은 순간적(instantaneous)이거나 장기적(long-term)이에요.

접근이 **원자적(atomic)**이라는 것은 Atomic이나 AtomicLazyReference에 대한 원자적 연산을 호출하거나, C의 원자적 연산만을 사용하는 경우를 말해요. 그 외에는 모두 비원자적(nonatomic)이죠. C 원자적 함수의 목록은 stdatomic(3) 매뉴얼 페이지에서 볼 수 있어요.

접근이 **순간적(instantaneous)**이라는 것은, 접근이 시작된 뒤 끝나기 전에 다른 코드가 실행될 수 없는 경우를 말해요. 순간적 접근 두 개는 그 특성상 동시에 일어날 수 없어요. 대부분의 메모리 접근은 순간적이죠. 아래 코드 목록의 읽기 접근과 쓰기 접근은 모두 순간적이에요.

func oneMore(than number: Int) -> Int {
    return number + 1
}

var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// Prints "2".

하지만 장기적(long-term) 접근이라고 부르는, 다른 코드의 실행에 걸쳐 이어지는 메모리 접근 방법이 몇 가지 있어요. 순간적 접근과 장기적 접근의 차이는, 장기적 접근이 시작된 뒤 끝나기 전에 다른 코드가 실행될 수 있다는 점이에요. 이것을 *겹침(overlap)*이라고 해요. 장기적 접근은 다른 장기적 접근이나 순간적 접근과 겹칠 수 있어요.

겹치는 접근은 주로 함수와 메서드에서 in-out 매개변수를 사용하거나, 구조체의 mutating 메서드를 사용하는 코드에서 나타나요. 장기적 접근을 사용하는 구체적인 Swift 코드 종류는 아래 절들에서 다뤄요.

in-out 매개변수에 대한 충돌 접근

함수는 모든 in-out 매개변수에 대해 장기적 쓰기 접근을 가져요. in-out 매개변수에 대한 쓰기 접근은 in-out이 아닌 모든 매개변수가 평가된 뒤 시작되고, 그 함수 호출이 진행되는 전체 시간 동안 지속됩니다. in-out 매개변수가 여러 개라면 쓰기 접근은 매개변수가 나타나는 순서와 같은 순서로 시작돼요.

이 장기적 쓰기 접근의 한 가지 결과는, 스코프 규칙과 접근 제어가 허용한다고 해도 in-out으로 전달된 원래 변수에 접근할 수 없다는 점이에요. 원래 변수에 대한 어떤 접근이든 충돌을 만들기 때문이죠. 예를 들어:

var stepSize = 1

func increment(_ number: inout Int) {
    number += stepSize
}

increment(&stepSize)
// Error: Conflicting accesses to stepSize.

위 코드에서 stepSize는 전역 변수이고, 보통은 increment(_:) 안에서 접근할 수 있어요. 하지만 stepSize에 대한 읽기 접근이 number에 대한 쓰기 접근과 겹쳐요. numberstepSize는 같은 메모리 위치를 가리키고 있죠. 따라서 읽기 접근과 쓰기 접근이 같은 메모리를 가리키면서 겹치므로 충돌이 발생해요.

이 충돌을 해결하는 한 가지 방법은 stepSize의 명시적인 복사본을 만드는 거예요.

// Make an explicit copy.
var copyOfStepSize = stepSize
increment(&copyOfStepSize)

// Update the original.
stepSize = copyOfStepSize
// stepSize is now 2

increment(_:)를 호출하기 전에 stepSize의 복사본을 만들면 copyOfStepSize의 값이 현재의 step size만큼 증가된다는 것이 분명해져요. 읽기 접근이 쓰기 접근이 시작되기 전에 끝나기 때문에 충돌이 없어요.

in-out 매개변수에 대한 장기적 쓰기 접근의 또 다른 결과는, 같은 함수의 여러 in-out 매개변수 인자로 하나의 변수를 전달하면 충돌이 발생한다는 점이에요. 예를 들어:

func balance(_ x: inout Int, _ y: inout Int) {
    let sum = x + y
    x = sum / 2
    y = sum - x
}
var playerOneScore = 42
var playerTwoScore = 30
balance(&playerOneScore, &playerTwoScore)  // OK
balance(&playerOneScore, &playerOneScore)
// Error: Conflicting accesses to playerOneScore.

위의 balance(_:_:) 함수는 두 매개변수를 수정해서 전체 값을 둘 사이에 공평하게 나눠요. playerOneScoreplayerTwoScore를 인자로 호출하면 충돌이 발생하지 않아요. 시간상 겹치는 쓰기 접근 두 개가 있긴 하지만 서로 다른 메모리 위치에 접근하기 때문이죠. 반면 playerOneScore를 두 매개변수의 값으로 모두 전달하면, 같은 메모리 위치에 동시에 두 번의 쓰기 접근을 시도하기 때문에 충돌이 발생해요.

Note: 연산자도 함수이기 때문에 자기 in-out 매개변수에 대한 장기적 접근을 가질 수 있어요. 예를 들어 balance(_:_:)<^>라는 이름의 연산자 함수였다면, playerOneScore <^> playerOneScore라고 쓰는 것은 balance(&playerOneScore, &playerOneScore)와 같은 충돌을 만들어 냈을 거예요.

메서드에서 self에 대한 충돌 접근

구조체의 mutating 메서드는 메서드 호출이 진행되는 동안 self에 대한 쓰기 접근을 가져요. 예를 들어 각 플레이어가 체력(health)과 에너지(energy)를 갖는 게임을 생각해 볼게요. 체력은 데미지를 받으면 줄어들고, 에너지는 특수 능력을 사용하면 줄어들어요.

struct Player {
    var name: String
    var health: Int
    var energy: Int

    static let maxHealth = 10
    mutating func restoreHealth() {
        health = Player.maxHealth
    }
}

위의 restoreHealth() 메서드에서는 self에 대한 쓰기 접근이 메서드의 시작과 함께 시작되어 메서드가 반환될 때까지 지속돼요. 이 경우 restoreHealth() 안에는 Player 인스턴스의 프로퍼티에 겹치는 접근을 할 수 있는 다른 코드가 없어요. 아래의 shareHealth(with:) 메서드는 다른 Player 인스턴스를 in-out 매개변수로 받아서, 겹치는 접근이 생길 가능성을 만들어요.

extension Player {
    mutating func shareHealth(with teammate: inout Player) {
        balance(&teammate.health, &health)
    }
}

var oscar = Player(name: "Oscar", health: 10, energy: 10)
var maria = Player(name: "Maria", health: 5, energy: 10)
oscar.shareHealth(with: &maria)  // OK

위 예제에서 Oscar의 플레이어가 Maria의 플레이어와 체력을 공유하도록 shareHealth(with:) 메서드를 호출하는 것은 충돌을 일으키지 않아요. mutating 메서드에서 oscarself의 값이므로 메서드 호출 동안 oscar에 대한 쓰기 접근이 있고, maria가 in-out 매개변수로 전달되었으므로 같은 시간 동안 maria에 대한 쓰기 접근이 있어요. 하지만 이것들은 서로 다른 메모리 위치에 접근해요. 두 쓰기 접근이 시간상 겹치더라도 충돌하지 않는 거죠.

하지만 oscarshareHealth(with:)의 인자로 전달하면 충돌이 발생해요.

oscar.shareHealth(with: &oscar)
// Error: Conflicting accesses to oscar.

mutating 메서드는 메서드가 진행되는 동안 self에 대한 쓰기 접근이 필요하고, in-out 매개변수는 같은 시간 동안 teammate에 대한 쓰기 접근이 필요해요. 메서드 안에서 selfteammate는 같은 메모리 위치를 가리키죠. 두 쓰기 접근이 같은 메모리를 가리키면서 겹치므로 충돌이 발생해요.

프로퍼티에 대한 충돌 접근

구조체, 튜플, 열거형 같은 타입은 구조체의 프로퍼티나 튜플의 요소 같은 개별 구성 값들로 이루어져 있어요. 이런 것들은 값 타입이므로 값을 일부만 수정해도 전체 값이 수정되는데, 즉 프로퍼티 하나에 대한 읽기나 쓰기 접근은 전체 값에 대한 읽기나 쓰기 접근을 요구해요. 예를 들어 튜플의 요소들에 대한 겹치는 쓰기 접근은 충돌을 만들어요.

var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// Error: Conflicting access to properties of playerInformation.

위 예제에서 튜플의 요소들에 balance(_:_:)를 호출하면 playerInformation에 대한 겹치는 쓰기 접근이 생기므로 충돌이 발생해요. playerInformation.healthplayerInformation.energy가 모두 in-out 매개변수로 전달되므로, balance(_:_:)는 함수 호출이 진행되는 동안 이 둘에 대한 쓰기 접근이 필요해요. 두 경우 모두 튜플 요소에 대한 쓰기 접근이 튜플 전체에 대한 쓰기 접근을 요구하죠. 따라서 playerInformation에 대한 쓰기 접근 두 개가 겹치는 지속 시간을 갖게 되어 충돌이 발생해요.

아래 코드는 전역 변수에 저장된 구조체의 프로퍼티들에 대한 겹치는 쓰기 접근에서도 같은 오류가 나타난다는 것을 보여 줘요.

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

실제로는 구조체의 프로퍼티에 대한 대부분의 접근이 안전하게 겹칠 수 있어요. 예를 들어 위 예제의 holly 변수를 전역 변수 대신 지역 변수로 바꾸면, 컴파일러는 구조체의 저장 프로퍼티(stored property)에 대한 겹치는 접근이 안전하다는 것을 증명할 수 있어요.

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

위 예제에서 Oscar의 체력과 에너지는 balance(_:_:)의 두 in-out 매개변수로 전달돼요. 컴파일러는 두 저장 프로퍼티가 어떤 식으로든 상호작용하지 않으므로 메모리 안전성이 보존된다는 것을 증명할 수 있어요.

구조체의 프로퍼티에 대한 겹치는 접근 제한이 메모리 안전성을 지키기 위해 항상 필요한 것은 아니에요. 메모리 안전성이 우리가 원하는 보장이긴 하지만, 독점적 접근은 메모리 안전성보다 더 엄격한 요구사항이에요. 즉 어떤 코드는 독점적 접근을 위반하면서도 메모리 안전성은 지키는 경우가 있어요. Swift는 컴파일러가 메모리에 대한 비독점적 접근이 여전히 안전하다는 것을 증명할 수 있으면 이런 메모리 안전 코드를 허용해요. 구체적으로, 다음 조건이 적용되면 구조체의 프로퍼티에 대한 겹치는 접근이 안전하다는 것을 증명할 수 있어요.

  • 인스턴스의 저장 프로퍼티에만 접근하고, 계산 프로퍼티(computed property)나 클래스 프로퍼티에는 접근하지 않는다.
  • 구조체가 전역 변수가 아니라 지역 변수의 값이다.
  • 구조체가 어떤 클로저에도 캡처되지 않거나, 오직 비탈출(nonescaping) 클로저에만 캡처된다.

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

더 알아보기

  • 프로퍼티가 왜 값 타입 전체에 대한 접근을 요구하는지 궁금하다면 Structures and Classes 챕터의 값 타입 설명을 함께 보면 이해가 잘 돼요.
  • mutating 메서드가 어떻게 동작하는지, 언제 쓰는지 헷갈린다면 Methods 챕터를 참고하세요.
  • in-out 매개변수가 함수에서 어떤 역할을 하는지 다시 짚어보고 싶다면 Functions 챕터의 in-out 매개변수 부분을 보면 좋아요.