10가지 Rust 성능 팁: 기본부터 고급까지 🚀
Min-jun Kim
Dev Intern · Leapcell

10가지 Rust 성능 최적화 팁: 기본부터 고급까지
Rust의 "안전성 + 고성능"이라는 이중적인 명성은 자동으로 얻어지는 것이 아닙니다. 부적절한 메모리 작업, 타입 선택 또는 동시성 제어는 성능을 크게 저하시킬 수 있습니다. 다음 10가지 팁은 일상적인 개발에서 자주 발생하는 시나리오를 다루며, Rust의 잠재력을 최대한 활용할 수 있도록 "최적화 로직"을 심층적으로 설명합니다.
1. 불필요한 복제 방지
방법
- 가능한 한
T
대신&T
(borrowing)를 사용하십시오. clone
을clone_from_slice
로 바꾸십시오.- 고빈도 읽기-쓰기 시나리오에는
Cow<'a, T>
스마트 포인터를 사용하십시오 (읽기에는 borrows, 쓰기에는 clones).
작동 원리
Rust의 Clone
trait은 기본적으로 깊은 복사를 수행합니다 (예: Vec::clone()
은 새로운 힙 메모리를 할당하고 모든 요소를 복사합니다). 반면, borrowing (&T
)은 기존 데이터만 참조하며, 메모리 할당이나 복사 오버헤드가 없습니다. 예를 들어, 큰 문자열을 처리할 때 fn process(s: &str)
은 fn process(s: String)
에 비해 힙 메모리 전송을 한 번 줄여주어 고빈도 호출에서 몇 배 더 나은 성능을 제공합니다.
2. 함수 매개변수에 String
대신 &str
사용
방법
- 함수 매개변수를
String
보다는&str
로 선언하십시오 (우선순위). &s
(whens: String
)를 사용하거나 리터럴을 직접 전달하여 호출을 조정하십시오 (예:"hello"
).
작동 원리
String
은 힙에 할당된 "소유된 문자열"입니다. 이를 전달하면 소유권 이전 (또는 복제)이 발생합니다.&str
(문자열 슬라이스)는 기본적으로(&u8, usize)
튜플 (포인터 + 길이)이며, 힙 연산 오버헤드 없이 스택 메모리만 차지합니다.- 더 중요한 것은
&str
이 모든 문자열 소스 (String
, 리터럴,&[u8]
)와 호환되므로 호출자가 매개변수와 일치시키기 위해 추가 복제를 수행하지 않아도 됩니다.
3. 올바른 컬렉션 타입 선택: "만능" 거부
방법
- 임의 접근 또는 반복에는
LinkedList
보다Vec
을 우선시하십시오. - 빈번한 조회를 위해
HashSet
(O(1))을 사용하십시오. 정렬된 시나리오에서만BTreeSet
(O(log n))을 사용하십시오. - 키-값 조회를 위해
HashMap
을 사용하십시오. 정렬된 순회가 필요한 경우BTreeMap
을 사용하십시오.
작동 원리
Rust 컬렉션 간의 성능 차이는 메모리 레이아웃에서 비롯됩니다.
Vec
은 연속적인 메모리를 사용하므로 캐시 적중률이 높습니다. 임의 접근에는 오프셋 계산만 필요합니다.LinkedList
는 흩어져 있는 노드로 구성되어 각 접근마다 포인터 점프가 필요합니다. 성능이Vec
보다 10배 이상 나쁩니다 (테스트 결과 100,000개 요소를 순회하는 데Vec
은 1ms,LinkedList
는 15ms 소요됨).HashSet
은 해시 테이블 (조회가 더 빠르지만 정렬되지 않음)을 기반으로 하는 반면,BTreeSet
은 균형 트리를 사용합니다 (정렬되지만 오버헤드가 더 높음).
4. 인덱스 루프 대신 반복자 사용
방법
for i in 0..collection.len() { collection[i] }
보다for item in collection.iter()
를 우선시하십시오.- 복잡한 로직에는 반복자 메서드 체이닝 (예:
filter().map().collect()
)을 사용하십시오.
작동 원리
Rust 반복자는 제로 코스트 추상화입니다. 컴파일 후에는 손으로 작성한 루프와 동일하거나 더 나은 어셈블리 코드로 최적화됩니다.
- 인덱스 루프는 경계 검사를 트리거합니다 (collection[i]에 대해
i
가 유효한 범위 내에 있는지 확인). 그러나 반복자를 사용하면 컴파일러가 컴파일 시간에 "접근 안전성"을 증명하고 이러한 검사를 자동으로 제거할 수 있습니다. - 메서드 체이닝을 사용하면 컴파일러가 "루프 융합"을 수행할 수 있습니다 (예:
filter
와map
을 단일 순회로 병합). 이렇게 하면 루프 수가 줄어듭니다.
5. Box<dyn Trait>
을 사용한 동적 디스패치 방지
방법
성능이 중요한 시나리오에서는 Box<dyn Trait>
+ 동적 디스패치 (예: fn process(t: Box<dyn Trait>)
) 대신 "제네릭 + 정적 디스패치" (예: fn process<T: Trait>(t: T)
)를 사용하십시오.
작동 원리
Box<dyn Trait>
은 동적 디스패치를 사용합니다. 컴파일러는 trait에 대한 "가상 함수 테이블 (vtable)"을 생성하고 각 trait 메서드 호출에는 포인터 기반 vtable 조회가 필요합니다 (런타임 오버헤드 발생).- 제네릭은 정적 디스패치를 사용합니다. 컴파일러는 각 구체적인 타입 (예:
T=u32
,T=String
)에 대한 특수화된 함수 코드를 생성하여 vtable 조회 오버헤드를 제거합니다. 테스트 결과 동적 디스패치가 단순 메서드 호출에서 정적 디스패치보다 20%-50% 더 느린 것으로 나타났습니다.
6. 작은 함수에 #[inline]
속성 추가
방법
"자주 호출되는 + 작은 본문" 함수 (예: 유틸리티 함수, getter)에 #[inline]
을 적용하십시오.
#[inline] fn get_value(&self) -> &i32 { &self.value }
작동 원리
함수 호출은 "스택 프레임 생성/소멸" 오버헤드 (레지스터 저장, 스택, 점프)를 발생시킵니다. 작은 함수의 경우 이 오버헤드가 함수 본문을 실행하는 데 걸리는 시간을 초과할 수도 있습니다. #[inline]
은 컴파일러에게 "호출 위치에 함수 본문을 삽입"하여 호출 오버헤드를 제거하도록 지시합니다.
참고: 큰 함수에는 #[inline]
을 추가하지 마십시오. 이로 인해 바이너리 비대 (코드 중복)가 발생하고 캐시 적중률이 저하됩니다.
7. 구조체 메모리 레이아웃 최적화
방법
- 구조체 필드를 크기 내림차순으로 정렬하십시오 (예:
u64
→u32
→bool
). - 교차 언어 상호 작용 또는 컴팩트한 레이아웃을 위해
#[repr(C)]
또는#[repr(packed)]
를 추가하십시오 (#[repr(packed)]
는 정렬되지 않은 접근을 유발할 수 있으므로 주의해서 사용하십시오).
작동 원리
Rust는 기본적으로 "메모리 정렬"에 최적화된 구조체 레이아웃을 사용하므로 "메모리 간격"이 발생할 수 있습니다. 예를 들어:
// 나쁨: 정렬되지 않은 필드, 총 크기 = 24 바이트 (15 바이트 간격) struct BadLayout { a: bool, b: u64, c: u32 } // 좋음: 내림차순 필드 순서, 총 크기 = 16 바이트 (간격 없음) struct GoodLayout { b: u64, c: u32, a: bool }
메모리 사용량 감소는 캐시 적중률을 향상시킵니다. CPU는 단일 캐시 페치에서 더 많은 구조체를 로드하여 순회 또는 접근 속도를 높일 수 있습니다.
8. MaybeUninit
을 사용하여 초기화 오버헤드 줄이기
방법
큰 메모리 블록 (예: Vec<u8>
, 사용자 정의 배열)의 경우 std::mem::MaybeUninit
을 사용하여 기본 초기화를 건너뜁니다.
use std::mem::MaybeUninit; // 초기화 없이 1,000,000 바이트 Vec 생성 let mut buf = Vec::with_capacity(1_000_000); let ptr = buf.as_mut_ptr(); unsafe { buf.set_len(1_000_000); // 'ptr'이 가리키는 메모리를 수동으로 초기화 }
작동 원리
Rust는 기본적으로 모든 변수를 초기화합니다 (예: Vec::new()
는 포인터, 길이 및 용량을 초기화하고 let x: u8 = Default::default()
는 x
를 0으로 설정합니다). 큰 메모리 블록을 초기화하면 상당한 CPU 리소스가 소비됩니다. MaybeUninit
을 사용하면 "먼저 메모리를 할당하고 나중에 초기화"하여 의미 없는 기본값 채우기를 건너뛸 수 있습니다. 테스트 결과 1GB 메모리 블록을 생성할 때 기본 초기화보다 50% 이상 빠릅니다.
참고: 사용하기 전에 초기화가 완료되었는지 확인하려면 unsafe
를 사용해야 합니다. 그렇지 않으면 정의되지 않은 동작이 발생합니다.
9. 잠금 세분성 줄이기
방법
- 읽기 위주의, 쓰기 빈도가 낮은 시나리오의 경우
Mutex
(완전 배타적) 대신std::sync::RwLock
(여러 스레드가 병렬로 읽을 수 있음, 쓰기는 배타적)을 사용하십시오. - 잠금 범위를 최소화하십시오. 전체 함수가 아닌 공유 데이터에 접근할 때만 잠그십시오.
작동 원리
잠금은 동시 성능의 가장 큰 병목 현상입니다.
Mutex
는 한 번에 하나의 스레드만 접근할 수 있도록 허용하므로 다중 스레드 경쟁에서 대규모 스레드 차단이 발생합니다.RwLock
의 "읽기-쓰기 분리"는 병렬 읽기 작업을 가능하게 하여 읽기 위주의 시나리오에서 처리량을 몇 배로 증가시킵니다.
잠금 범위를 최소화하면 "스레드가 잠금을 유지하는 시간"을 줄여 경쟁 가능성을 낮출 수 있습니다. 예를 들어:
// 나쁨: 과도하게 큰 잠금 범위 (관련 없는 계산 포함) let mut data = lock.lock().unwrap(); compute(); // 관련 없는 계산, 하지만 잠금이 유지됨 data.update(); // 좋음: 데이터에 접근할 때만 잠금 compute(); // 잠금 없는 계산 { let mut data = lock.lock().unwrap(); data.update(); }
10. 프로파일 기반 최적화 (PGO) 활성화
방법
Cargo PGO로 최적화 (Rust 1.69+에서 지원):
- 성능 프로파일링 데이터 생성:
cargo pgo instrument run
- 프로파일링 데이터를 사용하여 컴파일 최적화:
cargo pgo optimize build --release
작동 원리
일반 컴파일은 "맹목적인 최적화"입니다. 컴파일러는 코드의 실제 런타임 핫스팟 (예: 어떤 함수가 자주 호출되는지, 어떤 분기가 가장 자주 실행되는지)에 대한 지식이 없습니다. PGO는 "먼저 프로그램을 실행하여 핫스팟 데이터를 수집한 다음 대상에 맞게 최적화"하여 컴파일러가 더 정확한 결정을 내릴 수 있도록 합니다. 예를 들어, 자주 호출되는 함수를 인라인하거나 핫 분기에 대한 어셈블리 코드를 최적화합니다. 테스트 결과 PGO는 웹 서비스 및 데이터베이스와 같은 복잡한 프로그램의 성능을 10%-30% 향상시킬 수 있는 것으로 나타났습니다.
요약
Rust 성능 최적화의 핵심 로직은 다음과 같습니다.
- 메모리 오버헤드 감소 (복제 방지, 적절한 타입 선택)
- 런타임 중복 제거 (정적 디스패치, 반복자)
- 컴파일 시간 최적화 활용 (
inline
, PGO)
실제로 프로파일링 도구 (예: cargo flamegraph
)를 먼저 사용하여 병목 현상을 식별한 다음 대상에 맞게 최적화하는 것이 좋습니다. "핫스팟이 아닌 코드"의 맹목적인 최적화는 유지 관리 비용만 증가시키므로 피하십시오. 이러한 팁을 마스터하면 Rust의 고성능 이점을 최대한 활용할 수 있습니다!
Leapcell: 최고의 서버리스 웹 호스팅
마지막으로 Rust 서비스를 배포하기에 가장 적합한 플랫폼에 대한 권장 사항입니다. Leapcell
🚀 좋아하는 언어로 빌드
JavaScript, Python, Go 또는 Rust로 쉽게 개발하십시오.
🌍 무료로 무제한 프로젝트 배포
사용한 만큼만 지불하십시오. 요청도 없고 요금도 없습니다.
⚡ 사용한 만큼 지불, 숨겨진 비용 없음
유휴 요금 없이 원활한 확장성만 제공됩니다.
🔹 Twitter에서 팔로우하세요: @LeapcellHQ