Devin.KR

unsafe 의 경계 - 안전한 추상화 만들기

개발자KR 조회 0

이 장에서 배우는 것

지금까지 쓴 코드는 모두 컴파일러가 안전성을 검사해 주는 영역 안에 있었다. 이 장은 그 바깥 경계를 다룬다. unsafe 는 검사를 끄는 스위치가 아니다. 컴파일러가 증명할 수 없는 조건을 프로그래머가 대신 보증하겠다고 선언하는 표시다. 그래서 핵심은 unsafe 를 얼마나 쓰느냐보다 보증할 조건을 어디에 가두고 어떻게 적어 두느냐에 있다. 이 장에서는 창고의 두 칸 사이에서 재고를 옮기는 기능을 예로 들어 표준 라이브러리의 split_at_mut 와 같은 기능을 직접 만든다. 마지막에는 C 라이브러리 함수를 부르는 FFI(foreign function interface)도 짧게 살펴본다.

  • unsafe 가 허용하는 다섯 가지 일과, 허용하지 않는 일을 구분한다.
  • 원시 포인터(raw pointer)를 만들고, 읽고 쓰고, 널 여부를 확인한다.
  • unsafe 블록마다 SAFETY 주석으로 안전 불변식(invariant)을 적는다.
  • split_at_mut 를 직접 구현해 unsafe 를 안전한 함수 안에 가둔다.
  • unsafe extern "C" 블록으로 C 함수를 선언하고 안전한 함수로 감싼다.

문제 상황

창고에는 같은 부품 BOLT-M8 이 여러 칸에 나뉘어 쌓여 있다. 칸마다 위치 이름과 수량이 있고, 한 칸에서 다른 칸으로 수량을 옮기는 함수가 필요하다. 가장 자연스러운 코드는 원본 칸과 목적지 칸을 동시에 가변으로 빌리는 것이다.

let src = &mut bins[from];
let dst = &mut bins[to];   // 오류
src.qty -= amount;
dst.qty += amount;

이 코드는 컴파일되지 않는다. 오류의 요지는 bins 를 가변으로 두 번 빌릴 수 없다는 것(E0499)이다. 컴파일러는 from 과 to 가 다른 값인지 알 수 없고, 같은 칸이면 가변 참조 두 개가 같은 메모리를 가리키게 되기 때문이다. 해법은 슬라이스를 겹치지 않는 두 조각으로 나눈 뒤 조각마다 한 칸씩 빌리는 것이다. 표준 라이브러리에는 이미 split_at_mut 가 있고 실무에서는 그것을 쓰는 편이 맞다. 이 장에서는 unsafe 가 필요한 이유와 그 책임을 어떻게 가두는지 보려고 같은 기능을 직접 만든다.

unsafe 가 허용하는 일과 원시 포인터

다섯 가지 능력

unsafe 블록 안에서 새로 가능해지는 일은 다섯 가지뿐이다. 그 밖의 규칙은 그대로 적용된다. 빌림 검사기도, 타입 검사도, 소유권 이동 규칙도 unsafe 블록 안에서 계속 작동한다.

unsafe 로 할 수 있는 다섯 가지와 호출자가 보증해야 할 것
허용되는 일예보증할 조건
원시 포인터 역참조*ptr유효하고 정렬됐으며 초기화된 값이다
unsafe 함수 호출slice::from_raw_parts_mut함수 문서의 안전 조건을 만족한다
가변 정적 변수 접근static mut COUNT동시 접근이 없다
unsafe 트레이트 구현unsafe impl Send for T트레이트가 요구하는 성질이 실제로 성립한다
유니온 필드 읽기u.int_field해당 필드로 읽어도 되는 비트다

외부 함수(extern) 호출은 두 번째 항목에 속한다. 가변 정적 변수는 2024 에디션에서 그 참조를 만드는 것만으로 기본 거부 린트에 걸리므로, 이 장에서는 쓰지 않는다. 동시 접근을 다루는 도구는 앞서 배운 Mutex 와 채널이 더 알맞다.

원시 포인터

원시 포인터는 *const T 와 *mut T 두 종류다. 참조와 달리 수명이 없고, 널일 수 있고, 같은 곳을 가리키는 포인터가 여러 개 있어도 된다. 포인터를 만드는 일은 안전하다. 위험은 역참조하는 순간에 생긴다. 앞서 포인터 산술을 다룬 기본서의 내용과 달리 Rust 의 add 는 바이트가 아니라 원소 단위로 이동한다. 또한 add 의 결과는 같은 할당 영역 안에 있어야 한다는 조건이 있다.

SAFETY 주석과 안전한 추상화

SAFETY 주석으로 불변식을 적는다

unsafe 블록 바로 위에는 // SAFETY: 로 시작하는 주석을 둔다. 내용은 이 블록이 호출하는 연산의 요구 조건이 왜 지금 성립하는지에 대한 근거다. "안전하다" 같은 선언은 근거가 아니다. 근거는 앞의 코드가 만들어 낸 사실, 예를 들어 assert! 로 확인한 범위, 배타적 참조라는 타입의 보장, 길이 계산 같은 것이어야 한다. 반대로 unsafe 함수를 정의할 때는 문서에 # Safety 절을 두어 호출자가 지켜야 할 조건을 적는다. 정리하면 SAFETY 주석은 사용하는 쪽이 쓰고 Safety 문서는 정의하는 쪽이 쓴다.

split_at_mut 구현

슬라이스는 시작 포인터와 길이로 이루어진다. 가변 슬라이스 하나에서 시작 포인터를 꺼내 [0, mid) 와 [mid, len) 두 구간으로 슬라이스를 다시 만들면, 두 조각은 서로 겹치지 않는다.

mid 를 경계로 나눈 두 조각은 같은 칸을 공유하지 않으므로 가변 참조 두 개가 공존할 수 있다.

컴파일러는 이 겹치지 않음을 증명하지 못하므로 from_raw_parts_mut 호출에는 unsafe 가 필요하다. 이 함수가 요구하는 조건은 포인터가 유효하고, 길이만큼의 원소가 모두 한 할당 안에 있으며, 반환되는 가변 슬라이스가 다른 참조와 겹치지 않는 것이다. mid <= len 만 검사하면 세 조건이 모두 충족된다. 이 검사를 함수 안에서 하기 때문에 함수 자체는 unsafe fn 이 아닌 일반 함수로 공개할 수 있다.

불변식을 검사하는 책임은 안전한 함수 안에 있고 호출자는 unsafe 를 쓰지 않는다.

이것이 안전한 추상화(safe abstraction)다. unsafe 코드는 작은 함수 하나에 모아 두고, 그 함수가 어떤 입력에서도 정의되지 않은 동작을 일으키지 않도록 입구에서 검사한다. 검사로 막을 수 없는 조건이 남는다면 그 함수는 안전한 함수로 공개하면 안 되고 unsafe fn 으로 표시해야 한다.

두 칸을 동시에 빌리는 함수

pair_mut 는 my_split_at_mut 위에 만든 일반 함수다. 인덱스가 같거나 범위를 벗어나면 None 을 돌려준다. 그렇지 않으면 작은 인덱스 쪽과 큰 인덱스 쪽을 경계로 나눠 각각 한 칸씩 꺼낸다. 이 함수에는 unsafe 가 한 줄도 없다. 앞서 만든 안전한 추상화를 쓰는 쪽은 안전한 코드만으로 충분하다는 점이 중요하다.

FFI 개요

Rust 는 C ABI 를 따르는 외부 함수를 선언해 호출할 수 있다. 2024 에디션에서는 선언 블록 자체를 unsafe extern "C" 로 쓴다. 컴파일러는 선언한 시그니처가 실제 함수와 맞는지 확인할 수 없으므로 선언이 곧 보증이라는 뜻이다. 시그니처가 맞고 어떤 인자로 불러도 문제가 없다고 확신하는 함수에는 safe fn 을 붙여 호출 쪽에서 unsafe 를 생략할 수 있다. 이 장의 예제에서 abs 는 정수 하나를 받아 정수를 돌려주므로 safe fn 으로, strlen 은 포인터를 받으므로 unsafe 로 둔다. 문자열은 NUL 로 끝나야 하므로 CStr 과 C 문자열 리터럴(c"...")을 쓰고, strlen 호출은 &CStr 를 받는 안전한 함수로 감싼다. 직접 만든 Rust 함수를 C 쪽에서 부르게 하는 방향이나 구조체 배치(#[repr(C)])는 이 장에서 다루지 않는다. 자세한 규칙은 The Rustonomicon 에서 확인할 수 있다.

완성 코드

src/main.rs 한 파일이다.

use std::ffi::{CStr, c_char};
use std::slice;

unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
    fn strlen(s: *const c_char) -> usize;
}

struct Bin {
    place: &'static str,
    qty: u32,
}

fn my_split_at_mut<T>(items: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = items.len();
    assert!(mid <= len, "mid({mid}) 가 길이({len}) 보다 크다");
    let ptr = items.as_mut_ptr();
    // SAFETY: ptr 은 items 의 시작이고 items 는 &mut 이므로 len 개 원소에 대해
    // 유효하며 다른 참조와 겹치지 않는다. assert 로 mid <= len 을 확인했으므로
    // [0, mid) 와 [mid, len) 은 모두 범위 안이고 서로 겹치지 않는다.
    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn pair_mut<T>(items: &mut [T], i: usize, j: usize) -> Option<(&mut T, &mut T)> {
    if i == j || i >= items.len() || j >= items.len() {
        return None;
    }
    if i < j {
        let (left, right) = my_split_at_mut(items, j);
        Some((&mut left[i], &mut right[0]))
    } else {
        let (left, right) = my_split_at_mut(items, i);
        Some((&mut right[0], &mut left[j]))
    }
}

fn transfer(bins: &mut [Bin], from: usize, to: usize, amount: u32) -> Result<(), String> {
    let (src, dst) = pair_mut(bins, from, to)
        .ok_or_else(|| format!("칸 번호가 잘못됐다: {from} -> {to}"))?;
    if src.qty < amount {
        return Err(format!(
            "{} 재고 부족: 보유 {}, 요청 {}",
            src.place, src.qty, amount
        ));
    }
    src.qty -= amount;
    dst.qty += amount;
    Ok(())
}

fn raw_pointer_tour() {
    let mut stock = [10u32, 20, 30];
    let ptr = stock.as_mut_ptr();
    // SAFETY: ptr 은 길이 3 배열의 시작이고 add(1) 은 배열 안이다.
    // 이 시점에 stock 을 다른 경로로 접근하지 않는다.
    let second = unsafe { *ptr.add(1) };
    // SAFETY: 인덱스 2 는 배열 안이고 해당 칸은 초기화된 u32 다.
    unsafe { *ptr.add(2) += 5 };
    println!("둘째 칸 = {second}, 셋째 칸 = {}", stock[2]);

    let null: *const u32 = std::ptr::null();
    println!("널 포인터인가: {}", null.is_null());
    // SAFETY: as_ref 는 널이면 None 을 돌려주고, 널이 아닐 때만 유효성이 필요하다.
    // 여기서는 널이므로 역참조가 일어나지 않는다.
    println!("as_ref 결과: {:?}", unsafe { null.as_ref() });
}

fn c_len(text: &CStr) -> usize {
    // SAFETY: CStr 은 NUL 로 끝나는 유효한 바이트열을 가리키므로
    // strlen 이 요구하는 조건을 만족한다.
    unsafe { strlen(text.as_ptr()) }
}

fn main() {
    println!("== 원시 포인터 ==");
    raw_pointer_tour();

    println!("== split_at_mut 직접 구현 ==");
    let mut data = [1, 2, 3, 4, 5];
    let (left, right) = my_split_at_mut(&mut data, 2);
    left[0] = 10;
    right[0] = 30;
    println!("left={left:?} right={right:?}");
    println!("data={data:?}");

    println!("== 칸 사이 재고 이동 ==");
    let mut bins = vec![
        Bin { place: "A-1", qty: 40 },
        Bin { place: "A-2", qty: 15 },
        Bin { place: "B-1", qty: 0 },
    ];
    for (from, to, amount) in [(0, 2, 25), (2, 1, 30), (1, 1, 5), (2, 0, 10), (0, 7, 1)] {
        match transfer(&mut bins, from, to, amount) {
            Ok(()) => println!("이동 {from}->{to} x{amount}: 성공"),
            Err(e) => println!("이동 {from}->{to} x{amount}: 실패 ({e})"),
        }
    }
    for bin in &bins {
        println!("{} = {}", bin.place, bin.qty);
    }
    println!("합계 = {}", bins.iter().map(|b| b.qty).sum::<u32>());

    println!("== FFI ==");
    println!("abs(-42) = {}", abs(-42));
    let name = c"BOLT-M8";
    println!("strlen(\"BOLT-M8\") = {}", c_len(name));
}

줄별 해설

  • unsafe extern "C" { ... }: C 함수 두 개를 선언한다. 2024 에디션에서는 unsafe 없는 extern 블록이 오류다. safe fn abs 는 호출할 때 unsafe 가 필요 없고, strlen 은 유효하지 않은 포인터를 받을 수 있으므로 unsafe 호출로 남는다.
  • my_split_at_mut 의 assert!: 불변식 mid <= len 을 입구에서 검사한다. 어긴 입력은 정의되지 않은 동작이 아니라 panic 으로 끝난다. 반환 타입의 수명은 입력 하나뿐이므로 생략 규칙으로 입력 슬라이스와 같은 수명이 붙는다.
  • items.as_mut_ptr(): 슬라이스의 시작 주소를 *mut T 로 얻는다. 포인터를 얻는 일은 안전하다.
  • unsafe { ( ... ) } 블록: from_raw_parts_mut 두 번과 ptr.add(mid) 가 들어 있다. 왼쪽은 ptr 에서 mid 개, 오른쪽은 ptr.add(mid) 에서 len - mid 개다. SAFETY 주석은 유효성, 배타성, 범위의 세 조건을 각각 적었다.
  • pair_mut: 같은 인덱스나 범위 밖 인덱스를 None 으로 거른다. i < j 이면 j 를 경계로 나눠 왼쪽에서 i, 오른쪽의 첫 원소를 꺼낸다. 반대 경우는 i 를 경계로 나누므로 i 번 칸이 오른쪽의 첫 원소이고 j 번 칸이 왼쪽에 있다. 반환 순서는 항상 (i 번 칸, j 번 칸)이다.
  • transfer: ok_or_else 와 ? 로 None 을 오류 문자열로 바꾼다. 수량을 확인한 뒤 원본에서 빼고 목적지에 더한다. 두 변수는 서로 다른 칸에 대한 가변 참조이며 컴파일러가 이를 문제없이 받아들인다.
  • raw_pointer_tour: *ptr.add(1) 로 읽고 *ptr.add(2) += 5 로 쓴다. 포인터를 마지막으로 쓴 뒤에 stock[2] 를 읽으므로 두 접근이 엇갈리지 않는다. 널 포인터는 is_null 로 확인하고, as_ref 는 널이면 None 을 돌려준다.
  • c_len: CStr 를 받는 안전한 함수다. 호출자는 NUL 종료를 신경 쓰지 않아도 된다.
  • main 의 left·right: 두 조각은 동시에 가변으로 살아 있다. 마지막으로 쓴 뒤에는 빌림이 끝나므로 data 를 다시 읽을 수 있다.
  • for (from, to, amount) in [...]: 성공 두 건과 실패 세 건(재고 부족, 같은 칸, 범위 밖)을 순서대로 처리한다.

실행 결과

$ cargo run
== 원시 포인터 ==
둘째 칸 = 20, 셋째 칸 = 35
널 포인터인가: true
as_ref 결과: None
== split_at_mut 직접 구현 ==
left=[10, 2] right=[30, 4, 5]
data=[10, 2, 30, 4, 5]
== 칸 사이 재고 이동 ==
이동 0->2 x25: 성공
이동 2->1 x30: 실패 (B-1 재고 부족: 보유 25, 요청 30)
이동 1->1 x5: 실패 (칸 번호가 잘못됐다: 1 -> 1)
이동 2->0 x10: 성공
이동 0->7 x1: 실패 (칸 번호가 잘못됐다: 0 -> 7)
A-1 = 25
A-2 = 15
B-1 = 15
합계 = 55
== FFI ==
abs(-42) = 42
strlen("BOLT-M8") = 7

이동 후에도 합계가 55 로 유지된다. 수량을 옮기는 연산이 어느 경로에서도 재고를 만들거나 없애지 않는다는 뜻이다.

실무에서 자주 틀리는 것

검사 없이 안전한 함수로 공개한다

틀린 코드:

pub fn get_fast(v: &[u32], i: usize) -> u32 {
    unsafe { *v.get_unchecked(i) }
}

i 가 범위를 벗어나면 정의되지 않은 동작이다. 안전한 함수의 시그니처가 어떤 입력에도 안전하다고 약속하는데 코드는 그 약속을 지키지 않는다. 고친 코드는 검사를 함수 안으로 가져온다.

pub fn get_fast(v: &[u32], i: usize) -> Option<u32> {
    v.get(i).copied()
}

검사 비용이 정말 문제라면 측정으로 확인한 뒤 pub unsafe fn 으로 바꾸고 # Safety 문서에 i < v.len() 조건을 적는다.

재할당된 뒤에 옛 포인터를 쓴다

틀린 코드:

let mut v = vec![1, 2, 3];
let p = v.as_ptr();
v.push(4);
let x = unsafe { *p };

push 가 용량을 넘기면 벡터는 새 메모리로 옮겨 가고 p 는 해제된 곳을 가리킨다. 포인터에는 수명이 없어 컴파일러가 알려 주지 않는다. 고친 코드는 변경을 모두 끝낸 뒤에 포인터를 얻거나, 포인터가 필요 없으면 v[0] 으로 읽는다.

let mut v = vec![1, 2, 3];
v.push(4);
let p = v.as_ptr();
let x = unsafe { *p };

unsafe fn 본문 전체를 암묵적으로 unsafe 로 본다

틀린 코드:

unsafe fn read_at(p: *const u32, i: usize) -> u32 {
    *p.add(i)
}

2024 에디션에서는 unsafe_op_in_unsafe_fn 경고가 나온다. 요지는 역참조와 add 호출이 unsafe 블록을 요구한다는 것이다. 함수가 unsafe 라는 사실은 호출자에게 전하는 말이고, 본문의 위험한 연산은 따로 표시해야 한다. 고친 코드는 블록과 SAFETY 주석을 둔다.

/// # Safety
/// p 부터 i + 1 개의 초기화된 u32 가 유효해야 한다.
unsafe fn read_at(p: *const u32, i: usize) -> u32 {
    // SAFETY: 호출자가 p.add(i) 가 유효한 u32 를 가리킴을 보증한다.
    unsafe { *p.add(i) }
}

extern 블록에 unsafe 를 빼먹는다

틀린 코드:

extern "C" {
    fn strlen(s: *const std::ffi::c_char) -> usize;
}

2024 에디션에서는 extern blocks must be unsafe 라는 오류가 난다. 선언이 실제 함수와 맞는지 컴파일러가 확인할 수 없으므로 선언 자체를 unsafe 로 표시해야 한다는 뜻이다. 고친 코드는 unsafe extern "C" { ... } 로 쓴다.

한눈에 보기

unsafe 를 다루는 도구와 쓰임
도구쓰는 곳누가 보증하는가이 장의 예
unsafe { } 블록위험한 연산 한 곳블록을 쓴 사람from_raw_parts_mut
unsafe fn입구에서 막을 수 없는 조건호출자read_at
SAFETY 주석unsafe 블록 바로 위읽는 사람이 검증각 unsafe 블록
# Safety 문서unsafe fn 정의호출자read_at
assert! 등 입구 검사안전한 함수 안함수 작성자mid <= len
unsafe externC 함수 선언선언한 사람strlen
safe fn인자에 관계없이 안전한 외부 함수선언한 사람abs

연습 문제

  1. my_split_at_mut 에서 assert! 줄을 지우면 어떤 호출이 정의되지 않은 동작이 되는가. 그 경우 안전한 함수라는 표시는 왜 거짓이 되는지 설명하라.
  2. my_split_at_mut 을 이용해 fn swap_across<T>(items: &mut [T], mid: usize) 를 작성하라. 왼쪽 조각의 마지막 원소와 오른쪽 조각의 첫 원소를 맞바꾸고, 어느 한쪽이 비어 있으면 아무것도 하지 않는다.
  3. abs 선언에서 safe 를 빼면 main 의 호출 줄을 어떻게 고쳐야 하는가. 고친 줄에 SAFETY 주석도 적어라.
  4. 다음 함수는 안전한 함수로 공개하기에 적합하지 않다. 이유를 설명하고 두 가지 방식으로 고쳐라.
    fn first_byte(s: &[u8]) -> u8 {
        unsafe { *s.as_ptr() }
    }

정답과 해설

  1. mid 가 len 보다 큰 호출, 예를 들어 길이 5 슬라이스에 mid = 8 이면 오른쪽 조각의 길이 계산 len - mid 가 언더플로를 일으키고, 왼쪽 조각은 할당 범위를 넘어선다. 어느 쪽이든 유효하지 않은 슬라이스가 만들어진다. 함수의 시그니처는 어떤 입력에도 안전하다고 약속하는데 일부 입력에서 정의되지 않은 동작이 생기므로 그 약속이 거짓이다. 이 때문에 검사는 unsafe 블록 안이 아니라 입구에 있어야 한다.
  2. 두 조각을 얻은 뒤 각각 끝과 처음을 꺼내 맞바꾼다.
    fn swap_across<T>(items: &mut [T], mid: usize) {
        let (left, right) = my_split_at_mut(items, mid);
        if let (Some(a), Some(b)) = (left.last_mut(), right.first_mut()) {
            std::mem::swap(a, b);
        }
    }
    last_mut 과 first_mut 은 비어 있으면 None 을 돌려주므로 if let 이 두 경우를 모두 처리한다. 이 함수에는 unsafe 가 없다.
  3. 호출을 unsafe 블록으로 감싼다.
    // SAFETY: abs 는 어떤 i32 를 받아도 부작용 없이 값을 돌려주는 C 표준 함수다.
    println!("abs(-42) = {}", unsafe { abs(-42) });
  4. 빈 슬라이스를 넘기면 s.as_ptr() 는 읽을 수 있는 바이트를 가리키지 않는데도 역참조된다. 입력 하나로 정의되지 않은 동작이 생기므로 안전한 함수로 공개하면 안 된다. 첫째 방법은 안전하게 만드는 것이다.
    fn first_byte(s: &[u8]) -> Option<u8> {
        s.first().copied()
    }
    둘째 방법은 조건을 호출자에게 넘기는 것이다.
    /// # Safety
    /// s 는 비어 있지 않아야 한다.
    unsafe fn first_byte(s: &[u8]) -> u8 {
        // SAFETY: 호출자가 s 가 비어 있지 않음을 보증한다.
        unsafe { *s.as_ptr() }
    }
    실무에서는 첫째 방법이 거의 항상 알맞다. unsafe 를 쓸 이유가 없는 곳에서는 쓰지 않는 것이 가장 좋은 경계다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.