본문 바로가기
Swift

메모리 안전에 관하여

by iOS 개린이 2023. 3. 17.

메모리 안전

-Swift는 기본적으로 안전하지 않은 코드를 방지하도록 여러 장치를 제공한다.

예를 들면 변수가 사용되기 전에 초기화되고, 메모리가 해제된 후에는 접근되지 않으며, 배열 인덱스가 범위를 벗어났는지 확인 등이다.

 

-Swift는 메모리의 위치를 수정하는 코드가 해당 메모리에 대한 독접 접근 권한을 갖도록 요구함으로써 동일한 메모리 영역에 다중 접근으로 인한 충돌이 일어나지 않도록 한다.

이렇게 메모리를 자동으로 관리하기 때문에 프로그래머는 대부분의 경우, 메모리 접근에 대해 생각할 필요가 없다. 

하지만 메모리 접근 중 충돌이 발생할 수 있는 경우들을 이해하고 있다면 메모리 접근 충돌에 더 안전한 코드를 작성할 수 있다. (근데 어차피 컴파일러가 충돌이 일어날만한 코드인지 미연에 알려줌.)

 

 

메모리에 접근하는 충돌의 이해

 

-변수에 값을 할당하거나, 함수의 전달인자로 변수의 값을 전달하는 등의 동작을 할 때 메모리에 접근한다.  

 

ex) 읽기 접근과 쓰기 접근의 예시

 

메모리에 접근하는 충돌은 서로 다른 코드가 같은 시간에 같은 메모리의 위치에 접근할 때 발생할 수 있다.

동시에 같은 메모리의 위치에 다중 접근하는 메모리 접근 충돌은 예측할 수 없거나 일관성 없는 동작을 발생시킬 수 있다.

ex) 인스턴스 내부의 여러 프로퍼티의 값을 합산하여 반환하는 함수가 있을 경우,

1. 외부의 한 코드에서는 인스턴스의 프로퍼티 값 일부를 수정하고

2. 동시에 다른 코드에서는 프로퍼티 값을 합산하여 반환하고 있다면?

동시에 일어나기 때문에 수정이 모두 끝난 값을 합하는지, 수정하고 있는 도중 값을 합하는지, 수정하기 전 값들을 합산하고 반환하는지 등 프로그래머는 결과를 예측할 수 없을 것이다.

 

tip

-다중 쓰레드 프로그램에서는 메모리 접근 충돌이 익숙한 문제일 수 있다.

하지만 지금 설명하고 있는 메모리 접근 충돌은 단일 쓰레드에서 발생할 수 있는 문제들을 다룬다.

단일 쓰레드 내에서 메모리 접근 충돌이 일어났을 때 컴파일 에러나 런타임 에러가  발생한다.

다중 쓰레드 내에서는 Thread Sanitizer 를 사용하여 쓰레드 간 접근 충돌을 감지할 수 있다.

 

 

 

 

메모리 접근의 특징

-메모리 접근 충돌을 일으키는 맥락에서 고려해야 할 메모리 접근의 특성 3가지가 있다.

아래 특성 3가지의 조건을 만족하는 메모리 접근이 2개 이상의 코드에서 일어났을 경우 충돌이 발생한다.

1. 적어도 하나의 쓰기 접근 또는 nonatomic 접근이다.

2. 메모리의 같은 위치에 접근한다.

3. 접근 타이밍이 겹친다.

 

-읽기와 쓰기 접근 간의 차이는 일반적으로 명확하다.

쓰기접근은 메모리의 위치를 변경하지만, 읽기 접근은 변경하지 않는다.

메모리의 위치는 예를들어 변수, 상수 또는 프로퍼티와 같이 접근 중인 항목을 나타낸다.

이 때, 메모리의 접근 기간은 순간적이거나 장기적이다.

 

-순차적으로 코드를 실행하고, 메모리에 접근하는 것이 순간적이라면 다른 코드에서 같은 메모리 위치에 동시에 접근할 일이 없다.

단일 스레드 환경에서 대부분 메모리 접근은 순간적이고, 동시에 다른 코드에서 접근할 일이 없다.

 

ex) 순간적인 메모리 접근의 예시

 

위 코드는 순차적으로 메모리에 접근하여 읽기와 쓰기를 하고, 메모리에 접근하는 시간도 순간적이기 때문에 충돌이 일어나지 않는다.

 

 

위에서 메모리의 접근 기간은 순간적이거나 장기적이라고 했죠?

이번엔 장기적 메모리 접근에 대해서 알아보자.

장기적 메모리 접근과 순간적 메모리 접근의 차이는 장기적 메모리 접근중에는 다른 코드에서 메모리에 접근할 가능성이 있다는 것이다. 접근 타이밍이 겹치게 되는 이 상황을 오버랩이라고 한다.

따라서 장기접근은 다른 순간적 접근이나 장기 접근과 오버랩 될 수 있다.

 

오버랩되는 상황에는

1. 함수나 메서드에서 in-out 파라미터를 사용하거나

2. 구조체의 메서드 중 mutating 키워드가 붙어있는 변경하는 메서드를 사용했을 때 주로 나타난다.

 

참고해야 할 것은 메모리에 동시에 접근한다고 무조건 메모리 접근 충돌이 발생하는 것은 아니고, 메모리의 접근 충돌 발생 가능성이 높아지는 것이다.

 

 

in-out 파라미터의 접근 충돌

-in-out 파라미터를 갖는 함수는 모두 장기적인 쓰기 접근을 가지고 있다. 

-in-out 파라미터에 대한 메모리 접근은 함수가 실행될 때 모든 non-in-out 파라미터가 평가된 후에 시작되고, 함수가 종료될 때까지 쓰기 접근을 유지한다. 함수가 종료되면 쓰기 접근도 종료됨.

-in-out 파라미터가 여러개인 경우에는 파라미터의 호출 순서와 동일한 순서로 쓰기 접근이 시작된다.

 

-in-out 파라미터를 통한 장기적 메모리 접근 중에는 파라미터로 전달하는 변수는 다른 접근이 제한된다.

 

ex) stepSize에 읽기 접근과 number의 쓰기 접근의 오버랩

 

변수 stepSize는 in-out 파라미터를 가지고 있는 increment 함수의 전달인자로 전달된다.

stepSize가 전달인자로 들어가면서 장기적 쓰기 접근이 실행되고, 

내부에서 stepSize의 값에 읽기 접근이 또 한번 실행되기 때문에 메모리 접근 충돌이 발생한다.

 

아까 메모리의 접근 특성 3가지가 있었죠?

적어도 한 개의 쓰기 접근, 메모리의 같은 위치에 접근, 동시에 접근 이 세 가지 조건을 위 코드는 모두 충족하고 있다.

 

https://bbiguduk.gitbook.io/swift/language-guide-1/memory-safety

 

 

이 메모리 접근 충돌 문제를 해결하기 위해서는 새로운 변수를 만들어 stepSize의 복사본 역할을 하도록 하는 것이다.

 

새로운 변수 copyOfStepSize는 stepSize의 값을 복사해서 저장했다.

따라서 위 예제에서 했던 stepSize에 대한 읽기 역할을  copyOfStepSize 변수가 대신하기 때문에 메모리 접근 충돌이 발생하지 않는다.

 

 

in-out 파라미터를 통한 메모리 접근 충돌의 또 다른 예

 

balance함수는 파라미터 x와 y를 균등한 값으로 만들어주기 위해 수정해주고 있다.

x, y 둘 다 in-out 파라미터이기 때문에 장기적 쓰기 접근이 실행되는데,

x, y에다가 변수 playerOneScore를 똑같이 넣어주니, 함수가 실행되는 동안 동시에 변수의 메모리 위치에 장기 접근을 시도하기 때문에 메모리 접근 충돌이 발생한다. 

 

 

 

메서드에서 self 접근의 충돌

-구조체의 mutating 메서드는 실행 중 self에 대한 쓰기 접근을 가진다.

 

ex) 게임 캐릭터를 구조체로 정의했을 때

 

위 코드의 결과는 메모리 충돌이 일어나지 않는다.

먼저 코드를 설명하자면, 구조체 플레이어는 상처를 입으면 체력이 깎이고, 체력을 다시 회복할 수 있는 메서드가 존재한다.

restoreHealth() 메서드를 보면, 인스턴스 자신인 self에 장기적인 쓰기 접근을 한다. 이 접근은 메서드가 시작될 때부터 반환될 때까지 유지된다.

restoreHealth() 메서드의  내부를 보면, Player 인스턴스의 프로퍼티를 동시에 접근하는 코드가 없기 때문에 메모리 충돌이 발생하지 않는다.

 

 

shareHealth() 메서드를 추가해보자.  

func balance(x : inout Int, y : inout Int) {
    let sum = x + y
    x = sum / 2
    y = sum - x
}

struct Player {
    
    var name : String
    var health : Int
    var energy : Int
    
    static let maxHealth = 10
    
    mutating func restoreHealth() {
        self.health = Player.maxHealth
    }
}

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

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

 

oscar와 maria 플레이어의 체력을 shareHealth() 메서드를 통해서 공유한다.

 

maria는 in-out 파라미터를 갖는 shareHealth() 메서드의 전달인자로 들어가니 실행중에 maria에 대한 쓰기 접근이 있다.

 

shareHealth() 메서드 내부를 보면, in-out 파라미터를 가지고 있는 balance 메서드가 있죠?

이 balance 메서드의 y 전달인자는 self의 health 값을 인자로 전달받고 있다.

oscar가 shareHealth 메서드를 호출했으니, 이 balance 메서드의 전달인자로 oscar의 health가 사용되겠죠? 

따라서 메서드가 실행될 때, oscar는 self 값에 쓰기 접근이 있다.

 

두 코드 동시에 쓰기 접근이 이루어지지만, 메모리의 위치가 다르기 때문에 메모리 접근 충돌이 발생하지 않는다.

 

 

 

 

만약 shareHealth() 메서드의 전달인자로 maria가 아닌 oscar를 전달한다면?

 

오버랩 접근 에러라고 컴파일러가 알려주네요.

그냥 maria에 쓰기 접근을 했던 것을 oscar로 변경한 것이니 충분히 예상할 수 있다.

두 번의 쓰기 접근이 동시에 이루어지기 때문에 충돌이 일어난다!

 

 

 

프로퍼티에서 접근 충돌

-구조체, 열거형 등은 프로퍼티로 구성되어 있고, 튜플은 요소로 구성되어 있다.

각 프로퍼티나 요소는 개별 구성값으로 구성된다.

 

구조체, 열거형, 튜플 등은 값 타입이다.

그래서 자신의 인스턴스 내부 프로퍼티를 변경할 경우 전체 값도 같이 변경된다.

이것은 프로퍼티 중 하나에 읽기 또는 쓰기 접근을 하는 것이 전체 인스턴스에 읽기 또는 쓰기 접근을 요구한다는 것과 같다.

 

ex) 튜플의 요소에 쓰기 접근이 겹치면 충돌이 일어난다.

 

balance() 함수의 in-out 파라미터는 함수가 실행 중일 때, 매개변수 모두 쓰기 접근을 한다.

따라서 playerInformation 튜플의 요소들은 모두 balance() 함수가 실행되는 동안 쓰기 접근이 필요하다.

위에서 말했듯이 값 타입은 프로퍼티 중 하나에 읽기 또는 쓰기 접근을 할 때, 전체 인스턴스에도 접근을 요구한다고 했다.

따라서 playerInformation.health와 playerInformation.energy에 동시에 쓰기 접근을 할 때,

전체 playerInformation에도 쓰기 접근을 요구하기 때문에 두 개의 쓰기 접근이 오버랩 되므로 충돌이 일어난다.

 

 

ex) 전역 변수에 저장된 구조체의 프로퍼티

 

전역변수로 구조체 프로퍼티 holly를 선언해주었다.

튜플 예제와 같이 구조체의 프로퍼티인 holly도 두 개의 쓰기 접근이 오버랩되기 때문에 메모리 접근 충돌이 발생한다.

 

실제로 구조체의 프로퍼티에 접근하는 것은 안전하게 오버랩 될 수 있다.

위의 코드에서 holly는 전역변수이기 때문에 예외가 되지만, 이를 지역변수로 변경해주면 중복 접근에도 안전한 것을 볼 수 있다.

 

ex) 지역변수 holly

 

전역변수였던 holly를 지역변수로 변경했을 뿐인데, 메모리 접근 충돌이 발생하지 않는다.

 

이로써 전역변수와 지역변수의 특징 차이로 인한 것이라는 것을 알 수 있다.

전역변수는 다른 코드 어디에서든 쓰일 수 있다.

지역변수는 예상 가능한 코드 내에서만 사용하기 때문에 다른 코드에서 접근이 불가하다.

따라서 지역 변수 holly는 someFunction() 내에 있는 코드 외 다른 영역에서 접근할 일이 없기 때문에

컴파일러도 이를 알아채고 에러를 뱉지 않는 것이다.

 

 

구조체의 프로퍼티 중복 접근 제한은 꼭 메모리의 안정성을 위한 것만은 아니다.

위에서 보았듯이 컴파일러는 상황에 맞게 메모리가 안전한지를 판단한다.

그 판단의 기준에는 3가지가 있다.

1. 연산 프로퍼티 또는 클래스 프로퍼티가 아닌 인스턴스의 저장 프로퍼티에만 접근한다.

2. 구조체를 전역 변수가 아닌 지역변수의 값으로 할당할 때.

3. 구조체가 클로저에 의해 캡쳐되지 않거나, non-escaping 클로저에 의해서만 캡쳐되었을 때.

 

위 조건을 만족하지 않을 때, 컴파일러는 접근이 안전하다는 것을 증명할 수 없다고 판단하여 접근을 허용하지 않는다.

 

 

 

 

 

Reference :

-https://bbiguduk.gitbook.io/swift/language-guide-1/memory-safety

-야곰님의 Swift 문법 개정 3판

 

 

'Swift' 카테고리의 다른 글

Swift Concurrency  (1) 2024.09.22
CodingKey에 관하여  (0) 2023.03.31
Error Handling(에러 처리)에 관하여  (0) 2023.03.16
Where절에 관하여  (0) 2023.03.15
패턴에 관하여  (0) 2023.03.15