Rust로 작성한 프로그램의 실행 속도와 메모리 사용량은 대체로 C와 비슷해야 하지만, 두 언어의 프로그래밍 스타일이 충분히 달라 속도를 일반화하기는 어렵다. 여기서는 어디서 비슷하고, 어디서 C가 더 빠르며, 어디서 Rust가 더 빠른지에 대한 요약을 정리한다.
Rust로 작성한 프로그램의 실행 속도와 메모리 사용량은 대체로 C로 작성한 프로그램과 비슷해야 하지만, 두 언어의 전반적인 프로그래밍 스타일이 충분히 달라서 속도를 일반화하기는 어렵습니다. 이 글은 어디서 둘이 비슷하고, 어디서 C가 더 빠르며, 어디서 Rust가 더 빠른지에 대한 요약입니다.
면책 조항: 이 글은 두 언어에 대한 객관적 벤치마크를 통해 논쟁의 여지 없는 진실을 밝혀내려는 목적이 아닙니다. 이론적으로 언어가 달성할 수 있는 것과 실제로 사람들이 언어를 사용하는 방식 사이에는 큰 차이가 있습니다. 이 비교는 마감이 있고, 버그를 만들고, 게으를 때도 있는 제 개인적이고 주관적인 경험에 기반합니다. 저는 Rust를 4년 넘게 메인 언어로 써왔고, 그 전에는 C를 10년 정도 썼습니다. 여기서는 C만 비교 대상으로 삼습니다. C++과의 비교는 더 많은 “조건”과 “단서”가 따라오는데, 그걸 다루고 싶지 않기 때문입니다.
요약하면:
unsafe라는 비상구가 항상 있고(자주 필요하진 않습니다).제 전반적인 느낌은, 무한한 시간과 노력을 쓸 수 있다면 C 프로그램이 Rust만큼 빠르거나 더 빠를 것이라는 점입니다. 이론적으로 C가 못 하고 Rust만 할 수 있는 건 없기 때문입니다. 하지만 현실에서는 C는 추상화가 적고 표준 라이브러리가 원시적이며 의존성(디펜던시) 상황이 끔찍해서, 매번 바퀴를 다시 발명하되 최적으로 만들 시간은 제게 없습니다.
Rust와 C 모두 데이터 구조의 메모리 레이아웃, 정수 크기, 스택 vs 힙 메모리 할당, 포인터 간접 참조 등을 제어할 수 있고, 컴파일러가 큰 “마법”을 끼워 넣지 않은 채 이해 가능한 기계어로 번역됩니다. Rust는 심지어 바이트가 8비트이고, 부호 있는 정수가 오버플로할 수 있다는 사실도 인정합니다!
Rust에는 이터레이터, 트레이트, 스마트 포인터 같은 더 고수준 구성 요소가 있지만, 예측 가능하게 단순한 기계어로 최적화되도록 설계되어 있습니다(일명 “제로 코스트 추상화”). Rust 타입의 메모리 레이아웃은 단순합니다. 예를 들어 growable string과 vector는 정확히 {byte*, capacity, length}입니다. Rust에는 이동/복사 생성자 같은 개념이 없어서, 객체 전달은 포인터 전달이나 memcpy보다 복잡해질 수 없다는 것이 보장됩니다.
빌림 검사는 컴파일 타임 정적 분석일 뿐입니다. 실제로 아무것도 하지 않고, 라이프타임 정보는 코드 생성 전에 완전히 제거됩니다. 오토박싱이나 그와 비슷한 영리한 장치도 없습니다.
Rust가 “멍청한” 코드 생성기라는 관점에서 한 가지 아쉬운 점은 언와인딩(unwinding)입니다. Rust는 일반적인 오류 처리를 위해 예외를 쓰지 않지만, 패닉(처리되지 않은 치명적 오류)은 선택적으로 C++ 예외처럼 동작할 수 있습니다. 컴파일 시 비활성화(panic = abort)할 수 있지만, 그래도 Rust는 C++ 예외나 longjmp와 섞이는 것을 좋아하지 않습니다.
Rust는 LLVM과의 통합이 좋아서 ThinLTO를 포함한 링크 타임 최적화(LTO)를 지원하고, C/C++/Rust 언어 경계를 넘어선 인라이닝도 가능합니다. 프로파일 기반 최적화(PGO)도 있습니다. rustc가 clang보다 더 장황한 LLVM IR을 생성하긴 하지만, 최적화기는 여전히 꽤 잘 처리합니다.
제 C 코드 일부는 LLVM보다 GCC로 컴파일했을 때 더 빠른 경우가 있는데, 아직 GCC용 Rust 프런트엔드는 없어서 Rust는 그 이점을 누리지 못합니다.
이론상 Rust는 더 엄격한 불변성과 별칭(aliasing) 규칙 덕분에 C보다 더 좋은 최적화를 가능하게 하지만, 현실에서는 아직 그렇게 되지 않습니다. C가 하는 수준을 넘어서는 최적화는 LLVM에서 진행 중인 작업이라, Rust는 아직 잠재력을 완전히 발휘하지 못했습니다.
Rust 코드는 저수준이고 예측 가능해서, 최적화 결과로 어떤 어셈블리가 나올지 손으로 조정(hand-tune)할 수 있습니다. Rust는 SIMD 인트린식(intrinsics)을 지원하고, 인라이닝/호출 규약 등도 잘 제어할 수 있습니다. Rust는 C와 충분히 비슷해서, 보통 C용 프로파일러가 Rust에서도 즉시 동작합니다(예: Rust-C-Swift 샌드위치 프로그램에 Xcode Instruments를 사용할 수 있습니다).
일반적으로 성능이 절대적으로 중요해서 마지막 한 비트까지 수동 최적화해야 하는 경우에도, Rust 최적화는 C와 크게 다르지 않습니다.
다만 Rust에는 제대로 된 대체물이 없는 저수준 기능들이 조금 있습니다:
계산된 goto(computed goto). Rust에서는 “지루한” goto 용도는 loop { break } 같은 다른 구조로 대체할 수 있습니다. C에서 goto는 정리(cleanup)에 자주 쓰이는데, Rust는 RAII/소멸자 덕분에 그럴 필요가 없습니다. 하지만 인터프리터에 매우 유용한 비표준 확장 goto *addr이 있습니다. Rust는 이를 직접 할 수 없습니다(match를 쓰고 최적화되길 기대 해야 합니다). 반대로 인터프리터가 필요하다면, 저는 Cranelift JIT를 활용하려 할 겁니다.
alloca와 C99 가변 길이 배열(VLA). 이것들은 C에서도 논쟁적이기 때문에 Rust는 애초에 피합니다.
또한 Rust는 현재 16비트 아키텍처를 1개만 지원합니다. tier 1 지원은 32비트와 64비트 플랫폼에 집중되어 있습니다.
하지만 Rust를 수동 튜닝하지 않은 영역에서는 비효율이 스며들 수 있습니다:
Rust는 암묵적 타입 변환이 없고 인덱싱을 usize로만 하도록 강제하는 경향이 있어서, 더 작은 타입으로 충분한 경우에도 사용자가 usize를 쓰도록 유도합니다. 이는 32비트 int가 흔히 쓰이는 C와 대비됩니다. 64비트 플랫폼에서 usize 인덱싱은 미정의 동작(UB)에 기대지 않고도 최적화하기 쉽지만, 추가 비트는 레지스터/메모리 압박을 늘릴 수 있습니다.
관용적(idiomatic) Rust는 문자열과 슬라이스에 대해 항상 “포인터 그리고 길이”를 전달합니다. C 코드베이스 몇 개를 Rust로 포팅하고 나서야, C 함수가 메모리 포인터만 받고 사이즈는 안 받는 경우가 얼마나 많은지 깨달았습니다(사이즈는 문맥에서 간접적으로 알거나, 그냥 충분히 크다고 가정합니다).
모든 경계 검사(bounds check)가 최적화로 제거되는 것은 아닙니다. for item in arr 또는 arr.iter().for_each(…)는 가능한 한 효율적이지만, for i in 0..len { arr[i] } 형태가 필요할 때는 LLVM 최적화기가 길이 일치를 증명할 수 있는지에 성능이 달려 있습니다. 때때로 증명하지 못하고, 경계 검사가 자동 벡터화를 방해합니다. 물론 이를 피하는 안전/비안전 우회법이 여러 가지 있습니다.
Rust에서는 “영리한” 메모리 사용을 달가워하지 않습니다. C에서는 뭐든 가능합니다. 예를 들어 C에서는 한 용도로 할당한 버퍼를 나중에 다른 용도로 재사용하고 싶어질 수 있습니다(HEARTBLEED로 알려진 기법). 가변 크기 데이터를 위해 고정 크기 버퍼(예: PATH_MAX)를 두어 (재)할당을 피하는 것도 편리합니다. 관용적 Rust도 메모리 할당에 대한 제어를 많이 제공하고, 메모리 풀, 여러 할당을 하나로 합치기, 공간 미리 할당하기 같은 기본은 할 수 있지만, 전반적으로 사용자를 “지루한” 메모리 사용으로 이끕니다.
빌림 검사 규칙 때문에 코드가 어려워지는 경우, 쉬운 탈출구는 추가 복사를 하거나 참조 카운팅을 쓰는 것입니다. 시간이 지나며 빌림 검사기 요령을 많이 익혔고, 제 코딩 스타일도 빌림 검사기 친화적으로 바꿔서 요즘엔 자주 발생하지 않습니다. 필요하면 언제든 “raw” 포인터로 돌아갈 수 있으니 큰 문제로 발전하진 않습니다.
Rust의 빌림 검사기는 이중 연결 리스트를 싫어하는 것으로 악명이지만, 다행히도 연결 리스트는 현대 하드웨어에서 어차피 느립니다(캐시 지역성 불량, 벡터화 불가). Rust 표준 라이브러리에는 연결 리스트도 있고, 더 빠르며 빌림 검사기 친화적인 컨테이너도 선택할 수 있습니다.
빌림 검사기가 용납하지 못하는 경우가 두 가지 더 있습니다. 메모리 맵 파일(프로세스 외부에서 “마법처럼” 변하는 값이 참조의 불변^배타(immutable^exclusive) 의미론을 위반)과 자기 참조 구조체(값으로 전달되면 내부 포인터가 댕글링됨)입니다. 이런 경우는 C의 모든 포인터만큼 안전한 raw 포인터를 쓰거나, 안전한 추상화를 만들기 위한 정신 체조로 해결합니다.
Rust에게는 단일 스레드 프로그램이란 개념이 사실상 존재하지 않습니다. Rust는 개별 데이터 구조가 성능을 위해 스레드-세이프하지 않을 수는 있게 하지만, 스레드 간에 공유될 수 있는 것(전역 변수 포함)은 반드시 동기화되거나 unsafe로 표시되어야 합니다.
Rust 문자열이 make_ascii_lowercase() 같은 저렴한(in-place) 연산을 지원한다는 것을 자꾸 잊고(제가 C에서 하던 것과 직접 동등), 불필요하게 유니코드 인식이며 복사까지 하는 .to_lowercase()를 사용하곤 합니다. 문자열 얘기가 나온 김에, UTF-8 인코딩은 생각만큼 큰 문제가 아닌데, 문자열에는 .as_bytes() 뷰가 있어서 필요하면 유니코드를 무시하는 방식으로 처리할 수 있습니다.
libc는 stdout과 putc를 꽤 빠르게 만들기 위해 온갖 최적화를 합니다. Rust의 libstd는 그런 마법이 덜해서, BufWriter로 감싸지 않으면 I/O가 버퍼링되지 않습니다. Rust가 파이썬보다 느리다고 불평하는 사람을 본 적이 있는데, Rust가 결과를 바이트 단위로 매번 flush하느라 시간을 99% 쓰고 있었고, 그건 정확히 그렇게 하라고 시켰기 때문입니다.
모든 운영체제는 약 30MB짜리 표준 C 라이브러리를 기본 탑재하고 있으며, C 실행 파일은 이를 “공짜로” 얻습니다. 예를 들어 작은 C “Hello World” 실행 파일은 사실 자체적으로는 아무것도 출력할 수 없고, OS에 포함된 printf를 호출할 뿐입니다. Rust는 OS가 Rust 표준 라이브러리를 내장하고 있다고 기대할 수 없어서, Rust 실행 파일은 자체 표준 라이브러리(300KB 이상)를 함께 번들합니다. 다행히 이는 한 번만 드는 오버헤드이고 줄일 수도 있습니다. 임베디드 개발에서는 표준 라이브러리를 끄고 Rust가 “맨몸” 코드를 생성하게 할 수도 있습니다.
함수 단위로 보면 Rust 코드 크기는 C와 비슷하지만, “제네릭 부풀림(generics bloat)” 문제가 있습니다. 제네릭 함수는 사용되는 각 타입마다 최적화된 버전을 생성하므로, 같은 함수가 8개 버전으로 늘어날 수 있습니다. cargo-bloat가 이를 찾는 데 도움이 됩니다.
Rust에서는 의존성 사용이 매우 쉽습니다. JS/npm처럼 작고 단일 목적 라이브러리를 만드는 문화가 있는데, 이것들이 누적됩니다. 결국 제 실행 파일들은 유니코드 정규화 테이블, 서로 다른 난수 생성기 7개, Brotli 지원 HTTP/2 클라이언트 등을 포함하게 됩니다. cargo-tree는 중복 제거와 정리에 유용합니다.
오버헤드 얘기를 많이 했지만, Rust가 더 효율적이고 빠르게 끝나는 경우도 있습니다:
C 라이브러리는 구현 세부를 숨기고 구조체 인스턴스가 하나만 존재하도록 보장하기 위해 보통 내부 자료구조에 대한 불투명 포인터(opaque pointer)를 반환합니다. 이는 힙 할당과 포인터 간접 참조 비용을 유발합니다. Rust는 내장된 가시성(privacy), 단일 소유권 규칙, 관례 덕분에, 라이브러리가 간접 참조 없이 객체를 노출할 수 있어 호출자가 힙에 둘지 스택에 둘지 결정할 수 있습니다. 스택 객체는 매우 공격적으로 최적화될 수 있고, 심지어 완전히 제거되기도 합니다.
Rust는 기본적으로 표준 라이브러리, 의존성, 다른 컴파일 유닛에 있는 함수까지 인라인할 수 있습니다. C에서는 파일을 나누거나 라이브러리를 쓰면 인라이닝에 영향이 있고 헤더/심볼 가시성을 미세하게 관리해야 해서 망설일 때가 있습니다.
구조체 필드는 패딩을 최소화하도록 재정렬됩니다. C를 -Wpadding으로 컴파일해 보면 제가 이 디테일을 얼마나 자주 잊는지 알 수 있습니다.
문자열은 “fat 포인터”에 길이가 인코딩되어 있습니다. 덕분에 길이 검사가 빠르고, 실수로 O(n²)이 되는 문자열 루프 위험이 줄며, 메모리를 수정하거나 \0 종결자를 추가하기 위해 복사하지 않고도 제자리에서 부분 문자열(예: 토큰 분리)을 만들 수 있습니다.
C++ 템플릿처럼 Rust는 제네릭 코드를 사용된 타입마다 복제해 생성하므로, sort() 같은 함수나 해시 테이블 같은 컨테이너가 항상 해당 타입에 최적화됩니다. C에서는 매크로 해킹을 쓰거나, void*와 런타임 가변 크기에 의존하는 덜 효율적인 함수를 선택해야 합니다.
Rust 이터레이터는 체인으로 결합되어 하나의 단위처럼 함께 최적화될 수 있습니다. 그래서 buy(it); use(it); break(it); change(it); mail(upgrade(it));처럼 동일 버퍼를 여러 번 다시 쓰게 될 수 있는 호출 연쇄 대신, it.buy().use().break().change().upgrade().mail()을 호출하면, 이것이 단일 결합 패스로 전부 처리하도록 최적화된 buy_use_break_change_mail_upgrade(it) 같은 형태로 컴파일될 수 있습니다. (0..1000).map(|x| x*2).sum()은 return 999000으로 컴파일됩니다.
마찬가지로 버퍼링되지 않은 데이터를 스트리밍할 수 있게 하는 Read/Write 인터페이스가 있습니다. 이들은 잘 결합되어, 데이터를 쓰면서 즉석에서 CRC를 계산하고, 필요하면 프레이밍/이스케이핑을 추가하고, 압축해서, 네트워크로 쓰는 일을 한 번의 호출로 만들 수 있습니다. 또 이런 결합 스트림을 HTML 템플릿 엔진의 출력 스트림으로 넘기면, 각 HTML 태그가 스스로 압축된 상태로 전송되도록 만들 수도 있습니다. 내부 메커니즘은 단지 next_stream.write(bytes) 호출을 피라미드처럼 쌓은 것일 뿐이라, 기술적으로는 C에서도 할 수 있습니다. 하지만 C에는 트레이트와 제네릭이 없어서, 런타임에 콜백을 설정하는 방식 말고는 실제로 구현하기가 매우 어렵고, 그 방식은 효율도 떨어집니다.
C에서는 선형 탐색과 연결 리스트를 과하게 쓰는 것이 아주 합리적입니다. 누가 또 어설픈 해시 테이블 구현 하나를 유지보수하겠습니까? 내장 컨테이너가 없고 의존성은 쓰기 힘드니, 일을 끝내려고 지름길을 택하게 됩니다. 정말 필요하지 않으면 B-트리 같은 정교한 구현을 굳이 만들지 않습니다. qsort + bisect로 하루를 마무리하죠. 반면 Rust에서는 1~2줄 코드로 매우 높은 품질의 온갖 컨테이너 구현을 얻을 수 있습니다. 이는 Rust 프로그램이 매번 적절하고 엄청나게 최적화된 자료구조를 사용할 여유가 있음을 뜻합니다.
요즘은 뭐든 JSON이 필요해 보입니다. Rust의 serde는 세계에서 가장 빠른 JSON 파서 중 하나이며, Rust 구조체로 직접 파싱하므로 파싱된 데이터의 사용도 매우 빠르고 효율적입니다.
Rust는 모든 코드와 데이터에 대해 스레드 안전성을 강제합니다. 3rd party 라이브러리도 마찬가지이고, 그 코드의 작성자가 스레드 안전성에 신경 쓰지 않았더라도 마찬가지입니다. 모든 것은 특정 스레드 안전성 보장을 지키거나, 아니면 스레드 간 사용이 허용되지 않습니다. 스레드 안전하지 않은 코드를 작성하면 컴파일러가 정확히 어디가 안전하지 않은지 지적해 줍니다.
이는 C와 극적으로 다릅니다. C에서는 문서에 명확히 적혀 있지 않다면, 보통 어떤 라이브러리 함수도 스레드 안전하다고 신뢰할 수 없습니다. 모든 코드가 올바르도록 보장하는 것은 프로그래머의 몫이며, 컴파일러는 일반적으로 이런 부분에서 거의 도와주지 못합니다. 멀티스레드 C 코드는 책임이 더 크고 위험도 더 크기 때문에, 멀티코어 CPU가 유행일 뿐이라고 외면하고 사용자가 남은 7개나 15개 코어로 다른 일을 하겠거니 상상하는 편이 매력적입니다.
Rust는 데이터 레이스와 메모리 불안전(use-after-free 버그 등, 스레드 간도 포함)으로부터의 자유를 보장합니다. 휴리스틱이나 런타임 계측 빌드에서 발견될 “일부” 레이스가 아니라, 모든 곳의 모든 데이터 레이스를 보장합니다. 이는 생명을 구할 정도로 중요합니다. 데이터 레이스는 최악의 동시성 버그입니다. 제 디버거에서는 안 일어나지만 사용자 컴퓨터에서는 일어납니다. 물론 잠금 프리미티브를 잘못 사용해서 생기는 상위 수준의 논리 레이스나 데드락 같은 다른 종류의 동시성 버그도 있고, Rust가 그것까지 제거해주진 못하지만, 보통은 재현과 수정이 더 쉽습니다.
C에서는 단순한 for 루프에 OpenMP 프라그마를 몇 개 붙이는 것 이상은 감히 못 하겠습니다. 작업(task)과 스레드를 더 적극적으로 써 본 적이 있는데, 매번 후회했습니다.
Rust에는 데이터 병렬성, 스레드 풀, 큐, 태스크, 락프리 자료구조 등을 위한 좋은 라이브러리들이 있습니다. 이런 빌딩 블록과 타입 시스템의 강력한 안전망 덕분에, Rust 프로그램은 꽤 쉽게 병렬화할 수 있습니다. 어떤 경우에는 iter()를 par_iter()로 바꾸는 것만으로도 충분하고, 컴파일만 된다면 제대로 동작합니다! 항상 선형적인 속도 향상은 아니지만(암달의 법칙은 잔혹합니다), 비교적 적은 작업으로도 종종 2×~3× 속도 향상을 얻습니다.
Rust와 C 라이브러리가 스레드 안전성을 문서화하는 방식에도 흥미로운 차이가 있습니다. Rust에는 Send, Sync, 가드(guard), 셀(cell) 등 스레드 안전성의 특정 측면을 표현하는 어휘가 있습니다. C에는 “한 스레드에서 할당하고 다른 스레드에서 해제할 수는 있지만, 두 스레드에서 동시에 사용하면 안 된다”를 표현할 말이 없습니다. Rust는 데이터 타입의 관점에서 스레드 안전성을 설명하며, 이는 그 타입을 사용하는 모든 함수로 일반화됩니다. C에서는 스레드 안전성이 개별 함수나 설정 플래그 맥락에서 이야기됩니다. Rust의 보장은 대체로 컴파일 타임이거나 최소한 무조건적입니다. C에서는 “turboblub 옵션을 7로 설정한 경우에만 스레드 안전” 같은 문장을 흔히 보게 됩니다.
Rust는 필요하다면 C만큼이나 최대 성능을 위해 저수준으로 최적화할 수 있습니다. 고수준 추상화, 쉬운 메모리 관리, 풍부한 라이브러리 때문에 Rust 프로그램은 코드가 더 많고 더 많은 일을 하게 되며, 방치하면 비대해질 수 있습니다. 하지만 Rust 프로그램도 최적화가 잘 되는 편이며, 때로는 C보다 더 잘 되기도 합니다. C가 바이트 단위, 포인터 단위의 최소 코드를 작성하는 데 강점이 있다면, Rust는 여러 함수나 심지어 전체 라이브러리를 효율적으로 결합하는 강력한 기능이 있습니다.
하지만 가장 큰 잠재력은, C로는 병렬화하기에 너무 위험한 경우에도 Rust 코드의 대부분을 두려움 없이 병렬화할 수 있다는 점에 있습니다. 이 측면에서 Rust는 C보다 훨씬 성숙한 언어입니다.