안전하지 않은 Rust

지금까지 다룬 모든 코드는 컴파일 시점에 Rust의 메모리 안전성을 보장받았다. 하지만 Rust 내부에는 이러한 메모리 안전성을 보장하지 않는 또 다른 언어가 숨어 있다. 이를 _안전하지 않은 Rust(unsafe Rust)_라고 부르며, 일반적인 Rust와 동일하게 작동하지만 추가적인 강력한 기능을 제공한다.

안전하지 않은 Rust가 존재하는 이유는 정적 분석이 본질적으로 보수적이기 때문이다. 컴파일러가 코드가 보장 사항을 준수하는지 판단할 때, 잘못된 프로그램을 허용하는 것보다 일부 유효한 프로그램을 거부하는 것이 더 나은 선택이다. 코드가 문제 없을 수도 있지만, Rust 컴파일러가 확신할 만한 충분한 정보가 없으면 코드를 거부한다. 이런 경우, 안전하지 않은 코드를 사용해 컴파일러에게 “내가 무엇을 하는지 알고 있으니 믿어 달라“고 말할 수 있다. 하지만 주의할 점은, 안전하지 않은 Rust를 사용할 때는 본인의 책임 하에 사용해야 한다는 것이다. 안전하지 않은 코드를 잘못 사용하면 널 포인터 역참조와 같은 메모리 안전성 문제가 발생할 수 있다.

Rust가 안전하지 않은 기능을 제공하는 또 다른 이유는, 기본 컴퓨터 하드웨어가 본질적으로 안전하지 않기 때문이다. Rust가 안전하지 않은 작업을 허용하지 않는다면, 특정 작업을 수행할 수 없게 된다. Rust는 운영체제와 직접 상호작용하거나 심지어 자신만의 운영체제를 작성하는 것과 같은 저수준 시스템 프로그래밍을 가능하게 해야 한다. 저수준 시스템 프로그래밍 작업은 Rust 언어의 목표 중 하나이다. 이제 안전하지 않은 Rust로 무엇을 할 수 있는지, 그리고 어떻게 사용하는지 알아보자.

안전하지 않은 슈퍼파워

안전하지 않은 Rust 코드를 사용하려면 unsafe 키워드를 사용한 후 안전하지 않은 코드를 담을 새로운 블록을 시작한다. 안전하지 않은 Rust에서는 안전한 Rust에서는 할 수 없는 다섯 가지 작업을 수행할 수 있으며, 이를 _안전하지 않은 슈퍼파워_라고 부른다. 이 슈퍼파워는 다음과 같은 기능을 포함한다:

  • 원시 포인터 역참조
  • 안전하지 않은 함수나 메서드 호출
  • 가변 정적 변수 접근 또는 수정
  • 안전하지 않은 트레잇 구현
  • union의 필드 접근

여기서 중요한 점은 unsafe가 빌림 검사기(borrow checker)를 비활성화하거나 Rust의 다른 안전 검사를 끄지 않는다는 것이다. 안전하지 않은 코드에서 참조를 사용하면 여전히 검사가 이루어진다. unsafe 키워드는 단순히 이 다섯 가지 기능에 접근할 수 있게 해줄 뿐이며, 이 기능들은 컴파일러가 메모리 안전성을 검사하지 않는다. 하지만 안전하지 않은 블록 내부에서도 어느 정도의 안전성은 보장된다.

또한 unsafe는 블록 내부의 코드가 반드시 위험하거나 메모리 안전성 문제가 발생한다는 의미가 아니다. 프로그래머로서 안전하지 않은 블록 내부의 코드가 유효한 방식으로 메모리에 접근하도록 보장해야 한다.

사람은 실수할 수 있고, 실수는 발생하기 마련이다. 하지만 이 다섯 가지 안전하지 않은 작업을 unsafe로 주석이 달린 블록 내부에 포함시킴으로써, 메모리 안전성과 관련된 오류가 반드시 unsafe 블록 내부에서 발생한다는 것을 알 수 있다. unsafe 블록을 가능한 한 작게 유지하라. 나중에 메모리 버그를 조사할 때 이 점에 감사할 것이다.

안전하지 않은 코드를 최대한 격리하기 위해서는 이러한 코드를 안전한 추상화로 감싸고 안전한 API를 제공하는 것이 가장 좋다. 이에 대해서는 나중에 안전하지 않은 함수와 메서드를 살펴볼 때 자세히 다룰 것이다. 표준 라이브러리의 일부는 검토된 안전하지 않은 코드를 기반으로 한 안전한 추상화로 구현되어 있다. 안전하지 않은 코드를 안전한 추상화로 감싸면 unsafe 사용이 여러분이나 사용자가 안전하지 않은 코드로 구현된 기능을 사용하려는 모든 곳으로 퍼지는 것을 방지할 수 있다. 안전한 추상화를 사용하는 것은 안전하기 때문이다.

이제 다섯 가지 안전하지 않은 슈퍼파워를 하나씩 살펴보자. 또한 안전하지 않은 코드에 안전한 인터페이스를 제공하는 몇 가지 추상화도 함께 살펴볼 것이다.

Raw Pointer 역참조하기

4장의 “Dangling References”에서 언급했듯이, 컴파일러는 참조가 항상 유효하도록 보장한다. 하지만 안전하지 않은 Rust에서는 참조와 유사한 _raw pointer_라는 두 가지 새로운 타입을 제공한다. 참조와 마찬가지로 raw pointer는 불변(*const T) 또는 가변(*mut T)으로 선언할 수 있다. 여기서 별표(*)는 역참조 연산자가 아니라 타입 이름의 일부다. raw pointer의 맥락에서 _불변_은 포인터가 역참조된 후 직접 할당할 수 없음을 의미한다.

참조와 스마트 포인터와 달리, raw pointer는 다음과 같은 특징을 가진다:

  • 빌림 규칙을 무시하고 동일한 위치에 대해 불변 및 가변 포인터 또는 여러 가변 포인터를 가질 수 있다.
  • 유효한 메모리를 가리킨다는 보장이 없다.
  • null 값을 허용한다.
  • 자동 정리 기능을 구현하지 않는다.

Rust가 제공하는 이러한 보장을 포기함으로써, 더 높은 성능이나 Rust의 보장이 적용되지 않는 다른 언어 또는 하드웨어와의 인터페이스를 위해 안전성을 희생할 수 있다.

Listing 20-1은 불변 및 가변 raw pointer를 생성하는 방법을 보여준다.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}
Listing 20-1: Raw borrow 연산자를 사용해 raw pointer 생성하기

이 코드에서는 unsafe 키워드를 사용하지 않았다. 안전한 코드에서도 raw pointer를 생성할 수 있지만, 잠시 후에 살펴보겠지만, unsafe 블록 외부에서는 raw pointer를 역참조할 수 없다.

Raw borrow 연산자를 사용해 raw pointer를 생성했다: &raw const num*const i32 불변 raw pointer를 생성하고, &raw mut num*mut i32 가변 raw pointer를 생성한다. 이 예제에서는 로컬 변수에서 직접 생성했기 때문에 이 raw pointer가 유효하다는 것을 알 수 있지만, 모든 raw pointer에 대해 이런 가정을 할 수는 없다.

이를 확인하기 위해, Raw borrow 연산자 대신 as를 사용해 값을 캐스팅하여 유효성을 확신할 수 없는 raw pointer를 생성해보자. Listing 20-2는 메모리의 임의 위치를 가리키는 raw pointer를 생성하는 방법을 보여준다. 임의의 메모리를 사용하려고 시도하는 것은 정의되지 않은 동작이다: 해당 주소에 데이터가 있을 수도 있고 없을 수도 있으며, 컴파일러가 메모리 접근을 최적화하거나 프로그램이 세그먼테이션 오류로 종료될 수도 있다. 일반적으로 이런 코드를 작성할 이유는 없지만, 특히 Raw borrow 연산자를 대신 사용할 수 있는 경우에도 가능은 하다.

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}
Listing 20-2: 임의의 메모리 주소를 가리키는 raw pointer 생성하기

안전한 코드에서 raw pointer를 생성할 수 있지만, raw pointer를 _역참조_하고 포인터가 가리키는 데이터를 읽을 수는 없다. Listing 20-3에서는 unsafe 블록이 필요한 raw pointer에 역참조 연산자 *를 사용한다.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}
Listing 20-3: unsafe 블록 내에서 raw pointer 역참조하기

포인터를 생성하는 것은 아무런 해가 없지만, 포인터가 가리키는 값에 접근하려고 할 때 유효하지 않은 값을 다룰 수 있다.

또한 Listing 20-1과 20-3에서 num이 저장된 동일한 메모리 위치를 가리키는 *const i32*mut i32 raw pointer를 생성했다. 만약 num에 대해 불변 및 가변 참조를 생성하려고 했다면, Rust의 소유권 규칙으로 인해 코드가 컴파일되지 않았을 것이다. Raw pointer를 사용하면 동일한 위치에 대해 가변 포인터와 불변 포인터를 생성하고, 가변 포인터를 통해 데이터를 변경할 수 있지만, 이는 데이터 경쟁을 유발할 가능성이 있다. 주의해야 한다!

이렇게 위험한데도 왜 raw pointer를 사용할까? 주요 사용 사례 중 하나는 C 코드와의 인터페이스다. 이는 다음 섹션 “안전하지 않은 함수 또는 메서드 호출하기”에서 살펴볼 것이다. 또 다른 사례는 빌림 검사기가 이해하지 못하는 안전한 추상화를 구축할 때다. 안전하지 않은 함수를 소개한 후, 안전하지 않은 코드를 사용하는 안전한 추상화 예제를 살펴볼 것이다.

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

unsafe 블록 내에서 수행할 수 있는 두 번째 작업은 안전하지 않은 함수를 호출하는 것이다. 안전하지 않은 함수와 메서드는 일반 함수 및 메서드와 동일하게 보이지만, 정의 앞에 unsafe 키워드가 추가된다. 이 컨텍스트에서 unsafe 키워드는 함수를 호출할 때 지켜야 할 요구사항이 있음을 나타내며, Rust는 이러한 요구사항이 충족되었는지 보장할 수 없다. 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 `dangerous` is unsafe and requires unsafe 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` (bin "unsafe-example") due to 1 previous error

unsafe 블록을 사용함으로써, Rust에게 해당 함수의 문서를 읽었고, 올바르게 사용하는 방법을 이해했으며, 함수의 계약을 충족하고 있음을 확인했다고 선언하는 것이다.

안전하지 않은 함수의 본문에서 안전하지 않은 작업을 수행하려면, 일반 함수 내에서와 마찬가지로 여전히 unsafe 블록을 사용해야 한다. 이를 잊어버리면 컴파일러가 경고를 표시한다. 이는 unsafe 블록을 가능한 한 작게 유지하는 데 도움이 되며, 함수 전체 본문에 걸쳐 안전하지 않은 작업이 필요하지 않을 수도 있음을 의미한다.

안전하지 않은 코드 위에 안전한 추상화 만들기

함수 안에 안전하지 않은 코드가 있다고 해서 전체 함수를 안전하지 않다고 표시할 필요는 없다. 실제로 안전하지 않은 코드를 안전한 함수로 감싸는 것은 흔히 사용하는 추상화 방법이다. 예를 들어, 표준 라이브러리의 split_at_mut 함수를 살펴보자. 이 함수는 안전하지 않은 코드가 필요하다. 이 함수를 어떻게 구현할 수 있는지 알아보자. 이 안전한 메서드는 가변 슬라이스에 정의되어 있다: 하나의 슬라이스를 받아서 주어진 인덱스에서 두 개의 슬라이스로 나눈다. Listing 20-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]);
}
Listing 20-4: 안전한 split_at_mut 함수 사용하기

이 함수는 안전한 Rust만으로는 구현할 수 없다. Listing 20-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);
}
Listing 20-5: 안전한 Rust만으로 split_at_mut 구현 시도

이 함수는 먼저 슬라이스의 전체 길이를 가져온다. 그런 다음 주어진 인덱스가 슬라이스 내에 있는지 확인하기 위해 길이보다 작거나 같은지 검사한다. 이 검사는 슬라이스를 나누기 위해 길이보다 큰 인덱스를 전달하면 함수가 해당 인덱스를 사용하기 전에 패닉을 일으킨다는 것을 의미한다.

그런 다음 튜플로 두 개의 가변 슬라이스를 반환한다: 하나는 원래 슬라이스의 시작부터 mid 인덱스까지, 다른 하나는 mid부터 슬라이스의 끝까지이다.

Listing 20-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`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

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

Rust의 빌림 검사기는 슬라이스의 다른 부분을 빌리고 있다는 것을 이해하지 못한다. 같은 슬라이스에서 두 번 빌리고 있다는 것만 알고 있다. 슬라이스의 다른 부분을 빌리는 것은 근본적으로 괜찮은데, 두 슬라이스가 겹치지 않기 때문이다. 하지만 Rust는 이를 알아채지 못한다. 우리는 코드가 괜찮다는 것을 알고 있지만 Rust는 모르는 상황에서, 이때가 바로 안전하지 않은 코드를 사용할 때이다.

Listing 20-6은 unsafe 블록, raw 포인터, 그리고 몇 가지 안전하지 않은 함수 호출을 사용해 split_at_mut를 구현하는 방법을 보여준다.

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);
}
Listing 20-6: split_at_mut 함수 구현에 안전하지 않은 코드 사용하기

4장의 “The Slice Type”에서 슬라이스는 데이터에 대한 포인터와 슬라이스의 길이로 구성된다는 것을 기억할 것이다. len 메서드를 사용해 슬라이스의 길이를 가져오고, as_mut_ptr 메서드를 사용해 슬라이스의 raw 포인터에 접근한다. 이 경우 i32 값의 가변 슬라이스가 있으므로 as_mut_ptr*mut i32 타입의 raw 포인터를 반환하며, 이를 ptr 변수에 저장한다.

mid 인덱스가 슬라이스 내에 있다는 검사는 그대로 유지한다. 그런 다음 안전하지 않은 코드에 도달한다: slice::from_raw_parts_mut 함수는 raw 포인터와 길이를 받아 슬라이스를 생성한다. 이 함수를 사용해 ptr에서 시작하고 mid 길이만큼의 슬라이스를 생성한다. 그런 다음 mid를 인자로 ptradd 메서드를 호출해 mid에서 시작하는 raw 포인터를 얻고, 그 포인터와 mid 이후의 남은 길이를 사용해 슬라이스를 생성한다.

slice::from_raw_parts_mut 함수는 raw 포인터를 받기 때문에 안전하지 않으며, 이 포인터가 유효하다고 믿어야 한다. raw 포인터의 add 메서드도 안전하지 않은데, 오프셋 위치가 유효한 포인터라고 믿어야 하기 때문이다. 따라서 slice::from_raw_parts_mutadd 호출 주위에 unsafe 블록을 추가해 이들을 호출할 수 있도록 했다. 코드를 살펴보고 midlen보다 작거나 같아야 한다는 검사를 추가함으로써, unsafe 블록 내에서 사용된 모든 raw 포인터가 슬라이스 내의 데이터에 대한 유효한 포인터임을 알 수 있다. 이는 unsafe를 적절하고 허용 가능한 방식으로 사용한 것이다.

결과적으로 split_at_mut 함수를 unsafe로 표시할 필요가 없으며, 이 함수를 안전한 Rust에서 호출할 수 있다. 우리는 안전하지 않은 코드에 대한 안전한 추상화를 만들었는데, 이 함수가 접근할 수 있는 데이터로부터 유효한 포인터만 생성하기 때문이다.

반대로, Listing 20-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) };
}
Listing 20-7: 임의의 메모리 위치에서 슬라이스 생성하기

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

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

때로는 Rust 코드가 다른 언어로 작성된 코드와 상호작용해야 할 때가 있다. 이를 위해 Rust는 extern 키워드를 제공하며, 이를 통해 외부 함수 인터페이스(Foreign Function Interface, FFI) 를 생성하고 사용할 수 있다. FFI는 프로그래밍 언어가 함수를 정의하고, 다른 (외부) 프로그래밍 언어가 그 함수를 호출할 수 있도록 하는 방법이다.

리스트 20-8은 C 표준 라이브러리의 abs 함수와 통합을 설정하는 방법을 보여준다. extern 블록 내에서 선언된 함수는 일반적으로 Rust 코드에서 호출할 때 안전하지 않기 때문에, extern 블록은 unsafe로 표시해야 한다. 이는 다른 언어가 Rust의 규칙과 보장을 강제하지 않으며, Rust가 이를 확인할 수 없기 때문이다. 따라서 프로그래머가 안전을 보장할 책임이 있다.

Filename: src/main.rs
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
Listing 20-8: 다른 언어로 정의된 extern 함수 선언 및 호출

unsafe extern "C" 블록 내에서 호출하려는 다른 언어의 외부 함수 이름과 시그니처를 나열한다. "C" 부분은 외부 함수가 사용하는 애플리케이션 바이너리 인터페이스(Application Binary Interface, ABI) 를 정의한다. ABI는 어셈블리 수준에서 함수를 호출하는 방법을 정의한다. "C" ABI는 가장 일반적이며, C 프로그래밍 언어의 ABI를 따른다. Rust가 지원하는 모든 ABI에 대한 정보는 Rust Reference에서 확인할 수 있다.

unsafe extern 블록 내에서 선언된 모든 항목은 암묵적으로 unsafe이다. 그러나 일부 FFI 함수는 안전하게 호출할 수 있다. 예를 들어, C 표준 라이브러리의 abs 함수는 메모리 안전성 문제가 없으며, 어떤 i32 값으로도 호출할 수 있다는 것을 알고 있다. 이런 경우에는 safe 키워드를 사용해 이 특정 함수가 unsafe extern 블록 안에 있더라도 안전하게 호출할 수 있다고 명시할 수 있다. 이렇게 변경하면, 리스트 20-9에서 보여주는 것처럼 호출 시 unsafe 블록이 필요하지 않다.

Filename: src/main.rs
unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}
Listing 20-9: unsafe extern 블록 내에서 함수를 safe로 명시적으로 표시하고 안전하게 호출하기

함수를 safe로 표시한다고 해서 자동으로 안전해지는 것은 아니다! 대신, 이는 Rust에게 해당 함수가 안전하다고 약속하는 것과 같다. 여전히 프로그래머가 그 약속을 지킬 책임이 있다.

다른 언어에서 Rust 함수 호출하기

extern을 사용해 다른 언어가 Rust 함수를 호출할 수 있는 인터페이스를 생성할 수도 있다. 전체 extern 블록을 생성하는 대신, 해당 함수의 fn 키워드 앞에 extern 키워드와 사용할 ABI를 지정한다. 또한 #[unsafe(no_mangle)] 어노테이션을 추가해 Rust 컴파일러가 이 함수의 이름을 변경하지 않도록 해야 한다. _Mangling_은 컴파일러가 함수에 부여한 이름을 컴파일 과정의 다른 부분에서 사용하기 위해 더 많은 정보를 포함하지만 인간이 읽기 어려운 다른 이름으로 변경하는 것이다. 모든 프로그래밍 언어 컴파일러는 이름을 약간씩 다르게 변경하므로, Rust 함수가 다른 언어에서 호출 가능하려면 Rust 컴파일러의 이름 변경 기능을 비활성화해야 한다. 이는 내장된 이름 변경 기능이 없을 때 라이브러리 간 이름 충돌이 발생할 수 있기 때문에 안전하지 않으며, 프로그래머가 선택한 이름이 변경 없이 안전하게 내보낼 수 있는지 확인할 책임이 있다.

다음 예제에서는 call_from_c 함수를 C 코드에서 접근 가능하도록 만들고, 공유 라이브러리로 컴파일한 후 C에서 링크하는 방법을 보여준다:

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

extern 사용법은 어노테이션에서만 unsafe가 필요하며, extern 블록에서는 필요하지 않다.

변경 가능한 정적 변수에 접근하거나 수정하기

이 책에서 아직 다루지 않은 전역 변수는 Rust에서 지원하지만, Rust의 소유권 규칙과 충돌할 수 있다. 두 스레드가 동일한 변경 가능한 전역 변수에 접근하면 데이터 경쟁이 발생할 수 있다.

Rust에서 전역 변수는 정적 변수라고 부른다. Listing 20-10은 문자열 슬라이스를 값으로 가지는 정적 변수의 선언과 사용 예제를 보여준다.

Filename: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}
Listing 20-10: 변경 불가능한 정적 변수 정의 및 사용

정적 변수는 3장의 “상수”에서 다룬 상수와 유사하다. 정적 변수의 이름은 관례적으로 SCREAMING_SNAKE_CASE로 작성한다. 정적 변수는 'static 수명을 가진 참조만 저장할 수 있으며, 이는 Rust 컴파일러가 수명을 추론할 수 있기 때문에 명시적으로 주석을 달 필요가 없다는 것을 의미한다. 변경 불가능한 정적 변수에 접근하는 것은 안전하다.

상수와 변경 불가능한 정적 변수의 미묘한 차이점은 정적 변수의 값이 메모리에서 고정된 주소를 가진다는 것이다. 값을 사용할 때 항상 동일한 데이터에 접근한다. 반면, 상수는 사용될 때마다 데이터를 복제할 수 있다. 또 다른 차이점은 정적 변수가 변경 가능할 수 있다는 것이다. 변경 가능한 정적 변수에 접근하거나 수정하는 것은 안전하지 않다. Listing 20-11은 COUNTER라는 변경 가능한 정적 변수를 선언하고, 접근하며, 수정하는 방법을 보여준다.

Filename: src/main.rs
static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}
Listing 20-11: 변경 가능한 정적 변수에서 읽거나 쓰는 것은 안전하지 않음

일반 변수와 마찬가지로 mut 키워드를 사용해 변경 가능성을 지정한다. COUNTER에서 읽거나 쓰는 모든 코드는 unsafe 블록 내에 있어야 한다. 이 코드는 단일 스레드에서 실행되기 때문에 컴파일되고 COUNTER: 3을 출력한다. 여러 스레드가 COUNTER에 접근하면 데이터 경쟁이 발생할 가능성이 높기 때문에 이는 정의되지 않은 동작이다. 따라서 전체 함수를 unsafe로 표시하고, 안전성 제한 사항을 문서화하여 함수를 호출하는 사람이 무엇을 안전하게 할 수 있고 없는지 알 수 있도록 해야 한다.

안전하지 않은 함수를 작성할 때는 SAFETY로 시작하는 주석을 추가하고, 호출자가 함수를 안전하게 호출하기 위해 해야 할 일을 설명하는 것이 관례적이다. 마찬가지로, 안전하지 않은 작업을 수행할 때는 SAFETY로 시작하는 주석을 추가해 안전성 규칙이 어떻게 유지되는지 설명하는 것이 일반적이다.

또한 컴파일러는 변경 가능한 정적 변수에 대한 참조를 생성하는 것을 허용하지 않는다. 오직 원시 포인터를 통해 접근할 수 있으며, 이는 원시 차용 연산자 중 하나를 사용해 생성된다. 이는 println!과 같이 참조가 암묵적으로 생성되는 경우에도 해당된다. 정적 변경 가능 변수에 대한 참조가 오직 원시 포인터를 통해 생성되어야 한다는 요구 사항은 이를 사용할 때의 안전성 요구 사항을 더 명확히 한다.

전역적으로 접근 가능한 변경 가능한 데이터의 경우 데이터 경쟁이 없음을 보장하기 어렵기 때문에, Rust는 변경 가능한 정적 변수를 안전하지 않다고 간주한다. 가능한 경우 16장에서 다룬 동시성 기법과 스레드 안전한 스마트 포인터를 사용해 컴파일러가 다른 스레드에서의 데이터 접근이 안전하게 이루어지는지 확인하도록 하는 것이 바람직하다.

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

unsafe 키워드를 사용해 안전하지 않은 트레이트를 구현할 수 있다. 트레이트가 안전하지 않은 경우는, 해당 트레이트의 메서드 중 적어도 하나가 컴파일러가 검증할 수 없는 불변 조건을 가지고 있을 때이다. 트레이트를 unsafe로 선언하려면 trait 앞에 unsafe 키워드를 추가하고, 트레이트의 구현도 unsafe로 표시해야 한다. 이는 리스트 20-12에서 확인할 수 있다.

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}
Listing 20-12: 안전하지 않은 트레이트 정의 및 구현

unsafe impl을 사용함으로써, 우리는 컴파일러가 검증할 수 없는 불변 조건을 지킬 것을 약속한다.

예를 들어, 16장에서 논의한 SyncSend 마커 트레이트를 떠올려 보자. 이 트레이트는 우리의 타입이 SendSync를 구현한 다른 타입들로만 구성된 경우, 컴파일러가 자동으로 구현한다. 하지만 SendSync를 구현하지 않은 타입(예: raw 포인터)을 포함하는 타입을 구현하고, 그 타입을 SendSync로 표시하려면 unsafe를 사용해야 한다. Rust는 우리의 타입이 스레드 간에 안전하게 전송되거나 여러 스레드에서 안전하게 접근될 수 있는지 검증할 수 없다. 따라서 우리가 직접 이러한 검사를 수행하고, unsafe로 이를 표시해야 한다.

유니언(Union)의 필드 접근

unsafe 키워드와 함께만 사용할 수 있는 마지막 동작은 유니언(union)의 필드에 접근하는 것이다. 유니언은 구조체(struct)와 유사하지만, 특정 인스턴스에서 한 번에 하나의 선언된 필드만 사용된다. 유니언은 주로 C 코드의 유니언과 인터페이스하기 위해 사용된다. 유니언 필드에 접근하는 것은 안전하지 않은(unsafe) 작업으로 간주된다. 왜냐하면 Rust는 현재 유니언 인스턴스에 저장된 데이터의 타입을 보장할 수 없기 때문이다. 유니언에 대한 더 자세한 내용은 Rust Reference에서 확인할 수 있다.

Miri를 사용해 안전하지 않은 코드 검사하기

안전하지 않은 코드를 작성할 때, 실제로 안전하고 올바른지 확인하고 싶을 수 있다. 이를 확인하는 가장 좋은 방법 중 하나는 Miri를 사용하는 것이다. Miri는 정의되지 않은 동작을 감지하는 공식 Rust 도구다. 빌드 시점에 작동하는 정적 도구인 borrow checker와 달리, Miri는 런타임에 작동하는 동적 도구다. 프로그램이나 테스트 스위트를 실행하면서 Rust가 작동해야 하는 방식에 대한 규칙을 위반하는지 확인한다.

Miri를 사용하려면 Rust의 nightly 버전이 필요하다(부록 G: Rust의 제작 과정과 “Nightly Rust”에서 더 자세히 다룬다). rustup +nightly component add miri 명령어를 입력하면 Rust의 nightly 버전과 Miri 도구를 설치할 수 있다. 이 명령어는 프로젝트에서 사용하는 Rust 버전을 변경하지 않는다. 단지 시스템에 도구를 추가해 필요할 때 사용할 수 있게 해준다. 프로젝트에서 Miri를 실행하려면 cargo +nightly miri run 또는 cargo +nightly miri test를 입력하면 된다.

이 도구가 얼마나 유용한지 예를 들어보자. 리스트 20-11에 Miri를 실행하면 어떤 일이 발생하는지 살펴보자.

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
COUNTER: 3

Miri는 변경 가능한 데이터에 대한 공유 참조가 있다고 정확히 경고한다. 이 경우 Miri는 경고만 발행하는데, 이는 반드시 정의되지 않은 동작이 발생한다고 보장할 수 없기 때문이다. 또한 문제를 해결하는 방법을 알려주지 않는다. 하지만 최소한 정의되지 않은 동작의 위험이 있다는 것을 알 수 있고, 코드를 안전하게 만드는 방법을 고민할 수 있다. 어떤 경우에는 Miri가 확실히 잘못된 코드 패턴을 감지하고, 이를 수정하는 방법에 대한 권장 사항을 제시하기도 한다.

Miri는 안전하지 않은 코드를 작성할 때 발생할 수 있는 모든 문제를 잡아내지는 못한다. Miri는 동적 분석 도구이기 때문에 실제로 실행되는 코드의 문제만 감지한다. 따라서 작성한 안전하지 않은 코드에 대한 신뢰를 높이려면 좋은 테스트 기법과 함께 사용해야 한다. 또한 Miri는 코드가 불안정해질 수 있는 모든 가능한 경우를 다루지 않는다.

다시 말하면, Miri가 문제를 감지하면 버그가 있다는 것을 알 수 있지만, Miri가 버그를 감지하지 못했다고 해서 문제가 없다는 의미는 아니다. 그럼에도 Miri는 많은 문제를 잡아낼 수 있다. 이 장의 다른 안전하지 않은 코드 예제에 Miri를 실행해보고 어떤 결과가 나오는지 확인해보자!

Miri에 대해 더 자세히 알고 싶다면 GitHub 저장소를 참고하자.

언제 unsafe 코드를 사용할까

앞서 언급한 다섯 가지 강력한 기능을 사용하기 위해 unsafe를 사용하는 것은 잘못된 일도 아니고, 나쁜 것도 아니다. 하지만 unsafe 코드를 올바르게 작성하는 것은 더 까다롭다. 왜냐하면 컴파일러가 메모리 안전성을 보장해주지 못하기 때문이다. unsafe 코드를 사용할 만한 이유가 있다면, 사용해도 좋다. 명시적인 unsafe 어노테이션을 사용하면 문제가 발생했을 때 그 원인을 쉽게 추적할 수 있다. unsafe 코드를 작성할 때는 Miri를 사용해 코드가 Rust의 규칙을 준수하는지 더 확신할 수 있다.

unsafe Rust를 효과적으로 사용하는 방법에 대해 더 깊이 알고 싶다면, Rust의 공식 가이드인 Rustonomicon을 읽어보자.