티스토리 뷰

반응형

이번 글에서는 Swift 공식 문서의 25번째 단원인 Memory Safety을 읽고 정리한 글을 쓰려고 한다.

Apple Swift Document 25단원 - Memory Safety

Memory Safety

Swift는 코드가 안전하게 동작할 수 있도록 노력한다. 예를 들어 Swift는 변수가 사용되기 전에 초기화되고 메모리가 할당 해제된 후엔 접근되지 않으며 Array의 인덱스에서 벗어나면 오류를 발생시킨다.

 

Swift는 메모리의 위치를 수정하는 코드가 해당 메모리에 대한 독점 접근 권한을 갖도록 요구하여 동일한 메모리 영역의 접근들의 충돌을 방지한다. Swift는 메모리를 자동으로 관리해주기 때문에 메모리 접근에 대해 크게 생각할 필요는 없지만, 충돌이 발생할 수 있는 위치를 이해해야 코드를 잘 작성할 수 있기 때문에 이번 장에서는 이에 대해 알아보자.


Understanding Conflicting Access to Memory

메모리에 대한 접근은 변수의 값을 설정하거나 함수에 인수를 전달하는 것과 같은 작업을 수행할 때 발생한다.

 

// 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)!")

위의 코드에서는 어떤 메모리 접근이 요청되었을까? var one = 1에서 쓰기 접근이, print("We're number \(one)!")에서 읽기 접근이 발생한다.

 

위의 코드는 문제를 발생하지 않는다. 문제를 발생하기 위해선 동일한 메모리 위치에 동시에 접근하려고 할 때 발생할 수 있다. 그럼 문제를 발생하는 상황을 살펴보자.

 

위의 그림은 종이에 쓰여진 budget을 업데이트하는 방법을 그린 그림이다. budget 업데이트는 2단계 프로세스로 이뤄지는데, 먼저 항목의 이름과 가격을 추가 한 다음 총금액을 변경하여 현재 목록에 있는 항목을 반영한다. 위의 그림처럼 budget에서 모든 정보를 읽을 수 있다.

 

budget에 항목을 추가하는 동안 총 금액의 값이 새로 추가된 항목에 따라 업데이트되지 않았기 때문에 잘못된 상태이다. 만약 항목을 추가하는 동안 총금액을 읽으려고 하면 잘못된 정보가 제공된다.

 

위의 예는 동일한 메모리에 대해 충돌하는 접근을 수정할 때 발생할 수 있는 문제를 보여준다. 충돌이 존재하기 때문에 서로 다른 답을 생성할 수 있다. 위의 예에서는 $5, $320이 총금액으로 나올 수 있는 것이다. 이런 문제를 해결하려면 먼저 수행할 작업을 결정해줘야 한다.

 

이러한 문제는 동시성, 다중 스레드 코드에서는 자주 발생하는 문제일 수 있다. 하지만 이번 글에서는 단일 스레드에서 발생 가능한 충돌을 살펴보도록 할 예정이다. 단일 스레드 내에서 메모리 접근이 충돌하는 경우 Swift는 컴파일 타임, 런타임 오류가 발생시킨다. 다중 스레드 코드의 경우 Thread Sanitizer를 사용하여 스레드 간에 충돌하는 접근을 감지한다.


Characteristics of Memory Access

접근 충돌은 다음 세 가지 특성을 충족한 두 개 이상의 접근이 있을 경우 발생할 수 있다.

  1. 적어도 하나는 쓰기 접근이다.
  2. 메모리의 동일한 위치에 접근한다.
  3. 메모리에 접근하는 시간이 겹친다.

먼저 읽기 접근과 쓰기 접근의 차이를 살펴보자. 쓰기 접근은 메모리의 위치를 변경하지만 읽기 접근은 변경하지 않는다. 여기서 메모리의 위치는 접근하는 항목(변수, 상수, 프로퍼티)을 나타내고 메모리 접근 시간은 순간적이거나 장기적일 수 있다.

 

접근이 시작된 후 종료되기 전에 다른 코드를 실행할 수 없는 경우 접근은 즉시 이루어진다. 기본적으로 두 개의 순간적인 접근은 동시에 발생할 수 없다. 대부분의 메모리 접근은 순간적으로 이뤄지는데 이러한 순간적인 접근의 예를 살펴보자.

 

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 코드는 다음 섹션에서 알아보자.


Conflicting Access to In-Out Parameters

함수는 모든 in-out 매개변수에 대한 장기적인 쓰기 접근 권한을 갖는다. in-out 매개변수에 대한 쓰기 접근은 non-in-out 매개변수가 평가된 후에 시작되며 함수 호출의 전체 기간 동안 지속된다. 만약 in-out 매개변수가 여러 개인 경우 쓰기 접근은 매개 변수가 나타나는 순서와 동일한 순서로 시작된다.

 

이러한 장기적인 쓰기 접근의 문제는 지역적인 규칙과 접근 제어가 허용하더라도 원본 변수에 접근할 수 없다는 것이다. 원본 변수에 대한 접근에 대해 충돌을 일으키는데 예를 살펴보자.

var stepSize = 1

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

increment(&stepSize)
// Error: conflicting accesses to stepSize

위의 코드와 같이 increment(_ :)number 매개변수가 in-out 매개변수이다. 그리고 increment(_ :) 함수 내에서 접근한 stepSize 변수는 전역 변수로 선언되어있다.

 

위의 코드에서 만약 increment 함수를 호출하면 number 매개변수에 대한 쓰기 접근과 stepSize 전역 변수에 대한 읽기 접근이 겹치게 된다. 위의 그림과 같이 동일한 메모리를 참조하게 되며 충돌이 발생하게 되는 것이다.

 

이러한 문제를 해결하기 위한 방법은 stepSize의 복사본을 만들어 사용하는 것이다.

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

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

위의 코드와 같이 increment(_ :) 함수를 호출하기 전에 stepSize의 복사본을 만들어 수행하면 읽기 접근이 쓰기 접근이 시작되기 전에 종료되기 때문에 충돌이 일어나지 않는다.

 

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(_ : _ :)함수는 두 개의 in-out 매개변수를 사용한다. 이때 두 매개변수에 서로 다른 변수를 입력하면 문제가 발생하지 않지만 두 매개변수에 동일한 변수를 입력하면 문제를 발생한다. 이는 메모리의 동일한 위치에 동시에 두 개의 쓰기 접근을 수행하려고 시도하기 때문에 충돌이 발생하는 것이다.


Conflicting Access to self in Methods

메모리 접근에서 문제는 구조체에서 mutating 메서드를 사용할 때도 발생할 수 있다. 구조체의 mutating 메서드는 메서드 호출 기간 동안 self에 대한 쓰기 접근 권한을 갖는다. 예제를 보며 이해해보자.

 

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

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

위의 코드에서 Player 구조체는 3개의 프로퍼티와 1 개의 타입 프로퍼티, 그리고 이번 섹션에서 주의 깊게 봐야 할 mutating 메서드인 restoreHealth()메서드가 정의되어 있다. restoreHealth() 메서드는 시작 부분에서 self에 대한 쓰기 접근을 시작하고 이는 메서드가 반환될 때까지 지속된다. 이 경우 Player 구조체 인스턴스의 프로퍼티에 대해 중복 접근 권한을 가질 수 있는 코드가 restoreHealth()에는 없다. 따라서 이러한 코드를 만들어 주자.

 

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

위의 코드에서 shareHealth(with:)메서드를 익스텐션으로 추가하여 다른 Player 인스턴스를 in-out 매개변수로 사용하여 중복 접근 가능성을 만들어준다.

 

위의 코드에서 oscar, maria 라는 Player 인스턴스를 만들고 shareHealth(with:) 메서드를 호출해도 충돌이 발생하지 않는다. oscarmutating 메서드에 self에 대한 쓰기 접근이 가능하고 mariain-out 매개변수로 전달되었기 때문에 동일한 기간에 maria에 대한 쓰기 접근도 가능하다. 즉 두 개의 쓰기 접근이 같은 시간에 발생하지만 다른 메모리에 접근하기 때문에 충돌이 발생하지 않는다. 위의 그림은 이런 과정을 나타낸 그림이다.

 

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

하지만 만약 위의 코드를 실행하면 어떻게 될까? oscarshareHealth(with:)에 대한 인수로 전달하면 충돌이 발생한다.

 

mutating 메서드는 메서드가 호출된 기간 동안 self에 대한 쓰기 권한이 필요하고 in-out 매개변수는 같은 기간 동안 다른 Player 인스턴스에 대한 쓰기 권한이 필요하다. 하지만 위의 코드는 두 접근이 같은 메모리 위치에 접근해야 하기 때문에 충돌이 발생한다.


Conflicting Access to Properties

구조체, 튜플, 열거형과 같은 타입에서 구조체의 프로퍼티, 튜플의 요소와 같은 개별 구성 값으로 구성된다. 이들은 값 타입이기 때문에 값의 일부를 변경하면 전체 값이 변경된다. 즉 프로퍼티 중 하나에 대한 읽기 또는 쓰기 접근은 전체 값에 대한 읽기 또는 쓰기 접근이 필요하다. 예를 들어 튜플의 요소에 대한 쓰기 접근이 겹치면 충돌이 발생한다.

 

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

위의 코드에서 튜플의 요소로 balance(_: _:)를 호출하면 playerInformation에 대한 쓰기 접근이 겹치기 때문에 충돌이 발생한다. playerInformation.health, playerInformation.energy가 모두 in-out 매개변수로 전달되므로 함수가 호출된 기간 동안 balance(_: _:)에 대한 쓰기 접근 권한이 필요하다. 두 경우 모두 튜플 요소에 대한 쓰기 접근에서 전체 튜플에 대해 쓰기 접근이 필요한데 이 때문에 충돌이 발생한다.

 

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

위의 코드는 전역 변수에 저장된 구조체 인스턴스의 프로퍼티에 대해 쓰기 접근이 중복될 때도 충돌이 발생하는 것을 보여준다.

 

실제로 구조체 인스턴스의 프로퍼티에 대한 대부분의 접근은 안전하게 겹칠 수 있다.

 

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

예를 들어 아까 문제를 발생한 코드에서 holly 변수가 전역 변수가 아닌 지역변수로 변경되면 컴파일러는 구조체의 저장 프로퍼티에 대한 중복 접근이 안전하다는 것을 증명할 수 있다.

 

위의 예에서 oscarhealth,energy 프로퍼티는 balance(_:_:)에 대한 두 개의 in-out 매개 변수로 전달된다. 컴파일러는 두 개의 저장 프로퍼티가 상호작용하지 않기 때문에 메모리 안전이 유지되는 것을 증명할 수 있다.

 

구조체 인스턴스의 프로퍼티에 대한 중복 접근에 대한 제한은 메모리 안전성을 유지하기 위해 항상 필요한 것은 아니다. 메모리 안전보다 배타적 접근은 더욱 엄격한 요구사항이다. 즉 일부 코드는 메모리에 대한 배타적 접근을 위반하더라도 메모리 안전을 유지한다. Swift는 컴파일러가 메모리에 대한 비 독점적 접근이 여전히 안전하다는 것을 증명할 수 있다면 메모리 안전 코드를 허용한다. 특히 다음 조건이 적용되는 경우 구조체 인스턴스 프로퍼티에 대한 중복 접근이 안전하다는 것을 증명할 수 있다.

  • 계산 프로퍼티나 클래스 인스턴스가 아닌 저장 프로퍼티에만 접근한다.
  • 구조체가 전역 변수가 아닌 지역 변수 값일 때
  • 구조체가 클로저로 캡처되지 않거나 nonescaping 클로저로만 캡처된다.

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

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함