Nate cook   cover

Swift가 제공하는 여러 포인터 타입들과 동작 방식

Swift는 강력한 타입 시스템, value semantics, 자동 메모리 관리 등을 통해 안전성(safety)을 제공하면서도 뛰어난 성능을 보입니다. 또한, Swift는 메모리를 직접 할당하고 조작할 수 있는 도구도 제공하고 있습니다. 이 강연에서는 Swift에서 사용하는 포인터에 관해 다루고 있으며, typed 포인터, raw 포인터, buffer 포인터, 암묵적 브리징(bridging) 및 캐스팅(casting), 안전한 unsafe API 사용법에 대해 살펴볼 것입니다.


소 개

저는 오늘 Swift 표준 라이브러리에서 제공하는 unsafe 계열의 포인터 타입에 관해 이야기하고자 합니다. 강연에서는 Swift가 제공하는 여러 포인터 타입들의 동작 방식과 탄생 배경에 대해 살펴보고, 이들을 안전하게 사용하는 방법에 대해 알아보도록 하겠습니다.

Swift의 안정성(Safety)

unsafe 계열의 포인터 타입은 무엇이고, 무엇이 이들을 unsafe 하게 만들었는지에 대해 알아보기 전에, Swift의 안전성(safe)에 대해 우선 이야기해보겠습니다. Swift는 안전성을 최우선으로 하는 언어로 알려져 있는데 Swift에서 말하는 안전성이란 무엇일까요? Swift가 처음 소개되었을 때 Swift는 안전하다라는 말은 정말 멋지게 들렸습니다. 앞으로는 크래쉬가 절대 발생하지 않는 프로그램을 짤 수 있게 되었으니 정말 엄청난 일이라고 생각했습니다.

Swift는 다양한 방법으로 안정성을 제공하는데 Optional이 대표적인 예입니다. Optional 덕분에 null 포인터 이슈를 굉장히 쉽게 처리할 수 있게 되었지요.

ages 배열을 예로 들어볼까요? 이 배열에 관한 계산식을 작성하려 할 때, Swift의 안전성이 어떻게 동작하는지 살펴보겠습니다.


let ages = [13.3, 17.5, 18.9, 21.2]
let firstPlusOne = ages.first + 1
// Value of optional type 'Int?' not unwrapped

Optional이 에러를 방지하는 역할을 하고 있네요. Array 타입의 first 속성은 Optional 타입이기 때문에 1을 추가할 때 컴파일러가 미리 문제점을 파악해서 알려주고 있습니다.

어떻게 오류를 없앨 수 있을까요? 간단하게 언래핑 optional만 만들어주면 됩니다.


let ages = [13.3, 17.5, 18.9, 21.2]
let firstPlusOne = ages.first! + 1
// firstPlusOne = 14.3

위의 코드에서 문제점을 발견할 수 있나요? 아무 문제가 없어 보이네요. 만약 빈 배열인 경우 어떤 일이 생길까요? 크래쉬가 발생합니다.


let ages: [Double] = []
let firstPlusOne = ages.first! + 1
// error: EXC_BAD_INSTRUCTION

optional 바인딩이나 nil-coalescing 연산자를 이용해 안전하게 optional을 언래핑할 수 있는 여러 가지 방법이 있기 때문에 이것은 제 실수라고 할 수 있습니다. 크래쉬되지 않고도 값의 존재여부를 처리할 수 있으며, force unwrapping 연산자를 사용해 쉽게 크래쉬 상태를 만들 수도 있습니다. 왜 이러한 동작 방식이 Swift 언어의 한 부분이 된 것일까요?

이번에는 배열에 있는 값들의 평균을 계산하는 코드를 작성해볼까요? for 루프 대신 reduce 함수를 이용해 함수적인(functional) 방식으로 평균을 계산해보도록 하겠습니다.


let ages = [13.3, 17.5, 18.9, 21.2]
let average = ages.reduce(0, +) / Double(ages.count)
// average = 17.725

optional에 대해 걱정할 필요 없는 깔끔하고 가독성 높은 Swift 코드네요.

이번에는 빈 배열을 0으로 나눴을 때 어떻게 되는지 볼까요? 역시, Swift에서도 크래쉬가 발생하네요.


let ages: [Double] = []
let average = ages.reduce(0, +) / 0
// error: EXC_BAD_INSTRUCTION

마지막으로 age 배열에서 마지막 요소에 접근하기 위해 ages[4]를 사용했습니다. 하지만 배열 인덱스는 0부터 시작한다는 사실을 깜빡했네요. 어떤 결과가 나올까요? “off by one” 에러가 발생했네요.


let ages = [13.3, 17.5, 18.9, 21.2]
let last = ages[4] // off by one
// error: Index out of range

이곳에서 무슨 일이 일어난 걸까요? Swift의 규칙에 따라 문제가 전혀 없어 보이는 코드를 작성했는데 프로그램은 크래쉬되었네요.

이 언어를 안전하다고 말할 수 있을까요? 안전하다고 알려진 프로그래밍 언어가 이렇게 쉽게 크래쉬될 수 있는 거죠? Swift에서 이야기하는 안전성이 크래쉬로부터의 안전성이 아니라면 무엇으로부터 안전하다는 뜻일까요?

이런 개발 뉴스를 더 만나보세요

다시 좀 전의 예제로 돌아가 보죠. 네 번째 인덱스에 해당하는 요소가 없기 때문에 존재하지 않는 요소에 대해 접근을 시도한 예제 코드는 크래쉬되었습니다. Swift는 무엇으로부터 우리를 안전하게 지킨 걸까요? 크래쉬보다 더 나쁜 상황이 무엇일까요?


let ages = [13.3, 17.5, 18.9, 21.2]
let last = ages[4] // off by one
// sure, fine, whatever

프로그램이 크래쉬되지 않고 계속 실행된다면 last 변수에 어떤 데이터가 있는지 누가 알까요?

시뮬레이터에서는 정상적으로 동작하더라도 실제 기기에서는 문제가 발생할 수도 있고 디버그 모드에서는 컴파일되었다가 릴리즈 모드에서는 컴파일이 실패할 수 있습니다. 또한, 문제가 바로 나타나지 않고 나중에 나타난다면 디버그하기 힘들고 문제 원인을 찾기도 어려워집니다.

예상을 벗어난 동작(Unexpected Behavior)

Swift는 예상을 벗어난 동작으로부터 우리를 지켜줍니다. Swift는 타입 시스템, value semantic, collection 및 numberic 타입의 바운더리 검사, 자동 메모리 관리 등을 통해 안전성을 제공합니다.

하지만, 성능이나 보다 세밀한 제어를 위해 Swift가 제공하는 안전성을 사용하지 않는 경우도 있는데 이때 unsafe 계열의 API가 사용됩니다.

Swift Unsafe APIs

Swift가 제공하는 안전 영역에서 벗어났다는 것을 명확히 알려주기 위해 Swift는 각각의 포인터 타입에 Unsafe로 시작하는 이름을 붙였습니다. Swift의 unsafe API를 사용한다는 것은 Swift가 기본적으로 제공하는 안전성을 포기한다는 것을 의미하며, 개발자는 직접 안전성을 제공해야 합니다. 포인터를 이용하면 메모리에 직접 읽고 쓸 수 있게 됩니다.

Swift 언어의 메모리 레이아웃과 포인터

Swift의 메모리, 메모리 레이아웃, 포인터 동작 방식을 Swift 코드와 함께 설명하겠습니다.

우선, 메모리부터 살펴볼까요? Swift로 작성한 프로그램을 실행하면 우리가 작성한 타입과 함수들이 컴퓨터 메모리를 차지하게 됩니다. 변수나 상수를 만들 때마다 바이너리 형태로 저장된 값이 메모리에 존재하게 됩니다.

바이너리는 0과 1로 구성되어 있죠. 컴퓨터 메모리는 이들 바이너리로 가득 차 있습니다. 보통 바이너리는 아래와 같이 8개로 묶어 바이트 단위로 나타냅니다. 01_binary_to_byte

만약 이들을 바이너리 대신 16진수 표기법으로 표현한다면 아래와 같은 모습이 됩니다. 02_byte_to_hexa

이들을 좀 더 압축된 형태로 표현하면 이런 모습이 됩니다. 03_compact_hexa

각각의 행은 8 바이트(64비트)이며 요즘 모든 기기에서 사용하는 64비트 프로세서에서는 word 라는 이름의 단위로 표현할 수 있습니다.

메모리의 모든 위치에는 주소가 있기 때문에 이를 이용해 값을 저장하거나 불러올 수 있습니다. 아래 그림에서 주소는 왼쪽에 표기했습니다.

04_address_in_memory

주소에도 16진수 표기법을 사용했습니다. 그림을 자세히 살펴보면, 각각의 행에 대한 주소는 다음 행과 8 만큼 차이가 난다는 사실을 알 수 있습니다. 각각의 행이 8 바이트라고 했던 것을 기억하시나요? 이 때문에 메모리 주소도 바이트를 기준으로 표기했습니다. 참고로 메모리 주소와 관련해서는 0과 1이 아닌 바이트가 가장 작은 단위입니다.

프로그램에서 작성한 값은 항상 메모리에 저장됩니다.

05_storing_value

위의 예제코드에서 ageInt이며, Int는 word 크기의 signed integer입니다. 64비트 기기라면, 이 값은 word의 64 비트 모두를 사용해 저장됩니다. 위의 그림에서 age 변수는 메모리에서 한 행 전체를 사용한 것을 알 수 있습니다. Swift의 withUnsafePointer 함수를 사용하면 값 대신 값에 대한 포인터에 일시적으로 접근할 수 있게 됩니다.

예제에서는 withUnsafePointer(to:_:)&age를 인자로 전달했고, 함수 내부에서 실행되는 trailing closure에서 포인터 인자인 agePointer를 얻을 수 있습니다. 이 포인터는 age 변수의 값이 아닌 age 변수의 주소를 나타냅니다.

06_access_address


var age = 5

withUnsafePointer(to: &age) { agePointer in
    print(agePointer.pointee)
}
> 5

agePointerage 변수의 주소를 값으로 가지고 있습니다. agePointer는 값이 아니라 메모리에서의 위치를 나타냅니다. 만약 포인터가 가리키고 있는 값에 접근하고 싶을 때는 포인터의 pointee 속성을 사용하면 됩니다.

정리하자면, 포인터란 메모리에서 값의 위치에 접근하거나 변경하는 방법입니다.

Unsafe 포인터 타입

Swift 표준 라이브러리에는 8가지 unsafe 포인터 타입이 있습니다. 이것은 다시 4개의 포인터와 4개의 buffer 포인터로 나눌 수 있습니다.

우선 4개의 포인터 타입에 대해 살펴보죠. 이들은 UnsafePointer<T>, UnsafeRawPointer, UnsafeMutablePointer<T>, UnsafeMutableRawPointer이며 공통으로 메모리 주소에 대한 래퍼(wrapper) 기능을 제공하며 내부에서는 unsigned integer로 표현됩니다. 왜 4종류의 다른 타입이 존재할까요?

이들은 바운더리 검사, 타입 안전성, 메모리 관리 등을 하지 않는 unsafe API지만, Swift는 여전히 당신을 도울 준비가 되어있습니다. 그래서 타입 시스템을 적용한 unsafe API가 있는 것이고, 이러한 관점에서 다시 4개의 타입을 두 개의 그룹으로 나눌 수 있습니다.

Typed 포인터와 Raw 포인터

unsafe API는 “typed 포인터”와 “raw 포인터” 그룹으로 나눌 수 있습니다. UnsafePointer<T>, UnsafeMutablePointer<T>가 typed 포인터에 해당합니다. 이들 포인터가 가리키는 메모리 주소에는 특정 타입의 값이 저장되어 있습니다. 예를 들면, Int 타입이 저장된 값에 대한 포인터가 있다면, 포인터의 pointee에 접근했을 때 Int 타입의 값을 얻게 됩니다. Swift는 같은 메모리에 서로 다른 타입들이 함께 접근하는 것을 제한하고 있습니다. 따라서, signed integer와 다른 타입이 같은 메모리 주소를 사용하려고 시도할 때 어떻게 동작해야 하는지에 대해서는 정의되어 있지 않습니다(undefined behavior). typed 포인터는 메모리를 일시적 또는 영구적으로 다른 타입과 연결해주는 메서드를 제공하여 이 문제를 해결합니다.

typed 포인터는 그것이 가리키고 있는 타입의 크기와 alignment에 대한 정보를 알고 있기 때문에 typed 포인터를 사용할 때는 stride나 alignment에 대해 고민하지 않아도 됩니다. 앞선 예제에서 age 변수에 대한 포인터로 UnsafePointer가 사용되었는데 typed 포인터이기 때문에 age 변숫값에 대한 위치(location)와 타입 정보를 알고 있습니다.

또한, 연속된 요소들로 구성된 배열에 접근하는 경우 typed 포인터는 요소 하나에 하나의 인스턴스를 매칭시키기 때문에 실수로 인스턴스의 중간 부분에 매칭되는 경우는 발생하지 않습니다.

반면 raw 포인터의 경우 저장된 값이 어떤 타입인지에 대한 정보가 없습니다.

raw 바이트를 사용하거나 메모리에서 로드되길 원하는 데이터 타입을 직접 명시하는 방법을 이용해 raw 포인터가 가리키는 데이터에 접근할 수 있습니다. age에 대해 typed 포인터 대신 raw 포인터를 사용한다면 이전 주소와 같은 주소를 참조하였더라도 해당 주소에 저장된 값에 대한 타입 정보는 알 수 없습니다. 즉, 해당 주소에 64비트 signed integer가 저장되어 있는지 알지 못합니다. raw 포인터는 주소 그 자체입니다. 바이트를 이용해 해당 주소에 접근할 수도 있고 저장된 값의 타입에 상관없이 그 값을 불러올 수 있으며, 메모리 바인딩을 통해 raw 포인터를 typed 포인터로 변환할 수도 있습니다.

Mutable 포인터와 Immutable 포인터

unsafe API를 mutability를 기준으로 분류할 수도 있습니다. 포인터는 어떤 값을 참조하기 위한 것이므로 포인터를 let으로 선언하더라도 포인터가 참조하는 메모리의 값을 변경할 수 있습니다. Swift는 인스턴스 레벨에서 mutability를 제어하는 대신, 타입 시스템 레벨에서 mutable 포인터에 의해 참조되는 값을 변경할 수 있는 기능을 제공하여 mutability를 처리하고 있습니다.

참조하는 메모리에 대해 읽기만 가능한 immutable typed 포인터, raw 포인터와 함께 이들에 대한 mutable 버전도 있는 것이죠. Swift는 mutable 포인터에 대한 초기화, 비초기화(de-initialization), mutable 포인터 할당 등의 기능을 제공하고 있습니다.

buffer 포인터

이제 buffer 포인터에 대해 알아볼까요? buffer 포인터는 항상 count가 따라다닙니다. 특정한 하나의 주소에 저장하는 대신, buffer 포인터는 메모리의 범위를 지정합니다. buffer 포인터는 typed, raw, mutable, immutable 포인터 형태가 있습니다.

buffer 포인터는 collection처럼 동작할 수 있습니다. 특정 메모리 영역에 저장된 내용을 이터레이트(iterate)할 수 있으며, Array에서 사용했던 대부분의 연산자를 사용할 수 있습니다.

예제 코드에서는 withUnsafeBytes 함수에 age를 전달했으며, 이 함수는 인자로 넘겨준 age 변수의 바이트를 참조하는 UnsafeRawBuffer 포인터 타입의 closure를 내부에서 호출합니다.


var age = 5

withUnsafeBytes(of: &age) { ageBytes in
    ageBytes.count // 8
    ageBytes.first // Optional(5)
    ageBytes[0]    // 5
}

buffer를 일반적인 collection처럼 사용할 수 있습니다. count도 이용했고, first 속성을 이용해 첫 번째 값에 접근했으며, subscript도 사용했습니다.

이들이 메모리에 저장된 값의 raw 바이트라는 사실을 잊지 말아야 합니다. Int가 8 바이트 크기이기 때문에 8이라는 count 값을 얻게 된 것이고, age 값의 첫 번째 바이트를 읽었을 때 5라는 결과가 나온 것입니다.


var age = 2000

withUnsafeBytes(of: &age) { ageBytes in
    ageBytes.count // 8
    ageBytes.first // Optional(208)
    ageBytes[0]    // 208
}

만약 age의 값이 1바이트만으로 표현할 수 없을 때는 어떤 결과가 나올까요? 2,000살인 간달프의 나이를 다룬다고 가정했을 때 age 변수의 메모리에 대한 첫 번째 byte는 16진수 값으로는 d0이고 정수형으로는 208이 될 것입니다. 다시 한번 강조하지만, 우리는 raw 포인터를 사용하고 있기 때문에 인스턴스 레벨이 아니라 바이트 레벨에서 이 값을 읽어야 합니다.

C API 임포트

unsafe 포인터 타입이 필요한 때는 언제일까요? 대부분의 개발자에게 이들이 필요할 때는 크게 두 가지 경우일 것입니다. unsafe 포인터 타입은 typed 포인터나 Void 포인터가 사용된 대부분의 C API와의 호환성을 제공합니다. 따라서 C API와 호환이 필요한 경우 unsafe API를 사용할 수 있습니다. 다른 하나는 unsafe 포인터 타입으로만 최적화 작업이 가능한 경우입니다. 예제를 통해 좀 더 자세히 살펴보도록 하죠.


func SKSearchFindMatches(
    _ inMaximumCount: CFIndex,
    _ outDocumentIDsArray: UnsafeMutablePointer<SKDocumentID>!,
    _ outFoundCount: UnsafeMutablePointer<CFIndex>!
) -> Bool

예제코드는 macOS 10 SearchKit 프레임워크가 제공하는 SKSearchFindMatches 함수 시그니처를 간략하게 보여주고 있습니다. 약간 복잡해 보이지만 in, out 인자를 가진 일반적인 C 함수 형태라고 보시면 됩니다. 세 가지 인자가 있는데 한 개는 함수에 대한 input이며 나머지 두 개는 output입니다.

이 함수는 검색이 시작되면 반복적으로 호출됩니다. 매번 호출될 때마다 out 인자값에는 검색결과가 저장되며 검색이 완료되면 false를 반환합니다. inMaximumCount는 최대 검색결과 수를 의미하며, 첫 번째 out 인자값인 C 배열에 저장되는 documentID의 최대 개수를 설정하는 값이기도 합니다. 두 번째 out 인자는 반환되는 실제 검색결과 수를 의미합니다.

두 개의 out 인자들은 모두 typed mutable 포인터 타입이지만 pointee 타입은 다르네요. 흥미로운 사실은 이 함수를 호출할 때 unsafe 포인터를 직접 다룰 필요가 없다는 점입니다.

Swift는 &documentIDs와 같은 형태로 inout 문법을 사용하면 변수 또는 배열을 typed 포인터나 raw 포인터로 암묵적(implicit)으로 변환할 수 있습니다.


let limit = 100
var foundCount = 0 as CFIndex
var documentIDs = Array(repeating: 0 as SKDocumentID,
		count: limit)

_ = SKSearchFindMatches(CFIndex(limit),
        &documentIDs, &foundCount)

for i in 0..<Int(foundCount) {
	loadDocument(id: documentIDs[i])
}

documentIDs 배열과 foundCount 변수를 생성하고, inout 문법을 통해 SKSearchFindMatches 함수에 대해 인자로 전달했습니다. &documentIDsdocumentIDs 배열의 요소들을 가리키는 포인터로 변환하는 것이고, &foundCountfoundCount 변수에 대한 포인터로 변환하는 것입니다.

하지만, 암묵적 변환에는 제약사항이 있다는 것을 잊지 말아야 합니다. 암묵적 포인터 변환을 사용해 documentIDs 배열을 인자로 전달하면, 배열의 첫 번째 요소에 해당하는 포인터만 전달되는 것입니다. 이 포인터를 전달받은 함수는 배열의 크기에 대한 정보를 알지 못하고, 배열의 count도 변경할 수 있는 능력도 없습니다. 때문에 inMaximumCount 만큼의 요소들을 저장할 수 있는 충분한 공간을 가지고 있는 배열을 넘겨주는 것이 중요합니다.

또한, 암묵적인 변환으로 생성된 포인터는 함수가 호출된 시점에만 유효(valid)합니다. 따라서, 함수 실행 이후에 이들 포인터를 이용하는 것은 예상치 못한 결과를 가져올 수 있습니다.

documentIDs를 얻고 난 후 배열에 대한 루프를 수행할 수 있으며 각각의 검색결과를 처리할 수 있는 핸들러를 호출할 수 있습니다.

예제 코드는 예상한 대로 잘 동작하고, 테스트도 무난히 통과할 테지만, 성능 테스트를 해보면 unsafe 포인터를 명시적으로 사용하여 이 코드 성능을 좀 더 향상할 수 있다는 것을 깨닫게 될 것입니다.

약간의 속도 향상을 위해 Swift 언어가 제공하는 안전성을 포기해야 하는 상황에 직면했네요. 이러한 최적화가 정말로 필요하고 도움이 된다는 확신이 들 때만 최적화를 진행하길 바랍니다.

Swift가 제공하는 안전성을 개발자가 직접 제공하기 위해서 예제 코드를 약간 수정해야 합니다. 우선, documentIDs를 선언한 부분을 살펴보죠. 배열 초기화는 Array(repeating:count:)를 이용해 documentIDs를 위한 100개의 공간을 확보하고, 각각의 공간을 0으로 초기화했습니다.


var documentIDs = Array(repeating: 0 as SKDocumentID,
		count: limit)

일반적으로는 이러한 초기화 방법은 괜찮다고 할 수 있습니다. Array 타입은 초기화가 완료되기 전까지는 요소에 접근할 수 없게 함으로써 안전성을 제공합니다. 하지만 우리 예제의 경우 배열 요소들에 접근하는 코드가 나타나기 전에 SKSearchFindMatches 함수에 배열이 전달되는 코드가 먼저 발생하며, 이 함수에서는 배열에 검색결과를 저장하는 작업을 수행할 것입니다. 때문에, 초기화 과정이 꼭 필요하지는 않습니다.

다른 접근법을 고려해보죠. documentIDs를 빈 배열로 선언한 다음, 충분한 공간을 확보하면 어떨까요?


var documentIDs: [SKDocumentID] = []
documentIDs.reserveCapacity(limit)

괜찮은 방법처럼 보이지만, 아쉽게도 이 방법은 좋은 해결책이 아닙니다. 함수에 documentIDs를 전달했을 때 한 개의 포인터만 얻을 수 있었던 점을 기억하시나요? 배열에 대한 count 정보를 알 수도 없고 count를 변경할 수도 없습니다. 그 때문에 이 방법으로는 배열에 들어있는 요소들에 접근할 방법이 없습니다.

추가적인 작업을 할 곳이 한 군데 더 있는데 documentIDs의 요소 i에 접근할 때입니다.


for i in 0..<Int(foundCount) {
	loadDocument(id: documentIDs[i])
}

배열의 subscript를 사용할 때마다 배열은 바운더리를 벗어나는 요소에 접근하는 시도를 막기 위해 바운드 검사를 수행합니다.

이것은 굉장히 중요한 안전성 기능이지만 성능 향상을 위해 취할 수 있는 모든 방안을 고민해봐야 합니다. 이터레이팅을 수행하는 동안 우리가 생성한 바운더리 안에서 머물게 한다면, 굳이 요소에 매번 접근할 때마다 바운드 검사를 수행할 필요는 없습니다.

앞서 언급한 두 가지 경우에 대한 해결책은 documentIDs를 배열 대신 typed mutable 포인터로 선언하는 것입니다. allocate 함수를 호출하면 요소들 크기에 해당하는 공간을 확보하고 블록에 대한 시작 부분을 가리키는 포인터가 반환됩니다.


let documentIDs = UnsafeMutablePointer<SKDocumentID>.allocate(capacity: limit)
defer { documentIDs.deallocate(capacity: limit) }

메모리를 할당했으니 메모리 해제도 해야겠죠? 배열에서 unsafe 포인터로 변경했기 때문에 메모리 해제 작업이 필요합니다. Swift에서는 메모리 할당 코드 바로 뒤에 defer 블록을 만들고 그 안에 메모리 해제 코드를 추가하면 안전하게 메모리를 해제할 수 있습니다.

예제 코드에서 추가로 수정되어야 하는 부분은 인자로 전달될 때 사용되었던 &를 생략하는 것입니다. 암묵적 변환 대신 포인터를 직접 넘겨주기 때문에 inout 문법을 사용하지 않아도 되는 것이죠.


_ = SKSearchFindMatches(CFIndex(limit),
        documentIDs, &foundCount)

for i in 0..<Int(foundCount) {
	loadDocument(id: documentIDs[i])
}

배열에서 subscript를 사용했던 것처럼 포인터에 대해서도 subscript를 사용할 수 있습니다. 그 때문에 나머지 코드들은 수정하지 않아도 됩니다. 지금까지 최적화 작업을 진행해봤습니다. 불필요한 초기화 코드와 바운드 검사를 제거했고 Swift가 기본적으로 제공했던 아래의 세 가지 기능을 직접 처리했습니다.

  • documentIDs에 대한 읽기 요청이 발생하기 전에 documentIDs를 초기화해야 한다.
  • 할당한 메모리는 반드시 해제해야 한다.
  • 할당한 공간의 바운더리를 벗어나면 안 된다.

이제 마지막 예제네요. 제가 가장 좋아하는 정렬 알고리즘인 버블 정렬입니다.


func bubbleSort<T: Comparable>(_ array: inout [T]) {
    guard !array.isEmpty else { return }

    for n in 1..<array.count {
        for i in 1...(array.count - n) {
            if array[i - 1] > array[i] {
                swap(&array[i - 1], &array[i])
            }
        }
    }
}

bubbleSort 함수는 Comparable 프로토콜을 준수하는 배열을 인자로 받아, 이 배열을 오름차순으로 정렬합니다. 버블 정렬 대신 다른 정렬 방법을 사용하여 성능을 향상하고 싶겠지만 여기서는 정렬 방법을 변경할 수 없다고 가정하겠습니다.

Swift의 Array 타입은 항상 바운드 검사를 수행하는 Array 대신 unsafe buffer 포인터를 사용할 수 있는 메서드를 제공합니다. unsafe buffer 포인터를 사용하게 되면 디버그 모드만 바운드 검사가 수행되고 릴리즈 모드에서는 검사가 생략됩니다.

배열에서 withUnsafeMutableBufferPointer 메서드를 호출하도록 예제 코드를 수정했습니다. 메서드만 변경했을 뿐인데 성능은 눈에 띄게 향상되었습니다.


func bubbleSort<T: Comparable>(_ array: inout [T]) {
	guard !array.isEmpty else { return }

    array.withUnsafeMutableBufferPointer { buffer in
        for n in 1..<buffer.count {
            for i in 1...(buffer.count - n) {
                if buffer[i - 1] > buffer[i] {
                    swap(&buffer[i - 1], &buffer[i])
                }
            }
        }
    }
}

unsafe 포인터 사용을 통해 얻은 이점은 분명합니다. 요소들을 버퍼에 유지함으로써 배열과 유사한 인터페이스를 제공하면서도 바운드 검사를 생략하여 성능은 향상했습니다.

지금까지 unsafe 포인터 타입에 대해 알아보고, unsafe 포인터 타입으로 안전한 코드를 작성하는 방법에 대해 살펴보았습니다. 이번에는 잘못된 포인터 사용법에 대해 알아보겠습니다.

잘못된 포인터 사용법

unsafe 포인터 타입을 사용할 때 가장 많이 실수하는 부분은 암묵적 포인터 변환 또는 withUnsafePointer, withUnsafeByte 함수를 통해 얻은 포인터를 escape 하는 것입니다. 예제를 통해서 이 문제를 좀 더 살펴보죠.


var age = 2000
let agePointer = UnsafeMutablePointer(&age)
agePointer.pointee = 10
// age == 10

age 변수를 선언하고 UnsafeMutablePointer를 이용해 변수에 대한 포인터를 생성했습니다. 그리고 포인터 주소에 저장된 값을 변경했습니다. age 변수에 의해 사용된 같은 주소를 쓰고 있기 때문에 값이 변경됩니다.

언뜻 보기에는 괜찮아 보이지만 이 코드에는 매우 큰 문제가 있습니다. 암묵적 변환 대신 명시적인 형태로 코드를 약간 수정해보겠습니다.


var age = 2000
let agePointer =
	    withUnsafeMutablePointer(to: &age) { p in
            return p
        }
agePointer.pointee = 10
// age == 10

암묵적 포인터 변환을 이용해 UnsafeMutablePointer를 초기화하는 것은 withUnsafeMutablePointer closure에서 escaping 포인터를 전달받은 것과 같습니다. 문제없이 컴파일되지만 사실 이것은 예상치 못한 동작(undefined behavior)에 해당합니다.

이러한 형태의 코드는 오류를 발견하기가 쉽지 않습니다. 명시적으로 escape를 선언하지도 않았고 함수에 변수를 넘겨줄 때 &를 사용하는 것은 일반적인 형태니까요. 따라서 두 가지 규칙을 꼭 기억하시길 바랍니다. 첫째, withUnsafe-에서 얻은 포인터는 절대 escape 하지 마세요. 둘째, 암묵적 변환으로 변수에 대한 포인터를 얻지 마세요.

감사합니다. 이 강연 내용이 Swift unsafe 포인터 타입을 이해하는 데 도움이 되었으면 좋겠네요.

질의응답

Q: bindMemory메서드와 assumingMemoryBound메서드 차이점은?

A: unsafe raw 포인터는 그들이 가리키고 있는 메모리에 대한 정보가 없지만 메모리 자체는 바운드된 상태일 수 있습니다. 그 때문에 integer 타입과 바운드된 메모리에 대한 raw 포인터를 같은 메모리지만 다른 타입과 바운드된 raw 포인터로 만들 수 있습니다. 인스턴스에 좀 더 쉽게 접근하기 위해 raw 포인터를 type 포인터로 변환할 때 메모리를 바인드해야 합니다. 즉 이 타입으로 메모리를 바인드할 것이라고 컴파일러에 알려주는 것이죠. bind는 unsfae typed pointer를 반환합니다.

assumingMemoryBound를 호출하면 검사를 우회하는 것입니다. 컴파일러 측면에서 보면 실제로는 어떠한 바인드도 수행하지 않습니다. 대신 이것은 이미 바운드되어 있다고 가정하기 때문에 여러분은 메모리가 해당 타입과 이미 바운드되어 있다는 정보를 가지고 있습니다. 만약 아직 메모리가 바운드되지 않은 상태에서 raw 메모리를 할당하고 assumingMemoryBound(to:)를 호출했다면 이것은 안전하지 않고 예상치 못한 동작에 해당하게 됩니다. 왜냐하면, 해당 타입과 실제로 바운드되지 않은 상태에서 메모리에 접근했기 때문이죠.

Q: 마지막에 보여주신 예제에 대해 궁금한 점이 있습니다. 예제를 설명하시면서 포인터에 대한 escape를 하지 말라고 하셨는데 escaping 속성을 추가하더라도 절대 escape를 하지 말라는 뜻인가요?

A: 네 escaping 속성을 추가한 경우라도 포인터를 가지고 escape하지 말라는 뜻입니다. 암묵적 포인터 변환을 위해, 변수를 인자로 전달해서 mutable 포인터로 변환하고 싶을 것입니다. 예제 코드의 UnsafePointer 초기화 코드처럼 말이죠. C 함수에 변수를 전달할 수도 있지만, C 함수는 일반적으로 반환하지 않기 때문에 C 함수에서 반환받을 수 있다는 보장이 없습니다.

문제는 이것이 대부분의 경우 잘 동작한다는 것이죠. 실제로 마지막 예제는 playground에서도 잘 동작합니다. age 변숫값을 변경할 수 있죠. 하지만 computed 속성인 경우라면 의도대로 동작하지 않을 것입니다. 컴파일러는 포인터와 관련된 최적화를 수행하지 않게 설정할 수 있는데 만약 최적화 옵션을 적용해서 실행할 경우 해당 코드 라인을 삭제 처리하고 age 변숫값은 업데이트되지 않을 것입니다.

컨텐츠에 대하여

2016년 3월에 진행한 try! Swift Tokyo 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Nate Cook

An independent web and application developer, he works on projects of all sizes, from websites and blogs for nonprofits to customized enterprise applications. He is also the managing editor of NSHipster, where he writes weekly about obscure topics in Objective-C, Swift, and Cocoa.

4 design patterns for a RESTless mobile integration »

close