C++ 개발자를 위한 Rust | 차이점과 전환 가이드

이 글의 핵심

C++ 경험이 있으면 Rust의 많은 개념이 익숙하지만, 기본이 복사가 아니라 이동이라는 점과 컴파일러가 빌림을 강제한다는 점에서 자주 막힙니다. 메모리 관리·null·에러·동시성을 나란히 비교한 표와 C++ 코드를 Rust로 옮기는 예제를 통해 전환할 때 버려야 할 습관과 그대로 가져갈 감각을 구분합니다.

시리즈 안내

#12 | 📋 전체 목차 | 이전: #11 CLI 도구


들어가며

C++에서 익숙한 수동 메모리 관리·RAII와 대비해, Rust는 소유권(열쇠 하나)·빌림(대여) 규칙으로 같은 문제를 컴파일 타임에 막는 쪽에 가깝습니다. 문법만 익히는 것보다 이 규칙에 익숙해지는 데 시간을 쓰는 편이 좋습니다.


Rust와의 첫 만남

“빌려주기 검사기(Borrow Checker)와 싸우는 게 프로그래밍의 반”이라는 농담이 있을 정도로, Rust는 처음에 정말 어렵습니다. 저도 첫 프로젝트에서 컴파일러 에러와 씨름하며 “이게 정말 생산성이 높은 언어인가?” 의심했습니다. 하지만 몇 주간 고생 끝에 컴파일이 통과된 코드는 메모리 관련 런타임 에러가 거의 없다는 걸 깨달았습니다. C++에서는 세그멘테이션 폴트가 프로덕션에서 터지는 악몽을 자주 겪었는데, Rust에서는 unsafe를 쓰지 않는 한 그런 부류의 버그를 걱정할 일이 크게 줄어듭니다. 컴파일러가 미리 잡아주므로요. 물론 로직 버그나 unwrap() 실패로 인한 panic까지 막아 주지는 않습니다. 특히 멀티스레드 코드를 작성할 때 이 차이가 극명합니다. C++에서는 데이터 레이스를 찾느라 디버거와 씨름했지만, Rust는 컴파일 단계에서 “이 코드는 스레드 안전하지 않아”라고 알려줍니다. 처음엔 답답했지만, 지금은 이 엄격함이 감사합니다.

메모리 관리: new/delete·스마트 포인터 vs 소유권

C++ 방식

#include <memory>
int main() {
    // 수동 메모리 관리
    int* ptr = new int(10);
    *ptr = 20;
    delete ptr;  // 수동 해제 필요
    
    // 스마트 포인터
    auto ptr1 = std::make_unique<int>(10);
    auto ptr2 = std::make_shared<int>(20);
    
    // 자동 해제 (RAII)
}

Rust 방식

fn main() {
    // 자동 메모리 관리
    let x = Box::new(10);
    
    {
        let y = Box::new(20);
    }  // y 자동 해제
    
    // x는 여전히 유효
    println!("{}", x);
}  // x 자동 해제

차이점: Rust는 소유권 시스템으로 컴파일 타임에 메모리 안전성을 보장합니다.

사실 이 두 예제는 거의 같은 일을 합니다. C++의 unique_ptr와 Rust의 Box는 둘 다 힙 객체를 단독 소유하고 스코프를 벗어날 때 해제하는 RAII 타입입니다. C++ 개발자에게 Rust의 메모리 관리가 낯설지 않은 이유가 이것입니다. 차이는 강제성에 있습니다. C++에서 new/delete는 여전히 언제든 쓸 수 있고, unique_ptr에서 get()으로 원시 포인터를 꺼내 객체보다 오래 들고 있어도 컴파일러가 막지 않습니다. Rust에서 Box 안의 값을 참조로 빌리면 그 참조가 Box보다 오래 살 수 없다는 것을 컴파일러가 검사하고, 어기면 “y does not live long enough”(E0597) 에러를 냅니다.

C++ 쪽 대응표를 정리하면 Box<T> ≈ unique_ptr<T>, Rc<T> ≈ 단일 스레드용 shared_ptr<T>, Arc<T> ≈ shared_ptr<T>, Weak<T> ≈ weak_ptr<T>입니다. 한 가지 다른 점은 C++의 shared_ptr은 항상 원자적 참조 카운트를 쓰지만, Rust는 원자 연산이 없는 Rc와 원자 연산을 쓰는 Arc를 나눠 두고 Rc를 스레드 사이로 넘기면 컴파일 에러를 낸다는 것입니다. 단일 스레드 코드에서는 원자 연산 비용을 아낄 수 있고, 실수로 스레드에 넘기는 일은 컴파일러가 막아 줍니다.


복사 의미론과 이동 의미론

C++ - 복사 의미론

#include <iostream>
#include <string>
int main() {
    std::string s1 = "hello";
    std::string s2 = s1;  // 깊은 복사
    
    // s1, s2 모두 사용 가능
    std::cout << s1 << std::endl;
    std::cout << s2 << std::endl;
}

Rust - 이동 의미론

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // 소유권 이동
    
    // println!("{}", s1);  // 컴파일 에러!
    println!("{}", s2);  // OK
    
    // 명시적 복사
    let s3 = String::from("world");
    let s4 = s3.clone();
    println!("{} {}", s3, s4);  // 둘 다 OK
}

C++에서 =는 기본이 복사이고, 이동은 std::move로 명시해야 합니다. Rust는 정반대로 =가 기본 이동이고, 복사는 .clone()으로 명시해야 합니다. 이 기본값 차이가 C++ 개발자가 가장 먼저 부딪히는 벽입니다. 주석 처리된 줄의 주석을 풀면 “borrow of moved value: s1”(E0382) 에러가 나는데, 컴파일러가 “value moved here”라며 이동이 일어난 줄까지 짚어 줍니다.

이동의 의미도 두 언어가 다릅니다. C++에서 std::move(s1) 뒤의 s1은 “유효하지만 지정되지 않은 상태”로 여전히 살아 있고, 소멸자도 호출되며, 실수로 다시 읽어도 컴파일러는 막지 않습니다(clang-tidy의 bugprone-use-after-move가 경고해 주는 정도). Rust의 이동은 비트 단위 복사 후 원본을 컴파일러가 사용 불가로 표시하는 것이라, 원본에는 소멸자(drop)도 호출되지 않고 다시 쓰면 컴파일 에러입니다. 그래서 Rust에는 이동 생성자나 이동 대입 연산자를 직접 작성하는 개념이 없고, 이동은 항상 memcpy 수준으로 저렴합니다.

i32, f64, bool처럼 작은 값 타입은 Copy 트레이트를 구현하고 있어서 let b = a; 후에도 a를 계속 쓸 수 있습니다. C++의 trivially copyable 타입과 비슷한 개념이며, 직접 만든 구조체도 모든 필드가 Copy면 #[derive(Clone, Copy)]로 같은 동작을 얻습니다. 반대로 String, Vec, Box처럼 힙 자원을 가진 타입은 Copy가 될 수 없습니다. 암묵적인 깊은 복사가 성능 문제를 숨기는 C++의 흔한 함정(for (auto x : bigVector))이 Rust에서는 원천적으로 불가능한 이유입니다.

참조 비교

// C++
void process(const std::string& s) {
    // 참조로 받음
}
// Rust
fn process(s: &String) {
    // 불변 참조
}
fn modify(s: &mut String) {
    // 가변 참조
}

위 process(s: &String)는 C++의 const std::string&를 글자 그대로 옮긴 형태지만, Rust에서는 보통 fn process(s: &str)로 씁니다. &str은 C++17의 std::string_view에 해당하는 타입으로, String뿐 아니라 문자열 리터럴이나 다른 문자열의 일부분도 받을 수 있습니다. &String을 넘기면 자동 역참조(deref coercion)로 &str이 되므로 호출하는 쪽 코드는 바뀌지 않습니다. Clippy도 &String 매개변수에 ptr_arg 경고를 냅니다. 같은 이유로 &Vec<T> 대신 &[T](C++20의 std::span)를 받는 것이 관례입니다.

C++ 참조와의 결정적 차이는 동시에 존재할 수 있는 참조의 규칙입니다. Rust에서는 불변 참조(&T)를 여러 개 만들거나, 가변 참조(&mut T)를 딱 하나만 만들 수 있고 둘을 섞을 수 없습니다. C++에서 벡터 원소의 참조를 들고 있는 상태로 push_back을 호출해 댕글링 참조를 만드는 버그를 떠올리면 이 규칙의 목적이 분명해집니다. Rust에서 같은 코드를 쓰면 “cannot borrow v as mutable because it is also borrowed as immutable”(E0502) 에러로 컴파일되지 않습니다. 처음에는 이 규칙이 가장 답답하게 느껴지지만, C++에서 반복자 무효화로 고생해 본 사람이라면 무엇을 막아 주는지 금방 이해하게 됩니다.


null 포인터 대신 Option

C++ - Null 포인터

#include <iostream>
int* ptr = nullptr;
if (ptr != nullptr) {
    *ptr = 10;
} else {
    std::cout << "Null 포인터" << std::endl;
}

Rust - Option

fn divide(a: i32, b: i32) -> Option<i32> {
    if b == 0 {
        None
    } else {
        Some(a / b)
    }
}
fn main() {
    match divide(10, 2) {
        Some(result) => println!("결과: {}", result),
        None => println!("0으로 나눌 수 없음"),
    }
    
    // 또는
    if let Some(result) = divide(10, 2) {
        println!("결과: {}", result);
    }
}

C++에서 null 검사는 관례입니다. 포인터를 받는 함수가 null을 허용하는지는 문서나 주석을 봐야 알 수 있고, 검사를 빼먹어도 컴파일은 됩니다. Rust의 참조(&T)와 Box<T>는 절대 null이 될 수 없고, 값이 없을 수 있다면 타입이 Option<T>여야 합니다. Option<i32>에서 i32를 꺼내려면 match, if let, unwrap_or 같은 방법으로 None인 경우를 반드시 다뤄야 하므로 “검사를 잊는” 일이 타입 수준에서 불가능해집니다. C++17의 std::optional과 비슷하지만, optional은 *opt로 검사 없이 꺼낼 수 있고 비어 있으면 미정의 동작이라는 점이 다릅니다.

비용 걱정은 할 필요가 없습니다. Option<Box<T>>나 Option<&T>는 컴파일러가 null 포인터 값을 None으로 사용하는 최적화(niche optimization)를 적용해 원시 포인터와 크기가 같습니다. 즉 C++의 nullable 포인터와 같은 메모리 표현을 가지면서 검사만 강제되는 셈입니다. 다만 unwrap()을 남발하면 None일 때 panic이 나므로 결국 C++의 null 역참조와 비슷한 런타임 실패가 됩니다. 차이는 panic이 미정의 동작이 아니라 명확한 메시지와 함께 멈춘다는 점, 그리고 unwrap()이 코드 리뷰에서 눈에 띄는 표시라는 점입니다.


예외와 Result<T, E>

C++ - 예외

#include <iostream>
#include <stdexcept>
int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("0으로 나눌 수 없음");
    }
    return a / b;
}
int main() {
    try {
        int result = divide(10, 0);
    } catch (const std::exception& e) {
        std::cout << "에러: " << e.what() << std::endl;
    }
}

Rust - Result<T, E>

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("0으로 나눌 수 없음"))
    } else {
        Ok(a / b)
    }
}
// ? 연산자: Result를 반환하는 함수 안에서만 사용 가능
fn compute() -> Result<i32, String> {
    let result = divide(10, 2)?;  // Err이면 즉시 호출자에게 반환
    Ok(result * 2)
}
fn main() {
    match divide(10, 0) {
        Ok(result) => println!("결과: {}", result),
        Err(e) => println!("에러: {}", e),
    }
    
    println!("{:?}", compute());  // Ok(10)
}

?를 별도 함수로 뺀 이유가 있습니다. main() 안에서 바로 ?를 쓰면, main의 반환 타입이 ()이므로 “the ? operator can only be used in a function that returns Result or Option”(E0277) 에러가 납니다. ?는 “에러면 지금 함수에서 그 에러를 반환하라”는 뜻이라, 함수 자체가 Result를 반환해야 합니다. main에서도 쓰고 싶다면 fn main() -> Result<(), Box<dyn std::error::Error>>로 선언하면 됩니다.

?는 C++ 예외의 “자동 전파”를 눈에 보이게 만든 것이라고 이해하면 쉽습니다. 예외는 호출 스택을 타고 조용히 올라가서 어떤 함수가 실패할 수 있는지 시그니처로는 알 수 없지만, Rust에서는 반환 타입이 Result이고 호출하는 줄마다 ?가 붙어 있어 실패 가능 지점이 코드에 드러납니다. 또 ?는 에러 타입이 다르면 From 트레이트로 자동 변환해 주므로, 하위 계층의 io::Error를 상위 계층의 에러 타입으로 바꾸는 코드를 반복해서 쓸 필요가 없습니다. 에러 타입 설계는 Rust 에러 처리 글에서 자세히 다룹니다.

그렇다고 Rust에 예외 같은 메커니즘이 전혀 없는 것은 아닙니다. panic!은 스택을 풀면서 소멸자를 호출한다는 점에서 C++ 예외와 비슷하게 동작하지만, 복구용이 아니라 버그 신호로 쓰는 것이 관례입니다. C++ 개발자가 흔히 하는 실수는 std::out_of_range를 잡던 습관대로 panic을 catch_unwind로 잡으려 하는 것인데, 예상 가능한 실패는 처음부터 Result로 표현해야 합니다.


스레드와 데이터 레이스 방지

C++ - 스레드

#include <thread>
void task() {
    // 작업
}
int main() {
    std::thread t(task);
    t.join();
}

Rust - 스레드

use std::thread;
fn main() {
    let handle = thread::spawn(|| {
        // 작업
    });
    
    handle.join().unwrap();
}

차이점: Rust는 컴파일 타임에 데이터 레이스를 방지합니다.

두 예제는 모양이 거의 같지만 join을 빼먹었을 때의 동작이 다릅니다. C++의 std::thread는 join()이나 detach() 없이 소멸되면 std::terminate()를 호출해 프로그램 전체를 종료합니다. Rust의 JoinHandle은 버려지면 스레드를 조용히 분리(detach)할 뿐입니다. C++20의 std::jthread는 소멸자에서 자동으로 join해서 이 함정을 없앴습니다.

데이터 레이스를 막는 원리는 Send와 Sync라는 두 마커 트레이트입니다. thread::spawn은 클로저가 Send(다른 스레드로 옮겨도 안전)이고 'static(빌린 참조를 들고 있지 않음)이어야 한다고 요구합니다. 그래서 지역 변수의 참조를 스레드에 넘기려 하면 “closure may outlive the current function, but it borrows data”(E0373) 에러가 나고, Rc를 넘기려 하면 “Rc<...> cannot be sent between threads safely”(E0277) 에러가 납니다. 여러 스레드에서 같은 데이터를 수정하려면 Arc<Mutex<T>>처럼 공유와 잠금을 타입으로 표현해야 하는데, Rust의 Mutex는 데이터를 안에 담고 있어 잠금 없이는 데이터에 접근하는 코드 자체를 쓸 수 없습니다. C++에서 std::mutex와 보호 대상 변수가 따로 있어서 “이 변수를 만질 때 잠금을 잡았던가”를 사람이 기억해야 하는 것과 대조적입니다. 자세한 내용은 Rust 동시성 글을 참고하세요.

단, 컴파일러가 막는 것은 데이터 레이스(동기화 없이 같은 메모리에 동시에 쓰기)이지 모든 동시성 버그가 아닙니다. 두 뮤텍스를 반대 순서로 잡아 생기는 데드락이나, 잠금은 올바르지만 논리적으로 순서가 어긋나는 경쟁 조건은 Rust에서도 여전히 생길 수 있습니다.


C++와 Rust 항목별 비교표

특징C++Rust
메모리 안전수동 (RAII)자동 (소유권)
Null 포인터가능Option
데이터 레이스미정의 동작 (탐지는 TSan 등 도구에 의존)컴파일 에러 (safe 코드 기준)
에러 처리예외 (try-catch)Result<T, E>
패키지 관리CMake, vcpkgCargo
학습 곡선가파름매우 가파름
성능매우 빠름매우 빠름

예제: C++ 코드를 Rust로 옮기기

C++ 코드

#include <iostream>
#include <string>
#include <vector>
#include <memory>
class User {
public:
    std::string name;
    int age;
    
    User(std::string n, int a) : name(n), age(a) {}
};
int main() {
    std::vector<std::shared_ptr<User>> users;
    users.push_back(std::make_shared<User>("Alice", 30));
    users.push_back(std::make_shared<User>("Bob", 25));
    
    for (const auto& user : users) {
        std::cout << user->name << std::endl;
    }
}

Rust 변환

struct User {
    name: String,
    age: u32,
}
impl User {
    fn new(name: String, age: u32) -> Self {
        User { name, age }
    }
}
fn main() {
    let mut users = Vec::new();
    users.push(User::new(String::from("Alice"), 30));
    users.push(User::new(String::from("Bob"), 25));
    
    for user in &users {
        println!("{}", user.name);
    }
}

변환에서 눈여겨볼 부분은 shared_ptr이 사라졌다는 점입니다. C++ 코드는 vector<shared_ptr<User>>를 쓰지만, 이 프로그램에서 User를 공유하는 곳은 없습니다. C++에서는 “나중에 어딘가에서 필요할지 몰라서”, 또는 포인터 수명을 따지기 귀찮아서 shared_ptr을 습관적으로 쓰는 경우가 흔한데, Rust로 옮길 때 이를 그대로 Vec<Rc<User>>로 번역하면 코드가 불필요하게 복잡해지고 Rc 안의 값을 수정하려면 RefCell까지 필요해집니다. Vec<User>에 값을 직접 담고, 다른 곳에서는 &User로 빌리는 것이 Rust다운 설계입니다. 원소가 연속 메모리에 놓여 캐시 효율도 좋아집니다.

for user in &users의 &도 중요합니다. &를 빼고 for user in users라고 쓰면 벡터의 소유권이 루프로 이동해서, 루프가 끝난 뒤 users를 다시 쓰면 E0382 에러가 납니다. C++의 for (const auto& user : users)에 대응하는 것이 for user in &users, for (auto& user : users)에 대응하는 것이 for user in &mut users입니다.

C++ 코드를 Rust로 옮길 때 제가 가장 오래 붙잡혀 있던 구조는 부모와 자식이 서로를 가리키는 그래프(트리의 부모 포인터, 옵저버 목록 등)였습니다. C++에서는 원시 포인터로 간단히 만들던 구조가 Rust에서는 소유권 규칙과 정면으로 충돌합니다. 해결책은 Rc/Weak 조합을 쓰거나, 노드를 Vec에 담고 인덱스로 서로를 가리키는 “아레나” 방식으로 바꾸는 것인데, 대부분의 경우 후자가 더 단순하고 빠릅니다. 소유권을 억지로 우회하려 들기보다 데이터 구조를 Rust에 맞게 다시 설계하는 편이 결국 빠르다는 것이 전환하며 얻은 가장 큰 교훈이었습니다.


C++ 개발자를 위한 요약

  1. 메모리: Rust가 더 안전 (컴파일 타임 보장)
  2. 소유권: Rust의 핵심 (이동 vs 복사)
  3. Null: Option로 안전하게
  4. 에러: Result<T, E>로 명시적 처리
  5. 성능: 둘 다 비슷 (제로 코스트)

전환 팁

  • 소유권 이해: 가장 중요한 개념
  • 컴파일러 신뢰: 에러 메시지가 매우 친절함
  • 작은 프로젝트부터: CLI 도구로 시작

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. C++의 const std::string&를 Rust로 옮기면 무엇으로 바꾸나요?

A. 읽기만 하는 참조라면 Rust의 불변 참조 &String, 더 일반적으로는 &str로 받습니다. C++에서 const 없는 참조로 값을 수정하던 함수는 &mut String처럼 가변 참조로 바꾸는데, Rust에서는 가변 참조가 살아 있는 동안 다른 참조를 동시에 만들 수 없다는 점이 가장 큰 차이입니다. 이 제약 덕분에 C++에서는 런타임에야 드러나던 댕글링 참조나 동시 수정 문제가 컴파일 단계에서 걸러집니다.