C++ 개발자가 Rust로 전환하며 배운 것들 — 소유권 시스템의 실체

게시일: 2025년 3월 28일 · 17분 읽기

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는 어렵지만, 그 어려움이 버그를 막는다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.