본문 바로가기
iOS/SWIFT

[Swift] Chapter 29. 메모리 안전

by 원만사 2021. 10. 24.
반응형

스위프트는 안전을 중요시하는 언어이다. 그래서 컴파일러가 코드에서 위험을 줄일수 있도록 많은 장치를 두었다. 그 중 큰 부분을 차지하는 것이 메모리의 안전한 접근이다. 변수를 사용하기 전에 초기화를 강제하고, 해제된 메모리에 접근할 수 없도록 설계된 것들이 그 대표적인 예다.

스위프트는 메모리를 자동으로 관리하기 때문에 특별한 경우가 아니라면 프로그래머가 메모리의 접근에 대해 크게 신경쓸 필요가 없다. 스위프트 컴파일러는 메모리 접근 충돌이 생길만한 코드를 미연에 알려준다. 이번 장에서는 이에 대해 알아보자.

 

29.1 메모리 접근 충돌의 이해


프로그래머가 변수에 값을 할당한다던가 함수의 전달인자로 변수의 값을 전달하는 등 다양한 경우에 코드를 통해 메모리에 접근하게 된다.

 

/*
 코드 29-1. 코드를 통해 메모리에 접근하는 유형
 */

// one이 저장될 메모리 위치에 쓰기 접근
var one: Int = 1

// one이 저장된 메모리 위치에 읽기 접근
print("숫자 출력 : \(one)")

 

메모리 접근 충돌은 서로 다른 코드에서 동시에 같은 위치의 메모리에 접근할 때 발생한다. 동시에 여러 접근을 하게 되면 예상치 못한 결과를 얻을 수 있다. 예를 들어, 어떤 인스턴스 내부의 여러 프로퍼티의 값을 합산하여 반환하는 함수가 있을 때, 외부의 한 코드에서 인스턴스의 프로퍼티 값 일부를 수정을 하고, 동시에 또 다른 어딘가의 코드에서 합산하여 결과를 돌려주는 함수를 호출한다면 그 결과를 예측할 수 있을까? 동시에 일어나는 일이라면 수정 전의 합산 결과를 돌려줄지, 수정된 값의 합산 결과를 돌려줄지 장담할 수 없다.

이는 다중 스레드 프로그램에서는 흔히 겪을 수 있는 일이다. 이번 장에서는 다중 스레드 환경에서의 문제는 뒤로하고, 단일 스레드 프로그래밍 중에 발생할 수 있는 문제를 컴파일러가 미연에 방지해주는 것에 대한 내용을 다룰 것이다.

 

29.1.1 메모리 접근의 특성

메모리 접근 충돌을 일으키는 메모리 접근에는 세 가지 특성이 있다. 다음의 세 가지 조건에 모두 해당하는 메모리 접근이 두 군데 이상의 코드에서 동시에 일어나면 메모리 접근 충돌이 발생한다.

  • 최소한 한 곳에서 쓰기 접근한다.
  • 같은 메모리 위치에 접근한다.
  • 접근 타이밍이 겹친다.

 

순차적으로 코드를 실행하고, 메모리에 접근하는 것이 순간적이라면 다른 코드에서 같은 메모리 위치에 동시에 접근할 일이 없다. 단일 스레드 환경에서는 대부분의 메모리 접근이 순간적 접근이고 동시에 다른 코드에서 접근할 일이 없다.

 

/*
 코드 29-2. 순차적, 순간적 메모리 접근
 */

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

var myNumber: Int = 1
myNumber = oneMore(than: myNumber)
print(myNumber)

 

반면에 장기적 메모리 접근이라는 접근 방식도 있다. 장기적 메모리 접근 중에는 해당 메모리 접근이 끝나기 전에 다른 코드에서 메모리에 접근할 가능성이 있다. 접근 타이밍이 겹치게 되는 것이다.

접근 타이밍이 겹치게 되는 대표적 상황은 함수나 메서드에서 inout을 사용한 입출력 매개변수를 사용하는 경우나 구조체에서 mutating 키워드를 사용하는 가변 메서드를 사용하는 경우이다. 메모리의 같은 위치에 접근하는 여러 접근의 타이밍이 겹친다고해서 무조건적으로 메모리 접근 충돌이 발생하는 것은 아니다. 그렇지만 접근 타이밍이 겹치는 경우 대개 메모리 접근 충돌이 발생할 가능성이 크다. 메모리 접근 충돌을 코드에서 정적으로 예측할 수 있는 경우 컴파일러에서 오류로 취급하여 컴파일하지 않는다.

 

29.2 입출력 매개변수에서의 메모리 접근 충돌


입출력 매개변수를 갖는 함수는 동작 중 모두 장기적 메모리 접근을 한다. 즉, 함수의 실행과 동시에 입출력 매개변수의 쓰기 접근이 시작되고 함수가 종료될 때까지 쓰기 접근을 유지한다. 함수가 종료될 때 쓰기 접근을 종료한다.

입출력 매개변수를 통한 장기적 메모리 접근 중에는 매개변수로 전달하는 변수는 다른 접근이 제한된다.

 

/*
 코드 29-3. 입출력 매개변수에서의 메모리 접근 충돌
 */

var step: Int = 1

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

increment(&step) // 오류 발생!

 

[코드 29-3]의 step 변수는 increment(_: ) 함수의 입출력 매개변수로 전달되었는데 함수 내부에서 같은 메모리 공간에 읽기 접근을 하려고 시도하기 때문에 메모리 접근 충돌이 발생한다. 따라서 실행했을 때 런타임 오류가 발생한다. 이런 경우, [코드 29-4]처럼 새로운 변수를 생성해서 해결할 수 있다.

 

/*
 코드 29-4. 입출력 매개변수에서의 메모리 접근 충돌 해결
 */

var step: Int = 1
var copyOfStep: Int = step

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

increment(&step)

 

입출력 매개변수에서 메모리 접근 충돌이 발생할 수 있는 다른 예를 들자면 두 개 이상의 입출력 매개변수로 같은 변수를 전달하는 상황을 들 수 있다.

 

/*
 코드 29-5. 복수의 입출력 매개변수로 하나의 변수를 전달하여 메모리 접근 충돌
 */

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

var playerOneScore: Int = 42
var playerTwoScore: Int = 30
balance(&playerOneScore, &playerTwoScore) // 문제 없음
balance(&playerOneScore, &playerOneScore) // 오류 발생!

 

[코드 29-5]에서 두 번째 호출의 경우 같은 변수를 동시에 두 개의 입출력 매개변수로 전달하여 메모리 접근 충돌이 발생한다. 왜냐하면 playerOneScore라는 변수의 메모리 위치를 함수가 실행되는 동안 동시에 장기적 접근을 시도하기 때문에 문제가 발생한다. 이 경우에는 컴파일러에서 미리 컴파일 오류로 알려준다.

 

29.3 메서드 내부에서 self 접근의 충돌


구조체의 가변 메서드는 메서드 실행 중에 self에 쓰기 접근을 한다. 게임 캐릭터를 구조체로 구현했다고 생각했을 때, 캐릭터가 상처를 입으면 체력이 닳는다. 체력을 다시 회복하는 메서드와 체력을 다른 캐릭터와 공유하는 메서드를 만들고 그것을 [코드 29-6]처럼 표현해 보았다.

 

/*
 코드 29-6. 게임 캐릭터를 정의한 GamePlayer 구조체
 */

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

struct GamePlayer {
    var name: String
    var health: Int
    var energy: Int
    
    static let maxHealth = 10
    
    mutating func restoreHealth() {
        self.health = GamePlayer.maxHealth
    }
    
    mutating func shareHealth(with teammate: inout GamePlayer) {
        balance(&teammate.health, &health)
    }
}

 

[코드 29-6]의 restoreHealth() 메서드는 실행 중 인스턴스 자신인 self에 장기적으로 쓰기 접근을 한다. 현재 restoreHealth() 메서드는 내부의 코드 중 인스턴스의 다른 프로퍼티를 동시에 접근하는 코드가 없다. 반면에 shareHealth(with:) 메서드는 다른 캐릭터의 인스턴스를 입출력 매개변수로 받기 때문에 메모리 접근 충돌이 발생할 여지가 있다.

 

/*
 코드 29-7. 메모리 접근 충돌이 없는 shareHealth(with:) 메서드 호출
 */

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

 

[코드 29-7]에서 shareHealth(with:) 메서드의 호출은 메모리 접근 충돌을 일으키지 않는다. teammate 입출력 매개변수로 전달된 maria는 shareHealth(with:) 메서드가 실행되는 중에 쓰기 접근을 하고, 가변 메서드를 실행해야 하는 oscar도 쓰기 접근을 한다. 하지만 서로 다른 메모리 위치에 있기 때문에 메모리 접근 충돌이 발생하지 않는 것이다.

 

/*
 코드 29-8. 메모리 접근 충돌이 발생하는 shareHealth(with:) 메서드 호출
 */

oscar.shareHealth(with: &oscar) // 오류 발생!

 

그렇지만 [코드 29-8]에서는 teammate 입출력 매개변수로 전달받은 메모리 위치와 oscar 인스턴스의 메모리 위치는 같은 곳이기 때문에 동시에 쓰기 접근을 하면 메모리 접근 충돌이 발생한다.

 

29.4 프로퍼티 접근 중 충돌


프로퍼티에 읽고 쓰기를 위한 접근을 하는 것은 인스턴스 자신 전체에 대한 읽고 쓰기 접근 권한이 필요하다고 생각할 수 있다.

 

/*
 코드 29-9. 프로퍼티 접근 중 메모리 접근 충돌
 */

balance(&oscar.health, &oscar.energy)

 

[코드 29-9]에서 balance(_: _:) 함수의 두 매개변수는 모두 입출력 매개변수이므로 함수가 실행 중이면 두 매개변수 모두 쓰기 접근을 한다. oscar의 프로퍼티인 health만 매개변수로 전달했더라도 oscar 인스턴스 자체의 값이 변경될 것을 의미하므로 oscar 인스턴스 자체에 쓰기 접근을 해야한다. 이와 마찬가지로 두 번째 입출력 매개변수로 oscar의 energy 프로퍼티를 전달하더라도 oscar 인스턴스의 쓰기 접근을 해야하므로 두 접근이 충돌할 수밖에 없다.

[코드 29-9]의 예는 oscar가 전역변수일 때 이야기이다. 우리가 자주 사용하는 지역변수를 사용하면 이야기가 조금 달라질 수 있다. 만약 oscar가 특정 함수나 메서드, 반복문, 조건문 등의 안에서만 쓰이는 지역변수라면 어떤지 [코드 29-10]을 통해 알아보자.

 

/*
 코드 29-10. 전역변수와 지역변수의 메모리 접근의 차이
 */

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

 

[코드 29-10]의 oscar는 balance(_: _:) 함수가 실행되는 도중 다른 곳에서 oscar의 메모리 위치에 접근하려는 코드가 있을 수 있을까? 무슨 뜻이냐 하면, [코드 29-7]에서 생성한 oscar는 다른 코드 어디에서든 쓰일 수 있는 전역변수였던 반면에 [코드 29-10]의 oscar는 someFunction() 함수 안에서만 사용하는 변수기 때문에 다른 위치의 코드에서 접근할 일이 없다는 뜻이다. 지역변수로 쓰이는 oscar는 현재 함수 안에서 순차적 실행될 코드 외의 영역에서 접근할 코드가 없기 때문에 다른 코드에서 oscar의 메모리 위치에 접근하여 문제가 발생할 여지가 없다. balance(_: _:) 함수 안에서만 oscar의 메모리 위치에 접근하면 되는 상황인것이다. 이는 전혀 문제될 것이 없는 상황이고 그렇기 때문에 컴파일러도 이를 오류로 취급하지 않는다.

 

메모리 안전 때문에 구조체의 프로퍼티 메모리에 접근하는 타이밍이 겹치는 것을 무조건 제한해야 하는 것은 아니다. 다음 세 조건을 충족하면 구조체의 프로퍼티 메모리에 동시에 접근하더라도 안전이 보장될 것이다.

  • 연산 프로퍼티나 클래스 프로퍼티가 아닌 인스턴스의 저장 프로퍼티에만 접근
  • 전역 변수가 아닌 지역 변수일 때
  • 클로저에 의해 획득(Captured) 되지 않았거나, 비탈출 클로저에 의해서만 획득 되었을 때

 

앞의 세 조건을 충족하지 않는 경우에는 컴파일러가 안전을 담보할 수 없기 때문에 접근을 제한할 수 있도록 오류로 취급한다.

 

 

스위프트 프로그래밍: Swift 5 - 교보문고

객체지향, 함수형, 프로토콜 지향 패러다임까지 한 번에! | 스위프트를 제대로 이해하고 싶은 개발자를 위한 책스위프트는 iOS와 macOS용 애플리케이션 개발에 주로 사용하는 프로그래밍 언어입니

www.kyobobook.co.kr

반응형

'iOS > SWIFT' 카테고리의 다른 글

[Swift] Chapter 30. 불명확 타입  (0) 2021.10.24
[Swift] Chapter 28. 오류처리  (0) 2021.10.24
[Swift] Chapter 27. ARC  (0) 2021.10.23
[Swift] Chapter 26. where 절  (0) 2021.10.23
[Swift] Chapter 25. 패턴  (0) 2021.10.23

댓글