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 스마트 포인터.
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
- 문제 시나리오
- 소유권·이동 비교
- 댕글링·use-after-free
- 동시성·Data Race
- 완전한 Rust vs C++ 메모리 비교표
- 자주 발생하는 에러와 해결법
- C++에서 Rust로 마이그레이션 가이드
- 프로덕션 패턴
- 정리
1. 문제 시나리오: 실무에서 겪는 메모리 버그
시나리오 1: “버퍼를 넘긴 뒤 다시 썼어요”
"네트워크 패킷을 파싱한 std::vector<uint8_t>를 다른 스레드로 넘겼는데,
원본을 다시 쓰다가 크래시가 났어요."
원인: C++에서 std::move로 이동한 뒤에도 원본 변수는 유효하지만 unspecified 상태입니다. 컴파일러는 “이동 후 사용 금지”를 강제하지 않아, 실수로 접근하면 런타임에 크래시할 수 있습니다. Rust에서는 이동 후 원본이 사용 불가로 처리되어 컴파일 에러가 납니다.
시나리오 2: “로컬 포인터를 반환했어요”
"함수에서 문자열을 만들고 c_str()를 반환했는데,
호출자가 사용할 때 이미 해제된 메모리를 가리키고 있어요."
원인: C++에서 std::string의 c_str()는 내부 버퍼의 포인터를 반환합니다. std::string이 소멸되면 그 포인터는 댕글링이 됩니다. Rust에서는 수명(lifetime)으로 “참조가 가리키는 값보다 참조가 더 오래 살 수 없다”를 검사해, 댕글링 가능한 코드는 컴파일이 되지 않습니다.
시나리오 3: “스레드에 Rc를 넘겼어요”
"Rc<T>를 스레드로 넘기려 했는데 Rust에서 컴파일 에러가 나요.
C++에서는 shared_ptr로 비슷하게 했을 때 잘 됐는데요."
원인: C++의 std::shared_ptr는 스레드 간 공유가 가능하지만, 내부 데이터에 대한 동시 접근은 보호하지 않습니다. Data Race는 undefined behavior입니다. Rust의 Rc는 non-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 순환 참조와 동일합니다. Rc는 weak reference로 순환을 끊어야 합니다. Rc::downgrade로 Weak를 만들면 순환 참조를 방지할 수 있습니다. 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 = 1 은 nullptr 역참조로 미정의 동작입니다. 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나 레거시 코드에서는 여전히 실수가 가능합니다.
s 는 get_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가 아님
});
}
코드 설명:
Rc는 non-atomic 참조 카운팅입니다. 스레드 간 안전하지 않습니다.thread::spawn은Send가 구현된 값만 넘길 수 있습니다.- 컴파일 에러: “
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 | 통과, UB | Send/Sync로 컴파일 에러 |
| 이중 해제 | 수동 관리 시 가능 | 소유권으로 불가능 |
| null 역참조 | 가능 | Option으로 컴파일 시 검사 |
| 이터레이터 무효화 | 런타임 UB | borrow 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 T | unsafe |
T& | &T / &mut T | 빌림 |
std::string | String | 소유 문자열 |
std::string_view | &str | 수명 있는 슬라이스 |
std::vector<T> | Vec<T> | 동적 배열 |
std::mutex | Mutex<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::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 사용
// 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_ptr→Box또는 소유권 -
shared_ptr→Arc(스레드) 또는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:
RcvsArc— 스레드 사용 시Arc - Rust: 수명 에러 시 소유권 반환(
String) 또는 수명 파라미터 - 양쪽: Sanitizer (ASan, TSan)로 C++ 경계 검증
9. 정리: 무엇이 다르게 잡히는가
| 문제 | C++ | Rust |
|---|---|---|
| 이동 후 사용 | 보통 통과 (경고만 있을 수 있음) | 컴파일 에러 |
| 댕글링 참조 | 통과 가능, 런타임 UB | 수명으로 컴파일 에러 |
| Data Race | 통과, UB | Send/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. cppreference와 The 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·상태 보유