Rust 웹 애플리케이션 컴파일 및 바이너리 크기 최적화
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
Rust는 성능, 안전성 및 동시성 보장으로 웹 개발에서 중요한 위치를 차지했습니다. 그러나 애플리케이션이 복잡해짐에 따라 개발자에게 두 가지 일반적인 문제점이 나타납니다. 바로 점점 길어지는 컴파일 시간과 늘어나는 최종 바이너리 크기입니다. 이러한 문제는 개별적으로는 사소해 보일 수 있지만, 느린 피드백 루프로 인해 개발자 생산성을 저해하고 서버리스 환경에서 배포 비용을 증가시키거나 콜드 스타트 시간을 영향을 줄 수 있습니다. 이 문서는 Rust 웹 애플리케이션 개발에서 이러한 문제가 발생하는 이유와, 더 중요하게는 이를 효과적으로 완화하여 잠재적으로 답답한 경험을 streamlined되고 효율적인 워크플로로 전환하는 방법에 대한 포괄적인 이해를 제공하는 것을 목표로 합니다.
Rust 빌드 퍼즐 해독
해결책을 살펴보기 전에 핵심 개념에 대한 공통된 이해를 확립해 봅시다.
핵심 용어
- 컴파일 시간: Rust 컴파일러 (
rustc
)가 소스 코드를 실행 가능한 바이너리로 변환하는 데 걸리는 시간입니다. 여기에는 종속성 확인, 타입 검사, 빌림 검사, 코드 생성 및 최적화 패스가 포함됩니다. - 바이너리 크기: 컴파일된 실행 파일이 차지하는 디스크 공간입니다. 웹 애플리케이션의 경우 일반적으로 웹 프레임워크, 데이터베이스 드라이버 및 최종 바이너리에 연결된 기타 라이브러리가 포함됩니다.
- Crate: Rust에서 컴파일 및 종속성의 기본 단위입니다. 라이브러리 또는 실행 파일이 될 수 있습니다.
- Linker: 컴파일된 오브젝트 파일을 가져와 실행 가능한 바이너리 또는 공유 라이브러리로 결합하는 프로그램입니다.
- 정적 연결 (Static Linking): 필요한 모든 라이브러리 코드의 복사본이 최종 실행 파일에 직접 포함되는 프로세스입니다. 이로 인해 바이너리 크기가 커지지만 외부 런타임 종속성이 제거됩니다. Rust의 기본값입니다.
- 동적 연결 (Dynamic Linking): 실행 파일이 런타임에 공유 라이브러리에 연결되는 프로세스입니다. 즉, 라이브러리 코드가 이를 사용하는 모든 실행 파일에 중복되지 않습니다. 이로 인해 바이너리 크기가 작아지지만 대상 시스템에 공유 라이브러리가 존재해야 합니다.
- LTO (Link-Time Optimization): 링킹 단계에서 컴파일러가 여러 컴파일 단위(예: 여러 crate 간)에 걸쳐 최적화를 수행하는 최적화 기법입니다. 상당한 성능 향상을 가져올 수 있지만 컴파일 시간 증가라는 비용이 따르는 경우가 많습니다.
- Dead Code Elimination (DCE): 컴파일러가 실행되지 않거나 도달할 수 없는 코드를 식별하고 제거하여 바이너리 크기를 줄이는 최적화입니다.
큰 바이너리와 느린 컴파일의 메커니즘
Rust의 "제로 코스트 추상화" 철학과 강력한 컴파일 타임 검사는 런타임 성능과 안전성에 유익하지만, 컴파일 프로세스의 복잡성에 기여합니다. 각 #[derive]
매크로, 모든 제네릭 함수, 각 종속성은 처리해야 할 자체 코드 세트를 가져옵니다.
또한 Rust는 기본적으로 정적 연결을 사용합니다. 이는 배포하기 쉬운 자체 포함 실행 파일을 생성하지만, 연결된 모든 라이브러리의 모든 바이트 코드가 최종 바이너리 크기에 직접적으로 기여한다는 것을 의미합니다. 이는 동적 연결이 일반적인 환경과 대조되며, Rust를 처음 접하는 개발자들이 C/C++ 또는 Go 바이너리의 작은 크기에 익숙할 때 처음에는 놀라움을 줍니다.
웹 애플리케이션의 경우 actix-web
, warp
, axum
과 같은 프레임워크는 강력하지만 본질적으로 많은 제네릭과 매크로를 가져오며, rustc
는 사용되는 모든 특정 타입에 대해 이를 단형화(monomorphize)하고 처리해야 합니다. 데이터베이스 드라이버, serde
와 같은 직렬화 라이브러리, 비동기 런타임은 이러한 계산 부담을 더욱 가중시킵니다.
더 빠른 컴파일을 위한 전략
Rust 웹 애플리케이션의 컴파일 사이클을 가속화하기 위한 실질적인 방법을 살펴봅시다.
Cargo.toml
종속성 최적화
종속성 수를 최소화하세요. Cargo.toml
을 정기적으로 감사합니다. 더 이상 필요하지 않은 crate를 제거하세요.
필수 기능이 있는 종속성의 경우 실제로 필요한 기능만 활성화하세요. 이것은 매우 효과적인 기법입니다.
# 나쁨: 모든 기능을 가져옵니다. # tokio = { version = "1", features = ["full"] } # 좋음: 기본적인 웹 서버에 필요한 기능만 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } serde = { version = "1", features = ["derive"] } # JSON 직렬화만 필요한 경우 해당 기능을 명시적으로 활성화할 수 있습니다. serde_json = "1"
설명: tokio
또는 futures
와 같은 crate에서 full
기능을 사용하는 것은 파일 시스템 액세스, 프로세스 스폰 또는 IPC와 같이 상태 비저장 웹 API에 관련 없을 수 있는 많은 잠재적으로 사용되지 않는 코드를 가져옵니다. 명시적으로 지정하면 컴파일러가 처리해야 하는 코드의 양이 크게 줄어듭니다.
cargo check
및 clippy
활용
cargo check
는 코드 생성 및 최적화 이전까지 모든 컴파일 단계를 수행하므로 cargo build
보다 훨씬 빠릅니다. 활성 개발 중에 사용하여 구문 및 타입 정확성을 빠르게 확인하세요. clippy
는 일반적인 실수와 관용적인 문제를 포착하는 Rust 린터입니다. 이를 자주 실행하면 컴파일 오류가 되기 전에 문제를 포착할 수 있습니다.
cargo check # 빠른 구문 및 타입 검사 cargo clippy # 정적 분석 및 린팅
설명: 이러한 도구는 전체 빌드보다 더 빠른 피드백 루프를 제공하여 전체 컴파일을 기다리지 않고 더 빠르게 코드를 반복할 수 있습니다.
증분 컴파일 (Incremental Compilation)
이것은 기본적으로 활성화되어 있습니다. target
디렉토리가 필요하지 않은 이상 너무 자주 정리되지 않도록 하세요. 증분 컴파일은 중간 컴파일 아티팩트를 저장하므로 후속 빌드는 변경된 코드 부분만 다시 컴파일합니다.
# 불필요하게 `cargo clean`을 실행하지 마세요.
설명: cargo clean
을 계속 실행하면 효과적으로 매번 전체 재빌드를 강제하여 증분 컴파일의 이점을 무효화합니다.
sccache
고려
sccache
는 Rust를 위한 ccache와 유사한 컴파일 회피 도구입니다. 컴파일 아티팩트를 캐싱하여 동일한 코드(또는 동일한 버전의 종속성)를 여러 번 컴파일하는 경우 sccache
는 종종 다시 컴파일하는 대신 캐시에서 결과를 검색할 수 있습니다.
# 설치 cargo install sccache # Rust에 대해 활성화 export RUSTC_WRAPPER=sccache # 그런 다음 평소대로 빌드 cargo build
설명: sccache
는 빌드를 상당히 가속화할 수 있으며, 특히 CI 환경이나 공통 종속성을 공유하는 여러 프로젝트에서 작업할 때 그렇습니다.
빌드 프로파일링
cargo build --timings
를 사용하여 각 crate가 컴파일되는 데 얼마나 많은 시간이 걸리는지 자세히 살펴보세요. 이는 종속성 그래프의 병목 현상을 식별하는 데 도움이 됩니다.
cargo build --timings
설명: 이 명령은 가장 느리게 컴파일되는 crate를 보여주는 HTML 보고서(일반적으로 target/cargo-timings/
에 있음)를 생성하여 최적화 노력을 집중할 대상을 지정할 수 있습니다.
더 작은 바이너리를 위한 전략
이제 최종 실행 파일의 크기를 줄이는 데 주의를 기울여 봅시다.
릴리스 빌드 올바르게 구성
기본적으로 cargo build
는 디버깅 심볼을 포함하고 덜 공격적인 최적화를 수행하는 비교적 큰 디버그 바이너리를 생성합니다. 배포 시에는 항상 릴리스 모드로 빌드하세요.
cargo build --release
설명: 릴리스 빌드는 광범위한 최적화를 적용하며, 여기에는 죽은 코드 제거 및 일반적으로 디버깅 심볼 제거가 포함되어 훨씬 작고 빠른 실행 파일을 생성합니다.
디버깅 심볼 제거 (Strip Debug Symbols)
릴리스 빌드에서도 일부 디버깅 정보가 남아 있을 수 있습니다. 명시적으로 심볼을 제거하면 크기를 더욱 줄일 수 있습니다.
# Cargo.toml에서 [profile.release] strip = true # 바이너리에서 디버깅 심볼을 자동으로 제거합니다.
설명: 이렇게 하면 프로덕션 아티팩트에 바이너리 크기를 부풀릴 수 있는 디버깅 정보가 포함되지 않도록 보장합니다.
LTO (Link-Time Optimization) 활성화
LTO는 컴파일러가 전체 프로그램에 걸쳐 최적화를 수행할 수 있도록 합니다. 이는 상당한 런타임 성능 향상을 가져오며, 보다 공격적인 죽은 코드 제거로 인해 바이너리 크기가 작아지는 경우가 많습니다. 하지만 컴파일 시간을 증가시킵니다.
# Cargo.toml에서 [profile.release] lto = true
설명: 컴파일 시간을 증가시키지만, LTO는 일반적으로 최소 바이너리 크기와 최대 성능이 중요한 프로덕션 빌드에 대한 가치 있는 절충안입니다.
opt-level
로 크기 최적화
opt-level
설정은 최적화 수준을 제어합니다. 릴리스의 기본값은 3
이지만, s
(크기 최적화) 또는 z
(공격적으로 크기 최적화)는 바이너리 풋프린트를 줄이는 데 더욱 효과적일 수 있습니다.
# Cargo.toml에서 [profile.release] opt-level = "s" # 또는 극도로 작게 만들려면 "z"
설명: opt-level = "s"
는 컴파일러에게 원시 실행 속도보다 바이너리 크기를 우선하도록 지시하며, opt-level = "z"
는 "s"의 더욱 공격적인 변형입니다. 먼저 s
를 선택하고 추가 축소가 필요한 경우 성능 영향이 허용되면 z
를 시도하세요.
동적 연결 (고급)
매우 제한된 환경이나 특정 배포 모델(예: 임베디드 시스템, 일부 Docker 전략)의 경우 동적 연결을 고려할 수 있습니다. Rust는 기본 정적 연결과 잠재적으로 musl
(Linux의 경우)과의 플랫폼별 문제로 인해 이 작업이 더 복잡할 수 있습니다.
Linux에서 표준 라이브러리를 동적으로 연결하려면:
# Cargo.toml에서 [profile.release] # ... 다른 설정 # rustflags = ["-C", "prefer-dynamic"] # 이것은 일반적으로 .cargo/config.toml을 통해 적용됩니다.
그런 다음 동적 연결을 위해 gnu
툴체인(대부분의 Linux 배포판의 기본값)을 musl
대신 사용하는 것을 고려하세요. 이는 Cargo.toml
보다는 Dockerfile
을 만드는 방법에 더 관한 것입니다.
# Alpine(musl 기반)에서 동적 연결을 위한 예제 Dockerfile FROM rust:1.70-alpine AS build # 동적 연결을 위한 gnu 라이브러리 설치 (Alpine 기반) # 이 예제는 즉시 작동하지 않으며 더 깊은 설정이 필요합니다. # 종종 Alpine용 사용자 지정 glibc를 사용하거나 glibc 기반 이미지를 사용해야 합니다. # 더 간단한 접근 방식: 처음부터 glibc 기반 이미지를 직접 사용합니다. FROM rust:1.70 AS build WORKDIR /app COPY . . RUN cargo build --release FROM debian:stretch-slim COPY /app/target/release/your_app /usr/local/bin/your_app CMD ["your_app"]
설명: Rust에서 동적 연결을 달성하는 것은 특히 서로 다른 Linux 배포판과 해당 C 표준 라이브러리 구현(glibc 대 musl) 간에 복잡할 수 있습니다. 대부분의 웹 애플리케이션의 경우, 특히 좋은 정적 연결 최적화를 사용하면 동적 연결의 고통이 이점보다 더 큽니다. 그러나 glibc 기반 시스템의 슬림 Docker 컨테이너와 같은 환경을 대상으로 하는 경우, 실행 파일 간에 라이브러리 종속성이 공유되거나 기본 이미지가 일반 라이브러리를 이미 제공하는 경우 컨테이너 이미지 크기를 상당히 줄일 수 있습니다.
mimalloc
또는 jemalloc
사용
직접적으로 바이너리 크기에 영향을 미치지는 않지만, 기본 시스템 할당자(Linux의 경우 jemalloc
, 다른 곳에서는 mi_malloc
)를 mimalloc
또는 jemalloc
으로 교체하면 런타임 시 애플리케이션의 메모리 풋프린트가 줄어들 수 있습니다. 이는 관련 최적화 목표입니다. 할당자 코드가 작으면 바이너리 크기에 간접적으로 아주 약간 영향을 줄 수 있지만, 주요 이점은 런타임 메모리 효율입니다.
# Cargo.toml에서 [dependencies] mimalloc = { version = "0.1", default-features = false } # Rust >= 1.63의 경우 # 또는 .cargo/config.toml에서 전역적으로 할당자를 재정의하는 경우: # [build] # rustflags = ["-C", "linker-args=-L/path/to/mimalloc/lib", "-C", "link-arg=-Wl,--whole-archive,-lmimalloc,--no-whole-archive"]
설명: 이는 성능 향상과 메모리 감소로 이어질 수 있습니다. 그러나 이는 고급 단계이며 신중한 벤치마킹이 필요합니다. 바이너리 크기에 대한 영향은 다른 전략에 비해 종종 무시할 수 있습니다.
결론
Rust 웹 애플리케이션의 컴파일 시간 및 바이너리 크기 최적화는 Rust의 빌드 시스템 및 컴파일러 동작에 대한 좋은 이해가 필요한 반복적인 프로세스입니다. 종속성을 세심하게 관리하고, Cargo의 내장 기능을 활용하고, 릴리스 프로필을 현명하게 구성함으로써 개발 워크플로를 크게 개선하고 보다 효율적이고 컴팩트한 실행 파일을 배포할 수 있습니다. 핵심은 빌드 구성에 대해 적극적이고 의도적으로 접근하는 것이며, 다양한 프로젝트 측면에서 작고 점진적인 개선이 궁극적으로 상당한 전반적인 이점을 가져온다는 것을 이해하는 것입니다.