C++와 Rust: 두 언어의 상호 운용성과 Memory Safety 논쟁의 실체
들어가며: “Rust로 갈아타야 하나요?”
Rust가 시스템 프로그래밍, 임베디드, WebAssembly 영역에서 대안으로 자리 잡으면서 “C++ vs Rust”와 메모리 안전성(Memory Safety) 논의가 자주 나옵니다. 하지만 실무에서는 한쪽만 쓰기보다, 기존 C++ 코드를 유지하면서 새 모듈만 Rust로 작성하는 경우가 더 많습니다. 이때 두 언어는 C ABI(함수 호출 규약과 구조체 레이아웃 같은 이진 호환 규칙)를 경계로 삼고, FFI(Foreign Function Interface)로 서로를 호출합니다.
이 글에서는 실제로 부딪히는 상황에서 출발해, C ABI를 경계로 C++와 Rust가 서로를 호출하는 방법, 구조체와 문자열을 넘기는 예제, bindgen과 cxx 같은 도구, 자주 나는 에러와 경계 설계 패턴을 차례로 살펴봅니다.
레거시 C++를 Rust에서, Rust 모듈을 C++에서: 연동이 필요한 상황
시나리오 1: “레거시 C++ 라이브러리를 Rust에서 써야 해요”
"10년 전에 만든 C++ 암호화 라이브러리가 있습니다. 검증도 됐으며, 재작성 비용이 너무 커요."
"새 프로젝트는 Rust로 하는데, 이 라이브러리를 그대로 쓰고 싶습니다."
원인: 검증된 C++ 라이브러리를 Rust로 완전히 재작성하면 시간과 비용이 들고, 새 구현을 다시 검증해야 합니다. C ABI 경계만 만들어 FFI로 호출하면 기존 코드를 그대로 활용할 수 있습니다.
시나리오 2: “Rust로 만든 핵심 모듈을 C++ 앱에 넣고 싶어요”
"파서·시리얼라이저 같은 핵심 로직을 Rust로 새로 짰습니다. 메모리 안전하고 테스트도 잘 됐습니다."
"기존 C++ GUI 앱에서 이 모듈을 호출해야 해요."
원인: 입력 파싱처럼 외부 데이터를 다루는 코드는 메모리 버그가 보안 문제로 이어지기 쉬워서, 이런 부분만 Rust로 옮기는 전략이 효과적입니다. C++ 앱은 Rust 코드를 정적 라이브러리로 링크하거나 동적 라이브러리(.so/.dll/.dylib)로 로드해 호출합니다.
시나리오 3: “메모리 소유권이 FFI 경계에서 꼬여요”
"C++에서 할당한 버퍼를 Rust에 넘겼는데, C++에서 먼저 free 해버렸습니다."
"Rust에서 반환한 문자열을 C++에서 쓰다가 use-after-free가 났습니다."
원인: FFI 경계를 넘는 메모리의 소유권이 문서화되지 않으면 누가 할당하고 누가 해제하는지 불분명해집니다. 계약을 명확히 하고, 할당한 쪽이 해제도 책임지는 규칙을 두어야 합니다.
시나리오 4: “템플릿·예외·가상 함수를 그대로 노출하고 싶어요”
"C++의 std::vector<int>를 Rust에 넘기고 싶습니다."
"C++ 예외를 Rust로 전달하고 싶습니다."
원인: C++ ABI는 컴파일러와 표준 라이브러리 구현마다 다르고, Rust는 C++ ABI를 전혀 알지 못합니다. std::vector의 내부 레이아웃, 예외 전파, vtable 구조는 경계를 넘길 수 없으므로 포인터와 길이, 에러 코드 같은 C 스타일 래퍼로 경계를 두는 것이 안전합니다.
시나리오 5: “빌드 시스템이 C++와 Rust를 같이 돌리기 어려워요”
"CMake로 C++ 빌드하며, Cargo로 Rust 빌드하는데, 링크 순서가 맞지 않아요."
"크로스 컴파일할 때 두 툴체인이 충돌해요."
원인: C++와 Rust는 각자 다른 빌드 시스템을 쓰므로, 한쪽이 다른 쪽을 호출하는 통합 빌드(CMake에서 Cargo를 부르거나 Cargo의 build.rs에서 C++를 컴파일)를 설계해야 합니다.
시나리오 6: “팀에 Rust 경험이 없는데 점진적으로 도입하고 싶어요”
"전면 이전은 위험하며, 새 기능만 Rust로 시험해보고 싶습니다."
"경계를 어떻게 나누면 리스크를 줄일 수 있을까요?"
원인: 이런 팀에는 점진적 도입이 현실적입니다. C ABI로 경계를 명확히 긋고 작은 모듈부터 Rust로 옮기면 학습 부담을 나눠 가질 수 있습니다.
C++와 Rust 상호 운용 (FFI)
C ABI가 경계다
- Rust는
extern "C"로 C ABI 함수를 호출할 수도, 노출할 수도 있습니다. C++ 쪽에서extern "C"로 내보낸 함수는 Rust의extern "C" { ... }블록에 선언해 호출하고, 반대로 Rust에서#[no_mangle] pub extern "C" fn으로 내보낸 함수는 C++에서 일반 C 함수처럼 부릅니다. - 템플릿, 예외, 가상 함수 같은 C++ 고유 기능은 ABI가 컴파일러마다 달라 경계를 넘길 수 없으므로, C 스타일 래퍼로 감싸야 합니다.
- 어느 방향이든 사이에 C 레이어를 두고, 누가 메모리를 할당하고 해제하는지에 대한 소유권 계약을 문서화하는 것이 핵심입니다.
- bindgen은 C/C++ 헤더에서 Rust 선언을 자동 생성하고, cxx 크레이트는 C++와 Rust 사이의 타입 변환과 예외 전달을 안전한 형태로 생성해 줍니다.
FFI 호출 시퀀스 (C++ → Rust)
sequenceDiagram
participant CPP as C++ 앱
participant C_ABI as C 래퍼
participant RUST as Rust 라이브러리
CPP->>C_ABI: extern "C" rust_add(3, 5)
C_ABI->>RUST: #[no_mangle] rust_add 호출
RUST->>RUST: a + b 계산
RUST-->>C_ABI: 8 반환
C_ABI-->>CPP: 8 반환
FFI 아키텍처 다이어그램
flowchart TB
subgraph cpp[C++ 영역]
CPP_APP[C++ 애플리케이션]
CPP_LIB[C++ 라이브러리]
end
subgraph c_abi["C ABI 경계 (extern #quot;C#quot;)"]
C_WRAPPER[C 스타일 래퍼]
end
subgraph rust[Rust 영역]
RUST_LIB[Rust 라이브러리]
RUST_APP[Rust 애플리케이션]
end
CPP_APP -->|호출| C_WRAPPER
C_WRAPPER -->|호출| RUST_LIB
RUST_APP -->|호출| C_WRAPPER
C_WRAPPER -->|호출| CPP_LIB
기본 원리: extern “C”와 #[no_mangle]
C++에서 C ABI로 노출할 때는 extern "C"로 이름 맹글링(name mangling)을 막고 C 호출 규약을 사용합니다.
// cpp_math.h - C++에서 C ABI로 노출
#ifdef __cplusplus
extern "C" {
#endif
// 이름 맹글링 없이 "add"로 심볼 노출
int add(int a, int b);
double compute_hash(const char* data, size_t len);
#ifdef __cplusplus
}
#endif
// cpp_math.cpp - 구현
#include "cpp_math.h"
extern "C" {
int add(int a, int b) {
return a + b;
}
double compute_hash(const char* data, size_t len) {
double result = 0.0;
for (size_t i = 0; i < len; ++i) {
result = result * 31.0 + static_cast<unsigned char>(data[i]);
}
return result;
}
}
Rust에서는 extern "C" 블록에 같은 시그니처를 선언하고 unsafe 블록 안에서 호출합니다. (Rust 2024 에디션에서는 블록을 unsafe extern "C"로 써야 합니다.)
// Rust에서 C ABI 함수 선언
#[link(name = "cpp_math")]
extern "C" {
fn add(a: i32, b: i32) -> i32;
fn compute_hash(data: *const u8, len: usize) -> f64;
}
fn main() {
let result = unsafe { add(3, 5) };
println!("3 + 5 = {}", result); // 8
let s = b"hello";
let hash = unsafe { compute_hash(s.as_ptr(), s.len()) };
println!("hash = {}", hash);
}
양방향 호출, 구조체 전달, bindgen, cxx 브리지 예제
예제 1: C++ → Rust 호출 (Rust 라이브러리를 C++에서 사용)
목표: Rust로 만든 라이브러리를 C++ 앱에서 동적 로드해 호출합니다. Rust 라이브러리 (cdylib):
// src/lib.rs - Rust cdylib (crate-type은 Cargo.toml에서 지정)
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]
pub extern "C" fn rust_greeting() -> *const c_char {
// 정적 문자열: 수명이 프로그램 전체이므로 안전
b"Hello from Rust!\0".as_ptr() as *const c_char
}
Cargo.toml:
[package]
name = "rust_ffi_lib"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
C++ 호출 측:
// main.cpp - C++에서 Rust 라이브러리 호출
#include <iostream>
#include <dlfcn.h> // Linux/macOS. Windows는 LoadLibrary
int main() {
void* handle = dlopen("./librust_ffi_lib.so", RTLD_LAZY); // macOS: .dylib
if (!handle) {
std::cerr << "dlopen failed: " << dlerror() << "\n";
return 1;
}
auto add_fn = (int(*)(int, int)) dlsym(handle, "rust_add");
auto greeting_fn = (const char*(*)()) dlsym(handle, "rust_greeting");
if (add_fn && greeting_fn) {
std::cout << "rust_add(3, 5) = " << add_fn(3, 5) << "\n";
std::cout << "greeting: " << greeting_fn() << "\n";
}
dlclose(handle);
return 0;
}
빌드:
# Rust 라이브러리 빌드
cargo build --release
# C++ 빌드 (Linux)
g++ -std=c++17 -o main main.cpp -ldl
# 실행 (라이브러리를 실행 디렉터리로 복사하거나 dlopen 경로를 맞춘다)
cp target/release/librust_ffi_lib.so .
./main
예제 2: Rust → C++ 호출 (C++ 라이브러리를 Rust에서 사용)
C++ 라이브러리:
// libfoo.cpp
#include <cstdlib>
#include <cstring>
extern "C" {
int cpp_multiply(int a, int b) {
return a * b;
}
// 소유권: 호출자가 반환된 포인터를 free 해야 함 (C 스타일)
char* cpp_duplicate_string(const char* src) {
if (!src) return nullptr;
size_t len = strlen(src);
char* dst = (char*)malloc(len + 1);
if (dst) {
memcpy(dst, src, len + 1);
}
return dst;
}
void cpp_free_string(char* s) {
free(s);
}
}
Rust 호출 측:
// src/main.rs
use std::ffi::CString;
use std::os::raw::c_char;
#[link(name = "foo")]
extern "C" {
fn cpp_multiply(a: i32, b: i32) -> i32;
fn cpp_duplicate_string(src: *const c_char) -> *mut c_char;
fn cpp_free_string(s: *mut c_char);
}
fn main() {
let result = unsafe { cpp_multiply(4, 5) };
println!("4 * 5 = {}", result); // 20
let s = CString::new("hello").unwrap();
let ptr = unsafe { cpp_duplicate_string(s.as_ptr()) };
if !ptr.is_null() {
let rust_str = unsafe { std::ffi::CStr::from_ptr(ptr).to_str().unwrap() };
println!("duplicated: {}", rust_str);
unsafe { cpp_free_string(ptr) }; // 반드시 해제
}
}
예제 3: 구조체 전달 (레이아웃 일치 필수)
C/C++ 헤더:
// point.h - C와 C++ 모두에서 사용 가능한 구조체
#ifndef POINT_H
#define POINT_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
double x;
double y;
} Point;
Point point_add(Point a, Point b);
double point_distance(Point a, Point b);
#ifdef __cplusplus
}
#endif
#endif
C++ 구현:
// point.cpp
#include "point.h"
#include <cmath>
extern "C" {
Point point_add(Point a, Point b) {
return { a.x + b.x, a.y + b.y };
}
double point_distance(Point a, Point b) {
double dx = a.x - b.x;
double dy = a.y - b.y;
return sqrt(dx * dx + dy * dy);
}
}
Rust 바인딩:
// Rust에서 동일한 레이아웃 구조체 정의
#[repr(C)]
#[derive(Clone, Copy)]
struct Point {
x: f64,
y: f64,
}
#[link(name = "point")]
extern "C" {
fn point_add(a: Point, b: Point) -> Point;
fn point_distance(a: Point, b: Point) -> f64;
}
fn main() {
let a = Point { x: 1.0, y: 2.0 };
let b = Point { x: 3.0, y: 4.0 };
let sum = unsafe { point_add(a, b) };
let dist = unsafe { point_distance(a, b) };
println!("sum = ({}, {}), dist = {}", sum.x, sum.y, dist);
}
주의: Rust는 기본적으로 구조체 필드 순서를 재배치할 수 있으므로, #[repr(C)]를 붙여야 C와 같은 레이아웃이 보장됩니다. 필드 순서와 타입도 헤더와 정확히 맞아야 합니다.
예제 4: bindgen으로 헤더에서 바인딩 자동 생성
Cargo.toml:
[build-dependencies]
bindgen = "0.69"
build.rs:
// build.rs
fn main() {
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.generate()
.expect("bindgen failed");
let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("write bindings failed");
}
wrapper.h (C 헤더만 포함, C++ 직접 포함 시 주의):
#include "point.h"
lib.rs:
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
fn main() {
let a = Point { x: 1.0, y: 2.0 };
let b = Point { x: 3.0, y: 4.0 };
let sum = unsafe { point_add(a, b) };
println!("sum = ({}, {})", sum.x, sum.y);
}
예제 5: cxx 크레이트로 타입·예외 브리지
cxx는 브리지 모듈에 적은 선언을 바탕으로 양쪽 언어의 글루 코드를 생성하고, 두 선언이 일치하는지 컴파일 시점에 검사합니다. C++ 클래스를 Rust에서 불투명 타입으로 다룰 수 있어 C 스타일 포인터만 쓰는 것보다 편리합니다. Cargo.toml:
[dependencies]
cxx = "1.0"
[build-dependencies]
cxx-build = "1.0"
C++ 헤더 (include/calculator.h):
#pragma once
#include <memory>
class Calculator {
public:
Calculator() : last_result_(0) {}
int add(int a, int b) {
last_result_ = a + b;
return last_result_;
}
int get_last_result() const {
return last_result_;
}
private:
int last_result_;
};
// cxx는 C++ 생성자를 직접 호출할 수 없으므로 팩토리 함수를 둔다
inline std::unique_ptr<Calculator> new_calculator() {
return std::make_unique<Calculator>();
}
C++ 구현 (src/calculator.cpp):
#include "calculator.h"
// cxx가 자동 생성하는 브리지 코드와 링크
src/main.rs:
#[cxx::bridge]
mod ffi {
unsafe extern "C++" {
include!("rust_cxx_demo/include/calculator.h");
type Calculator;
fn new_calculator() -> UniquePtr<Calculator>;
fn add(self: Pin<&mut Calculator>, a: i32, b: i32) -> i32;
fn get_last_result(&self) -> i32;
}
}
fn main() {
let mut calc = ffi::new_calculator();
let result = calc.as_mut().unwrap().add(10, 20);
println!("10 + 20 = {}", result);
println!("last result = {}", calc.get_last_result());
}
build.rs:
fn main() {
cxx_build::bridge("src/main.rs")
.file("src/calculator.cpp")
.flag_if_supported("-std=c++17")
.compile("rust_cxx_demo");
}
cxx를 쓰면 Result<T>를 반환하도록 선언한 C++ 함수에서 던진 예외가 Rust의 Err(cxx::Exception)으로 바뀌고, std::string, std::vector, std::unique_ptr, std::shared_ptr을 각각 CxxString, CxxVector, UniquePtr, SharedPtr로 다룰 수 있습니다. 대신 빌드 설정이 복잡해지고, 브리지에서 지원하지 않는 타입(임의의 템플릿 등)은 여전히 직접 래핑해야 합니다.
링크 에러, ABI 불일치, FFI panic, double free
에러 1: “undefined reference to add” (링크 에러)
원인: Rust가 extern "C" 함수를 선언했지만, C++ 라이브러리가 링크되지 않았거나, 라이브러리 이름이 잘못됐습니다.
해결법: build.rs에서 라이브러리 검색 경로와 이름을 Cargo에 알려 줍니다.
// build.rs
fn main() {
println!("cargo:rustc-link-search=native=/usr/local/lib");
println!("cargo:rustc-link-lib=foo");
// C++로 작성된 정적 라이브러리라면 C++ 표준 라이브러리도 링크해야 한다
// println!("cargo:rustc-link-lib=stdc++");
}
링크는 성공했는데 실행할 때 공유 라이브러리를 찾지 못한다면 런타임 검색 경로를 지정합니다.
# Linux: .so 경로 지정
export LD_LIBRARY_PATH=/path/to/lib:$LD_LIBRARY_PATH
./your_rust_binary
에러 2: “ABI 불일치” (잘못된 인자/반환값)
원인: C++와 Rust에서 구조체 레이아웃이나 타입 크기가 다릅니다. #[repr(C)]를 빠뜨렸거나, 필드 순서가 다르거나, C의 long(Windows 32비트, Linux 64비트)처럼 플랫폼마다 크기가 다른 타입을 Rust에서 고정 크기로 선언한 경우가 흔합니다.
해결법:
// ❌ 잘못된 예: repr(C) 없음
struct Point {
x: f64,
y: f64,
}
// ✅ 올바른 예
#[repr(C)]
struct Point {
x: f64,
y: f64,
}
검증: std::mem::size_of::<Point>()와 C sizeof(Point)가 같아야 합니다.
에러 3: “dereferencing pointer to incomplete type”
원인: 불투명 포인터(opaque pointer)로 넘긴 타입의 내부를 헤더 밖에서 들여다보려 할 때 나는 에러입니다. 불투명 타입은 선언만 공개하고, 생성·해제·조회는 모두 함수로 제공해야 합니다. 해결법:
// C/C++ 헤더: 선언만 공개
typedef struct Handle Handle;
extern "C" Handle* create_handle();
extern "C" void destroy_handle(Handle* h);
// Rust: 크기 없는 opaque 타입 (Rustonomicon 권장 형태)
#[repr(C)]
pub struct Handle {
_data: [u8; 0],
_marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>,
}
extern "C" {
fn create_handle() -> *mut Handle;
fn destroy_handle(h: *mut Handle);
}
에러 4: “use after free” / “double free”
원인: FFI 경계에서 소유권이 불명확합니다. C++에서 malloc한 포인터를 Rust가 해제하지 않아 누수가 나거나, 양쪽에서 모두 해제하는 경우입니다. C++의 malloc 메모리를 Rust의 Box나 CString::from_raw로 해제하는 것처럼 할당자를 섞는 것도 정의되지 않은 동작입니다.
해결법: “반환된 포인터는 호출자가 cpp_free_string으로 해제해야 한다”처럼 계약을 문서화하고, 해제 함수는 할당한 쪽 라이브러리가 제공하게 합니다.
/// # Safety
/// 반환된 *mut c_char는 cpp_free_string로 해제해야 함.
/// 호출자가 소유권을 가짐.
unsafe fn get_string() -> *mut c_char {
cpp_duplicate_string(...)
}
에러 5: “panic in FFI” (Rust panic이 C++로 전파)
원인: Rust 1.81부터는 extern "C" 함수 밖으로 panic이 빠져나가려 하면 프로세스가 abort되고, 그 이전 버전에서는 정의되지 않은 동작이었습니다. 어느 쪽이든 C++ 호출자가 잡을 수 있는 예외가 아닙니다. 경계를 넘어 풀어야 한다면 extern "C-unwind"를 써야 하지만, 대개는 C ABI로 노출하는 Rust 함수 안에서 panic을 잡아 에러 코드로 바꾸는 편이 단순합니다.
해결법:
#[no_mangle]
pub extern "C" fn rust_safe_operation() -> i32 {
std::panic::catch_unwind(|| {
// 위험한 연산
do_something()
}).map(|r| r as i32).unwrap_or(-1)
}
에러 6: “symbol not found” (동적 로드 시)
원인: #[no_mangle] 누락, 또는 라이브러리 이름이 플랫폼마다 다름 (Linux: libfoo.so, macOS: libfoo.dylib, Windows: foo.dll).
해결법:
#[no_mangle] // 반드시 필요
pub extern "C" fn my_exported_fn() { ... }
# 심볼 확인
nm -D librust_ffi_lib.so | grep rust_add
에러 7: “alignment” / “unaligned access”
원인: C 구조체에 패딩이 있는데 Rust에서 #[repr(C)] 없이 정의했거나 필드 순서가 다릅니다. C 쪽에서 #pragma pack을 썼다면 Rust에도 #[repr(C, packed)]를 맞춰야 합니다.
해결법: #[repr(C)] 사용, std::mem::align_of로 정렬 확인.
에러 8: “cxx-build failed” (cxx 크레이트)
원인: C++ 컴파일러 미설치, C++17 미지원, include 경로 오류. 해결법:
# Linux: build-essential
sudo apt install build-essential
# macOS: Xcode Command Line Tools
xcode-select --install
// build.rs - include 경로 추가
fn main() {
cxx_build::bridge("src/lib.rs")
.file("src/calculator.cpp")
.include("include") // -I include
.flag("-std=c++17")
.compile("demo");
}
플랫폼별 라이브러리 확장자
| 플랫폼 | 정적 라이브러리 | 동적 라이브러리 |
|---|---|---|
| Linux | .a | .so |
| macOS | .a | .dylib |
| Windows (MSVC) | .lib | .dll |
| Windows (MinGW) | .a | .dll |
Rust cdylib는 플랫폼에 맞게 자동 생성합니다. C++에서 dlopen/LoadLibrary 사용 시 확장자를 조건부로 선택해야 합니다.
Memory Safety 논의
소유권·검사 vs 수동·도구
- Rust: 소유권과 빌림 검사로 데이터 경합, use-after-free, 이중 해제를
unsafe밖의 코드에서 컴파일 시점에 막습니다. 검사 대부분이 컴파일 시점에 끝나므로 런타임 비용이 거의 없습니다. - C++: 스마트 포인터와 RAII로 같은 목표를 관례와 도구로 추구합니다. 정적 분석(Clang-Tidy, #41-1), Sanitizer(ASan, TSan, #41-2), 코드 리뷰로 실수를 줄이지만 언어가 강제하지는 않습니다.
- 논쟁의 실체: “Rust가 안전하다”는 말은 언어가 보장하는 범위가 넓다는 뜻이고, “C++가 위험하다”는 말은 같은 수준의 안전을 도구와 습관으로 채워야 한다는 뜻입니다. 실무에서는 두 언어가 함께 쓰이는 경우가 많으므로, FFI 경계를 잘 정하고 계약을 명확히 하는 것이 결국 가장 중요합니다.
FFI 경계에서의 안전성
flowchart LR
subgraph safe[Rust 안전 영역]
R1[소유권 검사]
R2[빌림 검사]
end
subgraph unsafe[unsafe 경계]
U[FFI 호출]
end
subgraph manual[C++ 수동 관리]
C1[수동 할당/해제]
C2[RAII/스마트 포인터]
end
R1 --> U
U --> C1
FFI를 넘는 순간 Rust 컴파일러의 안전성 보장은 적용되지 않습니다. unsafe 블록 안에서는 포인터 유효성, 수명, 스레드 안전성을 프로그래머가 직접 보장해야 합니다. 그래서 unsafe 호출은 안전한 Rust 함수 하나로 감싸 경계를 최대한 좁히고, C++ 쪽에서도 RAII로 자원을 관리해 경계에서의 실수를 줄입니다.
레거시·성능·팀 사정에 따른 선택
- 레거시 C++ 코드가 많다면 전면 이전보다 새 기능이나 외부 입력을 다루는 경로만 Rust로 옮기는 점진적 접근이 현실적입니다.
- 성능 면에서는 두 언어 모두 제로 코스트 추상화를 지향하므로 큰 차이가 나지 않는 경우가 많습니다. 다만 FFI 호출 자체는 인라이닝이 막히므로, 아주 작은 함수를 경계 너머로 자주 부르는 설계는 피하는 편이 좋습니다.
- Rust의 학습 곡선과 C++ 유지보수 비용을 함께 따져, 도입 속도와 교육 계획을 같이 세우는 것이 좋습니다.
경계 레이어 분리, 소유권 계약, CMake + Cargo 통합 빌드
패턴 1: 경계 레이어 분리
원칙: C++/Rust 경계는 얇은 C 래퍼 레이어로만 두고, 복잡한 로직은 각 언어 내부에서 처리합니다.
[C++ 앱] → [C 래퍼 10줄] → [Rust 핵심 로직]
[Rust 앱] → [C 래퍼 10줄] → [C++ 레거시]
패턴 2: 소유권 계약 문서화
원칙: FFI 함수마다 할당과 해제 책임을 주석으로 명시합니다.
/// @param buf 호출자가 할당. 이 함수는 해제하지 않음.
/// @return 호출자가 free 해야 함.
extern "C" char* process_buffer(const char* buf, size_t len);
패턴 3: 에러 코드 반환 (예외 금지)
원칙: FFI 경계로는 예외나 panic을 넘기지 않습니다. 에러는 반환 코드, 널 포인터, out 파라미터로 전달합니다.
#[no_mangle]
pub extern "C" fn rust_parse(input: *const u8, len: usize, out: *mut i32) -> i32 {
if input.is_null() || out.is_null() {
return -1; // EINVAL
}
match parse_something(unsafe { std::slice::from_raw_parts(input, len) }) {
Ok(v) => {
unsafe { *out = v };
0
}
Err(_) => -2, // EPARSE
}
}
패턴 4: 통합 빌드 (CMake + Cargo)
# CMakeLists.txt
add_custom_target(rust_lib
COMMAND cargo build --release
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/rust_part
)
add_dependencies(my_app rust_lib)
target_link_libraries(my_app ${CMAKE_SOURCE_DIR}/rust_part/target/release/librust_ffi_lib.so)
패턴 5: 테스트 전략
- 단위 테스트: 각 언어 내부 로직은 각자의 테스트 프레임워크로 검증합니다.
- 통합 테스트: C 래퍼를 경계로 양쪽에서 실제로 호출해 소유권 계약과 에러 코드를 검증합니다.
- Sanitizer: C++ 쪽을 ASan, TSan으로 빌드해 FFI 호출 경로의 메모리 오류와 데이터 레이스를 잡습니다.
FFI 경계 점검 항목
- C ABI 경계에
extern "C"/#[no_mangle] extern "C"사용 - 구조체에
#[repr(C)]적용, 레이아웃 검증 - 소유권 계약 문서화 (할당/해제 책임)
- FFI 노출 Rust 함수에서
panic방지 (catch_unwind등) - 에러는 예외 대신 코드/널로 반환
- 빌드 시스템 통합 (CMake + Cargo)
- Sanitizer로 C++ 쪽 검증
참고 자료
- The Rustonomicon - FFI: Rust FFI 안전성 가이드
- cxx 공식 문서: C++/Rust 브리지
- bindgen: C/C++ 헤더 → Rust 바인딩
- C++ 시리즈 #41-1 Clang-Tidy: C++ 정적 분석으로 FFI 경로 검증
같이 보면 좋은 글
- C++ vs Rust: 소유권, 메모리 안전성, 에러 처리, 동시성, 성능 비교
- C++ 개발자가 보는 Rust 메모리 안전성
- C++26 프리뷰: Reflection과 신규 표준 라이브러리 제안들 [#44-1]
- SFINAE 패턴 정리
- C++ Fold Expressions 이해하기
- C++ 메타프로그래밍의 진화: Template에서 Constexpr, 그리고 Reflection까지
- constexpr 기초부터 C++26까지
자주 묻는 질문 (FAQ)
Q. Rust 함수에서 panic이 나면 C++ 호출자에게 어떻게 전달되나요?
A. Rust의 panic이 extern "C" 경계를 넘어 C++ 스택으로 풀려 나가는 것은 두 언어가 약속한 동작이 아니며, 버전과 설정에 따라 정의되지 않은 동작이 되거나 프로세스가 abort됩니다. 따라서 C++에서 try/catch로 잡을 수 있는 예외처럼 다루면 안 됩니다. FFI로 노출하는 Rust 함수 안에서 std::panic::catch_unwind로 panic을 잡아 에러 코드로 바꿔 반환하고, C++ 쪽은 그 코드를 확인하는 계약으로 설계하는 것이 안전합니다.
Q. bindgen과 cxx 중 뭘 쓰나요?
A. 순수 C 헤더나 C 스타일로 감싼 C++ 라이브러리라면 bindgen이 적합합니다. C++ 클래스, 예외, 스마트 포인터를 오가야 한다면 cxx가 편리합니다. 함수가 몇 개 안 되면 extern "C" 선언을 직접 쓰는 것으로도 충분합니다.
| 상황 | 권장 도구 |
|---|---|
| C 헤더가 이미 있고 함수가 많음 | bindgen |
C++ 클래스·std::string·std::unique_ptr을 주고받음 | cxx |
| 함수 몇 개짜리 얇은 경계 | 수동 extern "C" 선언 |