PHpullh

RUST · 심층 가이드

Rust 성능 완전 정리

사전 할당과 문자열 버퍼링, 메모이제이션, 배치 I/O, 불필요한 복사 줄이기까지 측정에 근거해 Rust 성능을 다루는 15가지 방법을 모았습니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

Rust는 기본값이 이미 빠른 편이라, 성능 작업의 무게중심이 다른 언어와 다릅니다. 가상 머신 튜닝이나 가비지 컬렉터 조정 같은 항목이 아예 없는 대신, 남는 비용은 대개 불필요한 할당과 복사에서 나옵니다. clone()을 습관적으로 붙여 빌림 검사기를 넘긴 코드가 가장 흔한 출발점입니다.

사전 할당과 문자열 버퍼링부터 보십시오. 크기를 알 때 용량을 미리 잡아 두는 것만으로 재할당이 사라집니다. 불필요한 복사 줄이기와 소유권 또는 참조 재사용은 함께 읽어야 의미가 살고, 조회 맵 사용과 Set 조회 최적화는 선형 탐색을 해시 조회로 바꾸는 전형적인 개선입니다. 시간 측정을 먼저 익혀 두면 나머지 항목의 효과를 스스로 확인할 수 있습니다.

측정할 때 반드시 지켜야 할 것이 두 가지 있습니다. 첫째, 디버그 빌드에서 잰 수치는 의미가 없습니다. 최적화가 꺼져 있어 릴리스 빌드와 몇 배씩 차이가 납니다. 둘째, 결과를 쓰지 않는 계산은 컴파일러가 통째로 지워 버릴 수 있습니다. 루프를 돌렸는데 시간이 0에 가깝게 나온다면 빨라진 것이 아니라 코드가 사라진 것입니다.

01문자열 버퍼링

String::with_capacity(32)로 버퍼를 미리 잡아 두고 push_str로 조각을 이어 붙입니다. 만들 문자열의 대략적인 길이를 아는 자리에서 이렇게 시작하면 중간 재할당이 줄어듭니다. 32는 상한이 아니라 초기 힌트라서, 넘어가면 오류가 아니라 더 큰 버퍼로 옮겨 복사되는 동작이 일어납니다.

Rust code

// 문자열 버퍼링
fn main() {
    let mut buffer = String::with_capacity(32);
    buffer.push_str("item-");
    buffer.push_str(&1.to_string());
    println!("{}", buffer);
}
알아두면 좋은 점

with_capacity에 준 32는 상한이 아니라 초기 힌트라, 넘어가면 오류가 아니라 더 큰 버퍼로 옮겨 복사하는 재할당이 일어납니다.

자주 하는 실수

용량을 실제 필요량보다 훨씬 크게 잡으면 재할당은 줄지만 쓰지 않는 메모리를 계속 붙들고 있게 되므로, 대략적인 길이를 근거로 정해야 합니다.

02사전 할당

Instant::now()로 시작 시각을 잡고 0부터 999까지 더한 뒤 합계와 elapsed()를 함께 출력합니다. 사전 할당 같은 변경의 효과를 눈으로 보려 할 때 쓰는 골격입니다. 다만 이 루프는 결과가 상수라 릴리스 빌드에서 통째로 접혀 사라지므로, 측정값이 0에 가깝게 나오기 쉽습니다.

Rust code

// 사전 할당
use std::time::Instant;
fn main() {
    let start = Instant::now();
    let mut total = 0;
    for i in 0..1000 { total += i; }
    println!("{} {:?}", total, start.elapsed());
}
알아두면 좋은 점

Instant::now()와 elapsed() 사이를 재는 이 골격은 변경 전후를 비교할 때 쓰며, 여러 번 반복해 분포를 봐야 의미가 생깁니다.

자주 하는 실수

이 루프는 결과가 상수라 릴리스 빌드에서 통째로 접혀 사라지므로, 0에 가까운 측정값을 최적화 효과로 오해하기 쉽습니다.

03지연 처리

지연 계산은 없고 버퍼에 두 조각을 곧바로 밀어 넣습니다. 1.to_string()이 임시 String을 새로 할당한 다음 그 내용을 버퍼로 다시 복사한다는 점이 눈에 띕니다. 숫자를 이어 붙일 때 fmt::Write를 가져와 write!를 쓰면 그 임시 할당 없이 버퍼에 직접 씁니다.

Rust code

// 지연 처리
fn main() {
    let mut buffer = String::with_capacity(32);
    buffer.push_str("item-");
    buffer.push_str(&3.to_string());
    println!("{}", buffer);
}
알아두면 좋은 점

std::fmt::Write를 가져오면 write!로 버퍼에 숫자를 바로 찍을 수 있어, 중간 문자열을 만들지 않고 한 번에 끝납니다.

자주 하는 실수

&3.to_string()은 임시 String을 새로 할당한 뒤 그 내용을 다시 복사하므로, 루프 안에 있으면 반복 횟수만큼 할당이 쌓입니다.

04메모이제이션 캐시

캐시는 등장하지 않고 1000번 더하는 합계를 매번 그대로 계산하며 소요 시간을 표시합니다. 같은 입력이 반복된다면 결과를 HashMap에 담아 두는 것이 다음 단계입니다. 다만 해시 계산과 조회도 비용이라, 덧셈처럼 값싼 연산은 캐싱을 얹는 쪽이 오히려 느려집니다.

Rust code

// 메모이제이션 캐시
use std::time::Instant;
fn main() {
    let start = Instant::now();
    let mut total = 0;
    for i in 0..1000 { total += i; }
    println!("{} {:?}", total, start.elapsed());
}
알아두면 좋은 점

같은 입력이 반복된다면 결과를 HashMap에 담아 두는 것이 다음 단계이며, 계산 비용이 조회 비용보다 확실히 클 때만 이득입니다.

자주 하는 실수

덧셈처럼 값싼 연산에 캐시를 얹으면 해싱과 조회 비용이 더해져 오히려 느려지므로, 재 보기 전에 캐싱부터 도입하지 않는 편이 낫습니다.

05조회 맵 사용

맵은 나오지 않고, 미리 확보한 String에 접두어와 숫자를 붙여 키처럼 생긴 문자열을 만듭니다. 조회용 키를 조립하는 자리에서 자주 보이는 코드입니다. HashMap<String, _>은 &str로도 조회되므로, 만들자마자 버릴 키라면 굳이 String을 새로 할당할 이유가 없습니다.

Rust code

// 조회 맵 사용
fn main() {
    let mut buffer = String::with_capacity(32);
    buffer.push_str("item-");
    buffer.push_str(&5.to_string());
    println!("{}", buffer);
}
알아두면 좋은 점

HashMap<String, _>은 &str로도 조회되므로, 조회용 키를 만들자마자 버릴 거라면 String을 새로 할당할 이유가 없습니다.

자주 하는 실수

키를 format!으로 매번 조립해 조회하면 조회 한 번마다 힙 할당이 생겨, 맵을 쓴 이득이 문자열 만드는 비용에 묻힙니다.

06배치 I/O

파일이나 네트워크 입출력은 전혀 없고 메모리 안에서 도는 루프의 시간만 잽니다. 실제 I/O를 다룰 때는 이 골격만으로 부족한데, println!이 호출마다 표준 출력 잠금을 잡기 때문입니다. 출력이 많다면 BufWriter로 감싸 모아 두었다가 한 번에 흘려보내는 것이 차이를 만듭니다.

Rust code

// 배치 I/O
use std::time::Instant;
fn main() {
    let start = Instant::now();
    let mut total = 0;
    for i in 0..1000 { total += i; }
    println!("{} {:?}", total, start.elapsed());
}
알아두면 좋은 점

출력이 많다면 BufWriter로 감싸 모아 두었다가 한 번에 흘려보내면 시스템 호출 횟수가 줄어 차이가 크게 납니다.

자주 하는 실수

메모리 안에서만 도는 루프를 재고 입출력 성능을 판단하면 실제 병목을 놓치는데, 대개 시간은 계산이 아니라 시스템 호출에서 소모됩니다.

07불필요한 복사 줄이기

push_str는 인자를 &str로 빌려 가므로 원본을 소비하지 않고 내용만 덧붙입니다. 조각을 여러 번 이어 붙일 때 format!을 반복하는 대신 이 방식을 씁니다. format!은 부를 때마다 새 String을 할당하지만, 여기서는 처음 잡아 둔 버퍼 하나를 계속 재사용합니다.

Rust code

// 불필요한 복사 줄이기
fn main() {
    let mut buffer = String::with_capacity(32);
    buffer.push_str("item-");
    buffer.push_str(&7.to_string());
    println!("{}", buffer);
}
알아두면 좋은 점

push_str는 인자를 &str로 빌려 가므로 원본을 소비하지 않고, 처음 잡아 둔 버퍼 하나를 계속 재사용합니다.

자주 하는 실수

같은 일을 format!으로 매번 새 문자열을 만들어 대입하는 식으로 하면, 호출마다 할당이 생기고 기존 내용까지 통째로 복사됩니다.

08시간 측정

Instant::now()와 elapsed() 사이 구간을 Duration으로 받아 {:?}로 찍습니다. 코드 한 조각의 대략적인 소요를 확인할 때 가장 손쉬운 방법입니다. Instant는 뒤로 가지 않는 단조 시계라 시스템 시각 변경에 흔들리지 않지만, 한 번 잰 값은 편차가 커서 여러 번 반복해 봐야 합니다.

Rust code

// 시간 측정
use std::time::Instant;
fn main() {
    let start = Instant::now();
    let mut total = 0;
    for i in 0..1000 { total += i; }
    println!("{} {:?}", total, start.elapsed());
}
알아두면 좋은 점

Instant는 뒤로 가지 않는 단조 시계라 시스템 시각이 조정돼도 흔들리지 않아, 구간 소요 시간 측정에 적합합니다.

자주 하는 실수

한 번 잰 값은 편차가 커서 그대로 비교하면 잘못된 결론을 내기 쉬우므로, 여러 번 반복해 중앙값을 보고 판단해야 합니다.

09병렬 처리

스레드는 쓰지 않고 한 버퍼에 순차적으로 push_str를 두 번 합니다. 여러 스레드가 같은 String에 쓰려면 Mutex로 감싸야 하고, 그러면 잠금 경합이 새로운 병목이 됩니다. 그래서 보통은 스레드마다 버퍼를 따로 두고 마지막에 한 번 합치는 형태로 짭니다.

Rust code

// 병렬 처리
fn main() {
    let mut buffer = String::with_capacity(32);
    buffer.push_str("item-");
    buffer.push_str(&9.to_string());
    println!("{}", buffer);
}
알아두면 좋은 점

여러 스레드로 문자열을 만들 때는 스레드마다 버퍼를 따로 두고 마지막에 한 번 합치면, 잠금 없이 각자 진행할 수 있습니다.

자주 하는 실수

하나의 String을 Mutex로 감싸 여러 스레드가 쓰면 잠금 경합이 새로운 병목이 되어, 스레드를 늘려도 오히려 느려질 수 있습니다.

10객체 재사용

새 값을 만들어 내는 대신 total 변수 하나를 계속 갱신하며 누적하고 걸린 시간을 출력합니다. 반복 할당을 피한다는 점에서 재사용의 가장 단순한 모습입니다. 다만 i32는 힙을 쓰지 않아 애초에 이득이 없고, 이 기법이 의미를 갖는 것은 버퍼를 가진 Vec이나 String입니다.

Rust code

// 객체 재사용
use std::time::Instant;
fn main() {
    let start = Instant::now();
    let mut total = 0;
    for i in 0..1000 { total += i; }
    println!("{} {:?}", total, start.elapsed());
}
알아두면 좋은 점

버퍼를 가진 Vec이나 String은 clear()로 내용만 비우면 확보한 용량이 남아, 다음 반복에서 재할당 없이 다시 채울 수 있습니다.

자주 하는 실수

i32처럼 힙을 쓰지 않는 값은 재사용해도 얻는 것이 없으므로, 이 기법을 아무 데나 적용하면 코드만 복잡해집니다.

11Set 조회 최적화

집합 자료구조 대신 String 버퍼에 값을 이어 붙이는 코드입니다. 포함 여부만 볼 거라면 문자열을 만들어 비교하기보다 HashSet에 담아 해시 한 번으로 끝내는 편이 낫습니다. 다만 조회마다 해시 계산 비용이 붙기 때문에, 항목이 아주 적을 때는 Vec의 선형 탐색이 더 빠를 수도 있습니다.

Rust code

// Set 조회 최적화
fn main() {
    let mut buffer = String::with_capacity(32);
    buffer.push_str("item-");
    buffer.push_str(&11.to_string());
    println!("{}", buffer);
}
알아두면 좋은 점

포함 여부만 본다면 문자열을 만들어 비교하기보다 HashSet에 담아 해시 한 번으로 끝내는 편이 항목이 늘어날수록 유리합니다.

자주 하는 실수

항목이 몇 개뿐인데 HashSet을 쓰면 해시 계산 비용이 붙어, 오히려 Vec의 선형 탐색이 더 빠를 수 있습니다.

12청크 스트리밍

0부터 999까지를 한 번에 순회하며 합을 구하고 소요 시간을 출력합니다. 데이터를 조각내 처리하려면 이터레이터의 chunks나 버퍼 단위 읽기를 쓰게 됩니다. 범위 반복은 값을 미리 만들어 두지 않고 하나씩 내놓기 때문에, 끝 값을 아무리 키워도 메모리 사용량은 늘지 않습니다.

Rust code

// 청크 스트리밍
use std::time::Instant;
fn main() {
    let start = Instant::now();
    let mut total = 0;
    for i in 0..1000 { total += i; }
    println!("{} {:?}", total, start.elapsed());
}
알아두면 좋은 점

0..1000 같은 범위 반복은 값을 미리 만들어 두지 않고 하나씩 내놓으므로, 끝 값을 키워도 메모리 사용량이 늘지 않습니다.

자주 하는 실수

같은 데이터를 먼저 collect로 벡터에 모아 두고 순회하면, 필요 없는 할당과 메모리 사용이 그대로 비용이 됩니다.

13정렬 비용 비교

정렬은 나오지 않고, 미리 잡은 버퍼에 문자열을 덧붙이는 과정만 보입니다. 정렬 대상이 문자열이면 비교할 때마다 바이트를 앞에서부터 훑어야 해서 정수 정렬보다 비쌉니다. sort_by_key가 비교마다 키를 다시 만드는 것이 문제라면, 키를 한 번만 계산해 두는 sort_by_cached_key가 대안입니다.

Rust code

// 정렬 비용 비교
fn main() {
    let mut buffer = String::with_capacity(32);
    buffer.push_str("item-");
    buffer.push_str(&13.to_string());
    println!("{}", buffer);
}
알아두면 좋은 점

문자열 정렬은 비교할 때마다 바이트를 앞에서부터 훑어야 해서 정수 정렬보다 비싸고, 공통 접두어가 길수록 더 나빠집니다.

자주 하는 실수

sort_by_key가 비교마다 키를 다시 만든다는 것을 모르고 무거운 키 함수를 넣으면, 정렬 시간이 몇 배로 늘어납니다.

14소유권 또는 참조 재사용

여기 쓰인 값은 모두 Copy인 정수라 total += i에서 이동도 빌림도 일어나지 않습니다. 소유권이 성능 문제로 이어지는 것은 힙을 쓰는 타입부터입니다. 빌림 검사 오류를 clone()으로 넘기는 습관이 가장 흔한 낭비이고, 대개는 &로 빌려 넘기도록 시그니처를 고치면 해결됩니다.

Rust code

// 소유권 또는 참조 재사용
use std::time::Instant;
fn main() {
    let start = Instant::now();
    let mut total = 0;
    for i in 0..1000 { total += i; }
    println!("{} {:?}", total, start.elapsed());
}
알아두면 좋은 점

이 코드의 값은 모두 Copy인 정수라 누적에서 이동도 빌림도 일어나지 않고, 소유권 비용은 힙을 쓰는 타입부터 생깁니다.

자주 하는 실수

빌림 검사 오류를 clone()으로 넘기는 습관이 가장 흔한 낭비이며, 대개는 인자를 &로 받도록 시그니처를 고치면 복사 없이 해결됩니다.

15페이지네이션 슬라이스

슬라이싱은 없고 String 하나를 조립해 출력하는 것이 전부입니다. 문자열을 잘라 페이지를 나눌 때는 인덱스가 문자가 아니라 바이트 기준이라는 점이 걸림돌이 됩니다. 한글처럼 여러 바이트를 쓰는 문자 중간을 가리키면 패닉이 나므로, char_indices로 경계를 먼저 찾아야 합니다.

Rust code

// 페이지네이션 슬라이스
fn main() {
    let mut buffer = String::with_capacity(32);
    buffer.push_str("item-");
    buffer.push_str(&15.to_string());
    println!("{}", buffer);
}
알아두면 좋은 점

문자열을 안전하게 자르려면 char_indices로 문자 경계를 먼저 찾거나 chars().take(n)으로 글자 단위로 다뤄야 합니다.

자주 하는 실수

바이트 인덱스로 문자열을 자르면 한글처럼 여러 바이트를 쓰는 문자 중간을 가리켰을 때 그 자리에서 패닉이 납니다.

정리하며

  • 남는 비용은 대개 습관적인 clone과 불필요한 할당에서 나옵니다
  • 크기를 알면 용량을 미리 잡아 재할당을 없앱니다
  • 선형 탐색을 해시 조회로 바꾸는 것이 가장 자주 통하는 개선입니다
  • 측정은 릴리스 빌드에서 하고 결과가 지워지지 않게 값을 소비합니다

더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.