Swift는 타입 안정성, 값 타입의 적극적인 사용, ARC 등으로 안전하면서도 비교적 높은 성능을 뽑아낼 수 있습니다. 하지만 때로는 더 큰 성능을 얻기위해 이러한 안전장치를 벗겨내서 프로그래머가 더 제어권을 가져가고 싶을 수도 있습니다. Swift는 이를 위한 도구들을 제공해주는데, 오늘은 이 도구들에 대해서 알아보겠습니다. 이 도구들은 안전하지 않기 때문에 Unsafe 등의 접두사가 붙어 있습니다. 여기서 안전하지 않다는 것은 모든 input에 대해서 행동들이 명확히 정해져 있다는 것이고, 안전하지 않다는 것은 일부 input에 대해서는 정의되지 않은 행동을 할 수도 있다는 것을 의미합니다.

  • 포인터란?
    C, C++ 등의 언어를 익히신 분들이라면 익숙한 개념일 포인터는 간단하게 생각하면 메모리의 주솟값을 나타내는 정수 타입의 값입니다. 32비트 시스템에서는 32비트(4바이트)의 크기를 가지고, 64비트 시스템에서는 64비트(8바이트)의 크기를 가집니다. 여기에 더해 포인터는 자신이 가리키고 있는 데이터가 몇바이트인지에 대한 정보를 추가로 가질 수 있는데, 이를 타입이 지정된 포인터(typed pointer)라고 합니다.

    다음으로는 포인터가 가질 수 있는 상태에 대해서 알아보겠습니다. 포인터는 할당여부(allocation), 가리키고 있는 객체의 초기화 여부(initialization), 타입의 바인딩 여부(type binding)에 따라 나눌 수 있습니다. 할당은 필요한 크기만큼의 메모리를 시스템에게 요청하여 사용할 수 있는 메모리 영역의 주소를 확보하는 단계이고, 초기화는 할당받은 영역을 의미있는 값으로 채우고, 그 외 필요한 작업을 수행하여 데이터가 올바르게 사용될 수 있도록 준비하는 단계입니다. 포인터를 사용하기 전에는 반드시 할당, 초기화의 단계를 순서대로 거쳐야되며, 바이트 단위로 조작을 할 게 아니라면 반드시 타입 바인딩도 되어 있어야 합니다. 그리고 사용이 끝나면 역순으로 소멸(deinitialize) 및 해제(deallocate) 포인터의 메소드들은 특정 상태를 가정하고 동작하기 때문에, 현재 포인터가 어떠한 상태에 있는지 반드시 알고 있어야 합니다. 포인터를 직접 사용한다면 이 관리는 개발자가 직접해야하며, 잘못된 가정을 할 경우 메모리 누수, 오염, 크래시등의 문제가 일어날 수 있습니다.

  • UnsafePointer
    UnsafePointer는 특정 타입의 객체를 가리키는 포인터입니다. C/C++의 타입을 가지는 포인터와 같다고 보시면 됩니다. 주소값을 하드코딩할 수도 있지만 이런 위험한 방법 대신에 이미 있는 객체의 포인터를 얻거나, 다른 타입의 포인터(후에 설명하겠습니다)를 바꿔서 사용합니다. UnsafePointer는 주로 withUnsafePointer 등의 함수와 함께 사용하는게 일반적입니다. 이 함수는 일반적인 변수와 프로퍼티를 포인터로 바꿔서 사용할 수 있게 만들어줍니다.

      let num = 2
    
      withUnsafePointer(to: num) { pointer in
          print(pointer.pointee) // 2
      }
    

    포인터에는 해당 포인터를 통해 값 변경을 할 수 없는 그냥 Pointer계열과 해당 포인터를 통해 값 변경이 가능한 MutablePointer 계열이 있습니다. 이때 MutablePointer를 통해서는 메모리를 할당하고 해제할 수 있으며, 그냥 Pointer로는 해제만 할 수 있습니다. 이 때 얼만큼의 메모리를 할당할 지 설정할 수 있는데, 이를 이용해 배열의 동적할당을 구현할 수 있습니다. 또한 포인터를 사용할 때 주의할 점은 객체 해제의 책임도 개발자가 져야 한다는 것입니다. 객체 해제를 하지 않으면 그대로 메모리 누수로 이어집니다.

      let pointer = UnsafeMutablePointer<Int>.allocate(capacity: 1) // allocated, not initialized
    
      pointer.initialize(to: 1) // allocated, initialized
    
      print(pointer, pointer.pointee) // (주소값) 1
    
      pointer.deinitialize(count: 1) // allocated, deinitialized
    
      pointer.deallocate() // deallocated
    

    만약 포인터가 가리키는 객체가 참조를 포함하지 않는 값타입일 경우, 객체 해제를 명시적으로 하지 않더라도 메모리가 누수되지는 않습니다. 이러한 타입을 단순타입(trivial type)이라고 합니다. Swift의 기본 타입들은 모두 여기에 해당합니다. 하지만 이러한 경우를 일일이 구분해서 쓰기 보다는 초기화한 객체는 반드시 해제한다라는 규칙을 엄수하는 게 좋습니다.

    또한 unsafePointer타입은 첨자와 덧셈, 뺄셈, 범위 연산등을 지원하는데 이를 통해 앞 뒤 메모리를 참조할 수 있습니다. 이 때 참조할 메모리도 할당과 초기화가 되어 있어야 안전하게 사용할 수 있습니다. 다시 한번 강조하지만 포인터를 사용하는 이상, 안전장치는 없기 때문에 이 역시 개발자가 스스로 체크해야 합니다.

      let pointer = UnsafeMutablePointer<Int>.allocate(capacity: 3) // Int 3개 분량의 메모리를 연속적으로 할당 받는다.
    
      for i in 0..<3 {
          let current = pointer+i // 초기화할 포인터로 이동한다.
          //let current = pointer.advanced(by: i) // 위와 동일한 코드
          current.initialize(to: i) // 초기화를 수행한다.
          print(current.pointee) // 0 1 2
      }
    
      pointer.deinitialize(count: 3) // 초기화한 객체를 정리한다.
    
      pointer.deallocate() // 메모리를 해제한다.
    
    
  • UnsafeBufferPointer
    UnsafeBufferPointer는 연속된 영역을 참조하기 위한 포인터입니다. C언어 포인터로 나타나는 배열과, Swift의 배열의 중간단계라고 볼 수 있습니다. BufferPointer는 Sequence 프로토콜을 채택하고 있어서 Iterator도 있고, Collection이기도 해서 여러 고차 함수 적용까지 가능합니다. 그리고 타입 바인딩이 되어 있습니다. 이 BufferPointer는 시작지점(UnsafePointer로 나타납니다.)과 끝까지의 거리(count)를 인자로 초기화를 합니다. 초기화 후 사용법은 배열과 동일합니다.
    그런데 이미 배열이 존재하는 상황에서 왜 이런걸 사용할까요? 이는 Swift의 배열이 접근할 때 해당 인덱스가 범위 내에 있는지를 검사하기 때문입니다. Swift에서 Array는 범위를 벗어나면 바로 런타임 에러가 나는데, 이는 범위 검사를 수행하기 때문입니다. 배열 접근이 적을때는 괜찮지만, 배열 접근이 많이지면 오버헤드가 커집니다. 로우레벨 작업을 할때는 이러한 오버헤드가 문제가 될 수 있기 때문에, 이를 없애기 위해 UnsafeBufferPointer를 사용합니다. UnsafeBufferPointer를 사용하면 유일성 검사와 (릴리즈 모드 한정으로) 범위 검사를 하지 않습니다. 따라서 속도가 중요한 영역에서는 Array 대신에 UnsafeBufferPointer를 사용할 수 있습니다.

      var arr: [Int] = [1,2,3]
    
      var p = UnsafeMutableBufferPointer(start: &arr, count: arr.count) // &를 붙여서 배열의 시작주소를 unsafeMutablePointer로 변환해서 넘긴다.
    
      for i in p {
          print(i) // 1 2 3
      }
    
      p[1] = 5 // MutableBufferPointer를 통해서 arr의 내용 변경
    
      for i in arr {
          print(i) // 1 5 3
      }
        
      print(p[4]) // 범위를 벗어나는 인덱스, 쓰레기 값이 출력
    
  • RawPointer
    위에서 본 포인터들은 모두 타입 바인딩이 되어 있는 포인터입니다. 타입이 없는 포인터들은 RawPointer라고 불립니다. 이는 C언어에서의 void 포인터(void *)에 해당하는 것입니다. 기본적인 특성은 동일하지만, 가리키는 타입이 없기 때문에 메모리의 최소단위인 바이트(UInt8로 나타납니다)덩어리를 가리키는 모양새가 됩니다. 이를 이용하면 서로 다른 타입을 가진 연속된 버퍼를 만들 수도 있습니다.

      var rawPointer = UnsafeMutableRawPointer.allocate(byteCount: 16, alignment: 16) // 16byte 크기를 할당받는다. 최소 alignment 이하 값은 모두 동일하게 취급된다. 최소 alignment 이상의 값을 지정할 경우, alignment는 반드시 2의 배수여야 한다.
    
      rawPointer.initializeMemory(as: UInt8.self, repeating: 0, count: 16) // 버퍼 초기화. 초기화하지 않으면 쓰레기 값이 들어간다.
    
      rawPointer.initializeMemory(as: Int.self, repeating: 150, count: 1) // 앞 8byte에는 Int 할당
      rawPointer.advanced(by: 8).initializeMemory(as: Double.self, repeating: 12.35, count: 1) // 뒤 8byte에는 Double 할당
    
      print(rawPointer.load(fromByteOffset: 0, as: Int.self)) // 150, 메모리를 바인딩하지 않고 값을 불러온다.
      print(rawPointer.load(fromByteOffset: 8, as: Double.self)) // 12.35
      print(rawPointer.load(fromByteOffset: 8, as: Int.self)) // 4623142049979511603. Double이지만 Int로 해석
    
      // 해당 영역을 바인딩한다.
      let intPointer: UnsafeMutablePointer<Int> = rawPointer.bindMemory(to: Int.self, capacity: 1)
      let doublePointer: UnsafeMutablePointer<Double> = rawPointer.advanced(by: 8).bindMemory(to: Double.self, capacity: 1)
    
      intPointer.deinitialize(count: 1) // 해당 영역의 데이터에 대해 소멸자를 호출한다. rawPointer로는 타입을 알 수 없기 때문에 deinitialize를 호출할 수 없다.
      doublePointer.deinitialize(count: 1) // Int나 Double 모두 단순 타입이기 때문에 실제로는 호출할 필요는 없지만 여기서는 규칙을 엄밀하게 따르기 위해 작성해준다.
        
      // intPointer.deallocate() // rawPointer와 주솟값이 같기 때문에 이렇게 해제해되 정상적으로 메모리가 해제된다.
      // doublePointer.deallocate() // alignment 불일치 때문에 이 포인터로 메모리 해제를 시도하게 되면 런타임 에러가 난다.
    
      rawPointer.deallocate() // 메모리를 해제한다. 이때 intPointer와 doublePointer는 무효화된다. 이제 이 포인터에 접근하는 것은 UB다.
    
  • 주의점
    포인터를 쓰게 되면 RC(Reference Count)등의 안전 장치조차 없기 때문에 모든 책임을 개발자가 가져가야 합니다. 또한 포인터만으로는 해당 메모리 영역의 값이 유효한지, 심지어 해당 메모리가 할당되지 않았는데도 접근이 가능하기 때문에 상당히 위험한 방식입니다. 하지만 C언어 API를 브릿징해서 쓸 때는 이러한 기능이 반드시 필요하기 때문에, 아주 안 쓸 수는 없습니다. 그렇기 때문에 이러한 API를 사용할 때는 반드시 사용 영역을 격리해서, 위험영역을 최소화해야합니다. 마찬가지 이유로 포인터는 언제든지 무효화 될 수 있고, 혹은 제대로 무효화하지 않아서 메모리 누수 등을 일으킬 수 있기 때문에 포인터 사용은 항상 조심스럽게 이루어져야 합니다.


참고 자료
Manual Memory Management
Swift가 제공하는 여러 포인터 타입들과 동작 방식
Unsafe In Swift
Safely Manage Pointers In Swift