Rust 메모리 레이아웃 공개 및 Unsafe의 양날의 검
Ethan Miller
Product Engineer · Leapcell

서론: 안전 그 이상 - Rust의 깊은 메커니즘 이해
Rust는 엄격한 소유권 및 빌림(borrowing) 시스템을 통해 달성되는 메모리 안전성과 성능에 대한 확고한 약속으로 유명합니다. 컴파일 타임에 강제되는 이 시스템은 데이터 경쟁(data races)이나 null 포인터 역참조와 같이 다른 언어에서 흔히 발생하는 버그의 전체 범주를 제거합니다. 그러나 이러한 안전성은 종종 Rust 프로그램이 작동하는 근본적인 메모리 아키텍처를 가리게 합니다. 많은 애플리케이션의 경우 이러한 저수준 세부 정보를 이해하는 것이 엄격하게 필요하지는 않습니다. 하지만 중요한 경로를 최적화하거나, C 라이브러리와 상호 작용하거나, 사용자 정의 데이터 구조를 구현하거나, 베어메탈 프로그래밍을 다룰 때는 Rust의 메모리 레이아웃에 대한 깊은 이해가 필수적입니다.
이 글은 추상화 계층을 벗겨내어 Rust가 메모리에서 데이터를 어떻게 배열하는지 밝히는 것을 목표로 합니다. 그런 다음 Rust의 안전 검사를 일시적으로 우회할 수 있는 강력하지만 위험한 기능인 unsafe
키워드를 탐구하는 것으로 전환할 것입니다. Rust의 기본 메모리 보장과 unsafe
가 제공하는 명시적 제어를 모두 이해함으로써 개발자는 Rust의 전체 잠재력을 활용하여 원시 메모리 액세스를 요구하는 시나리오에서도 매우 성능이 뛰어나고 안정적인 소프트웨어를 제작할 수 있습니다.
핵심 개념: 깊은 메모리 탐구를 위한 무대 설정
메모리 레이아웃과 unsafe
작업의 복잡성을 자세히 살펴보기 전에, 우리 논의를 뒷받침할 몇 가지 기본 개념을 정의하는 것이 중요합니다.
스택 vs. 힙 할당
이것들은 프로그램이 데이터를 저장하는 두 가지 주요 영역입니다.
- 스택: 지역 변수 및 함수 호출 프레임을 저장하는 데 사용되는 메모리 영역입니다. "선입선출(Last-In, First-Out, LIFO)" 특성을 특징으로 합니다. 스택 포인터를 이동하는 것만으로 할당 및 할당 해제가 매우 빠릅니다. 스택의 데이터는 컴파일 타임에 알려진 고정 크기를 가집니다.
- 힙: 런타임에 증가하거나 축소될 수 있거나 컴파일 타임에 크기를 알 수 없는 동적 데이터를 위한 보다 유연한 메모리 영역입니다. 힙에서의 할당 및 할당 해제는 할당자가 적합한 빈 블록을 찾고 관리해야 하므로 더 많은 오버헤드를 수반합니다. 힙의 데이터는 포인터를 통해 간접적으로 액세스됩니다.
데이터 레이아웃
이는 타입의 필드가 메모리에 어떻게 배열되는지를 참조합니다. Rust는 이를 제어하거나 영향을 미치는 여러 메커니즘을 제공합니다.
repr(Rust)
: 이것은 구조체와 열거형의 기본 레이아웃입니다. 필드 순서, 패딩 또는 정렬에 대한 보증이 없습니다. 컴파일러는 전체 크기를 최소화하고 성능을 향상시키기 위해 (예: 패딩 감소) 필드를 자유롭게 재정렬할 수 있습니다.repr(C)
: 이 속성은 대상 플랫폼의 C ABI(Application Binary Interface)를 준수하여 소스 코드에 선언된 순서대로 구조체의 필드가 메모리에 레이아웃되도록 보장합니다. 이것은 C 라이브러리와 상호 작용할 때 FFI(Foreign Function Interface)에 매우 중요합니다.repr(packed)
: 이 속성은 컴파일러에 필드 사이 또는 구조체 끝에 패딩을 삽입하지 않도록 지시합니다. 이것은 메모리 사용량을 줄일 수 있지만 정렬되지 않은 액세스가 일부 아키텍처에서 훨씬 느릴 수 있다는 단점이 있습니다.repr(align(N))
: 이 속성은 구조체가N
바이트에 정렬되도록 보장합니다. 이것은repr(C)
또는repr(packed)
와 함께 사용할 수 있습니다.
포인터: 원시 및 스마트
Rust는 다른 유형의 포인터를 구별합니다.
- 참조 (
&T
,&mut T
): 이것들은 Rust의 안전한 빌림 포인터입니다. 타입 안전성, null 없음, 소유권 규칙 준수(하나의 수정 가능한 참조 또는 많은 수정 불가능한 참조)를 보장합니다. 빌림 기간 동안 항상 유효합니다. - 원시 포인터 (
*const T
,*mut T
): 이것들은 C 포인터와 유사합니다. 유효성, 정렬 또는 null 없음과 같은 보증을 제공하지 않습니다. 원시 포인터를 역참조하는 것은unsafe
작업이며 Rust의 안전 검사를 우회하는 주요 방법입니다.unsafe
코드의 기본입니다. - 스마트 포인터: 힙 할당, 참조 카운팅, 스레드 안전성과 같은 추가 기능을 원시 포인터 위에 제공하는
Box<T>
,Rc<T>
,Arc<T>
와 같은 타입.
미정의 동작 (Undefined Behavior, UB)
이것은 unsafe
키워드를 구동하는 핵심 개념입니다. 미정의 동작은 프로그램이 언어 규칙 또는 기본 플랫폼 규칙을 위반할 때 발생합니다. UB가 발생하면 프로그램이 충돌하거나, 잘못된 결과를 생성하거나, 올바르게 작동하는 것처럼 보이지만 데이터를 조용히 손상시키는 등 어떤 일이든 발생할 수 있습니다. Rust의 유형 시스템과 소유권 규칙은 안전한 코드에서 UB를 방지하지만, unsafe
코드는 극도로 주의하지 않으면 UB를 유발할 수 있습니다. 예로는 끊어진 포인터를 역참조하거나, 잘못된 열거형 판별자를 생성하거나, unsafe
로 표시된 함수 계약 규칙을 위반하는 것이 있습니다.
Rust의 메모리 레이아웃: 심층 분석
이러한 개념이 실생활에서 어떻게 나타나는지 살펴보겠습니다.
기본 레이아웃: repr(Rust)
기본적으로 Rust 구조체는 repr(Rust)
를 가집니다. 이는 필드 순서에 대한 보증이 없음을 의미합니다. 컴파일러는 크기와 정렬을 최적화합니다.
이 구조체를 고려해 보십시오:
struct ExampleData { a: u32, b: u8, c: u16, }
크기와 정렬을 출력하면 다음과 같습니다:
fn main() { println!("Size of ExampleData: {} bytes", std::mem::size_of::<ExampleData>()); println!("Alignment of ExampleData: {} bytes", std::mem::align_of::<ExampleData>()); // 64비트 시스템에서 출력은 다음과 같을 수 있습니다: // Size of ExampleData: 8 bytes // Alignment of ExampleData: 4 bytes }
u32
는 4바이트, u8
는 1바이트, u16
는 2바이트입니다. 단순하게 생각하면 4 + 1 + 2 = 7바이트를 예상할 수 있습니다. 그러나 u32
는 일반적으로 4바이트 정렬이 필요합니다. b
와 c
가 a
앞에 배치되면 a
를 정렬하기 위해 패딩이 추가될 수 있습니다. Rust 컴파일러는 일반적으로 패딩을 최소화하기 위해 u8
, u16
, u32
순서로 재정렬하며, 이는 u8
(1바이트) + u16
(2바이트) + 1바이트 패딩 + u32
(4바이트) = 총 8바이트가 되고 4바이트로 정렬됩니다. 이 최적화는 필드가 임의의 메모리 오프셋이 아닌 이름으로 액세스되기 때문에 안전합니다.
레이아웃 제어: repr(C)
및 repr(packed)
C 라이브러리 또는 특정 하드웨어와 상호 작용할 때 repr(C)
가 필수적입니다.
#[repr(C)] struct RawDataC { field1: u32, field2: u8, field3: u16, } #[repr(C, packed)] struct RawDataPacked { field1: u32, field2: u8, field3: u16, } #[repr(C, align(8))] struct RawDataAligned { field1: u32, field2: u8, field3: u16, } fn main() { println!("Size of RawDataC: {} bytes", std::mem::size_of::<RawDataC>()); println!("Alignment of RawDataC: {} bytes", std::mem::align_of::<RawDataC>()); // 출력: Size: 8, Alignment: 4 (필드 순서 유지, field3에 대한 패딩) println!("Size of RawDataPacked: {} bytes", std::mem::size_of::<RawDataPacked>()); println!("Alignment of RawDataPacked: {} bytes", std::mem::align_of::<RawDataPacked>()); // 출력: Size: 7, Alignment: 1 (패딩 없음, 잠재적 성능 비용) println!("Size of RawDataAligned: {} bytes", std::mem::size_of::<RawDataAligned>()); println!("Alignment of RawDataAligned: {} bytes", std::mem::align_of::<RawDataAligned>()); // 출력: Size: 8 (또는 일부 시스템에서 총 크기가 8의 배수여야 하므로 16), Alignment: 8 }
RawDataC
는 필드가 선언된 순서대로 유지되고 필요한 패딩이 포함되도록 보장합니다. RawDataPacked
는 모든 패딩을 제거하여 정렬되지 않은 액세스를 유발할 수 있습니다. RawDataAligned
는 구조체 전체에 대해 최소 정렬을 강제합니다.
열거형(Enum) 레이아웃
Rust의 열거형은 메모리 레이아웃에서 매우 복잡할 수 있습니다.
-
C 스타일 열거형: 연결된 데이터가 없는
enum
변형은 단순히 정수 판별자입니다. 그 크기는 모든 판별자를 담을 수 있는 가장 작은 정수 타입입니다.#[repr(u8)] // 기본 타입 지정 enum Day { Monday = 1, Tuesday, // ... } // Day의 크기는 1바이트 (u8)가 됩니다.
-
데이터가 있는 열거형: 이것들은 태그가 지정된 공용체(tagged union)입니다. 가장 큰 변형이 열거형의 크기를 결정하며, 활성화된 변형을 나타내는 판별자가 포함됩니다. Rust는 가능한 경우 크기를 줄이기 위해 "틈새 최적화(niche optimization)"를 수행합니다. 예를 들어, 한 변형이
bool
을 포함하고 다른 변형이Option<&T>
를 포함하는 경우,Option
의None
케이스가bool
변형의 판별자로 재사용되어 공간을 절약할 수 있습니다.enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(u8, u8, u8), } // Message의 크기는 가장 큰 변형 (예: String 또는 {x:i32, y:i32} + 판별자)에 의해 결정됩니다. // 컴파일러는 이를 최대한 최적화하려고 시도할 것입니다. // Option<T> 및 Option<&T>의 경우, 틈새 최적화가 특히 효과적이어서 Option<&T>는 &T와 같은 크기가 됩니다.
Unsafe 블록: 힘, 위험, 책임
Rust의 unsafe
키워드는 타입 시스템을 우회하는 것이 아니라, 컴파일러에게 "무엇을 하고 있는지 알고 있으며, 불변성을 유지할 것을 약속합니다."라고 말하는 방법입니다. unsafe
블록 안에서는 컴파일러가 안전하다고 보장할 수 없는 작업들을 수행할 수 있게 됩니다. 예를 들어:
- 원시 포인터 (
*const T
,*mut T
) 역참조: 이것은unsafe
의 가장 일반적인 사용 사례입니다. unsafe
함수 또는 메서드 호출: 명시적으로unsafe
(표준 라이브러리 또는 타사 크레이트에서)로 표시된 함수는unsafe
블록을 필요로 합니다.- 가변 정적 변수 액세스 또는 수정:
static mut
변수는 잠재적인 데이터 경쟁으로 인해 본질적으로 안전하지 않습니다. unsafe
트레이트 구현:unsafe
를 요구하는 트레이트를 올바르게 구현합니다.union
필드 액세스: 공용체는 C 공용체와 유사하며 메모리 중첩 특성으로 인해 필드를 안전하게 액세스하려면unsafe
를 필요로 합니다.
왜 Unsafe를 사용하는가?
위험에도 불구하고 unsafe
는 여러 가지 이유로 필수적입니다.
- FFI (Foreign Function Interface): C 라이브러리 또는 운영 체제 API와 상호 작용하려면 종종 Rust 타입을 C 호환 타입으로 변환하고, 원시 포인터를 관리하고,
unsafe
를 자주 포함하는 C 함수를 호출해야 합니다. - 성능 최적화: 때로는 Rust의 엄격한 안전 검사가 오버헤드를 추가합니다.
unsafe
는 메모리 제어를 수동으로 허용하여, 매우 최적화된 시나리오 (예: 사용자 정의 할당자, 벡터 연산)에서 더 빠른 코드를 생성할 수 있습니다. - 사용자 정의 데이터 구조:
LinkedList
,HashMap
(표준 라이브러리 구현에 의존하지 않는 경우) 또는 사용자 정의 할당자와 같은 복잡한 데이터 구조를 구현하려면 종종 원시 포인터 조작이 필요합니다. - 저수준 시스템 프로그래밍: 베어메탈, 임베디드 시스템 또는 커널 개발에서는 하드웨어 레지스터 또는 메모리 매핑 I/O와 직접 상호 작용하기 위해
unsafe
가 자주 사용됩니다. - 추상화 구현: 안전한 Rust 추상화 (예:
Vec<T>
또는Box<T>
)는 종종unsafe
코드의 작은 핵심 위에 구축됩니다. 목표는unsafe
부분을 안전한 API로 캡슐화하는 것입니다.
예제: FFI 및 원시 포인터
두 정수를 더하는 C 함수를 사용하여 FFI를 시연해 보겠습니다.
my_c_lib.c:
int add_numbers(int a, int b) { return a + b; }
Rust 코드 (src/main.rs):
extern "C" { fn add_numbers(a: i32, b: i32) -> i32; } fn main() { let x = 10; let y = 20; // `add_numbers` 호출은 Rust 컴파일러가 C 함수의 올바른 구현을 보장할 수 없거나 // 인수가 유효한지 확인할 수 없기 때문에 unsafe합니다. let sum = unsafe { add_numbers(x, y) }; println!("Sum from C: {}", sum); // 또 다른 unsafe 작업: 원시 포인터 역참조 let mut value = 42; let raw_ptr: *mut i32 = &mut value as *mut i32; // 참조에서 원시 포인터 생성 unsafe { // 원시 포인터 역참조는 unsafe합니다. // `raw_ptr`가 유효하고 초기화된 메모리를 가리키는지 확인할 책임이 우리에게 있습니다. *raw_ptr = 100; println!("Value via raw pointer: {}", *raw_ptr); } println!("Original value: {}", value); // value는 이제 100입니다. }
이를 컴파일하려면 일반적으로 C 코드를 정적 라이브러리로 컴파일하고 Rust와 링크해야 합니다:
gcc -c my_c_lib.c -o my_c_lib.o
ar rcs libmy_c_lib.a my_c_lib.o
그런 다음 Cargo.toml
을 링크하도록 구성합니다:
[package] name = "ffi_example" version = "0.1.0" edition = "2021" [dependencies] [build-dependencies] cc = "1.0"
그리고 build.rs
를 추가합니다:
fn main() { cc::Build::new() .file("my_c_lib.c") .compile("my_c_lib"); }
마지막으로 cargo run
을 실행합니다.
이 예제는 add_numbers
가 Rust 컴파일러가 외부 C 함수의 안전성을 검증할 수 없으므로 unsafe
로 표시된다는 점을 강조합니다. Rust는 이 extern "C"
블록에서 프로그래머에게 신뢰를 위임합니다. 마찬가지로 raw_ptr
역참조는 Rust가 유효성을 보장할 수 없기 때문에 unsafe
입니다. raw_ptr
가 끊어졌거나 초기화되지 않은 경우, 이를 역참조하면 미정의 동작이 발생합니다.
Unsafe의 계약
unsafe
코드를 작성할 때, Rust 컴파일러가 일반적으로 적용하는 불변성을 유지할 책임을 집니다. 이것이 "unsafe의 계약"입니다. unsafe
코드가 이러한 불변성을 위반하면 즉시 충돌하지 않더라도 미정의 동작이 도입되어 예측할 수 없고 디버깅하기 어려운 문제가 발생할 수 있습니다. 목표는 unsafe
코드를 안전하고 잘 테스트된 추상화 내에 캡슐화하여 구현이 unsafe
를 사용하더라도 공개 API가 안전하게 유지되도록 하는 것입니다.
결론: Rust의 보이지 않는 깊이를 마스터하기
Rust의 기본 메모리 모델은 안정적인 소프트웨어 구축을 위한 매우 강력한 기반을 제공합니다. 메모리 레이아웃 및 포인터 관리의 복잡성을 추상화함으로써 개발자는 일반적인 메모리 관련 함정을 두려워하지 않고 고수준 논리에 집중할 수 있습니다. 그러나 세밀한 성능 튜닝, 외부 코드와의 상호 운용성 또는 사용자 정의 저수준 컴포넌트 개발과 같은 특수 작업의 경우, Rust의 명시적 메모리 레이아웃 메커니즘 및 unsafe
키워드에 대한 철저한 이해가 필수적입니다.
Unsafe
코드는 Rust의 약점이 아니라, 개발자가 C/C++와 동일한 제어 및 성능 수준을 달성할 수 있도록 하는 신중하게 설계된 릴리스 밸브이며, 잠재적인 위험을 캡슐화하고 추론할 수 있는 도구를 여전히 제공합니다. 안전한 추상화 안에 저수준 작업을 캡슐화하기 위해 unsafe
를 신중하게 사용하는 것은 Rust의 전체 잠재력을 활용하는 초석이며, 웹 서비스에서 임베디드 시스템에 이르기까지 모든 영역에서 탁월한 성능을 발휘할 수 있도록 합니다. 이러한 보이지 않는 깊이를 마스터하는 것은 Rust를 단순히 안전한 언어에서 진정으로 강력하고 다재다능한 시스템 프로그래밍 도구로 변화시킵니다.