안전하지 않은(unsafe) Rust 탐색: 언제 사용해야 하는가, 왜 중요한가, 그리고 안전하게 사용하는 방법
Emily Parker
Product Engineer · Leapcell

서론
Rust는 강력한 타입 시스템과 소유권 모델로 유명하며, 비교할 수 없는 메모리 안전 보장을 제공합니다. 이를 통해 개발자는 다른 언어에서 흔히 발생하는 버그들을 상당 부분 제거하고, 자신감을 가지고 견고하고 동시적인 애플리케이션을 구축할 수 있습니다. 하지만 세상은 항상 완벽하게 안전하지만은 않습니다. 베어 메탈(bare metal)과 상호 작용하거나, 성능을 극한으로 최적화하거나, 외부 코드와 연동해야 할 때는 Rust의 안전 검사의 보호 범위를 벗어나야 할 때가 있습니다. 이것이 바로 "unsafe Rust"의 영역입니다. 그 이름 자체만으로도 안전을 중시하는 Rust 개발자에게는 오싹함을 안겨줄 수 있지만, unsafe
는 혼돈으로의 초대장이 아닙니다. 오히려 컴파일러가 자동으로 보장할 수 없는 특정 불변성을 프로그래머가 유지할 책임을 지겠다는 정밀하게 정의된 구성 요소입니다. 이 글에서는 unsafe Rust의 논리적 근거를 파헤치고, 기본적인 메커니즘을 탐색하며, 가장 중요하게는 이를 안전하고 책임감 있게 사용하는 방법에 대해 안내할 것입니다.
unsafe Rust의 기둥 이해하기
"어떻게"에 대해 알아보기 전에, Rust에서 unsafe
가 실제로 무엇을 의미하는지와 이를 통해 얻을 수 있는 핵심 개념들을 명확히 해봅시다. 본질적으로 unsafe
는 Rust의 타입 시스템이나 소유권 규칙을 우회하는 것이 아닙니다. 그것은 여러분, 즉 프로그래머가 컴파일러가 더 이상 자동으로 보장할 수 없는 특정 불변성을 유지할 책임을 진다는 선언입니다.
unsafe
를 통해 얻을 수 있는 핵심 기능은 다음과 같습니다:
- 원시 포인터 역참조: 원시 포인터(
*const T
및*mut T
)는unsafe
Rust의 기본입니다. 참조(&T
및&mut T
)와 달리 원시 포인터는 null이거나, 무효한 메모리를 가리키거나, 컴파일러의 불만 없이 별칭 규칙(aliasing rules)을 위반할 수 있습니다. 이를 역참조하는 것은 극도의 주의를 기울여야 하는 위험한 작업입니다. unsafe
함수 호출 또는unsafe
트레이트 구현:unsafe
로 표시된 함수는 컴파일러가 확인할 수 없는 사전 조건을 가지고 있습니다. 이러한 사전 조건을 충족하는 것은 호출자의 책임입니다. 마찬가지로,unsafe
트레이트를 구현한다는 것은 해당 트레이트가 보장하는 특정 불변성을 유지함을 의미합니다.static mut
변수 접근 또는 수정:static mut
변수는 전역적이고 변경 가능한 상태를 나타냅니다. 이러한 변수의 사용은 동기화 부족 및 데이터 경쟁 가능성으로 인해 본질적으로 위험하므로 직접 접근하거나 수정하는 것은unsafe
합니다.union
필드 접근:union
은 C의 union과 유사하게 여러 필드가 동일한 메모리 위치를 차지하도록 허용합니다.union
의 필드에 접근하는 것은 올바른 변이가 활성 상태인지 확인해야 쓰레기 데이터를 읽는 것을 피할 수 있으므로unsafe
합니다.
unsafe
는 주로 메모리 안전과 관련된 몇 가지 컴파일 타임 검사만 비활성화한다는 점을 이해하는 것이 중요합니다. 이는 컴파일러가 보로 체커(borrow checker)를 완전히 비활성화하거나, 안전한 코드가 unsafe 블록과 상호 작용할 때 발생하는 데이터 경쟁과 같은 다른 Rust 보장을 비활성화하는 것을 의미하지는 않습니다. 단지 특정 불변성에 대한 책임을 프로그래머에게 위임할 뿐입니다.
unsafe
가 필요한 경우 및 안전하게 사용하는 방법
unsafe
키워드는 무차별적으로 사용되는 도구가 아닙니다. 그 적용은 의도적이고 잘 정당화된 결정이어야 합니다. unsafe
가 필수 불가결해지는 주요 시나리오는 다음과 같으며, 이를 책임감 있게 사용하는 방법을 보여주는 예시를 제공합니다.
1. 외부 함수 인터페이스(FFI)와의 연동
C 라이브러리 또는 운영 체제 API와 상호 작용할 때 unsafe
Rust는 종종 필수적입니다. 이러한 외부 함수는 Rust의 안전 보장을 따르지 않으며, 우리는 그 격차를 해소해야 합니다.
예시: 가변 메모리를 조작하는 C 함수 호출.
정수 배열의 각 요소를 증가시키는 modify_array
함수를 노출하는 C 라이브러리가 있다고 가정해 보겠습니다.
// lib.h void modify_array(int* arr, int len); // lib.c #include <stdio.h> void modify_array(int* arr, int len) { for (int i = 0; i < len; ++i) { arr[i] += 1; } }
Rust에서 이를 호출하려면 extern "C"
블록과 unsafe
를 사용합니다.
extern "C" { // C 함수의 시그니처를 선언합니다. fn modify_array(arr: *mut i32, len: i32); } fn main() { let mut data = vec![1, 2, 3, 4, 5]; let len = data.len() as i32; // 포인터가 유효하고 길이를 정확하게 유지해야 합니다. // C 함수는 유효하고 변경 가능한 포인터와 정확한 길이를 가정합니다. unsafe { // 벡터 버퍼 시작에 대한 변경 가능한 원시 포인터 가져오기 modify_array(data.as_mut_ptr(), len); } println!("Modified data: {:?}", data); // 출력: Modified data: [2, 3, 4, 5, 6] }
이 예시에서 unsafe
블록은 다음을 책임짐을 명시적으로 나타냅니다:
data.as_mut_ptr()
가 유효하고 null이 아닌 변경 가능한i32
배열에 대한 포인터를 반환합니다.len
이arr
를 통해 접근 가능한 요소의 수를 정확하게 나타냅니다.- C 함수
modify_array
가 Rust의 메모리 모델(예: 할당된 버퍼 외부로 쓰기)을 위반하지 않습니다.
2. 저수준 데이터 구조 구현
성능이 중요한 코드나 사용자 정의 Vec
또는 HashMap
과 같은 기본 데이터 구조를 구축할 때 unsafe
는 메모리 레이아웃 및 할당에 필요한 제어를 제공할 수 있습니다.
예시: 기본적인 unsafe
사용자 정의 Vec
(설명을 위한 단순화).
Rust의 Vec
은 내부적으로 재할당 및 원시 포인터 조작을 위해 unsafe
를 사용합니다. 개념적 스니펫은 다음과 같습니다.
use std::alloc::{alloc, dealloc, Layout}; use std::ptr; struct MyVec<T> { ptr: *mut T, cap: usize, len: usize, } impl<T> MyVec<T> { fn new() -> Self { MyVec { ptr: ptr::NonNull::dangling().as_ptr(), // 비어 있는 경우 플레이스홀더 cap: 0, len: 0, } } fn push(&mut self, item: T) { if self.len == self.cap { self.grow(); } // 안전: self.len < self.cap 임을 확인했습니다. // self.ptr는 self.len 위치에서 할당되고 쓰기에 유효함이 보장됩니다. unsafe { ptr::write(self.ptr.add(self.len), item); } self.len += 1; } // 안전: 호출자는 `index < self.len`임을 보장해야 합니다. unsafe fn get_unchecked(&self, index: usize) -> &T { &*self.ptr.add(index) } fn grow(&mut self) { let new_cap = if self.cap == 0 { 1 } else { self.cap * 2 }; let layout = Layout::array::<T>(new_cap).unwrap(); // 안전: 이전 ptr은 `alloc` 또는 `realloc`으로 할당되었습니다. // new_cap은 유효한 크기입니다. let new_ptr = unsafe { if self.cap == 0 { alloc(layout) } else { let old_layout = Layout::array::<T>(self.cap).unwrap(); std::alloc::realloc(self.ptr as *mut u8, old_layout, layout.size()) } } as *mut T; // 할당 실패 처리 if new_ptr.is_null() { std::alloc::handle_alloc_error(layout); } // 안전: `new_ptr`는 유효하며 `new_cap` 용량의 메모리를 가리킵니다. // 이전 `ptr`은 `self.cap` 항목에 대해 유효했습니다. // `new_ptr`가 null인 경우 항목을 두 번 드롭하지 않도록 보장합니다. let old_ptr = self.ptr; self.ptr = new_ptr; self.cap = new_cap; // 항목이 이동된 경우(즉, 재할당이 메모리를 이동시킨 경우), // 이전 버퍼에 항목이 있었다면 수동으로 복사해야 할 수 있지만, // `Vec`과 유사한 간단한 구조의 경우 `realloc`은 일반적으로 이를 처리합니다. // 또는 `ptr::copy`를 사용해야 합니다. 여기서는 단순화를 위해 직접 `realloc`을 가정합니다. } } impl<T> Drop for MyVec<T> { fn drop(&mut self) { if self.cap != 0 { // 안전: `ptr`은 `alloc` 또는 `realloc`으로 할당되었으며, // `cap`은 해당 용량입니다. // 항목은 메모리를 할당 해제하기 전에 드롭해야 합니다. while self.len > 0 { self.len -= 1; unsafe { ptr::read(self.ptr.add(self.len)); // 항목에 대한 drop 호출 } } let layout = Layout::array::<T>(self.cap).unwrap(); unsafe { dealloc(self.ptr as *mut u8, layout); } } } } fn main() { let mut my_vec = MyVec::new(); my_vec.push(10); my_vec.push(20); my_vec.push(30); println!("Len: {}", my_vec.len); // 안전: 인덱스 1이 유효함을 알고 있습니다. println!("Element at 1: {}", unsafe { my_vec.get_unchecked(1) }); }
이상의 MyVec
은 unsafe
가 다음과 같이 사용되는 방식을 명확하게 보여줍니다:
ptr::write
: 원시 포인터에 쓰기. 포인터가 유효하고 경계 내에 있는지 확인합니다.ptr::read
: 원시 포인터에서 읽기 (암묵적으로 값을 드롭합니다).- 메모리 할당 (
alloc
,realloc
,dealloc
):std::alloc
의 이 함수들은 원시 포인터를 반환하며, 레이아웃과 크기를 신중하게 처리해야 하므로unsafe
를 요구합니다. MyVec::get_unchecked
: 이 함수는index < self.len
을 보장해야 하는 호출자가 필요하므로unsafe
로 표시됩니다.index
가 범위를 벗어나면self.ptr.add(index)
를 역참조하는 것은 정의되지 않은 동작(Undefined Behavior)이 됩니다.
3. 고급 최적화 작성 (특정 CPU 명령어 컴파일)
때로는 최고 성능을 달성하기 위해 특정 CPU 명령어(예: SIMD 명령어)에 직접 매핑되는 내장 함수를 사용해야 할 수도 있습니다. 이러한 함수는 종종 원시 메모리 청크를 다루며 본질적으로 unsafe
합니다.
예시: SIMD 내장 함수 사용 (개념적).
Rust 안정 버전은 현재 std::arch
모듈을 통해 SIMD를 제공하며, 이는 unsafe
API입니다.
#![allow(non_snake_case)] // SIMD 내장 함수 명명 규칙용 #[cfg(target_arch = "x86_64")] use std::arch::x86_64::*; fn sum_array_simd(data: &[i32]) -> i32 { #[cfg(target_arch = "x86_64")] { if is_x86_feature_detected!("sse") { // SIMD를 다루고 있음을 확인합니다. 이는 특정 정렬 및 유효한 메모리를 요구합니다. unsafe { let mut sum_vec = _mm_setzero_si128(); // 128비트 영 벡터 초기화 let chunks = data.chunks_exact(4); // 한 번에 4개의 i32(128비트) 처리 let remainder = chunks.remainder(); for chunk in chunks { // 안전: `chunk`는 4개의 i32, 정렬됨, 유효한 메모리로 보장됩니다. // `_mm_loadu_si128`는 정렬되지 않은 주소에서 128비트를 로드합니다. let chunk_vec = _mm_loadu_si128(chunk.as_ptr() as *const _); sum_vec = _mm_add_epi32(sum_vec, chunk_vec); // 벡터 덧셈 } // 최종 벡터의 요소 합산 let mut final_sum = _mm_extract_epi32(sum_vec, 0) + _mm_extract_epi32(sum_vec, 1) + _mm_extract_epi32(sum_vec, 2) + _mm_extract_epi32(sum_vec, 3); // 나머지 요소 처리 for &val in remainder { final_sum += val; } return final_sum; } } } // x86_64가 아니거나 SSE가 없는 경우 대체 data.iter().sum() } fn main() { let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; let total = sum_array_simd(&numbers); println!("SIMD sum: {}", total); // 출력: SIMD sum: 55 }
여기서 unsafe
는 SIMD 내장 함수가 특정 메모리 레이아웃, 정렬, 직접 레지스터 접근을 가정하는 매우 낮은 수준에서 작동하기 때문에 필요합니다. 프로그래머는 다음을 보장합니다:
- 입력
data
포인터가 유효합니다. chunk
as_ptr()
캐스트가 내장 함수에 대해 올바르게 수행됩니다._mm_loadu_si128
및_mm_add_epi32
함수가 해당 사전 조건에 따라 올바르게 사용됩니다.
안전한 추상화
unsafe
를 사용하는 가장 좋은 방법은 이를 캡슐화하는 것입니다. 이는 저수준, 성능이 중요한, 또는 FFI 종속적인 기능의 일부를 구현하기 위해 unsafe
를 사용한 다음 안전한 API로 래핑하는 것을 의미합니다. 목표는 unsafe
코드의 양을 최소화하고 안전한 Rust 코드가 정의되지 않은 동작(UB)을 트리거하지 않고 쉽게 사용할 수 있도록 하는 것입니다.
예를 들어, 위에서 만든 MyVec
은 unsafe fn get_unchecked
를 가지고 있습니다. 안전한 Vec
은 범위를 확인하고 Option<&T>
를 반환하는 안전한 get
메서드를 제공할 것입니다.
impl<T> MyVec<T> { // 안전한 공개 API pub fn get(&self, index: usize) -> Option<&T> { if index < self.len { // 안전: index가 범위 내에 있는지 확인되었습니다. Some(unsafe { self.get_unchecked(index) }) } else { None } } }
이 패턴은 위험한 unsafe
코드가 포함되어 있으며 해당 안전 불변성이 주변 안전 코드를 통해 강제됨을 보장합니다.
정의되지 않은 동작(Undefined Behavior)의 위험
unsafe
블록 내에서 작업할 때 정의되지 않은 동작(UB)을 피할 책임이 있습니다. UB는 unsafe
Rust의 마무입니다. 단순히 충돌하는 것 이상으로, UB는 다음을 초래할 수 있습니다:
- 잘못된 프로그램 동작: 프로그램이 일부 입력에 대해서는 올바르게 작동하는 것처럼 보이다가 다른 입력에 대해서는 신비하게 실패할 수 있습니다.
- 메모리 손상: 데이터가 조용히 덮어쓰여 원래 UB 소스에서 멀리 떨어진 미묘한 버그로 이어질 수 있습니다.
- 보안 취약점: 잘못된 메모리 관리에서 착취 가능한 결함이 발생할 수 있습니다.
- 최적화 실패: 컴파일러는 Rust의 안전 보장을 기반으로 강력한 가정을 합니다.
unsafe
코드가 이러한 가정을 위반하면 컴파일러는 잘못된 동작으로 이어지는 최적화를 수행할 수 있습니다.
UB의 일반적인 원인:
- null 또는 댕글링 포인터 역참조.
- 원시 포인터를 통한 범위 외 메모리 접근.
- 별칭 규칙 위반 (예: 동일한 메모리를 가리키는
&mut T
와 다른&mut T
를 갖거나,&mut T
와&T
를 가지며&mut T
가 수정하는 경우). - 유효하지 않은 기본값 생성 (예: UTF-8이 아닌
str
,true
또는false
가 아닌bool
). - 데이터 경쟁 (Rust의 타입 시스템은
unsafe
코드에서도 많은 것을 방지하지만,static mut
및 FFI는 예외입니다).
항상 기억하십시오. 불변성과 잠재적 위험을 완전히 이해하지 못하면 unsafe
를 피하는 것이 더 안전합니다.
결론
Unsafe Rust는 Rust의 안전을 우회하기 위한 허점이 아니라, 시스템의 가장 낮은 수준과의 상호 작용을 가능하게 하고 고급 최적화를 허용하는 신중하게 설계된 기능입니다. 메모리 모델, 별칭, 그리고 정의되지 않은 동작의 가능성에 대한 깊은 이해를 요구합니다. unsafe
코드를 안전한 추상화로 캡슐화하고, 불변성을 철저히 문서화하며, 극도의 주의를 기울임으로써 개발자는 성능이 뛰어난 상호 운용 가능한 Rust 애플리케이션을 전체적인 안전성을 손상시키지 않으면서 책임감 있게 활용할 수 있습니다. 반드시 필요한 경우에만 unsafe
를 사용하고, 왜 필요한지 정확히 이해하며, 도입하는 불변성이 세심하게 유지되도록 하십시오.