이번 포스트에서는 swift의 기반에 깔려있는 안전장치들을 살펴보겠습니다. 대부분의 경우 프로그래머들은 이를 신경쓰지 않아도 되지만, 가끔은 필요한 때가 있을 것입니다. 오늘은 이러한 부분들을 알아보겠습니다.

이 포스트는 다음 가이드를 참조하여 작성되었습니다.
Swift Language Guide - Memory Safety

이 포스트는 싱글 스레드 상황에서 일어날 수 있는 메모리 문제에 대해서만 다룹니다. 멀티스레드 환경에 대해서는 컴파일러나 런타임이 안정성을 보장해 줄 수 없습니다. 만약 멀티스레드 환경에서의 메모리 불일치를 디버깅해야 한다면, Thread Sanitizer를 활용할 수 있습니다.

Swift는 잘못된 메모리 접근을 막기 위해 다음과 같은 기능을 제공합니다.

  • 모든 변수가 사용되기 전 초기화 되는 것을 보장합니다. 초기화하지 않고 변수를 사용하면 컴파일 단계에서 잡아냅니다.

  • 메모리가 해제된 이후에는, 해당 메모리에 대한 접근이 일어나지 않는다는 것을 보장합니다.

  • 배열의 범위를 넘어가는 접근을 검사하고, 범위 밖 접근에 대해 에러를 발생시키는 것을 보장합니다.

Swift는 대부분의 상황에서 메모리 관리를 알아서 해주기 때문에, 대부분의 경우는 전혀 의식하지 않고 사용할 수 있습니다. 하지만 문제가 생길 여지가 여전히 남아 있고, 이것을 이해하고 피해가도록 코드를 작성하는 것이 필요합니다. 만약 코드가 이러한 메모리 액세스 문제가 있다면, 컴파일 에러 혹은 런타임 에러를 받게 될 것 입니다.

메모리 접근은 값을 설정하거나, 값을 매개변수로 넘긴다거나 하는 경우에 일어납니다. 메모리 접근 문제는 서로 다른 코드가 같은 메모리에 동시에 접근할 때 일어납니다. swift에서는 여러줄에 걸쳐서 값 변화가 일어난다면, 변화 도중에 메모리 값 접근이 일어날 수 있는 가능성이 존재합니다.

Memory Conflict
메모리 값 변화의 중간의 올바르지 않은 상태가 존재할 수 있습니다

이 문제를 해결하는 방법은 다양할 수 있고, 어떤 게 정답인지 명확하지 않을 수도 있습니다. 예를 들어, 위 이미지에서는 갱신 되기 전 값을 원하는지 갱신된 값을 원하는 지에 따라 답이 달라집니다. 그래서 문제를 해결하기 전에 어떤 답을 원하는지를 결정해야 합니다.

메모리 접근 문제가 생기는 조건은 다음의 3가지 조건을 모두 만족해야만 합니다.

  • 읽기/쓰기 여부 : 최소 하나의 코드는 쓰기 동작을 해야합니다.

  • 메모리 접근 위치 : 모두 동일한 메모리에 접근해야 합니다.

  • 접근하고 있는 시간 : 접근하고 있는 시간대가 겹쳐야 합니다.

이 중 접근하고 있는 시간은 두가지로 나눌 수 있습니다.

  • 즉시 접근(Instantaneous access) : 해당 접근이 완료되기 전에 다른 코드가 실행될 수 없습니다.

  • 장기간 접근(long-term access) : 해당 접근 중이 완료되기 전에는 다른 코드가 실행될 수 있습니다. 이때 다른 코드가 끼어들면 메모리 접근 문제가 생기게 됩니다.

주로 메모리 문제가 생기는 부분은 inout 매개변수를 사용한 함수나 메소드, 혹은 구조체의 mutating 메소드입니다.

  1. inout 매개변수 : inout 매개 변수의 쓰기 접근은 inout이 아닌 매개변수가 모두 평가(evaluate)되고 나서, 가장 마지막에 이루어지고 함수 호출이 종료 될 때까지 유지됩니다. inout 매개변수가 여러개면, 정의된 순서대로 이루어집니다.

    이러한 장기간 접근으로 인해 생기는 문제는 inout 매개 변수로 넘긴 원래 변수에 접근이 불가능해진다는 것입니다. 변수의 범위와 접근제어를 만족하더라도 불가능합니다.

     var stepSize = 1
    
     func increment(_ number: inout Int) {
         number += stepSize
     }
    
     increment(&stepSize)
     // Error: stepSize에 대한 접근 문제 발생
    

    위 코드의 stepSize는 전역 변수이기 때문에 매개변수로 넘기는 것이나 함수 내부에서 참조하는 것 모두 가능합니다. 다만, 함수 내부에서 stepSize에 대한 읽기 요청이 number 매개변수를 통해 stepSize로의 쓰기 요청과 겹치기 때문에 문제가 발생하게 됩니다.

    stepsizeConflict}

    이 문제를 해결하는 방법은 stepSize의 복사본을 하나 만들어 사용하는 것입니다.

     var copyOfStepSize = stepSize
     increment(&copyOfStepSize)
        
     stepSize = copyOfStepSize // 원본을 업데이트 해줍니다.
     // stepSize == 2
    

    또 하나의 문제 시나리오는 여러개의 inout 매개변수를 가진 함수에 같은 값을 매개변수로 넘길 때 일어납니다.

     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!
    

    이 경우는 같은 위치에 2번의 쓰기 요청이 동시에 일어나서 생기는 문제입니다.

  2. 구조체에서의 mutating 메소드 : 구조체에서 mutating 메소드는 메소드가 호출되는 동안 자기 자신(self) 에 대해 쓰기 요청을 유지합니다.

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

    mutating 메소드인 restoreHealth 메소드를 호출하면, self에 대한 쓰기 요청이 메소드 호출이 끝날때 까지 유지됩니다. 현재까지는 겹쳐지는 메모리 요청이 없기 때문에 문제 없습니다. 그렇다면 다음 기능을 추가해보겠습니다.

     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
    

    저 코드는 내부적으로 메모리 요청이 한번 더 일어나긴 하지만, 서로 다른 메모리에 접근하기 때문에 문제없이 실행이 됩니다.

    HealthShareOK}

    하지만 다음 코드는 오류를 발생시킵니다.

     oscar.shareHealth(with: &oscar)// Error
    

    이미 shareHealth가 호출된 순간부터 self에 대한 쓰기 요청이 유지되고 있기 때문에, 자기 자신에 대한 쓰기 요청을 한번 더 하게 되는 상황이 되며 오류가 발생하기 됩니다.

    HealthShareError}

  3. 구조체, 튜플, 열거형 같은 값 타입: 이들은 프로퍼티들(튜플의 경우 원소들)의 조합으로 이루어진 값 타입이기 때문에, 내부 프로퍼티(원소)의 변경이 곧 타입 전체의 변화로 간주됩니다. 즉, 내부 값 하나만 접근요청을 하려고 해도, 값 타입 전체에 대해 접근 요청을 해야하는 것입니다. 따라서 다음과 같은 코드는 오류가 발생하게 됩니다.

     var playerInformation = (health: 10, energy: 20)
     balance(&playerInformation.health, &playerInformation.energy) // Error!
    
     var holly = Player(name: "Holly", health: 10, energy: 10) // 전역 변수
     balance(&holly.health, &holly.energy)  // Error
    

    다만, 실제 사용할 때는 이를 안전하게 사용하는 방법이 있습니다. 위 코드에서 holly를 전역 변수에서 지역변수로 변경하게 된다면 컴파일러가 이를 안전하다고 판단할 수 있게 되어 허용하게 됩니다.

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

    이와 같이 컴파일러가 메모리 접근이 배타적이지 않아도 메모리 안정성이 유지된다고 판단할 수 있는 상황에서는 이러한 접근을 허용하게 됩니다. 이는 구조체 타입에서 다음과 조건을 만족해야만 가능합니다.

    • 저장 프로퍼티에만 접근해야 합니다. 계산 프로퍼티나 타입 프로퍼티로의 접근은 허용되지 않습니다.

    • 지역 변수여야 하고, 전역변수는 허용되지 않습니다.

    • 해당 구조체 값이 다른 클로저에 의해 캡쳐되어서는 안됩니다. 캡쳐되더다도 non-escaping 클로져여야 합니다.

    이러한 조건을 충족하지 못하면, 컴파일러는 비배타적 접근을 허용하지 않습니다.


조금은 낮설고 쓸 일이 많이 없을 수도 있지만, swift를 더 깊이 이해할 수 있을 만한 내용을 알아보았습니다.