C++ 개발자가 Rust로 전환하며 배운 것들 — 소유권 시스템의 실체
C++을 오래 쓰다가 Rust 컴파일러에게 매일 혼났다. 처음 6개월은 거의 전쟁이었다. "이게 컴파일되지 않는 건 말이 안 돼"라는 생각을 수백 번했다.
하지만 1년이 지나니까 깨달았다. C++의 방식이 사실 위험했던 것이다. Rust는 그 위험을 컴파일 타임에 막는 것뿐이다.
C++과 Rust의 근본적인 차이
C++에서는 이 코드가 완벽하게 작동한다:
class AudioBuffer {
public:
float* data;
size_t size;
AudioBuffer(size_t sz) : size(sz) {
data = new float[size];
}
~AudioBuffer() {
delete[] data;
}
};
void processAudio() {
AudioBuffer* buffer = new AudioBuffer(1024);
// 사용
buffer->data[0] = 0.5f;
// 명시적으로 delete
delete buffer; // ← 이걸 빼먹으면? 메모리 누수!
}
이건 RAII(Resource Acquisition Is Initialization) 패턴이다. C++ 커뮤니티는 이걸 "최선의 실무"라고 부른다. 하지만 실제로는 수백만 줄의 C++ 코드에서 delete를 빼먹는 실수가 발생한다.
Rust는 다르다:
struct AudioBuffer {
data: Vec<f32>,
}
impl AudioBuffer {
fn new(size: usize) -> Self {
AudioBuffer {
data: vec![0.0; size],
}
}
}
fn process_audio() {
let mut buffer = AudioBuffer::new(1024);
buffer.data[0] = 0.5;
// buffer는 함수를 벗어날 때 자동으로 드롭됨
// 개발자가 할 수 있는 일이 없음
}
Rust는 delete를 빼먹을 수 없다. 컴파일러가 강제한다.
개념 1: Ownership — C++의 unique_ptr와는 다르다
C++에서 unique_ptr를 쓰면:
std::unique_ptr<AudioBuffer> buffer = std::make_unique<AudioBuffer>(1024);
// 다른 곳에 넘기기
std::unique_ptr<AudioBuffer> buffer2 = std::move(buffer);
// 이제 buffer는 nullptr? 아니다. 정의되지 않은 상태다.
// buffer에 접근하면... 컴파일러가 경고 안 함. 런타임 에러!
Rust에서는:
let buffer = AudioBuffer::new(1024);
let buffer2 = buffer; // ownership 이동
// 이제 buffer는 사용 불가
println!("{}", buffer.data[0]); // ❌ 컴파일 에러!
// error[E0382]: borrow of moved value: `buffer`
C++에서는 런타임에 터질 수 있는 에러가, Rust에서는 컴파일 타임에 잡힌다.
개념 2: Borrowing — 참조를 제어한다
C++에서 참조를 쓰면:
void modifyBuffer(AudioBuffer& buffer) {
buffer.data[0] = 1.0;
}
void processAudio() {
AudioBuffer buffer(1024);
modifyBuffer(buffer);
// buffer는 여전히 유효한가? 함수가 참조를 저장했다면?
// 컴파일러는 알 수 없다.
}
문제는 함수 서명만으로는 알 수 없다는 것이다. modifyBuffer가 포인터를 저장했나? 아니면 사용만 했나?
Rust는 명시적이다:
fn modify_buffer(buffer: &mut AudioBuffer) {
buffer.data[0] = 1.0;
}
fn process_audio() {
let mut buffer = AudioBuffer::new(1024);
modify_buffer(&mut buffer);
// 함수 signature에서 &mut는 "임시 빌림"을 의미한다
// 이 함수가 buffer의 ownership을 가져가지 않는다
println!("{}", buffer.data[0]); // ✅ OK, buffer는 여전히 유효
}
또한 Rust는 한 번에 하나의 mutable 참조만 허용한다:
let mut buffer = AudioBuffer::new(1024);
let ref1 = &mut buffer;
let ref2 = &mut buffer; // ❌ 컴파일 에러!
// error[E0499]: cannot borrow `buffer` as mutable more than once at a time
ref1.data[0] = 1.0;
ref2.data[0] = 2.0; // 이건 뭐? 어느 것이 최종값?
C++에서는:
AudioBuffer buffer(1024);
float* ptr1 = buffer.data;
float* ptr2 = buffer.data;
ptr1[0] = 1.0;
ptr2[0] = 2.0;
// 혼돈스럽지만 컴파일 됨. 데이터 레이스 가능성 있음.
개념 3: Lifetime — 참조의 유효성 기간
이것이 Rust의 가장 어려운 개념이다.
// ❌ Rust 컴파일 에러
fn get_buffer_data() -> &Vec<f32> {
let buffer = AudioBuffer::new(1024);
&buffer.data // buffer는 함수 종료 후 드롭됨
// 반환되는 참조는 무효해짐
}
C++에서는 컴파일되고, 런타임에 댕글링 포인터가 된다:
// C++ - 위험
const float* getBufferData() {
AudioBuffer buffer(1024);
return buffer.data; // buffer가 드롭되면서 data 포인터는 무효
}
void processAudio() {
const float* data = getBufferData();
printf("%f", data[0]); // 정의되지 않은 동작!
}
Rust는 이 문제를 컴파일 타임에 방지한다.
개념 4: 왜 C++ 방식이 불가능한가
C++의 관례:
// C++ 설계 패턴
class StreamProcessor {
private:
std::vector<AudioBuffer*> buffers; // 포인터 저장
public:
void addBuffer(AudioBuffer* buffer) {
buffers.push_back(buffer); // 포인터만 저장, ownership 불명확
}
void processAll() {
for (auto buffer : buffers) {
if (buffer) { // 유효한가? 누가 delete 했나?
process(*buffer);
}
}
}
};
누가 buffer를 소유하는가? 누가 delete해야 하는가? 주석에 쓸 수밖에 없다:
// buffer의 lifetime은 caller가 관리
// StreamProcessor는 buffer를 delete하지 않음
// ← 이걸 실수하면 메모리 누수 또는 use-after-free
Rust는 이것을 불가능하게 한다:
struct StreamProcessor {
buffers: Vec<AudioBuffer>, // ownership을 가짐
}
impl StreamProcessor {
fn add_buffer(&mut self, buffer: AudioBuffer) {
self.buffers.push(buffer); // ownership 이동
}
fn process_all(&self) {
for buffer in &self.buffers {
self.process(buffer);
}
}
}
ownership이 명확하다. StreamProcessor가 buffers를 소유하므로 드롭될 때 자동으로 cleanup된다.
좌절: Borrow Checker와의 싸움
처음 Rust로 짠 코드:
fn process_frame(
buffer: &mut Vec<f32>,
filters: &Vec<Filter>,
) {
for i in 0..buffer.len() {
let sample = buffer[i];
for filter in filters {
let processed = filter.apply(sample);
buffer[i] = processed; // ❌ 에러!
}
}
}
// error[E0502]: cannot borrow `buffer` as mutable because it is also borrowed as immutable
내 생각: "어? buffer를 mutable로만 빌렸는데?"
컴파일러의 말: "buffer[i]를 읽을 때 immutable borrow가 됐고, 다시 쓰려고 하니까 mutable borrow가 필요해. 동시에 둘 다 불가능해."
해결책: 인덱스를 쓴다:
fn process_frame(
buffer: &mut Vec<f32>,
filters: &Vec<Filter>,
) {
for i in 0..buffer.len() {
for filter in filters {
let processed = filter.apply(buffer[i]);
buffer[i] = processed; // ✅ OK
}
}
}
처음엔 답답했다. 하지만 이 규칙이 있어서 데이터 레이스가 일어날 수 없다.
예제: C++ vs Rust 멀티스레드
C++에서 공유 데이터:
class AudioMixer {
private:
std::vector<float> output; // 여러 스레드에서 접근
public:
void addSample(int index, float value) {
output[index] += value; // 스레드 안전? 아니다!
// 여러 스레드가 동시에 접근 → 데이터 레이스
}
};
C++ 개발자는 이 버그를 뮤텍스로 막는다:
class AudioMixer {
private:
std::mutex lock;
std::vector<float> output;
public:
void addSample(int index, float value) {
std::lock_guard<std::mutex> guard(lock);
output[index] += value; // 안전
}
};
하지만 뮤텍스를 빼먹을 수 있다. 다른 곳에서는 lock 없이 접근할 수 있다.
Rust에서는:
struct AudioMixer {
output: Mutex<Vec<f32>>,
}
impl AudioMixer {
fn add_sample(&self, index: usize, value: f32) {
let mut output = self.output.lock().unwrap();
output[index] += value;
}
}
// 다른 스레드
let mixer = Arc::new(AudioMixer::new());
let mixer_clone = Arc::clone(&mixer);
std::thread::spawn(move || {
mixer_clone.add_sample(0, 0.5);
});
Rust는 Mutex 없이는 여러 스레드에서 접근 불가능하게 한다. 컴파일러가 강제한다.
배운 것들
1. C++의 "최선의 실무"는 사실 좋은 관례일 뿐, 컴파일러가 강제할 수 없다
2. Rust의 ownership은 처음엔 답답하지만, 버그를 근본에서 막는다
3. 컴파일 타임 안전성은 런타임 성능을 희생하지 않는다 (0-cost abstraction)
4. 멀티스레드 코드는 Rust가 훨씬 안전하다
결론
C++을 오래 써온 나도 이제는 시스템 프로그래밍을 Rust로 한다. 처음 6개월의 고통은 가치가 있다. 뒤돌아보면 C++의 use-after-free 디버깅에 보냈던 시간을 생각나면, Rust의 borrow checker와의 싸움따위는 사소하다.
Rust는 어렵지만, 그 어려움이 버그를 막는다.