unsafe 의 경계 - 안전한 추상화 만들기
이 장에서 배우는 것
지금까지 쓴 코드는 모두 컴파일러가 안전성을 검사해 주는 영역 안에 있었다. 이 장은 그 바깥 경계를 다룬다. 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 블록 안에서 계속 작동한다.
| 허용되는 일 | 예 | 보증할 조건 |
|---|---|---|
| 원시 포인터 역참조 | *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) 두 구간으로 슬라이스를 다시 만들면, 두 조각은 서로 겹치지 않는다.
컴파일러는 이 겹치지 않음을 증명하지 못하므로 from_raw_parts_mut 호출에는 unsafe 가 필요하다. 이 함수가 요구하는 조건은 포인터가 유효하고, 길이만큼의 원소가 모두 한 할당 안에 있으며, 반환되는 가변 슬라이스가 다른 참조와 겹치지 않는 것이다. mid <= len 만 검사하면 세 조건이 모두 충족된다. 이 검사를 함수 안에서 하기 때문에 함수 자체는 unsafe fn 이 아닌 일반 함수로 공개할 수 있다.
이것이 안전한 추상화(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 { } 블록 | 위험한 연산 한 곳 | 블록을 쓴 사람 | from_raw_parts_mut |
unsafe fn | 입구에서 막을 수 없는 조건 | 호출자 | read_at |
| SAFETY 주석 | unsafe 블록 바로 위 | 읽는 사람이 검증 | 각 unsafe 블록 |
# Safety 문서 | unsafe fn 정의 | 호출자 | read_at |
assert! 등 입구 검사 | 안전한 함수 안 | 함수 작성자 | mid <= len |
unsafe extern | C 함수 선언 | 선언한 사람 | strlen |
safe fn | 인자에 관계없이 안전한 외부 함수 | 선언한 사람 | abs |
연습 문제
my_split_at_mut에서assert!줄을 지우면 어떤 호출이 정의되지 않은 동작이 되는가. 그 경우 안전한 함수라는 표시는 왜 거짓이 되는지 설명하라.my_split_at_mut을 이용해fn swap_across<T>(items: &mut [T], mid: usize)를 작성하라. 왼쪽 조각의 마지막 원소와 오른쪽 조각의 첫 원소를 맞바꾸고, 어느 한쪽이 비어 있으면 아무것도 하지 않는다.abs선언에서safe를 빼면main의 호출 줄을 어떻게 고쳐야 하는가. 고친 줄에 SAFETY 주석도 적어라.- 다음 함수는 안전한 함수로 공개하기에 적합하지 않다. 이유를 설명하고 두 가지 방식으로 고쳐라.
fn first_byte(s: &[u8]) -> u8 { unsafe { *s.as_ptr() } }
정답과 해설
mid가len보다 큰 호출, 예를 들어 길이 5 슬라이스에mid = 8이면 오른쪽 조각의 길이 계산len - mid가 언더플로를 일으키고, 왼쪽 조각은 할당 범위를 넘어선다. 어느 쪽이든 유효하지 않은 슬라이스가 만들어진다. 함수의 시그니처는 어떤 입력에도 안전하다고 약속하는데 일부 입력에서 정의되지 않은 동작이 생기므로 그 약속이 거짓이다. 이 때문에 검사는 unsafe 블록 안이 아니라 입구에 있어야 한다.- 두 조각을 얻은 뒤 각각 끝과 처음을 꺼내 맞바꾼다.
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 가 없다. - 호출을 unsafe 블록으로 감싼다.
// SAFETY: abs 는 어떤 i32 를 받아도 부작용 없이 값을 돌려주는 C 표준 함수다. println!("abs(-42) = {}", unsafe { abs(-42) }); - 빈 슬라이스를 넘기면
s.as_ptr()는 읽을 수 있는 바이트를 가리키지 않는데도 역참조된다. 입력 하나로 정의되지 않은 동작이 생기므로 안전한 함수로 공개하면 안 된다. 첫째 방법은 안전하게 만드는 것이다.
둘째 방법은 조건을 호출자에게 넘기는 것이다.fn first_byte(s: &[u8]) -> Option<u8> { s.first().copied() }
실무에서는 첫째 방법이 거의 항상 알맞다. unsafe 를 쓸 이유가 없는 곳에서는 쓰지 않는 것이 가장 좋은 경계다./// # Safety /// s 는 비어 있지 않아야 한다. unsafe fn first_byte(s: &[u8]) -> u8 { // SAFETY: 호출자가 s 가 비어 있지 않음을 보증한다. unsafe { *s.as_ptr() } }