C++ vs Rust: 소유권, 메모리 안전성, 에러 처리, 동시성, 성능 비교

들어가며: “C++로 갈까, Rust로 갈까” 실무에서 겪는 고민

왜 비교하는가

시스템 프로그래밍·고성능 서버·임베디드 영역에서 C++과 Rust는 모두 “제로 코스트 추상화”를 내세우는 언어다. C++은 40년 이상의 생태계와 레거시가 있으며, Rust는 소유권·Borrow checker로 메모리 안전성과 Data Race를 컴파일 타임에 보장합니다. 이 글은 실제 겪는 문제 시나리오, 완전한 비교 예제(소유권·메모리·에러 처리·동시성·성능), 자주 하는 실수, 프로덕션 패턴까지 포함해 두 언어를 실전 관점에서 비교합니다.

이 글에서 다루는 것:

  • 문제 시나리오: 기술 선택·마이그레이션 시 겪는 실제 상황
  • 소유권·메모리 안전성: C++ 이동 vs Rust 소유권·빌림 검사
  • 에러 처리: 예외·expected vs Result·Option
  • 동시성: 스레드·뮤텍스 vs Send·Sync·채널
  • 성능: 벤치마크·트레이드오프
  • 마이그레이션: C++ 메모리 관념을 Rust 타입으로 옮기는 순서
  • 자주 하는 실수: C++·Rust 각각에서 피해야 할 패턴
  • 프로덕션 패턴: 실전에서 쓰는 설계 패턴 관련 글: Rust 메모리 안전성: 소유권·Borrow checker·수명·unsafe, C++ 스마트 포인터.

기술 선택·마이그레이션 때 겪는 상황

기술 선택이나 마이그레이션을 잘못하면 아래와 같은 문제가 발생합니다.

flowchart TB
    subgraph Problems[실무 문제 시나리오]
        P1[메모리 버그 런타임 크래시]
        P2[에러 처리 누락]
        P3[Data Race·동시성 버그]
        P4[성능 요구 미충족]
        P5[팀 학습 곡선]
    end
    subgraph Solutions[해결 방향]
        S1[C++: Sanitizer·Clang-Tidy]
        S2[Rust: 타입 시스템]
        S3[요구사항에 맞는 언어 선택]
    end
    P1 --> S1
    P1 --> S2
    P2 --> S2
    P3 --> S2
    P4 --> S3
    P5 --> S3

상황: C++로 패킷 처리 서버를 만들었습니다. std::vector<uint8_t> 버퍼를 스레드 풀로 넘긴 뒤, 실수로 원본을 다시 사용했는데 컴파일은 통과했고 런타임에 간헐적 크래시가 발생했습니다. 원인: C++에서 std::move 후 원본은 “유효하지만 unspecified” 상태입니다. 컴파일러가 “이동 후 사용 금지”를 강제하지 않아, 실수로 접근하면 use-after-free가 됩니다. 해결 방향: Rust로 전환하면 이동 후 원본 사용 시 컴파일 에러가 납니다. C++ 유지 시 Clang-Tidy·AddressSanitizer로 검증합니다. 상황: C++ 팀이 Rust로 새 모듈을 작성하기 시작했습니다. 참조·수명 에러가 잦아서 초기 개발 속도가 크게 떨어졌습니다. 원인: Rust의 borrow checker는 C++에 없는 제약을 둡니다. “동시에 하나의 가변 참조만”, “참조 수명이 값보다 길 수 없음” 등. 익숙해지기 전에는 clone()으로 우회하거나 구조 재설계가 필요합니다. 해결 방향: Rc·Arc로 소유권 공유, clone()으로 초기 우회 후 점진적으로 참조·수명 익히기. The Rust Book 4장(Understanding Ownership)부터 체계적으로 학습합니다. 상황: C++에서 파일 열기·네트워크 연결 실패를 예외로 처리하지 않았으며, 호출자가 try-catch를 쓰지 않아 프로세스가 비정상 종료했습니다. 원인: C++ 예외는 “선택적”입니다. 호출자가 처리하지 않으면 스택 언와인딩 후 종료됩니다. Rust의 Result는 “에러 가능성”을 타입으로 강제해, ? 연산자나 match로 처리하지 않으면 컴파일 에러가 납니다. 해결 방향: C++에서는 std::expected(C++23) 또는 예외 규칙을 팀에 정착. Rust에서는 Result·?를 일관되게 사용합니다. 상황: C++에서 여러 스레드가 공유 카운터를 mutex 없이 증가시켰습니다. ThreadSanitizer로 확인했을 때만 발견되었으며, 그 전까지는 “가끔 잘못된 값”으로만 드러났습니다. 원인: C++에서 Data Race는 undefined behavior입니다. 컴파일러가 막지 않습니다. Rust의 Send·Sync 트레이트는 “스레드 간 전달·공유가 안전한 타입”만 허용해, Data Race 가능 코드는 컴파일이 되지 않습니다. 해결 방향: C++에서는 std::atomic·std::mutex 필수, TSan을 CI에 적용. Rust는 타입 시스템이 이를 강제합니다. 상황: Rust로 작성한 파서가 C++ 버전보다 느렸습니다. 조사 결과 clone() 과다 사용과 불필요한 Arc 래핑이 원인이었습니다. 원인: Rust도 “제로 코스트”를 지향하지만, clone()·Arc는 런타임 비용이 있습니다. 참조·소유권을 제대로 활용하지 않으면 C++보다 느려질 수 있습니다. 해결 방향: 참조(&T·&mut T)로 빌림, clone() 최소화, 프로파일링으로 병목 확인. C++과 Rust 모두 “올바르게 쓰면” 비슷한 성능을 냅니다.

”레거시 C++ 라이브러리와 연동이 필요하다”

상황: Rust 프로젝트에서 기존 C++ 라이브러리를 사용해야 합니다. FFI 경계에서 메모리 관리·에러 전달이 복잡했습니다. 원인: C++과 Rust의 메모리 모델이 다릅니다. FFI 경계는 unsafe 블록이 필요하며, 수명·소유권을 수동으로 맞춰야 합니다. 해결 방향: bindgen, cxx 등 도구 활용. 경계를 최소화하며, unsafe 블록을 잘 격리합니다.


std::move와 Rust move: 소유권·이동 비교

C++: std::move와 “유효하지만 unspecified”

C++에서 이동은 std::move로 명시합니다. 이동 후 원본은 “moved-from” 상태로, 표준은 유효하지만 unspecified만 보장합니다. unique_ptr는 nullptr가 되지만, 일반 타입은 “비어 있음”을 보장하지 않습니다. 재사용하면 미정의 동작이 될 수 있으며, 컴파일러는 대부분 막지 않습니다.

#include <memory>
#include <iostream>
int main() {
    auto p = std::make_unique<int>(42);
    auto q = std::move(p);
    // *p = 1;  // UB: p는 nullptr, 컴파일은 통과 가능
    std::cout << *q << "\n";  // 42
    return 0;
}

코드 설명:

  • std::move(p): p의 소유권을 q로 이동합니다. 이동 후 p는 nullptr입니다.
  • *p = 1: nullptr 역참조로 미정의 동작입니다. 컴파일러는 이를 막지 않습니다.
  • 주의: 이동 후 p를 사용하지 않는 것이 개발자 책임입니다.

컴파일러 경고(-Wall -Wextra)로는 이 패턴이 거의 잡히지 않습니다. 가장 가까운 도구는 Clang-Tidy의 bugprone-use-after-move 체크인데, 같은 함수 안에서 이동 직후 원본을 쓰는 경우를 찾아 줍니다. 다만 이동과 사용이 서로 다른 함수·분기에 흩어져 있거나 멤버 변수를 통해 일어나면 놓치는 경우가 있어, Rust처럼 “빌드가 통과했으니 이동 후 사용은 없다”고 말할 수는 없습니다.

Rust: 이동이 기본, 사용 불가 강제

Rust에서는 이동이 기본입니다. 값이 이동하면 원래 변수는 사용 불가가 됩니다. 컴파일러가 “이미 이동된 값”을 쓰려 하면 컴파일 에러입니다.

fn main() {
    let s = String::from("hello");
    let t = s;
    // println!("{}", s);  // 컴파일 에러: use of moved value: `s`
    println!("{}", t);  // OK
}

코드 설명:

  • let t = s;: s의 소유권이 t로 이동합니다. s는 더 이상 유효하지 않습니다.
  • println!("{}", s);: 컴파일 에러 — “use of moved value: s”
  • Rust는 이동 후 사용을 문법적으로 금지합니다.

완전한 비교 예제: 벡터 반환

C++:

#include <vector>
std::vector<int> create_vector() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    return v;  // RVO 또는 이동
}
int main() {
    auto data = create_vector();
    // data 사용
    return 0;
}

Rust:

// 함수 정의 및 구현
fn create_vector() -> Vec<i32> {
    let v = vec![1, 2, 3, 4, 5];
    v  // 소유권 반환 (이동)
}
fn main() {
    let data = create_vector();
    // data 사용
}

비교: 두 언어 모두 반환 시 이동이 적용됩니다. C++은 RVO로 복사/이동을 생략할 수 있으며, Rust는 항상 소유권이 이동합니다. C++에서 이동 후 v를 실수로 쓰면 UB, Rust에서는 컴파일 에러입니다.

소유권 비교 다이어그램

flowchart TB
    subgraph cpp[C++ 이동]
        C1[unique_ptr p] -->|std::move| C2[unique_ptr q]
        C2 --> C3[p = nullptr]
        C3 --> C4["*p 사용 시도 가능 (컴파일 통과)"]
        C4 --> C5[런타임 크래시·UB]
    end
    subgraph rust[Rust 이동]
        R1[String s] -->|let t = s| R2[String t]
        R2 --> R3[s 사용 불가]
        R3 --> R4["s 사용 시도"]
        R4 --> R5[컴파일 에러]
    end

댕글링·이터레이터 무효화·null: 메모리 안전성 비교

댕글링 포인터·use-after-free

C++: 로컬 객체의 포인터를 반환하면 댕글링이 됩니다. 컴파일러가 반드시 막는 것은 아닙니다.

const char* get_name() {
    std::string s = "hello";
    return s.c_str();  // s 소멸 후 댕글링, 컴파일 통과 가능
}

Rust: 수명(lifetime)으로 “참조가 가리키는 값보다 참조가 더 오래 살 수 없다”를 검사합니다.

// fn get_name() -> &'static str {
//     let s = String::from("hello");
//     &s  // error[E0515]: cannot return reference to local variable `s`
// }
fn get_name() -> String {
    String::from("hello")  // 소유권 반환
}

이터레이터 무효화

C++: 반복 중 push_back·erase 등으로 컨테이너가 변경되면 이터레이터가 무효화됩니다. 컴파일러는 막지 않습니다.

std::vector<int> v = {1, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 2) {
        v.push_back(4);  // UB: it 무효화
    }
}

Rust: 반복 중 Vec를 가변으로 빌리면 컴파일 에러입니다.

let mut v = vec![1, 2, 3];
for x in &v {
    if *x == 2 {
        // v.push(4);  // 컴파일 에러: cannot borrow `v` as mutable
    }
}

null 안전성

C++: raw 포인터는 null일 수 있으며, 역참조 시 UB입니다.

int* p = nullptr;
// int x = *p;  // UB

Rust: Option<T>로 “값이 있거나 없음”을 타입으로 표현합니다.

let maybe: Option<i32> = Some(42);
match maybe {
    Some(x) => println!("{}", x),
    None => println!("없음"),
}
// maybe.unwrap();  // None이면 패닉

메모리 안전성 요약표

문제C++Rust
이동 후 사용통과 가능, UB컴파일 에러
댕글링 참조통과 가능, UB수명으로 컴파일 에러
이터레이터 무효화런타임 UBborrow checker로 컴파일 에러
null 역참조가능, UBOption으로 검사
이중 해제수동 관리 시 가능소유권으로 불가능

같은 버그, 다른 증상: C++ 런타임 증상과 Rust 컴파일 에러 대조

요약표의 “UB”라는 말은 실제 디버깅 현장에서 여러 얼굴로 나타납니다. 같은 실수를 했을 때 C++에서 무엇을 보게 되는지와, Rust가 어떤 에러로 빌드를 멈추는지를 나란히 두면 두 언어의 차이가 더 구체적으로 보입니다.

실수C++에서 흔히 보는 증상ASan/TSan 리포트Rust 컴파일 에러
unique_ptr 이동 후 역참조대개 즉시 Segmentation fault (널 주소 접근)SEGV on unknown address 0x000000000000E0382 borrow of moved value
지역 std::string의 c_str() 반환한동안 정상처럼 보이다가 쓰레기 문자열·간헐 크래시stack-use-after-return 또는 heap-use-after-freeE0106 missing lifetime specifier → 'static을 붙이면 E0515
순회 중 push_back용량이 남아 있으면 멀쩡, 재할당되는 순간부터 오동작heap-use-after-freeE0502 cannot borrow v as mutable because it is also borrowed as immutable
락 없이 카운터 증가실행마다 결과가 조금씩 다름, 대부분 크래시 없음WARNING: ThreadSanitizer: data race공유 방식에 따라 E0373(클로저가 빌린 값보다 오래 살 수 있음)·E0499(가변 빌림 중복), Rc를 넘기면 E0277 cannot be sent between threads safely

nullptr 역참조는 “그나마 친절한” 경우입니다. 이동된 unique_ptr는 nullptr이 되고, 대부분의 OS는 0번지 근처를 매핑하지 않으므로 바로 segfault가 납니다. 코어 덤프의 스택만 봐도 어느 줄인지 알 수 있습니다. 다만 표준상으로는 UB라서, 최적화 컴파일러가 “여기서 역참조했으니 p는 널이 아니다”라고 가정하고 뒤쪽의 if (p) 검사를 지워 버리는 식의 이상한 흐름이 생길 수도 있습니다.

댕글링은 더 교활합니다. get_name()처럼 "hello" 같은 짧은 문자열은 대부분의 표준 라이브러리 구현에서 SSO(작은 문자열 최적화)로 std::string 객체 안(스택)에 저장되므로, 반환된 포인터는 이미 끝난 스택 프레임을 가리킵니다. 다음 함수 호출이 그 스택을 덮어쓰기 전까지는 “hello”가 그대로 읽혀서 테스트를 통과하는 일이 흔합니다. 문자열이 길어져 힙에 할당되면 ASan은 heap-use-after-free로 잡지만, SSO 경우의 stack-use-after-return은 ASan 옵션(detect_stack_use_after_return)이 켜져 있어야 잡히고 기본값은 컴파일러 버전마다 다릅니다. “ASan을 돌렸는데 아무것도 안 나왔다”가 곧 “버그가 없다”는 뜻이 아닌 이유입니다.

이터레이터 무효화는 용량에 따라 증상이 바뀝니다. push_back이 재할당을 일으키지 않으면 기존 이터레이터가 우연히 유효한 메모리를 가리켜 아무 일도 없습니다(그래도 end()는 무효화되므로 규칙상 UB입니다). 재할당이 일어나는 입력 크기에서만 크래시가 나므로, 작은 테스트 데이터로는 재현되지 않고 운영 데이터에서만 터지는 전형적인 패턴입니다.

Rust 쪽은 이 네 경우 모두 실행 파일이 만들어지지 않습니다. 증상이 입력 크기·최적화 수준·스케줄링에 따라 달라지는 일이 없다는 것이 가장 큰 차이입니다. 참고로 get_name()에서 Rust가 처음 내는 에러가 “does not live long enough”가 아니라 E0106(반환 참조의 수명이 어디서 오는지 모름)인 이유는, 인자가 없는 함수가 참조를 반환하려면 그 참조가 'static일 수밖에 없기 때문입니다. 'static을 붙이면 그때 “지역 변수의 참조는 반환할 수 없다”(E0515)가 나옵니다.

sequenceDiagram
    participant Caller
    participant get_name
    participant s
    Caller->>get_name: 호출
    get_name->>s: String 생성
    get_name->>Caller: s의 버퍼 주소 반환
    Note over get_name,s: 함수 종료, s 소멸
    Note over Caller: C++ - 소멸된 버퍼를 읽음 (UB)
    Note over Caller: Rust - 이 흐름 자체가 컴파일 거부

Rust도 막지 않는 것: 순환 참조와 메모리 누수

“Rust는 메모리 문제를 전부 막는다”고 오해하기 쉬운데, 메모리 누수는 Rust의 안전성 보장 범위 밖입니다. Rc로 두 노드가 서로를 가리키면 참조 카운트가 0이 되지 않아 해제되지 않으며, 이 코드는 safe Rust에서 경고 없이 컴파일됩니다(std::mem::forget도 safe 함수입니다). C++의 shared_ptr 순환과 똑같은 문제이고, 해결도 같습니다. 한쪽 방향을 약한 참조로 끊습니다.

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    parent: RefCell<Weak<Node>>,        // 부모 방향은 Weak (C++의 weak_ptr)
    children: RefCell<Vec<Rc<Node>>>,   // 자식 방향은 Rc (C++의 shared_ptr)
}

Rust가 보장하는 것은 “해제된 메모리에 접근하지 않는다”이지 “모든 메모리가 반드시 해제된다”가 아닙니다. 장시간 도는 서버라면 Rust로 옮긴 뒤에도 메모리 사용량 모니터링은 그대로 필요합니다.


예외·std::expected와 Result: 에러 처리 비교

C++: 예외와 std::expected

예외: 전통적인 방식. 호출자가 처리하지 않으면 스택 언와인딩 후 종료됩니다.

#include <stdexcept>
#include <string>
int parse_int(const std::string& s) {
    if (s.empty()) {
        throw std::invalid_argument("empty string");
    }
    return std::stoi(s);
}
int main() {
    try {
        int x = parse_int("42");
    } catch (const std::exception& e) {
        // 에러 처리
    }
    return 0;
}

std::expected (C++23): 예외 없이 에러를 값으로 반환합니다.

#include <expected>
#include <string>
std::expected<int, std::string> parse_int(const std::string& s) {
    if (s.empty()) {
        return std::unexpected("empty string");
    }
    try {
        return std::stoi(s);
    } catch (...) {
        return std::unexpected("parse error");
    }
}
int main() {
    auto result = parse_int("42");
    if (result) {
        int x = *result;
    } else {
        // result.error() 처리
    }
    return 0;
}

Rust: Result와 Option

Result: 에러 가능성을 타입으로 강제합니다. ? 연산자로 전파할 수 있습니다.

use std::num::ParseIntError;
fn parse_int(s: &str) -> Result<i32, ParseIntError> {
    s.parse()
}
fn main() -> Result<(), ParseIntError> {
    let x = parse_int("42")?;
    println!("{}", x);
    Ok(())
}

Option: 값이 없을 수 있는 경우입니다.

fn find_first(v: &[i32], target: i32) -> Option<usize> {
    v.iter().position(|&x| x == target)
}
fn main() {
    let v = vec![1, 2, 3];
    match find_first(&v, 2) {
        Some(i) => println!("인덱스: {}", i),
        None => println!("없음"),
    }
}

에러 처리 비교표

항목C++Rust
예외지원, 선택적 사용패닉(복구 불가)만
에러 값 반환std::expected (C++23)Result<T, E>
null 대신optional (C++17)Option
에러 전파try-catch? 연산자
컴파일 시 검사없음Result 미처리 시 에러

mutex·atomic과 Send·Sync: 동시성 비교

C++: 스레드·뮤텍스·atomic

Data Race: C++에서 mutex 없이 공유 변수를 수정하면 UB입니다. 컴파일러가 막지 않습니다.

#include <thread>
#include <atomic>
int main() {
    std::atomic<int> counter{0};
    std::thread t1([&] { for (int i = 0; i < 1000000; ++i) ++counter; });
    std::thread t2([&] { for (int i = 0; i < 1000000; ++i) ++counter; });
    t1.join();
    t2.join();
    // counter는 2000000
    return 0;
}

뮤텍스:

#include <thread>
#include <mutex>
int main() {
    std::mutex mtx;
    int counter = 0;
    std::thread t1([&] {
        for (int i = 0; i < 1000000; ++i) {
            std::lock_guard g(mtx);
            ++counter;
        }
    });
    std::thread t2([&] {
        for (int i = 0; i < 1000000; ++i) {
            std::lock_guard g(mtx);
            ++counter;
        }
    });
    t1.join();
    t2.join();
    return 0;
}

Rust: Send·Sync·Arc·Mutex

Send·Sync: 스레드 간 전달·공유가 안전한 타입만 허용합니다. Rc는 Send가 아니어서 스레드로 넘기면 컴파일 에러입니다.

use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    for _ in 0..2 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            for _ in 0..1_000_000 {
                *counter.lock().unwrap() += 1;
            }
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("{}", *counter.lock().unwrap());  // 2000000
}

채널:

use std::thread;
use std::sync::mpsc;
fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        tx.send(42).unwrap();
    });
    let received = rx.recv().unwrap();
    println!("{}", received);
}

동시성 비교표

항목C++Rust
Data RaceUB, 컴파일러 미검사Send/Sync로 컴파일 에러
공유 메모리mutex, atomicArc<Mutex>, Atomic*
메시지 전달수동 구현 또는 라이브러리std::sync::mpsc 채널
스레드 로컬thread_localthread_local!

벤치마크로 본 성능 차이

벤치마크 관점

항목C++Rust
컴파일 타임상대적으로 짧음borrow checker로 다소 김
런타임 오버헤드없음없음 (제로 코스트)
메모리 사용수동 제어 가능비슷한 수준
최적화오래된 컴파일러·풍부한 옵션LLVM 백엔드, 비슷한 최적화

실전 성능 팁

C++:

  • -O2·-O3·LTO 활용
  • std::move로 불필요한 복사 제거
  • 캐시 친화적 데이터 구조 Rust:
  • clone() 최소화, 참조 활용
  • release 빌드 (cargo build --release)
  • Arc·Rc 남용 지양

성능 다이어그램

flowchart LR
    subgraph cpp[C++]
        C1[수동 메모리]
        C2[이동 의미론]
        C3[최적화]
        C1 --> C2 --> C3
    end
    subgraph rust[Rust]
        R1[소유권]
        R2[제로 코스트]
        R3[LLVM]
        R1 --> R2 --> R3
    end
    cpp --> P[비슷한 성능]
    rust --> P

패킷 파이프라인·에러 전파 예제와 대응표

실전 예시: 네트워크 패킷 처리 파이프라인

동일한 로직을 C++과 Rust로 작성했을 때의 차이입니다.

C++ (위험한 패턴):

#include <vector>
#include <thread>
#include <functional>
void parse_packet(const std::vector<uint8_t>& packet) {
    // 패킷 파싱 로직
}
void on_packet_received(std::vector<uint8_t> packet) {
    auto worker = [packet = std::move(packet)]() {
        parse_packet(packet);
    };
    std::thread t(std::move(worker));
    t.join();
    // packet 사용 불가 — 하지만 실수로 packet.size() 호출하면?
    // 컴파일 통과, 런타임 UB (moved-from vector)
}

Rust (안전):

// 함수 정의 및 구현
fn parse_packet(packet: &[u8]) {
    // 패킷 파싱 로직
}
fn on_packet_received(packet: Vec<u8>) {
    let handle = std::thread::spawn(move || {
        parse_packet(&packet);
    });
    handle.join().unwrap();
    // packet.size() 호출 시도 → 컴파일 에러: use of moved value
}

Rust에서는 packet이 클로저로 이동했으므로, 이후 packet 사용 시 컴파일 에러가 납니다. C++에서는 같은 실수가 런타임에만 드러납니다.

실전 예시: 에러 전파 체인

C++ (예외):

int process_file(const std::string& path) {
    std::ifstream f(path);
    if (!f) throw std::runtime_error("파일 열기 실패");
    std::string line;
    std::getline(f, line);
    return std::stoi(line);  // 변환 실패 시 예외
}

Rust (Result):

use std::fs::File;
use std::io::{BufRead, BufReader};
fn process_file(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let f = File::open(path)?;
    let mut reader = BufReader::new(f);
    let mut line = String::new();
    reader.read_line(&mut line)?;
    let n: i32 = line.trim().parse()?;
    Ok(n)
}

Rust의 ? 연산자는 에러를 자동으로 전파하되, 호출자가 Result를 처리하지 않으면 컴파일 에러가 납니다. C++ 예외는 호출자가 try-catch를 쓰지 않아도 컴파일이 됩니다.

타입·메모리 대응표

C++Rust비고
std::unique_ptr<T>Box<T>단일 소유권
std::shared_ptr<T>Arc<T>스레드 안전 공유
std::shared_ptr<T> (단일 스레드)Rc<T>non-Send
T* (raw)*mut T / *const Tunsafe
T&&T / &mut T빌림
std::stringString소유 문자열
std::string_view&str수명 있는 슬라이스
std::vector<T>Vec<T>동적 배열
std::optional<T>Option<T>값 없음 표현
std::expected<T,E>Result<T,E>에러 처리

핵심 차이 요약

영역C++Rust
소유권수동, std::move기본 이동, 컴파일 검사
메모리 안전성개발자·도구 책임타입 시스템 보장
에러 처리예외, expectedResult, Option
동시성mutex, atomicSend, Sync
nullraw 포인터Option
빌드상대적으로 빠름borrow checker로 다소 김

Segfault·use-after-free와 borrow checker 에러 메시지

C++ 에러

”Segmentation fault” — nullptr 역참조

원인: std::move 후 unique_ptr 역참조, 또는 수동 delete 후 포인터 사용. 해결법:

// ❌ 잘못된 코드
auto p = std::make_unique<int>(42);
auto q = std::move(p);
int x = *p;  // UB
// ✅ 올바른 코드
auto p = std::make_unique<int>(42);
auto q = std::move(p);
int x = *q;  // q 사용

“Use-after-free” — 댕글링 포인터

원인: 로컬 객체의 포인터/참조 반환. 해결법:

// ❌ 잘못된 코드
const char* get_name() {
    std::string s = "hello";
    return s.c_str();
}
// ✅ 올바른 코드
std::string get_name() {
    return "hello";
}

“Data race” — 동기화 없이 공유

해결법:

// ❌ 잘못된 코드
int counter = 0;
std::thread t1([&]{ ++counter; });
std::thread t2([&]{ ++counter; });
// ✅ 올바른 코드
std::atomic<int> counter{0};
std::thread t1([&]{ ++counter; });
std::thread t2([&]{ ++counter; });

Rust 에러

”use of moved value”

해결법:

// ❌ 잘못된 코드
let s = String::from("hello");
let t = s;
println!("{}", s);  // 에러
// ✅ 올바른 코드: clone 또는 참조
let s = String::from("hello");
let t = s.clone();
println!("{}", s);  // OK

“borrowed value does not live long enough”

해결법:

// ❌ 잘못된 코드
// fn get_name() -> &str {
//     let s = String::from("hello");
//     &s
// }
// ✅ 올바른 코드
fn get_name() -> String {
    String::from("hello")
}

“cannot be sent between threads safely”

해결법:

// ❌ 잘못된 코드: Rc는 Send가 아님
// let r = Rc::new(42);
// thread::spawn(move || { println!("{}", r); });
// ✅ 올바른 코드: Arc 사용
let r = Arc::new(42);
let r_clone = r.clone();
thread::spawn(move || {
    println!("{}", r_clone);
});

“cannot borrow as mutable”

해결법:

// ❌ 잘못된 코드
// let mut v = vec![1, 2, 3];
// let r = &v[0];
// v.push(4);          // 에러: r이 아래에서 아직 쓰임
// println!("{}", r);
// ✅ 올바른 코드: 값을 복사해 빌림을 먼저 끝냄
let mut v = vec![1, 2, 3];
let x = v[0];
v.push(4);  // OK
println!("{}", x);

r을 push 이후에 다시 쓰지 않으면 이 코드는 에러가 나지 않습니다. Rust 2018부터 도입된 NLL(Non-Lexical Lifetimes) 덕분에 빌림은 스코프 끝이 아니라 마지막 사용 지점에서 끝나기 때문입니다. C++ 관점에서는 “push 뒤에 r을 쓰면 댕글링”이라는 규칙을 컴파일러가 정확히 그 조건에서만 적용하는 셈입니다.

”temporary value dropped while borrowed”

원인: 임시 값에서 얻은 참조를 문장이 끝난 뒤에도 쓰려 함. C++에서 std::string_view sv = std::string("hi") + "!";처럼 임시 문자열에 뷰를 걸어 두는 댕글링과 같은 종류입니다(C++은 이 코드를 컴파일하며, Clang이 경고를 내는 정도입니다).

// ❌ 잘못된 코드
// let r: &str = format!("hello {}", 1).as_str();  // format!이 만든 String은 이 문장 끝에서 drop
// println!("{}", r);                              // error[E0716]
// ✅ 올바른 코드: 소유 값을 변수에 묶어 수명을 늘림
let s = format!("hello {}", 1);
let r: &str = &s;
println!("{}", r);

“missing lifetime specifier”

원인: 참조를 받는 인자가 둘 이상이고 참조를 반환할 때, 반환값이 어느 인자에서 왔는지 컴파일러가 정할 수 없음. 인자가 하나뿐이면 수명 생략 규칙으로 자동 연결되므로 이 에러가 나지 않습니다.

// ❌ error[E0106]: x와 y 중 어디서 빌린 참조인지 모름
// fn longest(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } }
// ✅ 수명 파라미터로 "둘 중 짧은 쪽만큼 유효"를 명시
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
// 인자가 하나면 수명 생략으로 그대로 컴파일됨
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

C++에서 같은 longest를 const std::string&로 만들면 컴파일은 되지만, 호출부에서 임시 문자열을 넘기면 반환 참조가 바로 댕글링이 됩니다. Rust가 시그니처에 요구하는 'a는 C++에서 주석으로만 적던 “반환값은 인자보다 오래 살 수 없다”를 타입으로 옮긴 것입니다.

C++ Sanitizer 활용

Rust가 빌드 단계에서 거부하는 버그를 C++에서 잡으려면 Sanitizer를 CI에 넣는 것이 현실적인 대안입니다. 단, 테스트가 실제로 실행한 경로에서만 잡힌다는 한계는 메모리 안전성 비교의 증상 대조표에서 본 그대로입니다.

Sanitizer검출 대상빌드 옵션
AddressSanitizeruse-after-free, 댕글링, 버퍼 오버플로우-fsanitize=address
ThreadSanitizerData Race-fsanitize=thread
UBSannullptr 역참조, 부호 있는 정수 오버플로우-fsanitize=undefined
# CI 전용 옵션으로 켜기: ASan + UBSan 조합
option(USE_SANITIZERS "Enable ASan/UBSan" OFF)
if(USE_SANITIZERS)
    add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer)
    add_link_options(-fsanitize=address,undefined)
endif()

ASan과 TSan은 한 바이너리에 함께 켤 수 없습니다. 둘 다 메모리 주소 공간을 자기 방식으로 예약(섀도 메모리)하기 때문이며, CI에서는 ASan+UBSan 빌드와 TSan 빌드를 별도 job으로 나눠 돌립니다. 자세한 설정은 ASan·TSan(#41-2)을 참고하세요.


두 언어에서 지킬 코딩 습관

C++: 스마트 포인터·RAII·Sanitizer

  1. 스마트 포인터 일관 사용: raw 포인터 최소화, unique_ptr·shared_ptr 우선
  2. 이동 후 사용 금지: std::move 후 원본 접근 금지, Clang-Tidy로 검사
  3. RAII: 리소스 획득은 생성자, 해제는 소멸자
  4. const 정확성: 변경 없으면 const 사용
  5. Sanitizer CI 적용: ASan, TSan으로 버그 조기 발견

Rust: clone 최소화·unsafe 격리·Clippy

  1. clone() 최소화: 참조·빌림으로 대체
  2. Result·Option 처리: ? 연산자, match·if let 활용
  3. Rc vs Arc: 스레드 사용 시 Arc만
  4. unsafe 최소화: 경계를 좁게, 문서화
  5. Clippy: cargo clippy로 경고 수정

공통: 테스트·문서화·리뷰

  • 테스트: 단위 테스트·통합 테스트 작성
  • 문서화: 공개 API 문서화
  • 코드 리뷰: 메모리·동시성 관련 패턴 검토

C++에서 Rust로 옮길 때: 메모리 관념 대응

C++ 코드를 Rust로 옮길 때 가장 먼저 부딪히는 것은 문법보다 “이 포인터는 누가 소유하는가”를 매번 타입으로 답해야 한다는 점입니다. C++에서는 주석이나 팀 규칙으로 두던 약속을 Rust에서는 타입으로 적어야 컴파일이 됩니다. 아래 순서로 옮기면 borrow checker와 싸우는 시간을 줄일 수 있습니다.

단계 1: 관념을 타입으로 매핑

C++에서의 약속Rust 대응
”이 객체는 나만 해제한다”Box<T>, Vec<T>, String 등 소유 타입
”이 포인터는 보기만 한다”&T
”이 포인터로 수정도 한다, 그동안 아무도 안 본다”&mut T
”여러 곳에서 공유한다”Rc<T>(단일 스레드), Arc<T>(멀티스레드)
“공유하면서 수정도 한다”Rc<RefCell<T>>, Arc<Mutex<T>>
”이동 후엔 쓰지 않는다”언어가 강제, 따로 할 일 없음
”이 참조는 원본이 살아 있는 동안만 유효”수명 파라미터 'a

두 번째와 세 번째 줄의 구분이 C++ 개발자에게 가장 낯선 부분입니다. C++의 T*나 T&는 읽기와 쓰기를 모두 허용하지만, Rust는 “여러 명이 읽기” 또는 “한 명이 쓰기” 중 하나만 허용합니다. 옮기다가 막히는 지점은 대개 C++ 원본이 이 규칙을 암묵적으로 어기고 있던 곳이고, 그 자리가 원래 코드의 잠재 버그 후보이기도 합니다.

단계 2: 소유 포인터 전환

// C++
auto w = std::make_unique<Widget>();
process(std::move(w));
// w 사용 금지 (어기면 UB, 컴파일러는 모름)
// Rust
let w = Box::new(Widget::new());
process(w);
// w 사용 시 E0382 컴파일 에러

단계 3: 참조 반환은 수명으로

// C++: "반환값은 s보다 오래 살면 안 된다"는 약속이 주석에만 있음
const std::string& pick(const std::string& s) { return s; }
// Rust: 같은 약속이 시그니처에 들어감
fn pick<'a>(s: &'a str) -> &'a str { s }

단계 4: 공유 상태는 잠금과 함께 묶기

// C++: mutex와 데이터가 따로 있어, 락 없이 접근해도 컴파일됨
auto state = std::make_shared<State>();
std::thread t([state] { state->do_something(); });  // 내부 동기화는 개발자 몫
// Rust: 데이터가 Mutex 안에 있어, 락을 잡지 않으면 접근할 방법이 없음
let state = Arc::new(Mutex::new(State::new()));
let s2 = Arc::clone(&state);
thread::spawn(move || s2.lock().unwrap().do_something());

Mutex<T>가 데이터를 감싸는 구조라는 점이 C++의 std::mutex와 가장 크게 다릅니다. C++에서는 “이 mutex가 저 멤버를 보호한다”는 관계가 코드 어디에도 강제되지 않지만, Rust에서는 lock()이 돌려준 가드를 통해서만 안의 값에 닿을 수 있습니다.

옮길 때 자주 막히는 곳

  • 이터레이터 무효화 패턴: 순회 중 같은 컨테이너에 추가·삭제하던 코드는 그대로 옮겨지지 않습니다. retain, 인덱스 루프, 또는 “변경 목록을 모았다가 순회 후 적용” 방식으로 재설계합니다.
  • 부모·자식 상호 참조: 트리·그래프에서 양방향 포인터를 쓰던 구조는 Rc + Weak(메모리 안전성 비교의 순환 참조 항목 참고)로 가거나, 노드를 Vec에 두고 인덱스로 가리키는 방식이 더 단순한 경우가 많습니다.
  • clone()으로 일단 통과시키기: 초기 이식 단계에서는 괜찮은 전략이지만, 앞의 “성능 요구를 충족하지 못했다” 시나리오처럼 성능 문제로 돌아오므로 프로파일링 후 참조로 바꿀 곳을 정리합니다.

안전한 이동·수명 명시·버퍼 전달 패턴

C++에서 안전한 이동

[[nodiscard]] std::unique_ptr<Buffer> create_buffer() {
    return std::make_unique<Buffer>(1024);
}
void process() {
    auto buf = create_buffer();
    if (!buf) return;
    consume(std::move(buf));
    // buf 사용 금지
}

Rust에서 수명 명시

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

에러 처리

C++ (std::expected):

std::expected<Result, Error> parse(const std::string& input) {
    if (input.empty()) return std::unexpected(Error::Empty);
    return Result{};
}

Rust:

fn parse(input: &str) -> Result<ResultType, Error> {
    if input.is_empty() {
        return Err(Error::Empty);
    }
    Ok(ResultType::new())
}

스레드 안전 공유

C++:

struct SharedState {
    std::mutex mtx;
    int counter;
};
auto state = std::make_shared<SharedState>();
std::thread t([state] {
    std::lock_guard g(state->mtx);
    ++state->counter;
});

Rust:

let state = Arc::new(Mutex::new(0));
let state_clone = state.clone();
thread::spawn(move || {
    *state_clone.lock().unwrap() += 1;
});

버퍼 전달

C++:

void process(std::span<const uint8_t> data) {
    // data.data(), data.size() 사용
}

Rust:

fn process(data: &[u8]) {
    // data 사용
}

C++·Rust 코드 리뷰 점검 항목

  • C++: 이동 후 사용 금지, Clang-Tidy 검사
  • C++: 댕글링 방지 — 값 반환 또는 수명 명확화
  • C++: Data Race 방지 — atomic 또는 mutex
  • Rust: Rc vs Arc — 스레드 사용 시 Arc
  • Rust: 수명 에러 시 소유권 반환 또는 수명 파라미터
  • 양쪽: Sanitizer(ASan, TSan)로 C++ 경계 검증

C++와 Rust 선택 가이드

버그 유형별 C++·Rust 대응 요약

문제C++Rust
이동 후 사용통과 가능, UB컴파일 에러
댕글링 참조통과 가능, UB수명으로 컴파일 에러
Data Race통과, UBSend/Sync로 컴파일 에러
에러 처리예외·expectedResult·Option 강제
  • C++: 유연하고 생태계가 크지만, 메모리·동시성 오류는 개발자 책임과 도구(ASan, TSan)에 많이 의존합니다.
  • Rust: 컴파일이 더 까다롭지만, 같은 종류의 버그를 빌드 단계에서 줄일 수 있습니다.

언어 선택 가이드

상황권장
레거시 C++ 코드베이스가 큼C++ 유지 + Sanitizer·Clang-Tidy
새 프로젝트, 메모리 안전성 우선Rust
임베디드·리소스 극한C++ 또는 Rust no_std
동시성 중심 (서버·파이프라인)Rust (Send/Sync)
FFI·기존 라이브러리 연동cxx·bindgen 활용

성능: 컴파일 vs 런타임

항목C++Rust
컴파일 타임 검사제한적소유권·수명·Send/Sync
런타임 오버헤드없음없음
Sanitizer 사용 시ASan 약 2배, TSan 5~15배 느려짐 (공식 문서 기준)해당 없음
빌드 시간상대적으로 짧음borrow checker로 다소 김

같이 보면 좋은 글

자주 묻는 질문 (FAQ)

Q. C++의 std::move와 Rust의 move는 이동 후 원래 변수를 다루는 방식이 어떻게 다른가요?

A. C++에서 이동된 객체는 유효하지만 값이 정해지지 않은 상태로 남고, 컴파일러는 그 객체를 다시 사용하는 코드를 막지 않습니다. 그래서 이동한 unique_ptr을 역참조하는 use-after-move 버그가 런타임에야 드러납니다. Rust는 대입이나 함수 인자 전달이 기본적으로 이동이며, 이동된 변수를 다시 쓰면 use of moved value 컴파일 에러가 나므로 같은 실수가 빌드 단계에서 걸러집니다.

Q. C++에서 Rust 수준의 안전성을 얻으려면?

A. 스마트 포인터를 일관되게 사용하며, Clang-Tidy·AddressSanitizer·ThreadSanitizer를 CI에 적용하면 많은 버그를 사전에 잡을 수 있습니다.

Q. Rust의 borrow checker가 너무 까다로운데요?

A. 초기에는 clone()으로 우회할 수 있지만, 성능이 중요하면 참조와 수명을 익혀야 합니다. Rc·Arc로 소유권 공유를 명시하면 요구사항을 자연스럽게 만족할 수 있습니다. C++과 Rust의 소유권·메모리·에러 처리·동시성·성능 차이를 이해하면 기술 선택이 명확해집니다. 다음으로 Rust 메모리 안전성(#47-3)을 읽어보면 좋습니다. 다음 글: [C++ vs 타 언어 #47-2] C++ 개발자의 뇌 구조로 이해하는 Go 언어