콘텐츠로 이동

Rust unsafe

이 강좌에서 이야기한 모든 보장, 그러니까 댕글링 참조자가 없고, 데이터 경쟁이 없고, 범위를 벗어난 접근이 없다는 건 모두 컴파일러가 지켜 줘요. unsafe는 컴파일러에게 “네가 안전한지 확인할 수 없는 일을 할게. 대신 책임은 내가 질게”라고 말하는 방법이에요.

unsafe가 Rust의 안전 검사를 통째로 꺼 버린다고 오해하는 경우가 많아요. 그렇지 않아요. unsafe 블록 안에서는 평소에 금지된 일을 딱 다섯 가지 더 할 수 있어요.

  1. 원시 포인터(raw pointer) 역참조하기
  2. unsafe 함수 호출하기
  3. 가변 static 변수에 접근하거나 수정하기
  4. unsafe 트레이트 구현하기
  5. union의 필드에 접근하기

그 밖의 모든 것, 즉 대여 규칙, 이동(move) 규칙, 타입 검사는 unsafe 코드 안에서도 그대로 적용돼요. unsafe는 다섯 가지 특정 작업을 위한 메스이지, 안전 장치를 끄는 스위치가 아니에요.

Rust의 일반 참조자(&와 &mut)는 언제나 올바르게 정렬된 유효한 데이터를 가리켜요. 이게 컴파일러가 보장하는 것 중 하나예요. 원시 포인터(*const T나 *mut T)는 그 보장을 내려놓아요. 그래서 원시 포인터를 읽으려면 unsafe가 필요해요.

fn main() {
let value = 5;
let pointer = &value as *const i32;
unsafe {
println!("{}", *pointer);
}
}

실행 결과:

5
▶ 직접 실행해 보기

원시 포인터를 만드는 건 안전한 코드에서도 할 수 있어요. unsafe가 필요한 건 역참조(포인터가 가리키는 값을 읽기)할 때뿐이에요. 컴파일러가 더 이상 그 포인터가 유효한지 확인할 수 없으니까요.

어떤 함수는 그 자체에 unsafe가 붙어 있어요. 컴파일러가 대신 확인해 줄 수 없는 요구 사항이 있기 때문이에요. 이런 함수를 호출하는 건 “그 요구 사항은 내가 직접 확인했어”라고 약속하는 거예요.

unsafe fn dangerous() {
println!("이 함수는 여러분이 요구 사항을 확인했다고 믿어요");
}
fn main() {
unsafe {
dangerous();
}
}

실행 결과:

이 함수는 여러분이 요구 사항을 확인했다고 믿어요
▶ 직접 실행해 보기

표준 라이브러리에는 실제로 호출할 수 있는 unsafe 함수들이 있어요. 그중 하나가 슬라이스의 get_unchecked예요. numbers[i]처럼 동작하지만, 범위 검사를 건너뛰어요. i가 범위를 벗어나도 패닉하지 않고, 그 자리에 우연히 있는 메모리를 그냥 읽어요. 이걸 정의되지 않은 동작(undefined behavior)이라고 해요. 프로그램이 죽을 수도 있고, 쓰레기 값을 출력할 수도 있고, 멀쩡해 보이다가 어느 날 갑자기 문제가 생길 수도 있어요.

그래서 이 함수를 호출하는 규칙은 “유효하다고 확실히 아는 인덱스로만 호출하기”예요. 보통은 평범하고 안전한 함수 안에서 그 규칙을 직접 확인하는 패턴을 써요.

fn first_or_zero(numbers: &[i32]) -> i32 {
if numbers.is_empty() {
return 0;
}
// SAFETY: 슬라이스가 비어 있지 않으니 인덱스 0은 범위 안이에요.
unsafe { *numbers.get_unchecked(0) }
}
fn main() {
println!("{}", first_or_zero(&[7, 8, 9]));
println!("{}", first_or_zero(&[]));
}

실행 결과:

7
0
▶ 직접 실행해 보기

호출하는 쪽에서 보이는 걸 보세요. fn first_or_zero(numbers: &[i32]) -> i32예요. 시그니처에 unsafe가 없고, 잘못 호출할 방법도 없어요. 함수가 unsafe 블록에 닿기 전에 규칙부터 확인하니까요. unsafe는 작게, 안쪽에 숨겨져 있어요.

경험 많은 Rust 개발자들이 지키는 습관이 두 가지 있어요.

  • 모든 unsafe 블록 위에 // SAFETY: 주석을 쓰세요. 규칙이 왜 지켜지는지 적는 거예요. 다음에 코드를 읽을 사람과, 미래의 자신을 위해서요.
  • unsafe 블록은 최대한 작게 유지하세요. 그래야 다시 확인해야 할 코드가 줄어요.

솔직히 말하면, 실제 코드에서는 이 함수를 이렇게 쓰지 않을 거예요. numbers.first()가 같은 일을 안전하게 해 주고, 컴파일러는 필요 없다고 증명할 수 있는 범위 검사를 아주 잘 없애 주거든요. 하지만 이 패턴(규칙을 확인한 다음, 안전한 함수 안에서 unsafe를 호출하기)은 Vec, String 같은 타입이 만들어지는 방식 그대로예요. FFI 페이지에서도 같은 방법으로 C 함수를 감싸요.

Rust가 안전이 전부라면, 왜 이런 걸 허용할까요? 실제로는 완벽하게 안전한데도 컴파일 시간에 안전하다고 증명하는 게 정말로 불가능한 일들이 있기 때문이에요. 하드웨어와 직접 대화하기, C 라이브러리 호출하기(FFI 참고), 특정 고성능 자료 구조 만들기 같은 것들이요. unsafe 덕분에 표준 라이브러리(그리고 드물게는 여러분의 코드)가 이런 저수준 부품을 구현할 수 있고, 그 위에 쌓이는 나머지 코드는 모두 평범하고 완전히 검사받는 안전한 Rust로 남아요.

unsafe를 쓴다고 해서 코드에 버그가 생기는 건 아니에요. 그 블록 안에서는 컴파일러가 특정 종류의 실수를 더 이상 잡아 줄 수 없으니, 같은 보장을 여러분이 직접 지켜야 한다는 뜻이에요. 경험 많은 Rust 개발자 대부분은 unsafe 코드를 직접 쓰는 일이 아주 적어요. unsafe는 보통 라이브러리 맨 아래쪽의 작고 꼼꼼히 검토된 부분에만 있고, 다른 모두가 쓸 수 있도록 안전한 API로 감싸져 있어요.

let mut score = 10;을 만들고, 그 값을 가리키는 *mut i32 원시 포인터를 만든 다음, 포인터를 통해 5를 더하세요. 그리고 score를 출력하세요. 힌트: 포인터는 &mut score as *mut i32로 만들고, unsafe 블록 안에서 *pointer += 5라고 쓰세요.

정답 보기
fn main() {
let mut score = 10;
let pointer = &mut score as *mut i32; // 만드는 건 안전해요
unsafe {
*pointer += 5; // 포인터를 통해 쓰는 건 안전하지 않아요
}
println!("점수 = {score}");
}

실행 결과:

점수 = 15
▶ 직접 실행해 보기

score가 아직 살아 있고, 값을 쓰는 동안 다른 누구도 score를 쓰지 않으니 실제로는 안전해요. unsafe를 쓰는 순간, 이런 약속을 지키는 건 여러분의 몫이에요.

fn get_or(numbers: &[i32], index: usize, default: i32) -> i32를 작성하세요. index 위치의 항목을 반환하고, 인덱스가 범위를 벗어나면 default를 반환해요. 값을 읽을 때는 get_unchecked를 쓰고, 이 함수가 규칙을 어기는 방식으로는 절대 호출될 수 없게 만드세요. 힌트: 먼저 index < numbers.len()을 비교하고, 그게 참일 때만 get_unchecked를 호출하세요.

정답 보기
fn get_or(numbers: &[i32], index: usize, default: i32) -> i32 {
if index < numbers.len() {
// SAFETY: 방금 index가 범위 안인지 확인했어요.
unsafe { *numbers.get_unchecked(index) }
} else {
default
}
}
fn main() {
let numbers = [10, 20, 30];
println!("{}", get_or(&numbers, 1, -1));
println!("{}", get_or(&numbers, 5, -1));
}

실행 결과:

20
-1
▶ 직접 실행해 보기

인덱스 5는 unsafe 블록에 절대 닿지 않으니 안전하게 처리돼요. 비교해 보면, 완전히 안전한 버전은 numbers.get(index).copied().unwrap_or(default)이고, 평소에는 이렇게 쓰면 돼요.

  • unsafe로 할 수 있게 되는 작업은 딱 다섯 가지예요(주로 원시 포인터와 unsafe 함수 호출). 나머지는 여전히 모두 검사받아요.
  • 원시 포인터(*const T / *mut T)는 안전한 코드에서도 만들 수 있어요. 역참조할 때만 unsafe가 필요해요.
  • unsafe는 규칙부터 확인하는 작고 안전한 함수로 감싸고, 왜 문제가 없는지 // SAFETY: 주석으로 설명하세요.
  • unsafe는 저수준 코드(하드웨어 접근, C 연동)를 만들 수 있도록 존재하고, 그걸 쓰는 코드는 안전하게 유지돼요.
  • 초보자라면 직접 쓸 일은 드물어요. 먼저 안전한 추상화를 찾아보세요.

이해도 점검

1. unsafe 블록이 실제로 허용해 주는 건 무엇일까요?

2. 안전한 코드에서 원시 포인터를 만들 수 있을까요?

3. 초보자라면 unsafe를 직접 쓸 일이 얼마나 자주 있을까요?

4. unsafe 코드를 쓰면 프로그램에 버그가 생기는 걸까요?

점수: 0 / 4