Rust와 C++ 상호 운용성: 더 안전한 애플리케이션을 위하여
Emily Parker
Product Engineer · Leapcell

소개
빠르게 발전하는 소프트웨어 개발 환경에서 다양한 프로그래밍 언어의 강점을 결합하는 능력은 강력하고 효율적인 애플리케이션을 구축하는 데 종종 중요합니다. 비교할 수 없는 메모리 안전성과 성능으로 찬사를 받는 Rust는 중요한 시스템에 점점 더 많이 채택되고 있습니다. 그러나 세계에서 고도로 최적화되고 시간을 검증한 소프트웨어 인프라의 상당 부분은 C 또는 C++로 작성되었습니다. 여기에는 고성능 수치 라이브러리, 운영 체제 API부터 임베디드 시스템 펌웨어까지 모든 것이 포함됩니다.
그렇다면 Rust 애플리케이션이 Rust의 핵심 메모리 안전성 및 데이터 무결성 보장을 희생하지 않고 이러한 기존의 강력한 C/C++ 라이브러리를 어떻게 활용할 수 있을까요? 여기서 Foreign Function Interface(FFI)가 등장합니다. FFI는 Rust 프로그램이 다른 언어로 작성된 루틴이나 함수를 호출할 수 있도록 허용하고 그 반대도 마찬가지입니다. 특히 C/C++의 경우 Rust의 FFI 기능을 통해 브리지를 만들어 수십 년 동안 구축된 C/C++ 코드베이스를 활용하고, 성능에 중요한 섹션을 최적화하거나, 주로 C API를 통해 노출되는 시스템 수준 기능과 인터페이스할 수 있습니다. 이 글에서는 Rust FFI를 사용하여 Rust 애플리케이션에서 C/C++ 코드를 안전하게 호출하는 방법에 대해 자세히 알아보고, Rust의 안전성 원칙을 준수하면서 이를 달성하는 방법을 시연할 것입니다.
Rust에서 기존 C/C++ 라이브러리 활용하기
FFI를 사용하여 Rust와 C/C++ 간의 격차를 효과적으로 해소하려면 몇 가지 핵심 개념과 용어를 이해하는 것이 중요합니다.
핵심 개념
- Foreign Function Interface (FFI): 한 프로그래밍 언어로 작성된 프로그램이 다른 언어로 작성된 루틴이나 함수를 호출할 수 있도록 하는 메커니즘입니다. Rust에서
ffi
는 주로 C 애플리케이션 이진 인터페이스(ABI)와 상호 작용하는 데 사용됩니다. - ABI (Application Binary Interface): 매개변수가 전달되는 방식, 반환 값이 수신되는 방식, 스택 프레임이 관리되는 방식을 포함하여 저수준에서 함수가 호출되는 방식을 정의합니다. C ABI는 Rust를 포함한 많은 언어가 상호 운용될 수 있는 사실상의 표준입니다.
unsafe
키워드: Rust에서 메모리 안전성 보장을 위반할 수 있는 코드 블록을 신중하게 사용하지 않으면 표시하는 방법입니다. FFI 작업은 Rust 컴파일러가 다른 언어로 작성된 코드의 안전성을 보장할 수 없기 때문에 종종unsafe
블록을 필요로 합니다.extern
블록: Rust에서 외부 라이브러리(예: C/C++ 라이브러리)에 정의된 함수를 선언하는 데 사용됩니다. 이러한 블록은 함수 서명과 선택적으로 사용할 ABI를 지정합니다.no_mangle
속성: Rust 컴파일러가 함수의 이름을 "mangling"(변경)하는 것을 방지하는 데 사용되는 Rust 속성으로, 컴파일된 이진 파일에서 C 호환 이름을 갖도록 보장합니다. 이는 Rust 함수가 C/C++에서 호출될 때 중요합니다.libc
크레이트: 일반적인 C 라이브러리 함수, 데이터 유형 및 상수에 대한 원시 FFI 바인딩을 제공하는 Rust 크레이트입니다. 유용하지만 사용자 지정 C/C++ 코드의 경우 직접extern
블록이 더 일반적입니다.
통합 원칙
통합의 기본 원칙은 C/C++ 코드에 대한 C 호환 인터페이스를 만드는 데 있습니다. 이는 일반적으로 다음을 의미합니다.
- C 호환 함수 노출: C++ 함수는 이름 mangling 또는 클래스 구조를 가질 수 있으므로 C FFI에서 직접 사용할 수 없습니다. Rust와 인터페이스하려면 C++ 로직을
extern "C"
함수로 래핑해야 합니다. 이렇게 하면 C++ 컴파일러가 C 호출 규칙을 사용하여 함수를 컴파일하여 이름 mangling을 방지합니다. - Rust에서 일치하는 서명 정의: Rust 측에서는
extern "C"
블록을 사용하여 이러한 C 호환 함수를 선언하여 함수 서명(매개변수 유형, 반환 유형)이 Rust와 C/C++ 간에 정확하게 일치하도록 합니다. - 데이터 유형 처리: 기본 유형(정수, 부동 소수점)은 일반적으로 직접 매핑됩니다. 문자열, 배열 또는 사용자 지정 구조와 같은 더 복잡한 유형은 일관된 메모리 레이아웃 및 소유권 의미 체계를 보장하기 위해 신중한 처리가 필요합니다. Rust의
std::ffi
모듈은 안전한 C 문자열 처리를 위한CStr
및CString
과 같은 유용한 유형을 제공합니다. 포인터는 일반적으로 Rust의 원시 포인터(*const T
,*mut T
)에 매핑됩니다.
실용적인 예: Rust에서 C 호출하기
간단한 예시를 통해 설명해 보겠습니다. 기본적인 산술 연산을 수행하는 C 라이브러리가 있다고 가정해 보겠습니다.
C 코드 (my_math.h
및 my_math.c
)
먼저 C 헤더 파일에서 함수 서명을 정의합니다.
// my_math.h #ifndef MY_MATH_H #define MY_MATH_H // 두 정수를 더하는 간단한 함수 int add_integers(int a, int b); #endif // MY_MATH_H
그리고 구현:
// my_math.c #include "my_math.h" #include <stdio.h> int add_integers(int a, int b) { printf("C function: Adding %d and %d\n", a, b); return a + b; }
이를 정적 라이브러리(.a
는 Linux/macOS, .lib
는 Windows)로 컴파일하려면 gcc
를 사용할 수 있습니다.
gcc -c my_math.c -o my_math.o ar rcs libmy_math.a my_math.o
이렇게 하면 libmy_math.a
가 생성됩니다.
Rust 코드 (src/main.rs
)
이제 Rust 프로젝트에서 C 함수를 선언하고 호출합니다.
// src/main.rs // 이 블록은 'add_integers' 함수가 // C 호환 라이브러리에서 외부적으로 정의되었음을 Rust에 알립니다. // 'link_name'은 라이브러리 파일의 이름을 지정합니다('lib' 접두사와 '.a/.so/.dll' 접미사 제외) // 'link'는 정적 링크인지 동적 링크인지 지정합니다(일반적인 경우 지정되지 않은 경우 기본값은 정적). #[link(name = "my_math", kind = "static")] // libmy_math.a에 대한 링크 extern "C" { // C 함수 서명을 선언합니다. // C의 int(Rust의 i32)와 정확히 일치하는지 확인합니다. fn add_integers(a: i32, b: i32) -> i32; } fn main() { let x = 10; let y = 20; // FFI 호출은 Rust가 외국어에서 실행되는 코드의 안전성을 보장할 수 없기 때문에 // 본질적으로 안전하지 않습니다. // 프로그래머가 C 함수 호출이 안전한지 직접 확인해야 합니다. let sum = unsafe { add_integers(x, y) }; println!("Rust: The sum from C is: {}", sum); }
이 Rust 코드를 컴파일하고 실행하려면 rustc
에 C 라이브러리를 찾을 위치를 알려야 합니다. 종종 프로젝트 루트에 libmy_math.a
를 배치하거나 빌드 스크립트를 사용하여 경로를 지정하여 수행할 수 있습니다. 간단한 경우의 일반적인 접근 방식은 cargo build
를 사용하고 수동으로 링크하는 것입니다. libmy_math.a
가 Cargo.toml
과 같은 디렉토리에 있다면 검색 경로를 지정해야 할 수 있습니다.
A build.rs
스크립트 in your Rust project can automate this:
// build.rs fn main() { println!("cargo:rustc-link-search=native=/path/to/your/lib"); // 이 경로를 조정하세요! println!("cargo:rustc-link-lib=static=my_math"); }
또는 libmy_math.a
가 프로젝트 루트에 있다면 컴파일 중에 직접 연결할 수 있습니다.
# Rust 코드를 C 라이브러리에 연결하여 컴파일 rustc src/main.rs -L . -l static=my_math -o rust_app # 애플리케이션 실행 ./rust_app
예상 출력:
C function: Adding 10 and 20
Rust: The sum from C is: 30
Rust에서 C++ 코드 처리하기
C++ 함수를 Rust에서 직접 호출하는 것은 C++의 이름 mangling, 가상 함수 및 객체 모델로 인해 더 복잡합니다. 표준 접근 방식은 C++ 코드 주위에 C 호환 API 계층(종종 "C 래퍼"라고 함)을 만드는 것입니다.
C++ 래퍼 예시
C++ 클래스가 있다고 가정해 봅시다.
// my_cpp_class.hpp #ifndef MY_CPP_CLASS_HPP #define MY_CPP_CLASS_HPP #include <string> class MyCppClass { public: MyCppClass(int value); void greet(const std::string& name); int get_value() const; private: int m_value; }; #endif // MY_CPP_CLASS_HPP
// my_cpp_class.cpp #include "my_cpp_class.hpp" #include <iostream> MyCppClass::MyCppClass(int value) : m_value(value) { std::cout << "C++: MyCppClass constructed with value: " << m_value << std::endl; } void MyCppClass::greet(const std::string& name) { std::cout << "C++: Hello, " << name << "! My internal value is " << m_value << std::endl; } int MyCppClass::get_value() const { return m_value; }
이를 Rust에서 호출 가능하게 하려면 extern "C"
래퍼를 만듭니다.
// my_cpp_class_wrapper.cpp #include "my_cpp_class.hpp" #include <cstring> // strlen, strcpy 용 #include <string> // std::string 용 extern "C" { // MyCppClass 인스턴스에 대한 불투명 포인터 // 이렇게 하면 C++ 클래스 세부 정보가 Rust에서 숨겨집니다. typedef void MyCppClassOpaque; // 생성자 래퍼 MyCppClassOpaque* my_cpp_class_new(int value) { return reinterpret_cast<MyCppClassOpaque*>(new MyCppClass(value)); } // 메서드 래퍼: greet void my_cpp_class_greet(MyCppClassOpaque* ptr, const char* name_ptr) { MyCppClass* instance = reinterpret_cast<MyCppClass*>(ptr); if (instance && name_ptr) { instance->greet(std::string(name_ptr)); } } // 메서드 래퍼: get_value int my_cpp_class_get_value(MyCppClassOpaque* ptr) { MyCppClass* instance = reinterpret_cast<MyCppClass*>(ptr); if (instance) { return instance->get_value(); } return -1; // 또는 적절하게 오류 처리 } // 소멸자 래퍼 void my_cpp_class_free(MyCppClassOpaque* ptr) { MyCppClass* instance = reinterpret_cast<MyCppClass*>(ptr); delete instance; } } // extern "C"
C++ 코드와 래퍼를 라이브러리로 컴파일합니다.
g++ -c my_cpp_class.cpp my_cpp_class_wrapper.cpp -o my_cpp_class.o -o my_cpp_class_wrapper.o ar rcs libmy_cpp_class.a my_cpp_class.o my_cpp_class_wrapper.o
C++ 래퍼를 위한 Rust 코드
// src/main.rs use std::ffi::{CStr, CString}; use std::os::raw::c_char; // C++ 래퍼에서 C 호환 함수를 선언합니다. #[link(name = "my_cpp_class", kind = "static")] extern "C" { // C++ 클래스 인스턴스에 대한 불투명 포인터 유형 type MyCppClassOpaque; fn my_cpp_class_new(value: i32) -> *mut MyCppClassOpaque; fn my_cpp_class_greet(ptr: *mut MyCppClassOpaque, name_ptr: *const c_char); fn my_cpp_class_get_value(ptr: *mut MyCppClassOpaque) -> i32; fn my_cpp_class_free(ptr: *mut MyCppClassOpaque); } fn main() { let initial_value = 42; let instance_ptr = unsafe { // C++ 객체 생성 my_cpp_class_new(initial_value) }; if instance_ptr.is_null() { eprintln!("Failed to create C++ object."); return; } let rust_name = "Rustacean"; let c_name = CString::new(rust_name).expect("CString conversion failed"); unsafe { // C++ 객체에서 메서드 호출 my_cpp_class_greet(instance_ptr, c_name.as_ptr()); // C++ 객체에서 값 가져오기 let value = my_cpp_class_get_value(instance_ptr); println!("Rust: Retrieved value from C++ object: {}", value); // C++ 객체 메모리 해제 my_cpp_class_free(instance_ptr); } }
libmy_cpp_class.a
가 연결되도록 유사하게 컴파일하고 실행합니다.
안전 고려 사항
Rust의 unsafe
키워드는 FFI를 사용하는 책임에 대한 강력한 알림 역할을 합니다. unsafe
코드를 호출할 때 Rust의 불변성을 수동으로 유지해야 합니다.
- 메모리 안전성: C/C++에 전달된 포인터가 유효하고 C/C++에서 할당된 메모리(있는 경우)가 누수나 사용 후 해제 버그를 피하기 위해 적절하게 해제되도록 합니다.
- 데이터 일관성: Rust와 C/C++ 간에 공유되는 데이터 구조가 호환되는 레이아웃을 갖도록 합니다.
- 스레드 안전성: C/C++ 코드가 스레드 안전하지 않은 경우 적절한 동기화 없이는 여러 Rust 스레드에서 동시에 호출되지 않도록 합니다.
- 오류 처리: C/C++ 함수는 종종 오류 보고를 위해 반환 코드,
errno
또는 예외를 사용합니다. 이러한 것을 Rust의Result
유형 또는 적절한 오류 처리 메커니즘으로 매핑합니다. - Null 포인터: C/C++ 함수에서 반환된 null 포인터를 안전하게 처리합니다. Rust의
Option<T>
는 null 검사를 제공하기 위해 원시 포인터를 래핑하는 데 사용할 수 있습니다.
bindgen
과 같은 도구는 C 헤더 파일에서 Rust FFI 바인딩의 생성을 자동화하여 상용구 코드와 잠재적 오류를 줄일 수 있습니다. C++ 프로젝트의 경우 autocxx
는 C++ 브리지를 자동으로 생성하는 보다 고급 솔루션을 제공합니다.
애플리케이션 시나리오
C/C++를 사용한 Rust FFI는 여러 시나리오에서 매우 유용합니다.
- OS API와의 인터페이스: 대부분의 운영 체제 기능은 C API(예: Win32 API, POSIX 함수)를 통해 노출됩니다.
- 고성능 라이브러리 활용: 과학 컴퓨팅, 그래픽, 머신 러닝, 암호화는 종종 고도로 최적화된 C/C++ 라이브러리(예: BLAS, LAPACK, OpenCV, TensorFlow, OpenSSL)에 의존합니다.
- 레거시 시스템 마이그레이션: 기존 C/C++ 코드베이스의 일부를 Rust로 점진적으로 마이그레이션하여 새로운 Rust 구성 요소가 기존 C/C++ 로직과 상호 작용할 수 있도록 합니다.
- 임베디드 시스템 개발: C로 작성된 저수준 하드웨어 드라이버와의 인터페이스.
- 성능 임계 섹션: 유지 보수성 및 안전성을 위해 대부분의 애플리케이션을 Rust로 유지하면서 성능 병목 현상을 C/C++로 다시 작성합니다.
결론
Rust의 FFI 기능은 Rust 애플리케이션과 방대한 C/C++ 코드 라이브러리 생태계 간의 격차를 해소하는 강력하고 놀랍도록 안전한 방법을 제공합니다. 메모리 관리 및 데이터 표현과 관련하여 특히 세부 사항에 대한 신중한 주의가 필요하지만, 고도로 최적화된 기존 C/C++ 라이브러리를 활용하는 이점은 엄청납니다. extern "C"
래퍼와 unsafe
블록을 전략적으로 사용하고 세심한 유형 매핑과 결합하여 Rust 개발자는 Rust의 최신 안전성 보장과 C/C++ 코드베이스의 타의 추종을 불허하는 성능 및 확립된 기능을 결합한 강력한 애플리케이션을 구축할 수 있으며, Rust의 영향력을 새롭고 흥미로운 영역으로 확장할 수 있습니다.