Overview
Swift 팀과 함께 프로그래밍 언어의 안전 예방 조치를 살펴보자.
- 코드가 "안전하지 않다"는 의미는 무엇일까?
- 예기치 않은 상태와 동작을 방지하기 위해 보다 구체적으로 코드를 작성하는 방법에 대해서.
표준 라이브러리 중 몇 가지는 Unsafe로 표시되어 있다. Safe와 Unsafe의 차이점은 실제로 제공하는 인터페이스상으로는 명확하지 않지만 유효하지 않은 입력을 처리하는 방식에서 발생한다.
한 가지 예는 Optional에 대한 강제 언래핑 연산자(!
)이다.
이 코드의 예상되는 결과는 당연히 런타임 에러와 함께 앱이 실행 중지될 것이다.
당연히 잘못된 시도를 하고 있지만, 중요한 것은 우리가 결과를 알고 있다는 것.
강제 언래핑 연산자는 요구 사항을 충족하지 않는 입력을 포함하여 가능한 모든 입력에 대한 동작을 완전히 설명할 수 있기 때문에 "Safe"하다고 말할 수 있다.
.unsafelyUnwrapped
프로퍼티를 통해 강제 언래핑이 가능하다. 이 동작의 결과를 예상할 수 있을까?
컴파일러가 런타임에 이를 확인하고 요구사항을 충족하지 않는 입력이 들어오면 오류를 발생시킨다. 이런 예측가능하고 정의되어 있는 것을 Safe
하다고 표현한다.
이와 반대로 Unsafe
한 작업은 문서화된 기대치를 위반하는, 말 그대로 정의되지 않은 동작을 해야 한다.
안전하지 않은 동작은 컴파일러가 이러한 확인 과정을 거치지 않고 진행하기 때문에 어떤 동작을 할 지 예상할 수 없다. 임의의 상황에 따라 크래시를 발생시키거나 쓰레기 값을 반환할 수도 있다. 매번 같은 결과를 반환할 수도 있고, 실행할 때마다 결과가 다를 수도 있다.
따라서 디버깅이 매우 어렵기 때문에 해당 요구 사항을 충족할 전적인 책임을 개발자가 지게 된다.
이것은 모든 Unsafe 타입에 대한 전형적인 특징이다.
Unsafe
접두사는 위험 기호와 같은 명명 규칙으로 이 사용에 대한 내재적인 위험에 대해 경고한다. 그러나 실제로 일부 작업은 이를 통해서만 수행할 수 있기 때문에 사용에 각별한 주의가 필요하며 사용 조건을 명확히 이해하고 있어야 한다.
일반적으로 Unsafe를 사용하는 사례는 다음 두 종류 중 하나이다.
- C, Objective-C와의 상호 운용성 제공
- 런타임 성능이나 프로그램 실행에서의 세밀한 제어
옵셔널의 unsafelyUnerapped
속성은 두 번째 범주에 속한다. 옵셔널 검사와 같은 이런 작은 비용들이 성능 측정에 악영향을 미치기 때문이다. nil
값에 대해 검사가 불필요한 경우 검사하지 않고 넘어갈 수 있다.
따라서 Safe한 API가 no crashes를 의미하는 것이 아니라는 점에 유의해야 한다. 오히려 그 반대이다.
Swift 표준 라이브러리는 C언어의 포인터와 거의 동일한 수준의 (Unsafe한) 포인터 타입을 제공한다. 포인터에 대해 이야기하기 전에 먼저 메모리에 대해 알아보자.
Swift는 플랫 메모리 모델을 사용한다. 메모리를 개별적으로 주소 지정이 가능한 8바이트의 플랫한 주소 공간으로 취급한다. 그리고 이러한 각 바이트에는 일반적으로 16진수 값으로 주어진 고유한 주소가 있다.
이제 런타임에서, 주소 공간은 앱의 실행 상태를 나타내는 데이터들로 채워진다. 여기에 저장되는 것들은 다음과 같다.
- 앱의 실행 가능한 바이너리
- import하고 있는 모든 라이브러리와 프레임워크
- 임시 변수, 함수의 파라미터 등에 대한 저장공간인 스택
- 클래스 인스턴스 스토리지를 포함한 동적 할당 메모리
- 이미지 파일 등 읽기 전용 리소스 파일
각 개별 항목은 연속 메모리 영역이 할당되고, 앱이 실행되면서 메모리 상태는 계속 변화한다. 새 항목이 할당되고, 기존 항목이 사라지면서 메모리가 해제되고... 일반적으로 Swift에서는 수동으로 메모리를 관리할 필요가 없다.
그러나 만약 수동으로 메모리를 관리할 필요가 생기면, Unsafe 포인터가 필요한 모든 low-level한 작업들을 제공한다. 물론 Unsafe한 API이기 때문에 모든 제어에는 책임이 따른다. 주의해서 사용하지 않으면 포인터 작업이 주소 공간을 망쳐 잘 유지되고 있는 앱의 상태를 망칠 수 있다.
정수 값에 대한 저장 공간을 동적으로 할당하면 이에 대한 직접적인 포인터가 제공된다. 포인터는 메모리에 대한 완전한 제어를 제공하지만 관리하지는 않는다. 즉 해당 메모리 위치에 나중에 어떤 일이 발생하는지 추적이 불가능하며 단지 사용자가 지시한 작업을 실행할 뿐이다.
메모리를 초기화하고 할당 해제함에 따라 포인터가 무효화된다. 그러나 포인터가 무효화되었는지 유효화되었는지는 알 수 없다. 포인터 자체는 자신이 무효화되었다는 것을 알지 못하므로 이러한 댕글링 포인터를 역참조하는 오류를 범할 수 있다.
댕글링 포인터(Dangling Pointer): 해제된 메모리 영역을 여전히 가리키고 있는 포인터
운이 좋다면 할당 해제에 의해 완전히 접근할 수 없게 되어 즉시 충돌이 일어날 수도 있다. 그러나 포인터의 동작은 Unsafe하기 때문에 후속 동작이 보장되지 않는다. 후속 할당을 통해 동일한 주소를 재사용하여 다른 값을 저장했을 수도 있고 이 경우 앱의 전혀 관련없는 부분의 상태가 손상될 수 있다. 따라서 앱의 데이터 손상 혹은 사용자 데이터의 손실 등 임의의 영향을 미칠 수 있다는 위험이 있다.
Xcode의 Address Sanitizer라는 도구를 통해 이런 문제들을 디버깅할 수 있다.
포인터가 그렇게 위험하다면서 굳이 포인터를 고집하는 이유는 무엇일까? 가장 큰 이유는 C 혹은 Objective-C와 같은 언어와의 상호 운용성을 제공하기 때문이다. 이런 언어에서는 포인터 인자를 사용하기 때문에 Swift에서 호출할 수 있으려면 Swift 값에 대한 포인터를 생성하는 방법을 알아야 한다. C언어 API는 이러한 매핑을 통해 Swift로 번역된다.
예를 들면 정수 값 버퍼를 처리하는 이런 C언어의 함수를 Swift로 가져온다면 이렇게 구현할 수 있다.
이어서 사용 예시를 보자. static 메서드인 allocate()
를 통해 동적 버퍼를 생성하고 버퍼의 요소를 특정 값으로 설정한다. 설정이 끝나면 C 함수에 대응하는 process_integers
를 사용하여 초기화된 버퍼에 대한 포인터를 전달할 수 있다. 이후 소멸과 할당 해제 과정까지 모든 단계가 Unsafe하다.
모든 단계는 확인되지 않은 전제 조건들이 있으며 그 중 하나라도 잘못되면 정의되지 않은 동작이 발생한다. 단계별로 나눠서 잠재적인 위험은 다음과 같다.
- 할당 단계
- 버퍼의 생명주기는 관리되지 않으므로 수동으로 할당을 해제해야 한다. 그렇지 않으면 메모리 누수가 발생하여 영원히 남아있게 된다.
- 초기화 단계
- 지정된 위치의 주소가 할당한 버퍼 내에 있는지 자동으로 확인하지 않는다. 만약 주소를 잘못 지정한다면 정의되지 않은 동작이 발생한다.
- 함수 호출 단계
- 버퍼의 소유권을 가져갈 것인지 여부를 알아야 한다. 여기서는 함수 호출 기간동안만 액세스하고 포인터를 유지하거나 할당 해제 등을 시도하지 않는다고 가정한다
- 소멸(초기화 해제) 단계
- 메모리가 이전에 올바르게 초기화된 경우여야 한다.
- 할당 해제 단계
- 이전에 메모리 공간이 할당되어 있고 deinitialized 상태에 있는 메모리만 할당 해제할 수 있다.
이것이 표준 라이브러리가 UnsafeBufferPointer
를 제공하는 이유이다. 개별 값에 대한 포인터가 아니라 메모리 영역으로 작업해야 할 때 유용하다. 버퍼를 (시작위치, 길이)
값의 쌍으로 모델링한다. 이를 통해 버퍼의 경계를 쉽게 사용할 수 있어 Out of Bounds
문제를 쉽게 확인할 수 있다.
Swift의 표준 연속 컬렉션은 이러한 Unsafe 메서드를 사용한 버퍼 포인터를 사용하여 저장 공간 버퍼에 임시적인 직접 접근을 제공한다.
또한 개별 값에 대한 임시 포인터를 얻을 수 있으며 C 함수에 매개변수로 전달할 수 있다.
Swift 코드에서 C 함수로 포인터를 전달하는 예시
이러한 행위가 너무 자주 발생하기 때문에 이런 코드도 지원한다. 그러나 이런 방법들이 여전히 나중에 메모리 액세스에서 정의되지 않은 동작이 발생할 수 있다는 것을 기억해야 한다.
Swift에서 지원하는 암시적인 값-포인터 변환 목록은 다음과 같다.
다음은 더 복잡한 C언어 인터페이스의 예시이다. sysctl
함수는 여러 포인터와 값들을 인자로 사용한다.
대응되는 Swift 코드를 사용하여 함수를 호출하는 모습. 여기서 중요한 것은 모두 Unsafe한 함수를 사용하기 때문에 이들을 각각의 단일 함수 호출로 분리하여 간단하게 유효성 검사를 하는 것만으로도 위험을 방지하는데 많은 도움이 된다.
이런 형태의 클로저 기반 디자인은 포인터의 수명을 더 명확하게 알 수 있다. 댕글링 포인터의 값에 액세스하는 것은 정의되지 않은 동작이므로 Swift 5.3 컴파일러는 이러한 경우를 감지하여 경고를 생성한다.
또 다른 개선 중 하나는 새로운 초기화 구문을 제공한다는 것이다. 이 방법을 사용하면 초기화되지 않은 저장 공간에 데이터를 직접 복사하여 생성하기 때문에 임시 버퍼를 할당할 필요가 없다.
- Unsafe API를 사용하려면 요구 사항을 파악하고 항상 이를 충족하도록 구현해주어야 한다. 그렇지 않으면 예기치 못한 동작을 하게 될 것이다.
- Unsafe API 사용을 최소한의 단위로 유지하면 이를 제어하기 쉽다.
- 포인터 값을 직접 사용하기보다는 UnsafeBufferPointer를 사용하여 경계를 추적하는 것이 좋다.
- Xcode는 Address Sanitizer라는 도구를 제공하므로 이를 사용하면 디버깅에 유용하다.