Rust 웹 개발 컴파일 속도 향상시키기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
Rust는 비교할 수 없는 성능, 메모리 안전 보장 및 강력한 타입 시스템 덕분에 웹 개발에서 빠르게 주목받고 있습니다. Actix-web, Axum, Warp와 같은 프레임워크는 고성능 웹 서비스를 구축하기 위한 강력한 도구를 제공합니다. 하지만 nontrivial Rust 웹 애플리케이션을 다뤄본 개발자라면 누구나 곧 심각한 장애물에 부딪히게 됩니다. 바로 컴파일 시간입니다. 초기 전체 컴파일은 마치 영원처럼 느껴질 수 있으며, 점진적 빌드조차도 좌절할 만큼 느려서 개발 피드백 루프에 심각한 영향을 미칩니다. 이러한 고유한 특성은 특히 컴파일 주기가 더 빠르거나 동적 인터프리터를 사용하는 언어에서 온 사람들의 질문과 우려를 자주 불러일으킵니다. 이러한 느린 컴파일의 "이유"를 이해하는 것이 이를 효과적으로 완화하기 위한 첫걸음이며, 궁극적으로 더 생산적이고 즐거운 Rust 웹 개발 경험으로 이어집니다. 이 글에서는 Rust 컴파일 속도가 느린 이유를 살펴보고, 더 중요하게는 개발 워크플로를 최적화하기 위한 실질적인 도구와 기술을 탐색할 것입니다.
Rust 웹 컴파일 해부하기
솔루션을 살펴보기 전에 Rust 컴파일 프로세스를 이해하는 데 중요한 몇 가지 핵심 개념을 명확히 해 보겠습니다.
- 컴파일: 사람이 읽을 수 있는 소스 코드를 기계가 실행 가능한 이진 코드로 변환하는 과정입니다. Rust는 미리 컴파일(AOT)되는 언어이므로 이 단계는 실행 전에 발생합니다.
 - 점진적 컴파일: Rust 컴파일러의 기능으로, 마지막 성공적인 컴파일 이후 변경된 코드 부분만 다시 컴파일하려고 시도하므로 모든 것을 처음부터 다시 빌드하는 것을 방지합니다. 이는 후속 빌드 속도를 크게 향상시킵니다.
 - 링커: 컴파일러(오브젝트 파일)의 출력을 받아 단일 실행 파일로 결합하고 코드의 여러 부분 간의 참조를 해결하는 프로그램입니다. 이는 종종 전체 빌드에서 가장 느린 부분입니다.
 - 코드 생성 백엔드: 실제 기계 코드를 생성하는 컴파일러의 구성 요소입니다. Rust는 주로 LLVM을 코드 생성 백엔드(codegen backend)로 사용합니다.
 - 의존성 그래프: 프로젝트의 다른 모듈, 크레이트 및 라이브러리 간의 관계 네트워크입니다. 기본 의존성의 변경은 해당 의존성에 의존하는 모든 것을 다시 컴파일하는 것을 트리거할 수 있습니다.
 
Rust 웹 애플리케이션이 느리게 컴파일되는 이유
Rust의 안전성과 성능에 대한 약속은 여러 가지 이유로 인해 컴파일 시간에 더 긴 시간을 소요하게 합니다.
- 엄격한 빌림 및 생명 주기(Lifetimes): 빌림 검사기(borrow checker)는 가비지 컬렉터 없이 메모리 안전성을 보장하기 위해 광범위한 정적 분석을 수행합니다. 이 분석은 특히 큰 코드베이스나 웹 애플리케이션 로직에서 흔히 볼 수 있는 복잡한 데이터 구조의 경우 복잡하고 계산 집약적입니다.
 - 제네릭의 단위화(Monomorphization): Rust의 제네릭은 단위화됩니다. 즉, 컴파일러는 제네릭 함수 또는 구조체가 사용되는 각 구체적인 타입에 대해 고유한 버전을 생성합니다. 이는 런타임 오버헤드를 제거하지만 컴파일러가 처리하고 최적화해야 하는 코드의 양이 크게 증가할 수 있습니다. 웹 프레임워크는 요청 핸들러, 미들웨어 및 데이터 타입에 대해 제네릭을 많이 사용합니다.
 - 광범위한 최적화: Rustc, Rust 컴파일러는 LLVM을 활용하여 매우 효율적인 기계 코드를 생성하기 위해 공격적인 최적화를 수행합니다. 이러한 최적화는 성능에 매우 중요하지만 시간이 많이 소요될 수 있습니다.
 - 매크로 확장: Rust의 강력한 선언적 및 절차적 매크로는 컴파일 시간에 상당한 양의 코드를 생성할 수 있습니다. Actix-web과 같은 웹 프레임워크는 라우팅, 핸들러 속성 정의, 트레잇 파생을 위해 절차적 매크로에 크게 의존하며, 이는 컴파일 부담을 증가시킵니다.
 - 거대한 의존성 트리: 웹 애플리케이션은 종종 JSON 직렬화, 데이터베이스 상호 작용, 인증, 로깅 등 다양한 작업을 위해 수많은 크레이트를 가져옵니다. 이러한 각 의존성은 컴파일되어야 하며, 자체 전이적 의존성은 컴파일 그래프를 더욱 확장합니다. 일반적인 유틸리티 크레이트의 사소한 변경조차도 광범위한 재컴파일을 트리거할 수 있습니다.
 - I/O 및 링커 성능: 최종 링크 단계, 특히 Windows에서는 병목 현상이 될 수 있습니다. 링커는 생성된 모든 오브젝트 파일을 단일 실행 파일로 결합해야 하며, 이 과정은 I/O 바운드 및 CPU 집약적일 수 있습니다.
 
Rust 웹 개발 워크플로 최적화하기
Rust의 컴파일 특성은 고유하지만, 개발 경험을 크게 향상시킬 수 있는 강력한 도구와 전략이 있습니다.
1. cargo-watch의 힘
모든 코드 변경 후 cargo run 또는 cargo build를 반복해서 입력하는 것은 비효율적입니다. cargo-watch는 소스 코드 변경을 감지하면 애플리케이션을 자동으로 다시 컴파일하고 다시 실행하는 필수 도구입니다.
설치:
cargo install cargo-watch
사용 예시:
기본 Axum 웹 애플리케이션 구조를 가정해 봅시다.
src/main.rs:
use axum:: routing::get, Router, ; #[tokio::main] async fn main() { // build our application with a single route let app = Router::new().route("/", get(handler)); // run it with hyper on `localhost:3000` let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .unwrap(); println!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } async fn handler() -> &'static str { "Hello, Axum Web!" }
이제 cargo run 대신 다음을 사용합니다.
cargo watch -x run
이 명령은 프로젝트를 감시하며, .rs 파일(또는 구성된 다른 파일)을 저장할 때마다 cargo run을 실행합니다.
고급 cargo-watch 구성:
- 명령 지정: 
cargo watch -x 'run --bin my_app arg1' - 화면 지우기: 
cargo watch -c -x run(각 실행 전에 터미널 지우기) - 간격: 
cargo watch -i 1000 -x run(1000ms마다 확인, 기본값은 500ms) - 후-명령: 
cargo watch -x 'clippy --workspace' -s 'echo Clippy finished'(clippy 실행 후 메시지 출력) 
cargo-watch는 편집-컴파일-테스트 주기를 극적으로 단축하여 개발을 훨씬 더 유연하게 만듭니다.
2. sccache를 사용한 분산 캐싱 활용
sccache는 Mozilla에서 개발한 컴파일 캐싱 도구로, 동일한 컴파일 입력이 다시 발생할 때 중간 빌드 아티팩트를 저장하고 재사용하여 재컴파일 속도를 크게 향상시킬 수 있습니다. 이는 수많은 의존성을 가진 대규모 프로젝트나 브랜치를 전환할 때 특히 효과적입니다.
설치:
cargo install sccache --features=native
구성:
cargo가 기본적으로 sccache를 사용하도록 하려면 RUSTC_WRAPPER 환경 변수를 설정해야 합니다.
Linux/macOS:
echo "export RUSTC_WRAPPER=$(rg sccache)" >> ~/.bashrc # 또는 ~/.zshrc source ~/.bashrc
Windows (PowerShell):
[System.Environment]::SetEnvironmentVariable('RUSTC_WRAPPER', (Get-Command sccache).Source, 'User')
설정 후 cargo는 rustc를 호출하기 전에 자동으로 sccache를 호출합니다.
sccache 작동 방식:
sccache가 활성화되면:
rustc호출을 가로챕니다.- 컴파일 명령, 소스 코드 및 잠재적으로 다른 입력을 해시합니다.
 - 해당 해시에 대한 캐시된 출력이 이미 존재하는지 확인합니다.
 - 일치하는 항목이 있으면 캐시에서 컴파일된 출력을 검색합니다.
 - 일치하지 않으면 로컬에서 
rustc를 실행하고 출력을 캐시에 저장한 다음 반환합니다. 
sccache는 클라우드 스토리지 백엔드(AWS S3, Google Cloud Storage)를 사용하여 분산 캐싱을 구성할 수도 있으며, 이는 CI/CD 파이프라인이나 대규모 팀에 유익합니다.
sccache 통계를 확인하려면:
sccache --show-stats
3. 더 빠른 빌드를 위한 Cargo 최적화
Cargo 자체도 빌드 시간을 개선하기 위한 다양한 구성 옵션을 제공합니다.
3.1. 더 빠른 링커
링커는 병목 현상이 될 수 있습니다. Linux에서는 lld(LLVM의 링커)가 GNU ld 또는 gold보다 훨씬 빠릅니다.
설치 (Ubuntu/Debian):
sudo apt install lld
구성 (.cargo/config.toml 프로젝트 루트에 있거나 ~/.cargo/config.toml에 있음):
[target.x86_64-unknown-linux-gnu] # 타겟 삼중항(triple)을 필요에 따라 조정 linker = "clang" # 또는 "ld.lld" rustflags = ["-C", "link-arg=-fuse-ld=lld"] # 디버그 빌드(특히 cargo-watch와 함께 유용) 속도 향상 [profile.dev] opt-level = 1 # 디버그 빌드에서 일부 최적화 활성화 debug = 2 # 디버그 정보 유지 lto = "fat" # 링크 타임 최적화 (느려질 수 있지만 때로는 도움이 됨) codegen-units = 1 # 최대 최적화를 위해 컴파일 시간 증가. 빠른 빌드를 위해 더 높은 값 선호. [profile.dev.package."*"] codegen-units = 256 # 디버그 빌드 속도 향상을 위해 의존성에 대해 더 높은 codegen-units
Windows의 경우, mold는 고려할 만한 매우 성능이 좋은 링커입니다.
3.2. 디버그 정보 줄이기
기본적으로 디버그 빌드에는 광범위한 디버그 정보가 포함되어 컴파일 시간과 이진 파일 크기가 증가합니다. 특정 문제를 디버깅하는 데 중요하지만 일반 개발에서는 줄일 수 있습니다.
.cargo/config.toml에서:
[profile.dev] debug = 1 # 디버그 정보를 줄임 (기본값 2에서), 여전히 백트레이스에 사용 가능. # debug = 0은 디버그 정보를 완전히 제거하여 가장 빠르지만 디버그하기 어려움.
3.3. 병렬 처리 극대화
Cargo는 의존성 및 번역 단위(translation unit)를 병렬로 컴파일할 수 있습니다.
.cargo/config.toml에서:
[build] jobs = 8 # 보유한 CPU 코어 수 또는 약간 더 많은 수로 설정합니다. (기본값은 종종 시스템에 따라 다름)
그러나 jobs를 너무 많이 늘리면 I/O 경합이나 메모리 부족으로 인해 성능이 저하될 수 있습니다. 최적의 값을 찾기 위해 실험해 보세요.
3.4. 의존성 사전 컴파일
대규모 프로젝트의 경우 공통 의존성은 거의 변경되지 않습니다. 이후 디버그 빌드 속도를 높이기 위해 릴리스 모드에서 사전 컴파일할 수 있으며, 의존성 변경은 발생하지 않았습니다.
cargo build --release --workspace # 모든 크레이트를 릴리스 모드로 빌드
이렇게 하면 .cargo/target/release가 채워지며, rustc는 잠재적으로 연결할 수 있습니다.
4. 코드 구조 및 설계 선택
도구 외에도 Rust 웹 애플리케이션을 구성하는 방식도 빌드 시간에 영향을 줄 수 있습니다.
- 더 작은 크레이트: 대규모 애플리케이션을 더 작고 집중적인 크레이트(예: 
my_app_api,my_app_domain,my_app_utils)로 나누면 점진적 빌드 시간을 개선할 수 있습니다.my_app_domain의 API가 변경되지 않았다면my_app_api의 변경이 반드시my_app_domain을 다시 컴파일하는 것을 의미하지는 않습니다. - 트레이트 객체(동적 디스패치) 염두에 두기: 정적 디스패치(제네릭)는 제로 비용이지만, 동적 디스패치(트레이트 객체, 예: 
Box<dyn MyTrait>)는 단위화를 피합니다. 성능이 절대적으로 중요하지 않은 곳에 트레이트 객체를 분별적으로 사용하면 컴파일러가 처리해야 하는 코드 양을 줄일 수 있습니다. 그러나 이는 절충이며 신중한 설계를 하지 않으면 항상 전체 컴파일 속도를 높이는 것은 아닐 수 있습니다. - 제네릭 최소화: 제네릭 타입이 단 하나의 또는 두 개의 구체적인 타입으로만 사용되는 경우, 제네릭 추상화가 실제로 필요한지 아니면 구체적인 구현이 더 간단하고 컴파일 속도가 더 빠를 수 있는지 고려하세요.
 - 기능 플래그: Cargo 기능 플래그를 사용하여 애플리케이션 또는 의존성의 부분을 활성화/비활성화하여 특정 빌드 구성(예: 개발 전용 기능 대 프로덕션 기능)에 대해 컴파일되는 코드 양을 줄입니다.
 
결론
Rust의 성능 및 안전성에 대한 헌신은 복잡한 웹 애플리케이션의 경우 컴파일 시간 연장이라는 절충 효과를 가져옵니다. 그러나 근본적인 이유를 이해하고 자동화된 재컴파일을 위한 cargo-watch, 빌드 캐싱을 위한 sccache와 같은 도구를 전략적으로 사용하고 Cargo 구성을 최적화함으로써 개발자는 귀중한 개발 시간을 크게 되찾을 수 있습니다. 이러한 개선은 Rust 웹 개발 경험을 기다림의 게임에서 유연하고 생산적인 사이클로 전환하여, 빌드 시간과의 싸움보다는 견고하고 성능이 뛰어난 웹 서비스를 구축하는 데 집중할 수 있도록 합니다. 컴파일이 즉각적이지는 않을 수 있지만, 이러한 기술을 통해 놀랍도록 효율적으로 만들 수 있습니다.