Rust vs C++ 메모리 안전성 | 컴파일러 오류 차이 [#47-3]

Rust vs C++ 메모리 안전성 | 컴파일러 오류 차이 [#47-3]

이 글의 핵심

Rust vs C++ 메모리 안전성에 대한 실전 가이드입니다. 컴파일러 오류 차이 [#47-3] 등을 예제와 함께 상세히 설명합니다.

들어가며: “같은 버그를 C++은 통과, Rust는 컴파일 에러”

왜 비교하는가

C++ 실전 가이드 #44-2에서 C++과 Rust의 상호 운용·메모리 안전성을 다뤘습니다. 이 글은 동일한 시나리오(예: 네트워크 서버에서 버퍼·소유권을 다루는 코드)를 두 언어로 작성했을 때 컴파일 타임에 무엇이 걸리고 무엇이 통과하는지를 비교합니다. C++에서는 런타임에 터질 수 있는 댕글링·use-after-free·Data Race를 Rust는 소유권·Borrow checker로 컴파일 단계에서 막는 사례를 중심으로 정리합니다.

이 글에서 다루는 것:

  • 문제 시나리오: 실무에서 겪는 메모리 관련 버그 사례
  • 소유권·이동: C++ 이동 vs Rust 소유권·이동 시 접근 금지
  • 댕글링·use-after-free: C++에서 가능한 패턴 vs Rust에서 컴파일 에러
  • 동시성: Data Race가 C++에서는 UB, Rust에서는 타입 시스템으로 차단
  • 자주 발생하는 에러: 컴파일 에러·런타임 크래시 원인과 해결법
  • 마이그레이션 가이드: C++에서 Rust로 전환 시 메모리 모델 대응
  • 프로덕션 패턴: 안전한 메모리 사용을 위한 실전 패턴

관련 글: #44-2 C++와 Rust, #6 스마트 포인터.

개념을 잡는 비유

이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.


목차

  1. 문제 시나리오
  2. 소유권·이동 비교
  3. 댕글링·use-after-free
  4. 동시성·Data Race
  5. 완전한 Rust vs C++ 메모리 비교표
  6. 자주 발생하는 에러와 해결법
  7. C++에서 Rust로 마이그레이션 가이드
  8. 프로덕션 패턴
  9. 정리

1. 문제 시나리오: 실무에서 겪는 메모리 버그

시나리오 1: “버퍼를 넘긴 뒤 다시 썼어요”

"네트워크 패킷을 파싱한 std::vector<uint8_t>를 다른 스레드로 넘겼는데,
원본을 다시 쓰다가 크래시가 났어요."

원인: C++에서 std::move로 이동한 뒤에도 원본 변수는 유효하지만 unspecified 상태입니다. 컴파일러는 “이동 후 사용 금지”를 강제하지 않아, 실수로 접근하면 런타임에 크래시할 수 있습니다. Rust에서는 이동 후 원본이 사용 불가로 처리되어 컴파일 에러가 납니다.

시나리오 2: “로컬 포인터를 반환했어요”

"함수에서 문자열을 만들고 c_str()를 반환했는데,
호출자가 사용할 때 이미 해제된 메모리를 가리키고 있어요."

원인: C++에서 std::stringc_str()는 내부 버퍼의 포인터를 반환합니다. std::string이 소멸되면 그 포인터는 댕글링이 됩니다. Rust에서는 수명(lifetime)으로 “참조가 가리키는 값보다 참조가 더 오래 살 수 없다”를 검사해, 댕글링 가능한 코드는 컴파일이 되지 않습니다.

시나리오 3: “스레드에 Rc를 넘겼어요”

"Rc<T>를 스레드로 넘기려 했는데 Rust에서 컴파일 에러가 나요.
C++에서는 shared_ptr로 비슷하게 했을 때 잘 됐는데요."

원인: C++의 std::shared_ptr는 스레드 간 공유가 가능하지만, 내부 데이터에 대한 동시 접근은 보호하지 않습니다. Data Race는 undefined behavior입니다. Rust의 Rcnon-atomic이라 스레드 간 전달이 Send가 아니어서 컴파일 에러로 막습니다. 스레드 안전한 공유는 Arc를 써야 합니다.

시나리오 4: “unique_ptr 이동 후 역참조했어요”

"unique_ptr를 move한 뒤 원본을 *p로 쓰다가 nullptr 역참조로 크래시했어요."

원인: C++에서 std::move(unique_ptr) 후 원본은 nullptr가 됩니다. *p는 nullptr 역참조로 미정의 동작입니다. 컴파일러는 이를 막지 않습니다. Rust에서는 Box를 이동한 뒤 원본은 사용 불가로, 같은 실수를 하면 빌드가 실패합니다.

시나리오 5: “이터레이터 무효화”

"vector를 순회하면서 push_back을 했더니, 이터레이터가 무효화되어 크래시가 났어요."

원인: C++에서 std::vector는 재할당 시 이터레이터가 무효화됩니다. 반복문 중 삽입·삭제는 규칙을 지키지 않으면 UB입니다. Rust의 Vec는 반복 중 수정을 borrow checker로 막아, 같은 패턴을 시도하면 컴파일 에러가 납니다.

시나리오 6: “Mutex 없이 공유 변수”

"여러 스레드가 같은 카운터를 증가시키는데, mutex 없이 했더니
결과가 매번 달라요."

원인: C++에서 Data Race는 undefined behavior입니다. 컴파일러가 통과시키고, 런타임에 이상 동작·크래시가 날 수 있습니다. ThreadSanitizer로 실행 시에만 발견 가능한 경우가 많습니다. Rust에서는 Send·Sync로 “스레드 안전하지 않은 공유”를 컴파일 단계에서 차단합니다.

시나리오 7: “순환 참조로 메모리 누수”

"Rc로 A와 B가 서로를 가리키게 했는데, 참조 카운트가 0이 안 되어
메모리가 해제되지 않아요."

원인: C++의 shared_ptr 순환 참조와 동일합니다. Rcweak reference로 순환을 끊어야 합니다. Rc::downgradeWeak를 만들면 순환 참조를 방지할 수 있습니다. C++에서는 weak_ptr로 대응합니다.

시나리오 8: “Option 없이 null 체크”

"C++에서 포인터가 null인지 확인 안 하고 역참조했어요."

원인: C++의 raw 포인터는 null일 수 있고, 역참조 시 UB입니다. Rust의 Option<T>는 “값이 있거나 없음”을 타입으로 표현해, None을 역참조하려 하면 컴파일 에러가 납니다. unwrap()은 런타임 패닉이지만, if let·match로 처리하면 안전합니다.


2. 소유권·이동 비교

C++

  • std::move 후 객체는 “moved-from” 상태. 표준은 “유효하지만 unspecified”만 보장합니다. 재사용하면 미정의 동작이 될 수 있고, 컴파일러는 대부분 경고만 하거나 아무것도 하지 않습니다.
  • unique_ptr 이동 후 원본은 nullptr. 사용하면 런타임에 크래시할 수 있으나, 컴파일은 됩니다.

std::move(p)p 는 nullptr 이 되므로 *p = 1nullptr 역참조로 미정의 동작입니다. C++에서는 “이동된 객체를 다시 쓰지 말라”는 계약을 컴파일러가 강제하지 않아, 이런 코드도 컴파일은 되고 런타임에만 터질 수 있습니다. Rust 는 이동 후 원본을 쓰면 컴파일 에러로 막습니다.

auto p = std::make_unique<int>(42);
auto q = std::move(p);
*p = 1;  // 컴파일 통과, 런타임 위험 (nullptr 역참조)

코드 설명:

  • std::move(p): p의 소유권을 q로 이동합니다. 이동 후 p는 nullptr입니다.
  • *p = 1: nullptr를 역참조하므로 미정의 동작입니다. 컴파일러는 이를 막지 않습니다.
  • Clang: -Wpessimizing-move 등 일부 경고만 있을 수 있음. MSVC: 경고 없이 통과 가능.

Rust

  • 이동이 기본. 값이 이동하면 원래 변수는 사용 불가입니다. 컴파일러가 “이미 이동된 값”을 쓰려 하면 컴파일 에러입니다.
  • 소유권이 한 곳에만 있어, “누가 해제하는지”가 타입 시스템으로 보장됩니다.
let s = String::from("hello");
let t = s;
println!("{}", s);  // 컴파일 에러: s는 이동됨

코드 설명:

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

소유권 비교 다이어그램

flowchart TB
    subgraph cpp["C++ 이동"]
        C1[unique_ptr p] -->|std::move| C2[unique_ptr q]
        C2 --> C3[p = nullptr]
        C3 --> C4["*p 사용 가능br/(컴파일 통과)"]
        C4 --> C5[런타임 크래시]
    end

    subgraph rust["Rust 이동"]
        R1[String s] -->|let t = s| R2[String t]
        R2 --> R3[s 사용 불가]
        R3 --> R4["s 사용 시도"]
        R4 --> R5[컴파일 에러]
    end

비교

  • C++: 이동 후 재사용을 컴파일러가 막지 않음. 규칙을 지키는 것은 개발자 몫.
  • Rust: 이동 후 사용을 문법·타입으로 금지. 같은 실수를 하면 빌드가 안 됩니다.

3. 댕글링·use-after-free

C++

  • 로컬 버퍼 포인터를 반환하면 댕글링. 컴파일러가 반드시 경고하는 것은 아니고, 런타임에 크래시나 UB가 발생합니다.
  • 스마트 포인터를 쓰면 소유권이 명확해져 댕글링을 줄일 수 있지만, raw 포인터를 넘겨주는 API나 레거시 코드에서는 여전히 실수가 가능합니다.

sget_name() 반환 시 소멸되므로 s.c_str() 이 가리키던 메모리는 해제됩니다. 그 주소를 반환하면 호출자가 받은 포인터는 댕글링이 되고, C++에서는 이걸 타입만으로 막지 못해 컴파일이 될 수 있습니다. std::string 을 값으로 반환하거나 string_view 를 쓸 때 수명을 맞추는 식으로 설계해야 합니다.

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

코드 설명:

  • s.c_str(): std::string 내부 버퍼의 포인터를 반환합니다.
  • 함수 반환 시 s가 소멸되므로 해당 메모리는 해제됩니다.
  • 반환된 포인터는 댕글링 포인터입니다. 사용 시 use-after-free.

Rust

  • 참조의 수명(lifetime) 이 스코프를 넘어가면 컴파일 에러입니다. “이 참조가 가리키는 값보다 참조가 더 오래 살 수 없다”를 컴파일러가 검사합니다.
  • 수명 규칙을 만족하지 않으면 빌드가 실패하므로, 댕글링 포인터가 나가는 코드는 작성할 수 없습니다.
fn get_name() -> &str {
    let s = String::from("hello");
    &s  // 컴파일 에러: s가 반환 후 drop되는데 참조가 살 수 없음
}

코드 설명:

  • &s: s에 대한 참조를 반환하려 합니다.
  • s는 함수 종료 시 drop되므로, 반환된 참조는 댕글링이 됩니다.
  • 컴파일 에러: “borrowed value does not live long enough”
  • 해결: String을 소유권으로 반환하거나, &'static str 등 수명을 명시해야 합니다.

수명 검사 다이어그램

sequenceDiagram
    participant Caller
    participant get_name
    participant s
    participant Return

    Caller->>get_name: 호출
    get_name->>s: String 생성
    get_name->>Return: &s 반환 시도
    Note over get_name: s drop됨
    Return-->>Caller: 댕글링 포인터
    Note over Caller: Rust: 컴파일 에러
    Note over Caller: C++: 런타임 UB

비교

  • C++: 댕글링을 컴파일 단계에서 보장하지 않음. 정적 분석·Sanitizer로 일부만 잡을 수 있음.
  • Rust: 수명 분석으로 댕글링 가능한 코드는 컴파일 불가.

4. 동시성·Data Race

C++

  • Data Race는 표준상 undefined behavior. Mutex·Atomic 등으로 동기화하지 않으면 컴파일러는 통과시키고, 런타임에 이상 동작·크래시가 날 수 있습니다. ThreadSanitizer로 실행 시에만 발견 가능한 경우가 많습니다.
#include <thread>
#include <mutex>

int counter = 0;  // 공유 변수

void increment() {
    for (int i = 0; i < 1000000; ++i) {
        ++counter;  // Data Race! 컴파일 통과, UB
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    // counter 값은 예측 불가
}

코드 설명:

  • ++counter: 여러 스레드가 동시에 접근하므로 Data Race입니다.
  • C++ 표준: Data Race는 UB. 컴파일러는 이를 막지 않습니다.
  • 해결: std::atomic<int> 또는 std::mutex 사용.

Rust

  • Send·Sync 트레이트로 “이 타입이 스레드 간 전달·공유가 안전한가”를 타입 시스템에 반영합니다. Rc처럼 스레드 안전하지 않은 타입을 다른 스레드로 넘기려 하면 컴파일 에러입니다. 따라서 “Data Race가 나는 코드”를 컴파일할 수 없도록 설계되어 있습니다.
use std::thread;
use std::rc::Rc;

fn main() {
    let r = Rc::new(42);
    let r_clone = r.clone();
    thread::spawn(move || {
        println!("{}", r_clone);  // 컴파일 에러: Rc는 Send가 아님
    });
}

코드 설명:

  • Rcnon-atomic 참조 카운팅입니다. 스레드 간 안전하지 않습니다.
  • thread::spawnSend가 구현된 값만 넘길 수 있습니다.
  • 컴파일 에러: “Rc<i32> cannot be sent between threads safely”
  • 해결: Arc를 사용하면 Send + Sync입니다.
use std::thread;
use std::sync::Arc;

fn main() {
    let r = Arc::new(42);
    let r_clone = r.clone();
    thread::spawn(move || {
        println!("{}", r_clone);  // OK
    });
}

비교

  • C++: Data Race는 타입으로 막지 않음. 규칙·코드 리뷰·TSan에 의존.
  • Rust: Send/Sync로 “스레드 안전하지 않은 공유”를 컴파일 단계에서 차단.

5. 완전한 Rust vs C++ 메모리 비교표

핵심 차이 요약

문제C++Rust
이동 후 사용보통 통과 (경고만 있을 수 있음)컴파일 에러
댕글링 참조통과 가능, 런타임 UB수명으로 컴파일 에러
Data Race통과, UBSend/Sync로 컴파일 에러
이중 해제수동 관리 시 가능소유권으로 불가능
null 역참조가능Option으로 컴파일 시 검사
이터레이터 무효화런타임 UBborrow checker로 컴파일 에러

메모리 모델 비교

flowchart LR
    subgraph cpp["C++"]
        C1[수동/RAII]
        C2[스마트 포인터]
        C3[컴파일러 검사 없음]
        C1 --> C2
        C2 --> C3
    end

    subgraph rust["Rust"]
        R1[소유권]
        R2[빌림 검사]
        R3[Send/Sync]
        R1 --> R2
        R2 --> R3
    end

    cpp -->|실수 시| UB[런타임 UB]
    rust -->|실수 시| CE[컴파일 에러]

타입 대응표

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::mutexMutex<T>뮤텍스
std::atomic<T>AtomicU32원자 연산

이터레이터 무효화: C++ vs Rust

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);  // 컴파일 에러: v를 이미 불변으로 빌림 중
    }
}

해결: C++에서는 인덱스 기반 루프나 erase 반환값 사용. Rust에서는 collect로 필터링하거나 인덱스 루프를 씁니다.

실전 예시: 네트워크 버퍼 처리

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

C++ (위험한 패턴):

// 패킷 수신 후 파싱 스레드로 전달
void on_packet_received(std::vector<uint8_t> packet) {
    auto worker = [packet = std::move(packet)]() {
        parse_packet(packet);
    };
    thread_pool.submit(std::move(worker));
    // packet 사용 불가 — 하지만 실수로 packet.size() 호출하면?
    // 컴파일 통과, 런타임 UB (moved-from vector)
}

Rust (안전):

fn on_packet_received(packet: Vec<u8>) {
    let worker = move || {
        parse_packet(&packet);
    };
    thread_pool.submit(worker);
    // packet.size() 호출 시도 → 컴파일 에러: use of moved value
}

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


6. 자주 발생하는 에러와 해결법

C++ 에러

문제 1: “Segmentation fault” — nullptr 역참조

원인: std::moveunique_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 사용
// p 사용 금지

문제 2: “Use-after-free” — 댕글링 포인터

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

해결법:

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

// ✅ 올바른 코드: 소유권 반환
std::string get_name() {
    return "hello";
}

// ✅ 또는 string_view (호출자가 수명 관리)
std::string_view get_name(std::string& storage) {
    storage = "hello";
    return storage;
}

문제 3: “Data race” — 동기화 없이 공유

원인: 여러 스레드가 같은 변수를 mutex 없이 수정.

해결법:

// ❌ 잘못된 코드
int counter = 0;
std::thread t1([&]{ ++counter; });
std::thread t2([&]{ ++counter; });

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

// ✅ 또는 mutex
std::mutex mtx;
int counter = 0;
std::thread t1([&]{ std::lock_guard g(mtx); ++counter; });
std::thread t2([&]{ std::lock_guard g(mtx); ++counter; });

Rust 에러

문제 1: “use of moved value”

원인: 이동 후 원본 사용.

해결법:

// ❌ 잘못된 코드
let s = String::from("hello");
let t = s;
println!("{}", s);  // 에러

// ✅ 올바른 코드: 복사 (Copy 타입) 또는 clone
let s = String::from("hello");
let t = s.clone();
println!("{}", s);  // OK

// ✅ 또는 참조로 빌림
let s = String::from("hello");
let t = &s;
println!("{}", s);  // OK

문제 2: “borrowed value does not live long enough”

원인: 수명이 맞지 않는 참조 반환.

해결법:

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

// ✅ 올바른 코드: 소유권 반환
fn get_name() -> String {
    String::from("hello")
}

// ✅ 또는 수명 파라미터
fn get_name<'a>(s: &'a str) -> &'a str {
    s
}

문제 3: “cannot be sent between threads safely”

원인: Rc 등 non-Send 타입을 스레드로 전달.

해결법:

// ❌ 잘못된 코드
let r = Rc::new(42);
thread::spawn(move || {
    println!("{}", r);  // Rc는 Send가 아님
});

// ✅ 올바른 코드: Arc 사용
let r = Arc::new(42);
let r_clone = r.clone();
thread::spawn(move || {
    println!("{}", r_clone);  // OK
});

문제 4: “cannot borrow as mutable”

원인: 이미 불변 빌림이 있는 상태에서 가변 빌림 시도.

해결법:

// ❌ 잘못된 코드
let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4);  // 에러: r이 v를 빌리고 있음

// ✅ 올바른 코드: 스코프 분리
let mut v = vec![1, 2, 3];
let r = &v[0];
let x = *r;  // r 사용 완료
v.push(4);   // OK

문제 5: “temporary value dropped while borrowed”

원인: 임시 값에 대한 참조를 오래 유지하려 함.

해결법:

// ❌ 잘못된 코드
let r: &str = format!("hello").as_str();  // format!()은 임시 String

// ✅ 올바른 코드: 소유권 유지
let s = format!("hello");
let r: &str = &s;

코드 설명: format!("hello")는 **임시 String**을 만듭니다. 이 임시 값은 문장 끝에서 drop되므로, as_str()로 얻은 &str은 그보다 오래 살 수 없습니다. let s = ...로 소유권을 변수에 두면 s의 수명 동안 참조를 유지할 수 있습니다.

문제 6: “cannot infer an appropriate lifetime”

원인: 수명이 복잡할 때 컴파일러가 추론 실패.

해결법:

// ❌ 잘못된 코드
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}
// 에러: 반환 타입에 수명 필요

// ✅ 올바른 코드: 수명 명시
fn first_word<'a>(s: &'a str) -> &'a str {
    s.split_whitespace().next().unwrap_or("")
}

C++ Sanitizer 활용 가이드

C++에서 Rust 수준의 런타임 검증을 얻으려면 Sanitizer를 CI에 적용하는 것이 효과적입니다.

Sanitizer검출 대상빌드 옵션
AddressSanitizer (ASan)use-after-free, 댕글링, 버퍼 오버플로우-fsanitize=address
ThreadSanitizer (TSan)Data Race-fsanitize=thread
UndefinedBehaviorSanitizer (UBSan)nullptr 역참조, 정수 오버플로우-fsanitize=undefined

CMake 예시:

# Debug/CI 빌드에서 Sanitizer 활성화
if(CMAKE_BUILD_TYPE STREQUAL "Debug" OR USE_SANITIZERS)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address,undefined -fno-omit-frame-pointer")
    set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address,undefined")
endif()

주의: ASan과 TSan은 동시에 사용할 수 없습니다. 각각 별도 빌드로 실행해 검증합니다.


7. C++에서 Rust로 마이그레이션 가이드

단계 1: 메모리 모델 개념 매핑

C++ 관념Rust 대응
”이 객체는 내가만 해제한다”Box<T>, Vec<T> 등 소유 타입
”이 포인터는 참조만 한다”&T, &mut T
”여러 곳에서 공유한다”Arc<T> (스레드) 또는 Rc<T> (단일)
“이동 후 쓰지 않는다”Rust가 강제 — 이동 후 사용 불가
”이 참조는 유효한 동안만”수명(lifetime)

단계 2: 포인터 전환

// C++
std::unique_ptr<Widget> w = std::make_unique<Widget>();
process(std::move(w));
// w 사용 불가 (규칙 위반 시 UB)
// Rust
let w = Box::new(Widget::new());
process(w);
// w 사용 불가 (컴파일 에러)

단계 3: 참조 반환 → 수명

// C++
const std::string& get_ref(const std::string& s) {
    return s;  // 호출자가 s 수명 관리
}
// Rust
fn get_ref<'a>(s: &'a str) -> &'a str {
    s  // 수명 'a로 연결
}

단계 4: 동시성

// C++
std::shared_ptr<State> state = std::make_shared<State>();
std::thread t([state] {
    state->do_something();  // 내부 동기화 필요
});
// Rust
let state = Arc::new(Mutex::new(State::new()));
let state_clone = state.clone();
thread::spawn(move || {
    state_clone.lock().unwrap().do_something();
});

마이그레이션 체크리스트

  • unique_ptrBox 또는 소유권
  • shared_ptrArc (스레드) 또는 Rc (단일)
  • raw 포인터 반환 → String/Vec 소유권 또는 수명 명시
  • std::move 후 사용 → Rust에서는 불가능하므로 제거
  • mutex 없는 공유 → Mutex<T> 또는 Arc<Mutex<T>>
  • 이터레이터 무효화 패턴 → borrow checker가 막음, 재설계

8. 프로덕션 패턴

패턴 1: C++에서 안전한 이동 사용

원칙: 이동 후 원본을 절대 사용하지 않음. [[nodiscard]]와 정적 분석으로 보완.

[[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 사용 금지 — Clang-Tidy 등으로 검사
}

패턴 2: Rust에서 수명 명시

원칙: 복잡한 수명은 'a 등으로 명시해 컴파일러 오류를 줄임.

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

패턴 3: 에러 처리

C++: 예외 또는 std::expected (C++23)

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

Rust: Result 타입

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

패턴 4: 스레드 안전 공유

C++: shared_ptr + mutex 또는 atomic

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: Arc<Mutex<T>>

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

패턴 5: 버퍼 전달

원칙: 복사 없이 포인터+길이로 전달.

C++:

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

Rust:

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

구현 체크리스트

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

9. 정리: 무엇이 다르게 잡히는가

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

실제 네트워크 서버 코드에서 “버퍼를 넘기고 나서 다시 쓴다”, “다른 스레드에 스레드 로컬 타입을 넘긴다” 같은 패턴은 C++에서는 한 번 실수하면 런타임에 드러나고, Rust에서는 타입·수명·Send/Sync 때문에 먼저 컴파일 에러가 나는 경우가 많습니다. 이 차이를 이해하면 “언제 Rust를 고려할지”, “C++에서 어디를 더 엄격하게 짤지”를 결정하는 데 도움이 됩니다.

성능: 컴파일 타임 vs 런타임

항목C++Rust
컴파일 타임 검사제한적 (타입만)소유권·수명·Send/Sync
런타임 오버헤드없음 (Sanitizer 제외)없음 (제로 코스트)
Sanitizer 사용 시2~3배 느림, 메모리 2배해당 없음
빌드 시간상대적으로 짧음borrow checker로 다소 김

Rust의 메모리 안전성은 런타임 비용 없이 컴파일 타임에 보장됩니다. C++에서 같은 수준의 검증을 얻으려면 Sanitizer를 돌려야 하며, 이때 성능·메모리 오버헤드가 발생합니다.

언어 선택 가이드

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

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
  • Rust 메모리 안전성 완벽 가이드 | 소유권·Borrow checker·수명·unsafe 실전
  • C++와 Rust: 두 언어의 상호 운용성과 Memory Safety 논쟁의 실체 [#44-2]

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


이 글에서 다루는 키워드 (관련 검색어)

Rust C++ 메모리, 메모리 안전성, 소유권, Borrow checker, Send Sync, 댕글링 포인터 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 동일한 로직의 네트워크 서버를 C++과 Rust로 작성했을 때, 컴파일러가 잡아내는 오류와 보장하는 메모리 안전성의 차이를 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

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

A. 스마트 포인터를 일관되게 사용하고, Clang-Tidy·AddressSanitizer·ThreadSanitizer를 CI에 적용하면 많은 버그를 사전에 잡을 수 있습니다. raw 포인터 사용을 최소화하고, 이동 후 사용을 금지하는 규칙을 팀에 정착시키는 것이 중요합니다.

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

A. 초기에는 clone()으로 우회할 수 있지만, 성능이 중요하면 참조수명을 익혀야 합니다. Rc·Arc소유권 공유를 명시하면, borrow checker가 요구하는 제약을 자연스럽게 만족할 수 있습니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreferenceThe Rust Book을 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: Rust와 C++의 메모리 안전성·컴파일러 검사 차이를 이해하면 언어 선택에 도움이 됩니다. 다음으로 Redis 클론(#48-1)를 읽어보면 좋습니다.

이전 글: [C++ vs 타 언어 #47-2] C++ 개발자의 뇌 구조로 이해하는 Go 언어

다음 글: [실전 딥다이브 #48-1] 나만의 Redis 클론 코딩: Modern C++ 기반 인메모리 Key-Value 스토어


관련 글

  • C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
  • Rust 메모리 안전성 완벽 가이드 | 소유권·Borrow checker·수명·unsafe 실전
  • C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]
  • C++ 개발자의 뇌 구조로 이해하는 Go 언어 [#47-2]
  • C++ 함수 객체(Functor) 완벽 가이드 | operator·상태 보유