안전하지 않은 러스트

지금까지 살펴본 모든 코드에는 컴파일 타임에 러스트의 메모리 안전 보장이 적용되었습니다. 그러나 러스트에는 이러한 메모리 안전 보장을 적용하지 않는 두 번째 언어가 숨겨져 있습니다: 이 언어는 안전하지 않은 러스트 (unsafe Rust) 라고 불리며 일반 러스트와 똑같이 작동하지만 추가 슈퍼파워를 제공합니다.

정적 분석은 본질적으로 보수적이기 때문에 안전하지 않은 러스트가 존재합니다. 컴파일러가 코드가 보증을 준수하는지 여부를 판단하려고 할 때, 일부 유효하지 않은 프로그램을 허용하는 것보다 일부 유효한 프로그램을 거부하는 것이 더 낫습니다. 코드가 아마도 괜찮을 수 있겠지만, 러스트 컴파일러는 확신할 수 있는 정보가 충분하지 않다면 코드를 거부할 것입니다. 이러한 경우, 안전하지 않은 코드를 사용하여 컴파일러에게 ‘날 믿어, 내가 뭘 하고 있는지 알고 있어’라고 말할 수 있습니다. 하지만, 안전하지 않은 러스트를 사용하는 것은 사용자의 책임하에 사용해야 한다는 점에 유의하시기를 바랍니다: 안전하지 않은 코드를 잘못 사용하면, 메모리 불안정성으로 인하여 널 포인터 역참조와 같은 문제가 발생할 수 있습니다.

러스트가 안전하지 않은 분신을 가진 또 다른 이유는 밑바탕이 되는 컴퓨터 하드웨어가 본질적으로 안전하지 않기 때문입니다. 러스트가 안전하지 않은 작업을 허용하지 않으면, 특정 작업을 수행할 수 없습니다. 러스트는 운영 체제와 직접 상호 작용하거나 자체 운영 체제를 작성하는 등의 저수준 시스템 프로그래밍을 할 수 있도록 허용해야 합니다. 저수준 시스템 프로그래밍 작업은 이 언어의 목표 중 하나입니다. 안전하지 않은 러스트로 할 수 있는 작업과 그 방법을 살펴봅시다.

안전하지 않은 슈퍼파워

안전하지 않은 러스트로 전환하려면 unsafe 키워드를 사용한 다음 새 블록을 시작하여 안전하지 않은 코드를 집어넣으세요. 안전하지 않은 러스트에서는 안전하지 않은 슈퍼파워라고 부르는 다섯 가지 작업을 수행할 수 있습니다. 이러한 슈퍼파워에는 다음과 같은 기능이 포함됩니다:

  • 원시 포인터 (raw pointer) 역참조하기
  • 안전하지 않은 함수 혹은 메서드 호출하기
  • 가변 정적 변수에 접근하기 및 수정하기
  • 안전하지 않은 트레이트 구현하기
  • union의 필드 접근하기

unsafe가 대여 검사기를 끄거나 러스트의 다른 안전성 검사를 비활성화하지 않는다는 점을 이해하는 것이 중요합니다: 안전하지 않은 코드에서 참조를 사용하면, 검사는 여전히 이루어집니다. unsafe 키워드는 컴파일러가 메모리 안전성을 검사하지 않는 위의 다섯 가지 기능 허용만 제공할 뿐입니다. 안전하지 않은 블록 내부에서도 여전히 어느 정도의 안전성을 확보할 수 있습니다.

더불어 unsafe라는 것은 블록 내부의 코드가 반드시 위험하거나 메모리 안전에 문제가 있다는 것을 의미하지 않습니다: 그 의도는 unsafe 블록 내부의 코드가 유효한 방식으로 메모리에 접근하도록 프로그래머가 보장해야 한다는 것입니다.

사람은 누구나 실수를 할 수 있고 실수는 일어나기 마련이지만, 이 다섯 가지 안전하지 않은 연산을 unsafe로 주석 처리된 블록 안에 넣도록 하면 메모리 안전과 관련된 모든 에러는 unsafe 블록 안에 있을 수밖에 없음을 알 수 있습니다. unsafe 블록을 작게 유지하세요; 나중에 메모리 버그를 조사할 때 유용하게 사용할 수 있습니다.

안전하지 않은 코드를 최대한 분리하려면 안전하지 않은 코드를 안전한 추상화 안에 넣고 안전한 API를 제공하는 것이 가장 좋으며, 이는 이 장의 뒷부분에서 안전하지 않은 함수와 메서드를 살펴볼 때 설명할 것입니다. 표준 라이브러리의 일부는 감사를 거친 안전하지 않은 코드 위에 안전한 추상화로 구현되어 있습니다. 안전하지 않은 코드를 안전 추상화로 감싸면 unsafe 코드가 구현된 기능을 사용하려는 모든 곳에서 unsafe라고 쓰는 것을 방지할 수 있는데, 이는 안전 추상화를 사용하면 안전하기 때문입니다.

다섯 개의 안전하지 않은 슈퍼파워를 차례대로 살펴봅시다. 또한 안전하지 않은 코드에 안전한 인터페이스를 제공하는 추상화도 일부 살펴보겠습니다.

원시 포인터 역참조하기

4장의 ‘댕글링 참조’절에서 컴파일러가 참조가 항상 유효하다는 것을 보장한다고 언급했습니다. 안전하지 않은 러스트에는 참조와 유사한 원시 포인터 (raw pointer) 라는 두 가지 새로운 타입이 있습니다. 참조자와 마찬가지로 원시 포인터는 불변 또는 가변이며 각각 *const T*mut T로 작성됩니다. 별표는 역참조 연산자가 아니라 타입 이름의 일부입니다. 원시 포인터의 맥락에서 불변이란 포인터가 역참조된 후에 직접 할당할 수 없음을 의미합니다.

참조자와 스마트 포인터와는 다르게 원시 포인터는 다음과 같은 특징이 있습니다:

  • 원시 포인터는 대여 규칙을 무시할 수 있으며, 같은 위치에 대해 불변과 가변 포인터를 동시에 가질 수 있거나 여러 개의 가변 포인터를 가질 수 있습니다.
  • 원시 포인터는 유효한 메모리를 가리키는 것을 보장받지 못합니다.
  • 원시 포인터는 널 (null) 이 될 수 있습니다.
  • 원시 포인터는 자동 메모리 정리를 구현하지 않습니다.

러스트가 이러한 보증을 적용하지 않도록 선택하면, 이러한 보장된 안전성을 포기하는 대신 러스트의 보증이 적용되지 않는 다른 언어 또는 하드웨어와 인터페이싱할 수 있는 기능이나 더 나은 성능을 얻을 수 있습니다.

예제 19-1은 참조자로부터 불변과 가변 원시 포인터를 만드는 방법을 보여줍니다.

fn main() {
    let mut num = 5;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;
}

예제 19-1: 참조자로부터 원시 포인터 생성하기

이 코드에 unsafe 키워드를 포함시키지 않았음을 주목하세요. 원시 포인터는 안전한 코드에서 생성될 수 있습니다; 잠시 후 보게 될 것처럼, 그저 안전하지 않은 블록 밖에서 원시 포인터를 역참조하는 것이 불가능할 뿐입니다.

as를 사용하여 불변 참조자와 가변 참조자를 해당 원시 포인터 타입으로 캐스팅하여 원시 포인터를 생성했습니다. 유효성이 보장된 참조자로부터 직접 생성했기 때문에 이러한 특정 원시 포인터가 유효하다는 것을 알지만, 모든 원시 포인터에 대해 이러한 가정을 할 수는 없습니다.

이를 증명하기 위해 다음으로 유효성을 확신할 수 없는 원시 포인터를 생성해 보겠습니다. 예제 19-2는 메모리의 임의 위치에 대한 원시 포인터를 생성하는 방법을 보여줍니다. 임의의 메모리를 사용하려고 하면 해당 주소에 데이터가 있을 수도 있고 없을 수도 있으며, 컴파일러가 코드를 최적화하여 메모리 접근이 없도록 할 수도 있고, 세그먼트 에러로 인해 프로그램에서 에러가 발생할 수도 있습니다. 일반적으로 이런 코드를 작성할 좋은 이유는 없지만, 가능은 합니다.

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}

예제 19-2: 임의 메모리 주소를 가리키는 원시 포인터 생성하기

안전한 코드에서 원시 포인터를 생성할 수는 있지만, 원시 포인터를 역참조하여 가리키는 데이터를 읽을 수는 없다는 점을 상기하세요. 예제 19-3에서는 unsafe 블록이 필요한 원시 포인터에 역참조 연산자 *를 사용합니다.

fn main() {
    let mut num = 5;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}

예제 19-3: unsafe 블록 내에서 원시 포인터 역참조하기

포인터를 생성하는 것은 아무런 해를 끼치지 않습니다; 포인터가 가리키는 값에 접근하려고 할 때 유효하지 않은 값을 처리해야 할 수도 있는 경우가 문제를 일으키는 것입니다.

또한 예제 19-1과 19-3에서는 *const i32*mut i32 원시 포인터를 생성했는데, 이 두 포인터는 모두 num이 저장된 동일한 메모리 위치를 가리키는 것을 주의하세요. 대신 num에 대한 불변 참조자와 가변 참조자를 생성하려고 시도했다면 코드가 컴파일되지 않았을 것인데, 이는 러스트의 소유권 규칙에 따르면 가변 참조자와 불변 참조자를 동시에 허용하지 않기 때문입니다. 원시 포인터를 사용하면 같은 위치에 대한 가변 포인터와 불변 포인터를 생성하고 가변 포인터를 통해 데이터를 변경하여 잠재적으로 데이터 경합을 일으킬 수 있습니다. 조심하세요!

이런 위험성이 있는데도 왜 원시 포인터를 사용하게 될까요? 한 가지 주요 사용 사례는 다음 절 ‘안전하지 않은 함수 또는 메서드 호출하기’ 에서 볼 수 있듯이 C 코드와 상호작용할 때입니다. 또 다른 경우는 대여 검사기가 이해하지 못하는 안전한 추상화를 구축할 때입니다. 안전하지 않은 함수를 소개한 다음 안전하지 않은 코드를 사용하는 안전한 추상화의 예를 살펴보겠습니다.

안전하지 않은 함수 또는 메서드 호출하기

안전하지 않은 블록에서 수행할 수 있는 두 번째 유형의 작업은 안전하지 않은 함수를 호출하는 것입니다. 안전하지 않은 함수와 메서드는 일반 함수나 메서드와 똑같아 보이지만, 정의 앞부분에 unsafe가 추가됩니다. 이 컨텍스트에서 unsafe 키워드는 이 함수를 호출할 때 지켜야 할 요구사항이 있음을 나타내는데, 이는 러스트가 이러한 요구사항을 충족했다고 보장할 수 없기 때문입니다. 안전하지 않은 함수를 unsafe 블록 내에서 호출한다는 것은 이 함수의 문서를 읽었으며 함수의 계약서를 준수할 책임이 있음을 의미합니다.

아래는 본문에서 아무 일도 하지 않는 dangerous라는 이름의 안전하지 않은 함수입니다:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

dangerous 함수는 반드시 분리된 unsafe 블록 내에서 호출되어야 합니다. unsafe 블록 없이 dangerous를 호출하려고 시도하면 다음과 같은 에러가 발생합니다:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function is unsafe and requires unsafe function or block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` due to previous error

unsafe 블록을 사용하는 것은 해당 함수의 설명서를 읽었고, 해당 함수를 올바르게 사용하는 방법을 이해했으며, 해당 함수의 계약서를 이행하고 있음을 확인한다고 러스트에게 단언하는 꼴입니다.

안전하지 않은 함수의 본문은 사실상 unsafe 블록이므로, 안전하지 않은 함수 내에서 안전하지 않은 연산을 수행하기 위해 또 unsafe 블록을 추가할 필요는 없습니다.

안전하지 않은 코드를 감싸는 안전한 추상화 만들기

함수에 안전하지 않은 코드가 포함되어 있다고 해서 전체 함수를 안전하지 않은 것으로 표시할 필요는 없습니다. 사실 안전하지 않은 코드를 안전한 함수로 감싸는 것은 일반적인 추상화입니다. 예를 들어, 안전하지 않은 코드가 약간 필요한 표준 라이브러리의 split_at_mut 함수를 살펴봅시다. 이를 어떻게 구현할 수 있는지 살펴보겠습니다. 이 안전한 메서드는 가변 슬라이스에 대해 정의됩니다: 하나의 슬라이스를 받아 인수로 주어진 인덱스에서 슬라이스를 분할하여 두 개로 만듭니다. 예제 19-4는 split_at_mut을 사용하는 방법을 보여줍니다.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}

예제 19-4: 안전한 split_at_mut 함수 사용하기

안전한 러스트만 사용하여 이 함수를 구현할 수는 없습니다. 시도는 예제 19-5처럼 될텐데, 컴파일되지 않을 것입니다. 간단하게 하기 위해 split_at_mut를 메서드가 아닌 함수로 구현하고 제네릭 타입 T 대신 i32 값의 슬라이스에 대해서 만으로 구현하겠습니다.

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

예제 19-5: 안전한 러스트만 사용하여 split_at_mut 구현 시도하기

이 함수는 먼저 슬라이스의 전체 길이를 얻습니다. 그런 다음 매개변수로 주어진 인덱스가 슬라이스의 길이보다 작거나 같은지 확인하는 것으로 슬라이스 내에 있음을 단언합니다. 이 단언문은 슬라이스를 분할하기 위해 길이보다 큰 인덱스를 전달하면 해당 인덱스를 사용하기 전에 함수가 패닉을 일으키리란 것을 의미합니다.

그다음 두 개의 가변 슬라이스를 튜플 안에 넣어 반환합니다: 하나는 원본 슬라이스의 시작부터 mid 인덱스까지의 슬라이스이고, 다른 하나는 mid 인덱스부터 원본 슬라이스의 끝까지의 슬라이스입니다.

예제 19-5의 코드를 컴파일 시도하면 아래와 같은 에러가 발생합니다.

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` due to previous error

러스트의 대여 검사기는 슬라이스의 서로 다른 부분을 빌린다는 것을 이해할 수 없습니다; 러스트는 그저 동일한 슬라이스를 두 번 빌린다는 것만 알고 있습니다. 슬라이스의 서로 다른 부분을 빌리는 것은 두 슬라이스가 겹치지 않기 때문에 기본적으로 괜찮지만, 러스트는 이를 알아차릴 만큼 똑똑하지 못합니다. 우리는 코드가 괜찮다는 것을 알지만 러스트는 그렇지 않다면, 안전하지 않은 코드를 이용할 시간입니다.

예제 19-6은 split_at_mut의 구현체를 작동시키기 위해 unsafe 블록, 원시 포인터, 그리고 안전하지 않은 함수 호출을 사용하는 방법을 보여줍니다.

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

예제 19-6: split_at_mut 함수의 구현체에서 안전하지 않은 코드 사용하기

4장의 ‘슬라이스 타입’ 절에서 슬라이스란 데이터를 가리키는 포인터와 슬라이스의 길이인 것을 기억하세요. len 메서드를 사용하여 슬라이스의 길이를 얻고 as_mut_ptr 메서드를 사용하여 슬라이스의 원시 포인터를 얻었습니다. 이번 경우에는 i32 값에 대한 가변 슬라이스이므로, as_mut_ptr*mut i32 타입의 원시 포인터를 반환하며, 이 포인터는 ptr 변수에 저장됩니다.

mid 인덱스가 슬라이스 내에 있다는 단언은 유지합니다. 그다음 안전하지 않은 코드에 도달합니다: slice::from_raw_parts_mut 함수는 원시 포인터와 길이를 받아 슬라이스를 생성합니다. 이 함수를 사용하여 ptr에서 시작하고 mid 길이의 아이템을 가진 슬라이스를 만듭니다. 그런 다음 ptr에서 mid를 인수로 add 메서드를 호출하여 mid에서 시작하는 원시 포인터를 가져오고, 이 포인터와 mid 이후의 나머지 아이템 개수를 길이로 사용하여 슬라이스를 생성합니다.

slice::from_raw_parts_mut 함수는 원시 포인터를 얻어와서 이 포인터의 유효성을 신뢰해야 하기 때문에 안전하지 않습니다. 원시 포인터에 대한 add 메서드도 오프셋 위치가 유효한 포인터임을 신뢰해야 하기 때문에 안전하지 않습니다. 따라서 slice::from_raw_parts_mutadd를 호출할 수 있도록 주위에 unsafe 블록을 넣어야 했습니다. 코드를 살펴보고 midlen보다 작거나 같아야 한다는 단언문을 추가하면 unsafe 블록 내에서 사용되는 모든 원시 포인터가 슬라이스 내의 데이터에 대한 유효한 포인터가 될 것임을 알 수 있습니다. 이는 unsafe에 대한 받아들일 만하고 적절한 사용입니다.

결과인 split_at_mut 함수를 unsafe로 표시할 필요는 없으며, 안전한 러스트에서 이 함수를 호출할 수 있다는 점에 유의하세요. 이 함수는 접근할 수 있는 데이터에서 유효한 포인터만 생성하기 때문에, unsafe 코드를 안전한 방식으로 사용하는 함수의 구현을 통해 안전하지 않은 코드에 대한 안전한 추상화를 만든 것이 되었습니다.

반면 예제 19-7의 slice::from_raw_parts_mut 사용은 슬라이스가 사용될 때 크래시가 발생하기 쉽습니다. 이 코드는 임의의 메모리 위치를 가져와서 10,000개의 아이템을 가진 슬라이스를 생성합니다.

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}

예제 19-7: 임의의 메모리 위치로부터 슬라이스 생성하기

이 임의 위치에 있는 메모리를 소유하지 않고, 이 코드가 생성하는 슬라이스가 유효한 i32 값들을 포함하고 있는지에 대한 보장이 없습니다. values를 마치 유효한 슬라이스인 것처럼 사용하려고 하면 정의되지 않은 동작이 발생합니다.

extern 함수를 사용하여 외부 코드 호출하기

종종 러스트 코드는 다른 언어로 작성된 코드와 상호작용해야 할 필요가 있습니다. 이를 위해 러스트에는 외래 함수 인터페이스 (Foreign Function Interface, FFI) 의 생성과 사용을 용이하게 하는 키워드 extern이 있습니다. FFI는 프로그래밍 언어가 함수를 정의하고 다른 (외래) 프로그래밍 언어가 해당 함수를 호출할 수 있도록 하는 방법입니다.

예제 19-8은 C 표준 라이브러리의 abs 함수와의 통합을 설정하는 방법을 보여줍니다. extern 블록 내에 선언된 함수는 러스트 코드에서 호출되기에 항상 안전하지 않습니다. 그 이유는 다른 언어가 러스트의 규칙과 보증을 적용하지 않고, 러스트가 이를 확인할 수 없어서 프로그래머에게 안전을 보장할 책임이 있기 때문입니다.

파일명: src/main.rs

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

예제 19-8: 다른 언어에 정의된 extern 함수의 선언 및 호출

extern "C" 블록에는 호출하려는 다른 언어의 외부 함수의 이름과 시그니처를 나열합니다. "C" 부분은 외부 함수가 사용하는 ABI (application binary interface) 를 정의합니다: ABI는 어셈블리 수준에서 함수를 호출하는 방법을 정의합니다. "C" ABI는 가장 일반적이며 C 프로그래밍 언어의 ABI를 따릅니다.

다른 언어에서 러스트 함수 호출하기

또한 extern을 사용하여 다른 언어에서 러스트 함수를 호출할 수 있는 인터페이스를 만들 수도 있습니다. 전체 extern 블록을 생성하는 대신, extern 키워드를 추가하고 관련 함수에 대한 fn 키워드 바로 앞에 사용할 ABI를 지정합니다. 또한 #[no_mangle] 어노테이션을 추가하여 러스트 컴파일러가 이 함수의 이름을 맹글링하지 않도록 지시해야 합니다. 맹글링 (mangling) 이란 우리가 함수에 부여한 이름을 컴파일러가 컴파일 과정의 다른 부분에서 사용할 수 있도록 더 많은 정보를 포함하지만 사람이 읽기엔 불편한 다른 이름으로 변경하는 것을 말합니다. 모든 프로그래밍 언어 컴파일러는 이름을 조금씩 다르게 변경하므로, 다른 언어에서 러스트 함수의 이름을 불리도록 하려면 러스트 컴파일러의 이름 맹글링 기능을 비활성화해야 합니다.

다음 예제에서는 call_from_c 함수를 공유 라이브러리로 컴파일하고 C에서 링크한 후, C 코드에서 함수에 접근할 수 있도록 합니다:

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

이러한 extern의 사용에는 unsafe가 필요 없습니다.

가변 정적 변수의 접근 혹은 수정하기

이 책에서 아직 전역 변수 (global variable) 에 대해 언급하지 않았는데, 러스트는 이를 지원하지만 러스트의 소유권 규칙과 문제가 발생할 수 있습니다. 두 스레드가 동일한 가변 전역 변수에 접근하고 있다면 데이터 경합이 발생할 수 있습니다.

러스트에서 전역 변수는 정적 (static) 변수라고 부릅니다. 예제 19-9는 문자열 슬라이스를 값으로 사용하는 정적 변수의 선언과 사용례를 보여줍니다.

파일명: src/main.rs

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {}", HELLO_WORLD);
}

예제 19-9: 불변 정적 변수의 정의 및 사용

정적 변수는 3장의 ‘상수’절에서 다루었던 상수와 유사합니다. 정적 변수의 이름은 관례적으로 SCREAMING_SNAKE_CASE를 사용합니다. 정적 변수는 'static 라이프타임을 가진 참조자만 저장할 수 있으며, 이는 러스트 컴파일러가 라이프타임을 알아낼 수 있으므로 명시적으로 어노테이션할 필요가 없음을 의미합니다. 불변 정적 변수에 접근하는 것은 안전합니다.

상수와 불변 정적 변수의 미묘한 차이점은 정적 변수의 값이 메모리에 고정된 주소를 갖는다는 점입니다. 값을 사용하면 항상 동일한 데이터에 접근할 수 있습니다. 반면 상수는 사용할 때마다 데이터가 복제될 수 있습니다. 또 다른 차이점은 정적 변수가 가변일 수 있다는 점입니다. 가변 정적 변수에 접근하고 수정하는 것은 안전하지 않습니다. 예제 19-10은 COUNTER라는 가변 정적 변수를 선언하고, 접근하고, 수정하는 방법을 보여줍니다.

파일명: src/main.rs

static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    add_to_count(3);

    unsafe {
        println!("COUNTER: {}", COUNTER);
    }
}

예제 19-10: 가변 정적 변수를 읽거나 쓰는 것은 안전하지 않습니다

일반적인 변수와 마찬가지로 mut 키워드를 사용하여 가변성을 지정합니다. COUNTER를 읽거나 쓰는 모든 코드는 unsafe 블록 내에 있어야 합니다. 이 코드는 싱글스레드이기 때문에 예상대로 COUNTER: 3을 컴파일하고 출력합니다. 여러 스레드가 COUNTER에 접근하면 데이터 경합이 발생할 수 있습니다.

전역적으로 접근할 수 있는 가변 데이터의 경우 데이터 경합이 발생하지 않도록 보장하기가 어려우며, 이것이 러스트가 가변 정적 변수를 안전하지 않은 것으로 간주하는 이유입니다. 가능하면 16장에서 설명한 동시성 기술과 스레드-안전한 스마트 포인터를 사용해 컴파일러가 다른 스레드에서 접근한 데이터에 안전하게 접근하는지 검사하도록 하는 편이 좋습니다.

안전하지 않은 트레이트 구현하기

unsafe를 사용하여 안전하지 않은 트레이트를 구현할 수 있습니다. 메서드 중 하나 이상에 컴파일러가 확인할 수 없는 불변성 (invariant) 이 있는 경우 그 트레이트는 안전하지 않습니다. 예제 19-11에 표시된 것처럼 trait 앞에 unsafe 키워드를 추가하고 그 트레이트의 구현체도 unsafe로 표시함으로써 트레이트가 unsafe하다고 선언할 수 있습니다.

unsafe trait Foo {
    // 여기에 메소드가 작성됩니다
}

unsafe impl Foo for i32 {
    // 여기에 메소드 구현이 작성됩니다
}

fn main() {}

예제 19-11: 안전하지 않은 트레이트의 정의 및 구현

unsafe impl을 사용하면 컴파일러가 확인할 수 없는 불변성은 우리가 지키겠다는 약속을 하는 것입니다.

예를 들어, 16장의 SyncSend 트레이트를 이용한 확장 가능한 동시성’절에서 설명한 SyncSend 마커 트레이트를 상기해 봅시다: 타입이 SendSync 타입으로만 구성된 경우에는 컴파일러가 이러한 트레이트를 자동으로 구현합니다. 원시 포인터와 같이 Send 혹은 Sync가 아닌 타입을 포함하고 있는 타입을 구현하고, 해당 타입을 Send 또는 Sync로 표시하려면 unsafe를 사용해야 합니다. 러스트는 해당 타입이 스레드 간에 안전하게 전송되거나 여러 스레드에서 접근할 수 있다는 보장을 준수하는지 확인할 수 없습니다; 따라서 이러한 검사를 수동으로 수행하고 unsafe로 표시해야 합니다.

유니온 필드에 접근하기

unsafe 경우에만 작동하는 마지막 작업은 유니온 (union) 의 필드에 접근하는 것입니다. unionstruct와 유사하지만, 특정 인스턴스에서 한 번에 하나의 선언된 필드만 사용됩니다. 유니온은 주로 C 코드의 유니온과 상호작용하는데 사용됩니다. 러스트는 현재 유니온 인스턴스에 저장된 데이터의 타입을 보장할 수 없기 때문에, 유니온 필드에 접근하는 것은 안전하지 않습니다. 유니온에 대한 자세한 내용은 러스트 참고 자료 문서에서 확인할 수 있습니다.

unsafe 코드를 사용하는 경우

unsafe을 사용하여 방금 설명한 다섯 가지 동작 (슈퍼파워) 중 하나를 수행하는 것은 잘못된 것도 아니고, 심지어 눈살을 찌푸릴 일도 아닙니다. 하지만 컴파일러가 메모리 안전성을 유지할 수 없기 때문에, unsafe 코드를 올바르게 만드는 것은 더 까다롭습니다. unsafe 코드를 사용해야 할 이유가 있다면 그렇게 할 수 있으며, 명시적인 unsafe 어노테이션이 있으면 문제가 발생했을 때 문제의 원인을 더 쉽게 추적할 수 있습니다.