Rust 엔지니어링 관행 — cargo build 그 이상을 향해
저자 소개
- Microsoft SCHIE(Silicon and Cloud Hardware Infrastructure Engineering) 팀의 수석 펌웨어 아키텍트
- 보안, 시스템 프로그래밍(펌웨어, 운영체제, 하이퍼바이저), CPU 및 플랫폼 아키텍처, C++ 시스템 분야의 업계 전문가
- 2017년(@AWS EC2)부터 Rust로 프로그래밍을 시작했으며, 이후 이 언어의 매력에 빠져 활동 중
많은 팀이 너무 늦게 발견하곤 하는 Rust 툴체인 기능들에 대한 실무 가이드입니다. 빌드 스크립트, 교차 컴파일(cross-compilation), 벤치마킹, 코드 커버리지, 그리고 Miri와 Valgrind를 이용한 안전성 검증을 다룹니다. 각 장에서는 실제 하드웨어 진단 코드베이스(대규모 멀티 크레이트 워크스페이스)에서 추출한 구체적인 예제를 사용하여, 모든 기술을 실제 운영 코드에 직접 적용할 수 있도록 구성했습니다.
이 책의 활용 방법
이 책은 자기 주도 학습 또는 팀 워크숍을 위해 설계되었습니다. 각 장은 대부분 독립적으로 구성되어 있으므로 순서대로 읽거나 필요한 주제를 골라서 학습하실 수 있습니다.
난이도 범례
| 기호 | 레벨 | 의미 |
|---|---|---|
| 🟢 | 입문 (Starter) | 명확한 패턴을 가진 직관적인 도구 — 첫날부터 바로 활용 가능 |
| 🟡 | 중급 (Intermediate) | 툴체인 내부 구조나 플랫폼 개념에 대한 이해가 필요함 |
| 🔴 | 고급 (Advanced) | 깊이 있는 툴체인 지식, 나이틀리(nightly) 기능 또는 다중 도구 오케스트레이션 필요 |
학습 권장 시간
| 파트 | 장 | 예상 시간 | 핵심 성과 |
|---|---|---|---|
| I — 빌드 및 배포 | 01–02장 | 3–4시간 | 빌드 메타데이터, 교차 컴파일, 정적 바이너리 |
| II — 측정 및 검증 | 03–05장 | 4–5시간 | 통계적 벤치마킹, 커버리지 게이트, Miri/새니타이저 |
| III — 강화 및 최적화 | 06–10장 | 6–8시간 | 공급망 보안, 릴리스 프로필, 컴파일 타임 도구, no_std, Windows |
| IV — 통합 | 11–13장 | 3–4시간 | 운영 환경용 CI/CD 파이프라인, 실전 팁, 종합 실습 |
| 16–21시간 | 전체 운영 엔지니어링 파이프라인 완성 |
실습 진행 방법
각 장에는 난이도 표시가 있는 🏋️ 실습이 포함되어 있습니다. 솔루션은 확장 가능한 <details> 블록에 제공되니, 먼저 직접 실습해 본 후 결과를 확인하시기 바랍니다.
- 🟢 실습은 대개 10~15분 내에 완료할 수 있습니다.
- 🟡 실습은 20~40분 정도 소요되며, 로컬에서 도구를 실행해야 할 수도 있습니다.
- 🔴 실습은 상당한 설정과 실험이 필요합니다 (1시간 이상).
선수 지식
| 개념 | 학습 위치 |
|---|---|
| Cargo 워크스페이스 레이아웃 | Rust Book 14.3장 |
| 기능 플래그 (Feature flags) | Cargo Reference — Features |
#[cfg(test)] 및 기본 테스트 | Rust Patterns 12장 |
unsafe 블록 및 FFI 기초 | Rust Patterns 10장 |
장별 의존성 맵
┌──────────┐
│ ch00 │
│ 소개 │
└────┬─────┘
┌─────┬───┬──┴──┬──────┬──────┐
▼ ▼ ▼ ▼ ▼ ▼
ch01 ch03 ch04 ch05 ch06 ch09
Build Bench Cov Miri Deps no_std
│ │ │ │ │ │
│ └────┴────┘ │ ▼
│ │ │ ch10
▼ ▼ ▼ Windows
ch02 ch07 ch07 │
Cross RelProf RelProf │
│ │ │ │
│ ▼ │ │
│ ch08 │ │
│ CompTime │ │
└──────────┴───────────┴─────┘
│
▼
ch11
CI/CD 파이프라인
│
▼
ch12 ─── ch13
실전 팁 빠른 참조
순서 상관없이 읽기 가능: 01, 03, 04, 05, 06, 09장은 서로 독립적입니다. 선행 장 학습 후 읽기 권장: 02장(01장 필요), 07~08장(03~06장 선학습 시 유리), 10장(09장 선학습 시 유리). 마지막에 읽기 권장: 11장(모든 내용을 통합), 12장(실전 팁), 13장(참조).
주석이 달린 목차
파트 I — 빌드 및 배포 (Build & Ship)
| # | 장 | 난이도 | 설명 |
|---|---|---|---|
| 1 | 빌드 스크립트 — build.rs 심층 분석 | 🟢 | 컴파일 타임 상수, C 코드 컴파일, Protobuf 생성, 시스템 라이브러리 링크, 안티 패턴 |
| 2 | 교차 컴파일 — 하나의 소스, 다양한 타겟 | 🟡 | 타겟 트리플(Target triples), musl 정적 바이너리, ARM 교차 컴파일, cross 도구, cargo-zigbuild, GitHub Actions |
파트 II — 측정 및 검증 (Measure & Verify)
| # | 장 | 난이도 | 설명 |
|---|---|---|---|
| 3 | 벤치마킹 — 중요한 지표 측정하기 | 🟡 | Criterion.rs, Divan, perf 플레임그래프, PGO, CI에서의 지속적인 벤치마킹 |
| 4 | 코드 커버리지 — 테스트가 놓치는 부분 확인하기 | 🟢 | cargo-llvm-cov, cargo-tarpaulin, grcov, Codecov/Coveralls CI 통합 |
| 5 | Miri, Valgrind 및 새니타이저 | 🔴 | MIR 인터프리터, Valgrind memcheck/Helgrind, ASan/MSan/TSan, cargo-fuzz, loom |
파트 III — 강화 및 최적화 (Harden & Optimize)
| # | 장 | 난이도 | 설명 |
|---|---|---|---|
| 6 | 의존성 관리 및 공급망 보안 | 🟢 | cargo-audit, cargo-deny, cargo-vet, cargo-outdated, cargo-semver-checks |
| 7 | 릴리스 프로필 및 바이너리 크기 | 🟡 | 릴리스 프로필 구조, LTO 트레이드오프, cargo-bloat, cargo-udeps |
| 8 | 컴파일 시간 및 개발자 도구 | 🟡 | sccache, mold, cargo-nextest, cargo-expand, cargo-geiger, 워크스페이스 린트, MSRV |
| 9 | no_std 및 기능 검증 | 🔴 | cargo-hack, core/alloc/std 계층, 커스텀 패닉 핸들러, no_std 코드 테스트 |
| 10 | Windows 및 조건부 컴파일 | 🟡 | #[cfg] 패턴, windows-sys/windows 크레이트, cargo-xwin, 플랫폼 추상화 |
파트 IV — 통합 (Integrate)
| # | 장 | 난이도 | 설명 |
|---|---|---|---|
| 11 | 종합 정리 — 운영 환경용 CI/CD 파이프라인 | 🟡 | GitHub Actions 워크플로, cargo-make, pre-commit 훅, cargo-dist, 캡스톤 프로젝트 |
| 12 | 실전 팁과 요령 | 🟡 | 검증된 10가지 패턴: deny(warnings) 함정, 캐시 튜닝, 의존성 중복 제거, RUSTFLAGS 등 |
| 13 | 빠른 참조 카드 | — | 주요 명령어 요약, 60개 이상의 의사결정 테이블 항목, 추가 학습 링크 |
빌드 스크립트 — build.rs 심층 분석 🟢
학습 내용:
build.rs가 Cargo 빌드 파이프라인의 어디에 위치하며 언제 실행되는지- 5가지 실전 패턴: 컴파일 타임 상수, C/C++ 컴파일, Protobuf 코드 생성,
pkg-config링크, 기능 감지- 빌드 속도를 늦추거나 교차 컴파일을 망가뜨리는 안티 패턴
- 추적 가능성(traceability)과 재현 가능한 빌드(reproducible builds) 사이의 균형 잡기
참조: 교차 컴파일에서는 타겟 인식 빌드를 위해 빌드 스크립트를 사용합니다 ·
no_std및 기능은 여기서 설정된cfg플래그를 확장합니다 · CI/CD 파이프라인은 자동화 과정에서 빌드 스크립트를 조율합니다.
모든 Cargo 패키지는 크레이트 루트에 build.rs라는 파일을 포함할 수 있습니다.
Cargo는 크레이트를 컴파일하기 전에 이 파일을 컴파일하고 실행합니다. 빌드 스크립트는 표준 출력(stdout)에 println! 지시어를 출력하여 Cargo와 통신합니다.
build.rs란 무엇이며 언제 실행되는가
┌─────────────────────────────────────────────────────────┐
│ Cargo 빌드 파이프라인 │
│ │
│ 1. 의존성 해결 (Resolve dependencies) │
│ 2. 크레이트 다운로드 │
│ 3. build.rs 컴파일 ← 일반적인 Rust 코드, 호스트에서 실행 │
│ 4. build.rs 실행 ← stdout → Cargo 지시어 전달 │
│ 5. 크레이트 컴파일 (4단계의 지시어 사용) │
│ 6. 링크 (Link) │
└─────────────────────────────────────────────────────────┘
주요 특징:
build.rs는 타겟이 아닌 호스트(host) 머신에서 실행됩니다. 교차 컴파일 시에도 최종 바이너리가 다른 아키텍처를 타겟으로 하더라도 빌드 스크립트는 개발 머신에서 실행됩니다.- 빌드 스크립트의 범위는 해당 패키지로 제한됩니다. 다른 크레이트의 컴파일 방식에는 영향을 줄 수 없습니다. 단, 패키지가
Cargo.toml에links키를 선언한 경우cargo::metadata=KEY=VALUE를 통해 의존하는 크레이트에 메타데이터를 전달할 수 있습니다. - Cargo가 변경 사항을 감지할 때마다 매번 실행됩니다. 실행 횟수를 제한하려면
cargo::rerun-if-changed지시어를 사용해야 합니다.
참고 (Rust 1.71+): Rust 1.71부터 Cargo는 컴파일된
build.rs바이너리의 지문을 기록합니다. 바이너리가 동일하면 소스 타임스탬프가 변경되었더라도 다시 실행하지 않습니다. 하지만cargo::rerun-if-changed=build.rs는 여전히 유용합니다. 지시어가 전혀 없으면 Cargo는 패키지의 어떤 파일이라도 변경될 때마다(단순히build.rs뿐만 아니라)build.rs를 다시 실행하기 때문입니다.cargo::rerun-if-changed=build.rs를 명시하면build.rs자체가 변경될 때만 다시 실행하도록 제한하여 대규모 크레이트에서 컴파일 시간을 크게 단축할 수 있습니다.
- 메인 크레이트에서 사용할 수 있는 cfg 플래그, 환경 변수, 링커 인자, 파일 경로 등을 내보낼 수 있습니다.
가장 기본적인 Cargo.toml 설정:
[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
build = "build.rs" # 기본값 — Cargo는 자동으로 build.rs를 찾습니다.
# build = "src/build.rs" # 다른 위치에 둘 수도 있습니다.
Cargo 지시어 프로토콜 (The Cargo Instruction Protocol)
빌드 스크립트는 표준 출력에 지시어를 출력하여 Cargo와 통신합니다. Rust 1.77부터는 cargo:: 접두사를 사용하는 것이 권장됩니다 (기존의 cargo: 방식 대체).
| 지시어 | 목적 |
|---|---|
cargo::rerun-if-changed=PATH | PATH가 변경될 때만 build.rs 재실행 |
cargo::rerun-if-env-changed=VAR | 환경 변수 VAR이 변경될 때만 재실행 |
cargo::rustc-link-lib=NAME | 네이티브 라이브러리 NAME과 링크 |
cargo::rustc-link-search=PATH | 라이브러리 검색 경로에 PATH 추가 |
cargo::rustc-cfg=KEY | 조건부 컴파일을 위한 #[cfg(KEY)] 플래그 설정 |
cargo::rustc-cfg=KEY="VALUE" | #[cfg(KEY = "VALUE")] 플래그 설정 |
cargo::rustc-env=KEY=VALUE | env!()로 접근 가능한 환경 변수 설정 |
cargo::rustc-cdylib-link-arg=FLAG | cdylib 타겟의 링커에 FLAG 전달 |
cargo::warning=MESSAGE | 컴파일 중 경고 메시지 표시 |
cargo::metadata=KEY=VALUE | 의존 크레이트에서 읽을 수 있는 메타데이터 저장 |
// build.rs — 최소 예제 fn main() { // build.rs 자체가 변경될 때만 재실행 println!("cargo::rerun-if-changed=build.rs"); // 컴파일 타임 환경 변수 설정 let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs().to_string()) .unwrap_or_else(|_| "0".into()); println!("cargo::rustc-env=BUILD_TIMESTAMP={timestamp}"); }
패턴 1: 컴파일 타임 상수 (Compile-Time Constants)
가장 일반적인 사용 사례는 바이너리에 빌드 메타데이터(git 해시, 빌드 날짜, CI 작업 ID 등)를 포함하여 런타임에 보고할 수 있게 하는 것입니다.
// build.rs use std::process::Command; fn main() { println!("cargo::rerun-if-changed=.git/HEAD"); println!("cargo::rerun-if-changed=.git/refs"); // Git 커밋 해시 let output = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() .expect("git을 찾을 수 없습니다"); let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); println!("cargo::rustc-env=GIT_HASH={git_hash}"); // 빌드 프로필 (debug 또는 release) let profile = std::env::var("PROFILE").unwrap_or_else(|_| "unknown".into()); println!("cargo::rustc-env=BUILD_PROFILE={profile}"); // 타겟 트리플 (Target triple) let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".into()); println!("cargo::rustc-env=BUILD_TARGET={target}"); }
#![allow(unused)] fn main() { // src/main.rs — 빌드 타임 값 사용하기 fn print_version() { println!( "{} {} (git:{} target:{} profile:{})", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), env!("GIT_HASH"), env!("BUILD_TARGET"), env!("BUILD_PROFILE"), ); } }
Cargo 기본 환경 변수: 별도의 build.rs 없이도 사용할 수 있는 환경 변수들이 있습니다:
CARGO_PKG_NAME,CARGO_PKG_VERSION,CARGO_PKG_AUTHORS,CARGO_PKG_DESCRIPTION,CARGO_MANIFEST_DIR. 전체 목록을 확인해 보세요.
패턴 2: cc 크레이트를 이용한 C/C++ 코드 컴파일
Rust 크레이트가 C 라이브러리를 래핑하거나 작은 C 헬퍼 함수가 필요한 경우(하드웨어 인터페이스에서 흔함), cc 크레이트를 사용하면 build.rs 내에서 컴파일을 간편하게 처리할 수 있습니다.
# Cargo.toml
[build-dependencies]
cc = "1.0"
// build.rs fn main() { println!("cargo::rerun-if-changed=csrc/"); cc::Build::new() .file("csrc/ipmi_raw.c") .file("csrc/smbios_parser.c") .include("csrc/include") .flag("-Wall") .flag("-Wextra") .opt_level(2) .compile("diag_helpers"); // 이 과정에서 libdiag_helpers.a가 생성되며, 적절한 // cargo::rustc-link-lib 및 cargo::rustc-link-search 지시어가 출력됩니다. }
#![allow(unused)] fn main() { // src/lib.rs — 컴파일된 C 코드에 대한 FFI 바인딩 extern "C" { fn ipmi_raw_command( netfn: u8, cmd: u8, data: *const u8, data_len: usize, response: *mut u8, response_len: *mut usize, ) -> i32; } /// 로우(raw) IPMI 커맨드 인터페이스에 대한 안전한 래퍼 함수. /// 가정: enum IpmiError { CommandFailed(i32), ... } pub fn send_ipmi_command(netfn: u8, cmd: u8, data: &[u8]) -> Result<Vec<u8>, IpmiError> { let mut response = vec![0u8; 256]; let mut response_len: usize = response.len(); // SAFETY: response 버퍼가 충분히 크고 response_len이 올바르게 초기화되었습니다. let rc = unsafe { ipmi_raw_command( netfn, cmd, data.as_ptr(), data.len(), response.as_mut_ptr(), &mut response_len, ) }; if rc != 0 { return Err(IpmiError::CommandFailed(rc)); } response.truncate(response_len); Ok(response) } }
C++ 코드의 경우 .cpp(true)와 .flag("-std=c++17")을 사용합니다:
// build.rs — C++ 버전 fn main() { println!("cargo::rerun-if-changed=cppsrc/"); cc::Build::new() .cpp(true) .file("cppsrc/vendor_parser.cpp") .flag("-std=c++17") .flag("-fno-exceptions") // Rust의 무예외(no-exception) 모델과 일치시킴 .compile("vendor_helpers"); }
패턴 3: 프로토콜 버퍼 및 코드 생성 (Codegen)
빌드 스크립트는 .proto, .fbs, .json 스키마 파일을 컴파일 타임에 Rust 소스 코드로 변환하는 코드 생성 작업에 탁월합니다. 다음은 prost-build를 사용한 Protobuf 패턴입니다:
# Cargo.toml
[build-dependencies]
prost-build = "0.13"
// build.rs fn main() { println!("cargo::rerun-if-changed=proto/"); prost_build::compile_protos( &["proto/diagnostics.proto", "proto/telemetry.proto"], &["proto/"], ) .expect("Protobuf 정의 컴파일 실패"); }
#![allow(unused)] fn main() { // src/lib.rs — 생성된 코드 포함시키기 pub mod diagnostics { include!(concat!(env!("OUT_DIR"), "/diagnostics.rs")); } pub mod telemetry { include!(concat!(env!("OUT_DIR"), "/telemetry.rs")); } }
OUT_DIR: 빌드 스크립트가 생성된 파일을 저장해야 하는 Cargo 제공 디렉토리입니다. 각 크레이트는target/아래에 자신만의OUT_DIR을 가집니다.
패턴 4: pkg-config를 이용한 시스템 라이브러리 링크
.pc 파일을 제공하는 시스템 라이브러리(systemd, OpenSSL, libpci 등)의 경우, pkg-config 크레이트를 사용하여 시스템을 탐색하고 적절한 링크 지시어를 내보낼 수 있습니다.
# Cargo.toml
[build-dependencies]
pkg-config = "0.3"
// build.rs fn main() { // libpci 탐색 (PCIe 장치 열거에 사용됨) pkg_config::Config::new() .atleast_version("3.6.0") .probe("libpci") .expect("libpci >= 3.6.0을 찾을 수 없습니다 — pciutils-dev를 설치하세요"); // libsystemd 탐색 (선택 사항 — sd_notify 통합용) if pkg_config::probe_library("libsystemd").is_ok() { println!("cargo::rustc-cfg=has_systemd"); } }
#![allow(unused)] fn main() { // src/lib.rs — pkg-config 탐색 결과에 따른 조건부 컴파일 #[cfg(has_systemd)] mod systemd_notify { extern "C" { fn sd_notify(unset_environment: i32, state: *const std::ffi::c_char) -> i32; } pub fn notify_ready() { let state = std::ffi::CString::new("READY=1").unwrap(); // SAFETY: state는 유효한 널 종료 C 문자열입니다. unsafe { sd_notify(0, state.as_ptr()) }; } } #[cfg(not(has_systemd))] mod systemd_notify { pub fn notify_ready() { // systemd가 없는 시스템에서는 아무 작업도 하지 않음 } } }
패턴 5: 기능 감지 및 조건부 컴파일 (Feature Detection)
빌드 스크립트는 컴파일 환경을 탐색하고 메인 크레이트에서 조건부 코드 경로를 결정하는 데 사용할 cfg 플래그를 설정할 수 있습니다.
CPU 아키텍처 및 OS 감지 (컴파일 타임 상수이므로 안전함):
// build.rs — CPU 기능 및 OS 역량 감지 fn main() { println!("cargo::rerun-if-changed=build.rs"); let target = std::env::var("TARGET").unwrap(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); // x86_64에서 AVX2 최적화 경로 활성화 if target.starts_with("x86_64") { println!("cargo::rustc-cfg=has_x86_64"); } // aarch64에서 ARM NEON 경로 활성화 if target.starts_with("aarch64") { println!("cargo::rustc-cfg=has_aarch64"); } // /dev/ipmi0 사용 가능 여부 확인 (빌드 타임 확인) if target_os == "linux" && std::path::Path::new("/dev/ipmi0").exists() { println!("cargo::rustc-cfg=has_ipmi_device"); } }
⚠️ 안티 패턴 사례 — 아래 코드는 유혹적이지만 문제가 있는 접근 방식입니다. 실제 운영 환경에서는 사용하지 마세요.
// build.rs — 나쁨: 빌드 타임에 실행 중인 머신의 하드웨어 감지 fn main() { // 안티 패턴: 바이너리가 '빌드' 머신의 하드웨어에 고착됩니다. // GPU가 있는 머신에서 빌드하고 GPU가 없는 머신에 배포하면, // 바이너리는 GPU가 있다고 잘못 가정하게 됩니다. if std::process::Command::new("accel-query") .arg("--query-gpu=name") .arg("--format=csv,noheader") .output() .is_ok() { println!("cargo::rustc-cfg=has_accel_device"); } }
#![allow(unused)] fn main() { // src/gpu.rs — 빌드 타임 감지 결과에 따라 동작하는 코드 pub fn query_gpu_info() -> GpuResult { #[cfg(has_accel_device)] { run_accel_query() } #[cfg(not(has_accel_device))] { GpuResult::NotAvailable("빌드 타임에 accel-query를 찾을 수 없습니다".into()) } } }
⚠️ 이것이 잘못된 이유: 선택적인 하드웨어의 경우 빌드 타임 감지보다는 런타임 장치 감지가 거의 항상 더 낫습니다. 위 방식으로 생성된 바이너리는 빌드 머신의 하드웨어 구성에 종속되어 배포 타겟에서 다르게 동작할 수 있습니다. 빌드 타임 감지는 아키텍처, OS, 라이브러리 가용성 등 컴파일 타임에 확실히 고정된 요소에만 사용하세요. GPU와 같은 하드웨어는 런타임에
which accel-query나accel-mgmt등으로 감지하십시오.
안티 패턴 및 주의사항 (Anti-Patterns and Pitfalls)
| 안티 패턴 | 문제점 | 해결책 |
|---|---|---|
rerun-if-changed 부재 | 빌드할 때마다 build.rs가 실행되어 반복 작업 속도 저하 | 최소한 cargo::rerun-if-changed=build.rs라도 명시 |
| build.rs 내 네트워크 호출 | 오프라인 빌드 실패, 재현성 저하 | 파일을 벤더링(vendoring)하거나 별도의 fetch 단계 사용 |
src/에 파일 쓰기 | Cargo는 빌드 중 소스가 변경되는 것을 예상하지 않음 | OUT_DIR에 쓰고 include!() 사용 |
| 과도한 계산 작업 | 모든 cargo build 속도를 늦춤 | 결과를 OUT_DIR에 캐싱하고 rerun-if-changed로 제어 |
| 교차 컴파일 무시 | $CC를 존중하지 않고 Command::new("gcc") 직접 사용 | 교차 컴파일 툴체인을 올바르게 처리하는 cc 크레이트 사용 |
맥락 없는 패닉 (unwrap) | unwrap()은 원인을 알 수 없는 "build script failed" 오류만 발생시킴 | .expect("상세 메시지")를 사용하거나 cargo::warning= 출력 |
적용 사례: 빌드 메타데이터 포함하기
현재 프로젝트는 버전 보고를 위해 env!("CARGO_PKG_VERSION")를 사용하고 있습니다. 빌드 스크립트를 사용하면 더 풍부한 메타데이터를 추가할 수 있습니다:
// build.rs — 추가 제안 fn main() { println!("cargo::rerun-if-changed=.git/HEAD"); println!("cargo::rerun-if-changed=.git/refs"); println!("cargo::rerun-if-changed=build.rs"); // 진단 보고서의 추적성을 위해 git 해시 포함 if let Ok(output) = std::process::Command::new("git") .args(["rev-parse", "--short=10", "HEAD"]) .output() { let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); println!("cargo::rustc-env=APP_GIT_HASH={hash}"); } else { println!("cargo::rustc-env=APP_GIT_HASH=unknown"); } // 보고서 상관 관계 분석을 위해 빌드 타임스탬프 포함 let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs().to_string()) .unwrap_or_else(|_| "0".into()); println!("cargo::rustc-env=APP_BUILD_EPOCH={timestamp}"); // 타겟 트리플 출력 — 다중 아키텍처 배포 시 유용 let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".into()); println!("cargo::rustc-env=APP_TARGET={target}"); }
#![allow(unused)] fn main() { // src/version.rs — 메타데이터 사용하기 pub struct BuildInfo { pub version: &'static str, pub git_hash: &'static str, pub build_epoch: &'static str, pub target: &'static str, } pub const BUILD_INFO: BuildInfo = BuildInfo { version: env!("CARGO_PKG_VERSION"), git_hash: env!("APP_GIT_HASH"), build_epoch: env!("APP_BUILD_EPOCH"), target: env!("APP_TARGET"), }; impl BuildInfo { /// 필요한 경우 런타임에 에포크(epoch) 파싱 (안정 버전 Rust에서는 /// const &str를 u64로 파싱하는 const fn이 없습니다). pub fn build_epoch_secs(&self) -> u64 { self.build_epoch.parse().unwrap_or(0) } } impl std::fmt::Display for BuildInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "DiagTool v{} (git:{} target:{})", self.version, self.git_hash, self.target ) } } }
프로젝트의 핵심 통찰: 이 프로젝트의 수많은 크레이트 전반에
build.rs파일이 하나도 없는 이유는 C 의존성, 코드 생성, 시스템 라이브러리 링크가 필요 없는 순수 Rust로 작성되었기 때문입니다. 이러한 것들이 필요할 때build.rs가 훌륭한 도구가 되지만, "그냥" 추가하지는 마세요. 대규모 코드베이스에서 빌드 스크립트가 없다는 것은 결핍이 아니라 깨끗한 아키텍처의 긍정적인 신호입니다. 프로젝트가 커스텀 빌드 로직 없이 공급망을 관리하는 방법은 의존성 관리를 참조하세요.
직접 해보기
-
Git 메타데이터 포함:
APP_GIT_HASH와APP_BUILD_EPOCH를 환경 변수로 내보내는build.rs를 만들어 보세요.main.rs에서env!()로 이를 사용해 빌드 정보를 출력하고, 커밋 후 해시가 변경되는지 확인하세요. -
시스템 라이브러리 탐색:
pkg-config를 사용하여libz(zlib)를 찾는build.rs를 작성해 보세요. 찾았을 경우cargo::rustc-cfg=has_zlib를 내보내고,main.rs에서 cfg 플래그에 따라 "zlib 가용" 또는 "zlib 없음"을 출력해 보세요. -
의도적인 빌드 실패 유도:
build.rs에서rerun-if-changed라인을 제거하고cargo build및cargo test중에 얼마나 자주 재실행되는지 관찰해 보세요. 그 후 다시 라인을 추가하고 비교해 보세요.
재현 가능한 빌드 (Reproducible Builds)
1장에서 가르치는 타임스탬프와 git 해시를 바이너리에 포함하는 것은 추적성에는 좋지만, 재현 가능한 빌드와 충돌합니다. 재현 가능한 빌드란 동일한 소스에서 빌드하면 항상 동일한 바이너리가 생성되는 성질을 말합니다.
딜레마:
| 목표 | 성과 | 비용 |
|---|---|---|
| 추적 가능성 | 바이너리에 APP_BUILD_EPOCH 포함 | 모든 빌드가 고유해짐 — 무결성 검증 불가 |
| 재현성 | cargo build --locked가 항상 동일 결과 생성 | 빌드 타임 메타데이터 부재 |
실용적인 해결책:
# 1. CI에서는 항상 --locked 사용 (Cargo.lock 준수 보장)
cargo build --release --locked
# Cargo.lock이 없거나 최신이 아니면 실패 — "내 컴퓨터에선 되는데" 상황 방지
# 2. 재현성이 중요한 빌드에서는 SOURCE_DATE_EPOCH 설정
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) cargo build --release --locked
# "현재" 대신 마지막 커밋 타임스탬프 사용 — 동일 커밋 = 동일 바이너리
#![allow(unused)] fn main() { // build.rs 내에서: 재현성을 위해 SOURCE_DATE_EPOCH 준수 let timestamp = std::env::var("SOURCE_DATE_EPOCH") .unwrap_or_else(|_| { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs().to_string()) .unwrap_or_else(|_| "0".into()) }); println!("cargo::rustc-env=APP_BUILD_EPOCH={timestamp}"); }
모범 사례: 빌드 스크립트에서
SOURCE_DATE_EPOCH를 사용하면 릴리스 빌드의 재현성을 유지하면서(git-hash + locked 의존성 + 결정적 타임스탬프 = 동일 바이너리), 개발 빌드에서는 편의를 위해 실제 타임스탬프를 얻을 수 있습니다.
빌드 파이프라인 의사결정 다이어그램
flowchart TD
START["컴파일 타임 작업이 필요한가?"] -->|아니요| SKIP["build.rs 불필요"]
START -->|예| WHAT{"어떤 종류인가?"}
WHAT -->|"메타데이터 포함"| P1["패턴 1\n컴파일 타임 상수"]
WHAT -->|"C/C++ 컴파일"| P2["패턴 2\ncc 크레이트"]
WHAT -->|"코드 생성"| P3["패턴 3\nprost-build / tonic-build"]
WHAT -->|"시스템 라이브러리 링크"| P4["패턴 4\npkg-config"]
WHAT -->|"기능 감지"| P5["패턴 5\ncfg 플래그"]
P1 --> RERUN["항상 지시어 출력\ncargo::rerun-if-changed"]
P2 --> RERUN
P3 --> RERUN
P4 --> RERUN
P5 --> RERUN
style SKIP fill:#91e5a3,color:#000
style RERUN fill:#ffd43b,color:#000
style P1 fill:#e3f2fd,color:#000
style P2 fill:#e3f2fd,color:#000
style P3 fill:#e3f2fd,color:#000
style P4 fill:#e3f2fd,color:#000
style P5 fill:#e3f2fd,color:#000
🏋️ 실습
🟢 실습 1: 버전 스탬프
현재 git 해시와 빌드 프로필을 환경 변수로 포함하는 build.rs를 가진 최소 크레이트를 만들어 보세요. main()에서 이를 출력하고, 디버그 빌드와 릴리스 빌드 사이에서 출력이 변경되는지 확인하세요.
솔루션
// build.rs fn main() { println!("cargo::rerun-if-changed=.git/HEAD"); println!("cargo::rerun-if-changed=build.rs"); let hash = std::process::Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_else(|_| "unknown".into()); println!("cargo::rustc-env=GIT_HASH={hash}"); println!("cargo::rustc-env=BUILD_PROFILE={}", std::env::var("PROFILE").unwrap_or_default()); }
// src/main.rs
fn main() {
println!("{} v{} (git:{} profile:{})",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
env!("GIT_HASH"),
env!("BUILD_PROFILE"),
);
}
cargo run # profile:debug 표시
cargo run --release # profile:release 표시
🟡 실습 2: 조건부 시스템 라이브러리
pkg-config를 사용하여 libz와 libpci를 모두 탐색하는 build.rs를 작성하세요. 각 라이브러리를 찾았을 때 cfg 플래그를 내보내고, main.rs에서 빌드 타임에 어떤 라이브러리가 감지되었는지 출력하세요.
솔루션
# Cargo.toml
[build-dependencies]
pkg-config = "0.3"
// build.rs
fn main() {
println!("cargo::rerun-if-changed=build.rs");
if pkg_config::probe_library("zlib").is_ok() {
println!("cargo::rustc-cfg=has_zlib");
}
if pkg_config::probe_library("libpci").is_ok() {
println!("cargo::rustc-cfg=has_libpci");
}
}
// src/main.rs fn main() { #[cfg(has_zlib)] println!("✅ zlib 감지됨"); #[cfg(not(has_zlib))] println!("❌ zlib 찾을 수 없음"); #[cfg(has_libpci)] println!("✅ libpci 감지됨"); #[cfg(not(has_libpci))] println!("❌ libpci 찾을 수 없음"); }
핵심 요약
build.rs는 컴파일 타임에 호스트에서 실행됩니다. 불필요한 재빌드를 방지하기 위해 항상cargo::rerun-if-changed를 출력하세요.- C/C++ 컴파일 시에는 직접
gcc명령을 실행하지 말고cc크레이트를 사용하세요. 교차 컴파일 툴체인을 올바르게 처리해 줍니다. - 생성된 파일은
OUT_DIR에 작성하고src/에는 절대 작성하지 마세요. Cargo는 빌드 중 소스 변경을 원치 않습니다. - 선택적인 하드웨어의 경우 빌드 타임보다는 런타임 감지를 선호하세요.
- 타임스탬프를 포함할 때는 재현 가능한 빌드를 위해
SOURCE_DATE_EPOCH를 활용하세요.
교차 컴파일 — 하나의 소스, 다양한 타겟 🟡
학습 내용:
- Rust 타겟 트리플(target triples)의 작동 원리와
rustup으로 추가하는 방법- 컨테이너/클라우드 배포를 위한 정적 musl 바이너리 빌드
- 네이티브 툴체인,
cross,cargo-zigbuild를 이용한 ARM(aarch64) 교차 컴파일- 다중 아키텍처 CI를 위한 GitHub Actions 매트릭스 빌드 설정
참조: 빌드 스크립트 — 교차 컴파일 중 build.rs는 호스트(HOST)에서 실행됩니다 · 릴리스 프로필 — 교차 컴파일된 릴리스 바이너리를 위한 LTO 및 strip 설정 · Windows — Windows 교차 컴파일 및
no_std타겟
교차 컴파일(Cross-compilation)이란 한 머신(호스트)에서 다른 머신(타겟)에서 실행될 실행 파일을 빌드하는 것을 의미합니다. 호스트는 여러분의 x86_64 노트북일 수 있고, 타겟은 ARM 서버, musl 기반 컨테이너, 또는 Windows 머신일 수 있습니다. Rust는 rustc 자체가 이미 교차 컴파일러로 설계되었기 때문에 이 과정이 매우 수월합니다. 적절한 타겟 라이브러리와 호환되는 링커만 있으면 됩니다.
타겟 트리플의 구조 (The Target Triple Anatomy)
모든 Rust 컴파일 타겟은 타겟 트리플(이름과 달리 보통 4개 부분으로 구성됨)로 식별됩니다:
<아키텍처>-<벤더>-<운영체제>-<환경>
예시:
x86_64 - unknown - linux - gnu ← 표준 Linux (glibc)
x86_64 - unknown - linux - musl ← 정적 Linux (musl libc)
aarch64 - unknown - linux - gnu ← ARM 64비트 Linux
x86_64 - pc - windows- msvc ← MSVC를 사용하는 Windows
aarch64 - apple - darwin ← Apple 실리콘 기반 macOS
x86_64 - unknown - none ← 베어 메탈 (OS 없음)
사용 가능한 모든 타겟 목록 확인:
# rustc가 컴파일할 수 있는 모든 타겟 표시 (~250개)
rustc --print target-list | wc -l
# 시스템에 설치된 타겟 표시
rustup target list --installed
# 현재 기본 타겟 표시
rustc -vV | grep host
rustup을 이용한 툴체인 설치
# 타겟 라이브러리 추가 (해당 타겟용 Rust std)
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-gnu
# 이제 교차 컴파일이 가능합니다:
cargo build --target x86_64-unknown-linux-musl
cargo build --target aarch64-unknown-linux-gnu # 링커가 필요함 — 아래 내용 참조
rustup target add가 제공하는 것: 해당 타겟용으로 미리 컴파일된 std, core, alloc 라이브러리입니다. C 링커나 C 라이브러리는 제공하지 않습니다. C 툴체인이 필요한 타겟(대부분의 gnu 타겟)의 경우 별도로 설치해야 합니다.
# Ubuntu/Debian — aarch64용 교차 링커 설치
sudo apt install gcc-aarch64-linux-gnu
# Ubuntu/Debian — 정적 빌드를 위한 musl 툴체인 설치
sudo apt install musl-tools
# Fedora
sudo dnf install gcc-aarch64-linux-gnu
.cargo/config.toml — 타겟별 설정
매번 명령을 내릴 때마다 --target을 전달하는 대신, 프로젝트 루트나 홈 디렉토리의 .cargo/config.toml에서 기본값을 설정할 수 있습니다.
# .cargo/config.toml
# 이 프로젝트의 기본 타겟 (선택 사항 — 네이티브 기본값을 유지하려면 생략)
# [build]
# target = "x86_64-unknown-linux-musl"
# aarch64 교차 컴파일을 위한 링커
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
rustflags = ["-C", "target-feature=+crc"]
# musl 정적 빌드를 위한 링커 (보통 시스템 gcc가 작동함)
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
rustflags = ["-C", "target-feature=+crc,+aes"]
# ARM 32비트 (라즈베리 파이, 임베디드)
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
# 모든 타겟에 대한 환경 변수
[env]
# 예: 커스텀 시스루트(sysroot) 설정
# SYSROOT = "/opt/cross/sysroot"
설정 파일 검색 순서 (가장 먼저 발견되는 파일 적용):
<project>/.cargo/config.toml<project>/../.cargo/config.toml(상위 디렉토리로 올라가며 검색)$CARGO_HOME/config.toml(보통~/.cargo/config.toml)
musl을 이용한 정적 바이너리 (Static Binaries with musl)
최소한의 컨테이너(Alpine, scratch Docker 이미지)나 glibc 버전을 제어할 수 없는 시스템에 배포할 때는 musl을 사용하여 빌드하세요:
# musl 타겟 설치
rustup target add x86_64-unknown-linux-musl
sudo apt install musl-tools # musl-gcc 제공
# 완전한 정적 바이너리 빌드
cargo build --release --target x86_64-unknown-linux-musl
# 정적 빌드 여부 확인
file target/x86_64-unknown-linux-musl/release/diag_tool
# → ELF 64-bit LSB executable, x86-64, statically linked
ldd target/x86_64-unknown-linux-musl/release/diag_tool
# → not a dynamic executable (동적 실행 파일이 아님)
정적 빌드 vs 동적 빌드 트레이드오프:
| 특성 | glibc (동적) | musl (정적) |
|---|---|---|
| 바이너리 크기 | 작음 (공유 라이브러리 사용) | 큼 (~5-15 MB 증가) |
| 이식성 | 일치하는 glibc 버전 필요 | 모든 Linux에서 실행 가능 |
| DNS 해결 | nsswitch 완전 지원 | 기본 리졸버 (mDNS 미지원) |
| 배포 | 시스루트나 컨테이너 필요 | 의존성 없는 단일 바이너리 |
| 성능 | malloc이 약간 더 빠름 | malloc이 약간 더 느림 |
dlopen() 지원 | 지원함 | 지원 안 함 |
프로젝트 적용: 호스트 OS 버전을 보장할 수 없는 다양한 서버 하드웨어에 배포할 때 정적 musl 빌드가 이상적입니다. 단일 바이너리 배포 모델은 "내 컴퓨터에선 되는데" 문제를 제거합니다.
ARM (aarch64) 교차 컴파일
데이터 센터에서 ARM 서버(AWS Graviton, Ampere Altra, Grace 등) 사용이 점점 늘고 있습니다. x86_64 호스트에서 aarch64용으로 교차 컴파일하는 방법은 다음과 같습니다:
# 1단계: 타겟 및 교차 링커 설치
rustup target add aarch64-unknown-linux-gnu
sudo apt install gcc-aarch64-linux-gnu
# 2단계: .cargo/config.toml에 링커 설정 (위 내용 참조)
# 3단계: 빌드
cargo build --release --target aarch64-unknown-linux-gnu
# 4단계: 바이너리 확인
file target/aarch64-unknown-linux-gnu/release/diag_tool
# → ELF 64-bit LSB executable, ARM aarch64
타겟 아키텍처용 테스트 실행을 위해서는 다음 중 하나가 필요합니다:
- 실제 ARM 머신
- QEMU 유저 모드 에뮬레이션
# QEMU 유저 모드 설치 (x86_64에서 ARM 바이너리 실행)
sudo apt install qemu-user qemu-user-static binfmt-support
# 이제 cargo test가 QEMU를 통해 교차 컴파일된 테스트를 실행할 수 있습니다.
cargo test --target aarch64-unknown-linux-gnu
# (속도가 느림 — 각 테스트 바이너리가 에뮬레이션됨. 매일 하는 개발용이 아닌 CI 검증용으로 사용하세요.)
.cargo/config.toml에서 QEMU를 테스트 러너로 설정:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64-static -L /usr/aarch64-linux-gnu"
cross 도구 — Docker 기반 교차 컴파일
cross 도구는 미리 설정된 Docker 이미지를 사용하여 설정 과정이 필요 없는 교차 컴파일 경험을 제공합니다.
# cross 설치 (crates.io의 안정 버전)
cargo install cross
# 또는 최신 기능을 위해 git에서 설치:
# cargo install cross --git https://github.com/cross-rs/cross
# 교차 컴파일 — 툴체인 설정이 필요 없습니다!
cross build --release --target aarch64-unknown-linux-gnu
cross build --release --target x86_64-unknown-linux-musl
cross build --release --target armv7-unknown-linux-gnueabihf
# 교차 테스트 — Docker 이미지에 QEMU가 포함되어 있음
cross test --target aarch64-unknown-linux-gnu
작동 원리: cross는 cargo를 대신하여 올바른 교차 컴파일 툴체인이 미리 설치된 Docker 컨테이너 내에서 빌드를 실행합니다. 여러분의 소스 코드가 컨테이너에 마운트되고, 결과물은 평소와 같이 target/ 디렉토리에 저장됩니다.
Cross.toml을 이용한 Docker 이미지 커스터마이징:
# Cross.toml
[target.aarch64-unknown-linux-gnu]
# 추가 시스템 라이브러리가 포함된 커스텀 Docker 이미지 사용
image = "my-registry/cross-aarch64:latest"
# 빌드 전 시스템 패키지 설치
pre-build = [
"dpkg --add-architecture arm64",
"apt-get update && apt-get install -y libpci-dev:arm64"
]
[target.aarch64-unknown-linux-gnu.env]
# 컨테이너에 환경 변수 전달
passthrough = ["CI", "GITHUB_TOKEN"]
cross는 Docker(또는 Podman)가 필요하지만, 교차 컴파일러, 시스루트, QEMU를 수동으로 설치할 필요를 없애줍니다. CI 환경에서 권장되는 방식입니다.
Zig를 교차 컴파일 링커로 사용하기
Zig는 C 컴파일러와 약 40개 타겟에 대한 교차 컴파일 시스루트를 약 40MB 용량의 단일 다운로드 파일에 포함하고 있습니다. 덕분에 Rust를 위한 매우 편리한 교차 링커로 활용될 수 있습니다.
# Zig 설치 (단일 바이너리, 패키지 매니저 불필요)
# https://ziglang.org/download/ 에서 다운로드
# 또는 패키지 매니저 이용:
sudo snap install zig --classic --beta # Ubuntu
brew install zig # macOS
# cargo-zigbuild 설치
cargo install cargo-zigbuild
왜 Zig인가? 핵심 장점은 glibc 버전 타겟팅입니다. Zig를 사용하면 링크할 glibc 버전을 정확히 지정할 수 있어, 오래된 Linux 배포판에서도 바이너리가 실행되도록 보장할 수 있습니다.
# glibc 2.17 타겟 빌드 (CentOS 7 / RHEL 7 호환성)
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17
# glibc 2.28을 사용하는 aarch64 빌드 (Ubuntu 18.04+)
cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.28
# musl 빌드 (완전 정적)
cargo zigbuild --release --target x86_64-unknown-linux-musl
.2.17 접미사는 Zig의 확장 기능입니다. Zig 링커에 glibc 2.17 심볼 버전을 사용하도록 지시하여, 결과 바이너리가 CentOS 7 이상에서 실행될 수 있게 합니다. Docker, 시스루트 관리, 교차 컴파일러 설치가 모두 필요 없습니다.
비교: cross vs cargo-zigbuild vs 수동 방식
| 기능 | 수동 방식 | cross | cargo-zigbuild |
|---|---|---|---|
| 설정 노력 | 높음 (타겟별 툴체인 설치) | 낮음 (Docker 필요) | 낮음 (단일 바이너리) |
| Docker 필요 여부 | 아니요 | 예 | 아니요 |
| glibc 버전 타겟팅 | 불가 (호스트 glibc 사용) | 불가 (컨테이너 glibc 사용) | 가능 (정확한 버전 지정) |
| 테스트 실행 | QEMU 필요 | 포함되어 있음 | QEMU 필요 |
| macOS → Linux | 어려움 | 쉬움 | 쉬움 |
| Linux → macOS | 매우 어려움 | 지원 안 함 | 제한적 지원 |
| 바이너리 크기 오버헤드 | 없음 | 없음 | 없음 |
CI 파이프라인: GitHub Actions 매트릭스
여러 타겟을 빌드하는 운영 환경 수준의 CI 워크플로 예시입니다:
# .github/workflows/cross-build.yml
name: Cross-Platform Build
on: [push, pull_request]
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
name: linux-x86_64
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: linux-x86_64-static
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
name: linux-aarch64
use_cross: true
- target: x86_64-pc-windows-msvc
os: windows-latest
name: windows-x86_64
runs-on: ${{ matrix.os }}
name: Build (${{ matrix.name }})
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: musl 도구 설치
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get install -y musl-tools
- name: cross 설치
if: matrix.use_cross
run: cargo install cross
- name: 빌드 (네이티브)
if: "!matrix.use_cross"
run: cargo build --release --target ${{ matrix.target }}
- name: 빌드 (cross)
if: matrix.use_cross
run: cross build --release --target ${{ matrix.target }}
- name: 테스트 실행
if: "!matrix.use_cross"
run: cargo test --target ${{ matrix.target }}
- name: 아티팩트 업로드
uses: actions/upload-artifact@v4
with:
name: diag_tool-${{ matrix.name }}
path: target/${{ matrix.target }}/release/diag_tool*
적용 사례: 다중 아키텍처 서버 빌드
현재 바이너리에는 교차 컴파일 설정이 없습니다. 다양한 서버 집합에 배포되는 하드웨어 진단 도구의 경우, 다음을 추가하는 것이 권장됩니다:
my_workspace/
├── .cargo/
│ └── config.toml ← 타겟별 링커 설정
├── Cross.toml ← cross 도구 설정
└── .github/workflows/
└── cross-build.yml ← 3개 타겟을 위한 CI 매트릭스
권장 .cargo/config.toml:
# 프로젝트를 위한 .cargo/config.toml
# 릴리스 프로필 최적화 (이미 Cargo.toml에 있음, 참조용)
# [profile.release]
# lto = true
# codegen-units = 1
# panic = "abort"
# strip = true
# ARM 서버용 aarch64 (Graviton, Ampere, Grace)
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
# 이식성 있는 정적 바이너리를 위한 musl
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
권장 빌드 타겟:
| 타겟 | 사용 사례 | 배포 대상 |
|---|---|---|
x86_64-unknown-linux-gnu | 기본 네이티브 빌드 | 표준 x86 서버 |
x86_64-unknown-linux-musl | 정적 바이너리, 모든 배포판 | 컨테이너, 최소 설치 호스트 |
aarch64-unknown-linux-gnu | ARM 서버 | Graviton, Ampere, Grace |
핵심 통찰: 워크스페이스 루트의
Cargo.toml에 있는[profile.release]에는 이미lto = true,codegen-units = 1,panic = "abort",strip = true가 설정되어 있습니다. 이는 교차 컴파일된 배포용 바이너리에 이상적인 릴리스 프로필입니다(릴리스 프로필 장의 영향도 표 참조). musl과 결합하면 런타임 의존성이 없는 약 10MB 크기의 단일 정적 바이너리가 생성됩니다.
교차 컴파일 문제 해결 (Troubleshooting)
| 증상 | 원인 | 해결책 |
|---|---|---|
linker 'aarch64-linux-gnu-gcc' not found | 교차 링커 툴체인 누락 | sudo apt install gcc-aarch64-linux-gnu |
cannot find -lssl (musl 타겟) | 시스템 OpenSSL이 glibc에 링크됨 | vendored 기능 사용: openssl = { version = "0.10", features = ["vendored"] } |
build.rs가 잘못된 바이너리 실행 | build.rs는 타겟이 아닌 호스트에서 실행됨 | build.rs 내에서 cfg!(target_os) 대신 CARGO_CFG_TARGET_OS 확인 |
로컬 테스트는 통과하나 cross에서 실패 | Docker 이미지에 테스트 파일 누락 | Cross.toml을 통해 테스트 데이터 마운트: [build.env] volumes = ["./TestArea:/TestArea"] |
undefined reference to __cxa_thread_atexit_impl | 타겟의 glibc 버전이 너무 낮음 | cargo-zigbuild로 명시적 glibc 버전 사용: --target x86_64-unknown-linux-gnu.2.17 |
| ARM에서 바이너리 세그폴트 발생 | 잘못된 ARM 변종으로 컴파일됨 | 타겟 트리플이 하드웨어와 일치하는지 확인: 64비트 ARM은 aarch64-unknown-linux-gnu |
런타임에 GLIBC_2.XX not found 발생 | 빌드 머신의 glibc가 더 최신임 | 정적 빌드를 위해 musl을 사용하거나, cargo-zigbuild로 glibc 버전 고정 |
교차 컴파일 의사결정 트리
flowchart TD
START["교차 컴파일이 필요한가?"] --> STATIC{"정적 바이너리인가?"}
STATIC -->|예| MUSL["musl 타겟\n--target x86_64-unknown-linux-musl"]
STATIC -->|아니요| GLIBC{"오래된 glibc가 필요한가?"}
GLIBC -->|예| ZIG["cargo-zigbuild\n--target x86_64-unknown-linux-gnu.2.17"]
GLIBC -->|아니요| ARCH{"타겟 아키텍처?"}
ARCH -->|"동일 아키텍처"| NATIVE["네이티브 툴체인\nrustup target add + 링커"]
ARCH -->|"ARM/기타"| DOCKER{"Docker 사용 가능한가?"}
DOCKER -->|예| CROSS["cross build\nDocker 기반, 설정 불필요"]
DOCKER -->|아니요| MANUAL["수동 시스루트 설정\napt install gcc-aarch64-linux-gnu"]
style MUSL fill:#91e5a3,color:#000
style ZIG fill:#91e5a3,color:#000
style CROSS fill:#91e5a3,color:#000
style NATIVE fill:#e3f2fd,color:#000
style MANUAL fill:#ffd43b,color:#000
🏋️ 실습
🟢 실습 1: 정적 musl 바이너리
아무 Rust 바이너리나 x86_64-unknown-linux-musl용으로 빌드해 보세요. file과 ldd를 사용하여 정적으로 링크되었는지 확인하세요.
솔루션
rustup target add x86_64-unknown-linux-musl
cargo new hello-static && cd hello-static
cargo build --release --target x86_64-unknown-linux-musl
# 확인
file target/x86_64-unknown-linux-musl/release/hello-static
# 출력: ... statically linked ...
ldd target/x86_64-unknown-linux-musl/release/hello-static
# 출력: not a dynamic executable (동적 실행 파일이 아님)
🟡 실습 2: GitHub Actions 교차 빌드 매트릭스
x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-gnu 세 가지 타겟을 빌드하는 GitHub Actions 워크플로를 매트릭스 전략을 사용하여 작성해 보세요.
솔루션
name: Cross-build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
target:
- x86_64-unknown-linux-gnu
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-gnu
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: cross 설치
run: cargo install cross --locked
- name: 빌드
run: cross build --release --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: target/${{ matrix.target }}/release/my-binary
핵심 요약
- Rust의
rustc는 이미 교차 컴파일러입니다. 적절한 타겟과 링커만 준비하면 됩니다. - musl은 런타임 의존성이 전혀 없는 완전한 정적 바이너리를 생성하며, 컨테이너 환경에 이상적입니다.
- **
cargo-zigbuild**는 기업용 Linux 타겟을 위한 "glibc 버전" 문제를 해결해 줍니다. - **
cross**는 ARM이나 기타 특이한 타겟을 위한 가장 쉬운 방법입니다. Docker가 시스루트를 알아서 처리합니다. - 항상
file과ldd를 사용하여 바이너리가 배포 타겟과 일치하는지 테스트하세요.
벤치마킹 — 중요한 지표 측정하기 🟡
학습 내용:
Instant::now()를 이용한 단순한 시간 측정이 신뢰할 수 없는 이유- Criterion.rs와 더 가벼운 대안인 Divan을 이용한 통계적 벤치마킹
perf, 플레임그래프(flamegraphs), PGO를 이용한 핫스팟(hot spots) 프로파일링- 성능 저하를 자동으로 감지하기 위한 CI에서의 지속적인 벤치마킹 설정
참조: 릴리스 프로필 — 핫스팟을 찾았다면 바이너리를 최적화하세요 · CI/CD 파이프라인 — 파이프라인 내의 벤치마크 작업 · 코드 커버리지 — 커버리지는 무엇이 테스트되었는지 알려주고, 벤치마크는 무엇이 빠른지 알려줍니다.
"우리는 약 97%의 시간에 대해 사소한 효율성에 대해서는 잊어야 합니다. 섣부른 최적화(premature optimization)는 모든 악의 뿌리입니다. 하지만 우리는 결정적인 3%의 기회를 놓쳐서는 안 됩니다." — 도널드 커누스(Donald Knuth)
어려운 것은 벤치마크를 작성하는 것이 아니라, 의미 있고 재현 가능하며 실행 가능한 수치를 산출하는 벤치마크를 작성하는 것입니다. 이 장에서는 "빠른 것 같다"는 느낌에서 벗어나 "PR #347이 파싱 처리량을 4.2% 저하시켰다는 통계적 증거가 있다"고 말할 수 있게 해주는 도구와 기술을 다룹니다.
왜 std::time::Instant를 사용하면 안 될까요?
흔히 하는 실수:
// ❌ 단순한 벤치마킹 — 신뢰할 수 없는 결과 use std::time::Instant; fn main() { let start = Instant::now(); let result = parse_device_query_output(&sample_data); let elapsed = start.elapsed(); println!("파싱 소요 시간: {:?}", elapsed); // 문제 1: 컴파일러가 `result`를 최적화로 제거할 수 있음 (데드 코드 제거) // 문제 2: 단일 샘플 — 통계적 유의성 없음 // 문제 3: CPU 주파수 스케일링, 서멀 쓰로틀링, 다른 프로세스의 영향 // 문제 4: 콜드 캐시(cold cache) vs 웜 캐시(warm cache) 제어 불가 }
수동 시간 측정의 문제점:
- 데드 코드 제거 (Dead code elimination) — 결과값이 사용되지 않으면 컴파일러가 계산 전체를 건너뛸 수 있습니다.
- 워밍업 부재 (No warm-up) — 첫 번째 실행에는 캐시 미스, OS 페이지 폴트, 지연 초기화(lazy initialization) 등이 포함됩니다.
- 통계 분석 부재 — 단 한 번의 측정으로는 분산, 이상치(outliers) 또는 신뢰 구간에 대해 아무것도 알 수 없습니다.
- 회귀 감지 불가 — 이전 실행 결과와 정밀하게 비교할 수 없습니다.
Criterion.rs — 통계적 벤치마킹
Criterion.rs는 Rust 마이크로 벤치마크의 표준 도구입니다. 통계적 방법을 사용하여 신뢰할 수 있는 측정값을 생성하고 성능 회귀(performance regressions)를 자동으로 감지합니다.
설정:
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports", "cargo_bench_support"] }
[[bench]]
name = "parsing_bench"
harness = false # 내장 테스트 하네스 대신 Criterion의 하네스 사용
전체 벤치마크 예제:
#![allow(unused)] fn main() { // benches/parsing_bench.rs use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId}; /// 파싱된 GPU 정보를 담는 데이터 타입 #[derive(Debug, Clone)] struct GpuInfo { index: u32, name: String, temp_c: u32, power_w: f64, } /// 테스트 대상 함수 — 장치 쿼리 CSV 출력을 파싱하는 시뮬레이션 fn parse_gpu_csv(input: &str) -> Vec<GpuInfo> { input .lines() .filter(|line| !line.starts_with('#')) .filter_map(|line| { let fields: Vec<&str> = line.split(", ").collect(); if fields.len() >= 4 { Some(GpuInfo { index: fields[0].parse().ok()?, name: fields[1].to_string(), temp_c: fields[2].parse().ok()?, power_w: fields[3].parse().ok()?, }) } else { None } }) .collect() } fn bench_parse_gpu_csv(c: &mut Criterion) { // 대표 테스트 데이터 let small_input = "0, Acme Accel-V1-80GB, 32, 65.5\n\ 1, Acme Accel-V1-80GB, 34, 67.2\n"; let large_input = (0..64) .map(|i| format!("{i}, Acme Accel-X1-80GB, {}, {:.1}\n", 30 + i % 20, 60.0 + i as f64)) .collect::<String>(); c.bench_function("parse_2_gpus", |b| { b.iter(|| parse_gpu_csv(black_box(small_input))) }); c.bench_function("parse_64_gpus", |b| { b.iter(|| parse_gpu_csv(black_box(&large_input))) }); } criterion_group!(benches, bench_parse_gpu_csv); criterion_main!(benches); }
실행 및 결과 읽기:
# 모든 벤치마크 실행
cargo bench
# 특정 벤치마크 이름으로 실행
cargo bench -- parse_64
# 출력 결과:
# parse_2_gpus time: [1.2345 µs 1.2456 µs 1.2578 µs]
# ▲ ▲ ▲
# │ 신뢰 구간
# 하위 95% 중앙값 상위 95%
#
# parse_64_gpus time: [38.123 µs 38.456 µs 38.812 µs]
# change: [-1.2345% -0.5678% +0.1234%] (p = 0.12 > 0.05)
# 성능 변화가 감지되지 않았습니다. (No change in performance detected.)
black_box()의 역할: 컴파일러가 벤치마크 대상을 최적화로 제거하거나 과도하게 상수를 폴딩(folding)하는 것을 방지하는 힌트입니다. 컴파일러는 black_box 내부를 들여다볼 수 없으므로 실제로 계산을 수행해야만 합니다.
매개변수화된 벤치마크 및 벤치마크 그룹
여러 구현 방식이나 입력 크기를 비교할 때 유용합니다:
#![allow(unused)] fn main() { // benches/comparison_bench.rs use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId, Throughput}; fn bench_parsing_strategies(c: &mut Criterion) { let mut group = c.benchmark_group("csv_parsing"); // 다양한 입력 크기에 대해 테스트 for num_gpus in [1, 8, 32, 64, 128] { let input = generate_gpu_csv(num_gpus); // 초당 바이트 수 보고를 위한 처리량 설정 group.throughput(Throughput::Bytes(input.len() as u64)); group.bench_with_input( BenchmarkId::new("split_based", num_gpus), &input, |b, input| b.iter(|| parse_split(input)), ); group.bench_with_input( BenchmarkId::new("regex_based", num_gpus), &input, |b, input| b.iter(|| parse_regex(input)), ); group.bench_with_input( BenchmarkId::new("nom_based", num_gpus), &input, |b, input| b.iter(|| parse_nom(input)), ); } group.finish(); } criterion_group!(benches, bench_parsing_strategies); criterion_main!(benches); }
결과 확인: Criterion은 target/criterion/report/index.html에 바이올린 플롯(violin plots), 비교 차트, 회귀 분석 결과가 포함된 HTML 보고서를 생성합니다. 브라우저에서 열어보세요.
Divan — 더 가벼운 대안
Divan은 Criterion의 복잡한 매크로 DSL 대신 속성(attribute) 매크로를 사용하는 최신 벤치마킹 프레임워크입니다.
# Cargo.toml
[dev-dependencies]
divan = "0.1"
[[bench]]
name = "parsing_bench"
harness = false
// benches/parsing_bench.rs use divan::black_box; const SMALL_INPUT: &str = "0, Acme Accel-V1-80GB, 32, 65.5\n\ 1, Acme Accel-V1-80GB, 34, 67.2\n"; fn generate_gpu_csv(n: usize) -> String { (0..n) .map(|i| format!("{i}, Acme Accel-X1-80GB, {}, {:.1}\n", 30 + i % 20, 60.0 + i as f64)) .collect() } fn main() { divan::main(); } #[divan::bench] fn parse_2_gpus() -> Vec<GpuInfo> { parse_gpu_csv(black_box(SMALL_INPUT)) } #[divan::bench(args = [1, 8, 32, 64, 128])] fn parse_n_gpus(n: usize) -> Vec<GpuInfo> { let input = generate_gpu_csv(n); parse_gpu_csv(black_box(&input)) } // Divan 출력 결과는 깔끔한 표 형식입니다: // ╰─ parse_2_gpus fastest │ slowest │ median │ mean │ samples │ iters // 1.234 µs │ 1.567 µs │ 1.345 µs │ 1.350 µs │ 100 │ 1600
Criterion 대신 Divan을 선택하는 경우:
- 더 단순한 API (속성 매크로 사용, 보일러플레이트 적음)
- 더 빠른 컴파일 속도 (의존성 적음)
- 개발 중 빠른 성능 확인에 적합
Criterion을 선택하는 경우:
- 실행 간의 통계적 회귀 감지 필요
- 차트가 포함된 HTML 보고서 필요
- 확립된 에코시스템 및 더 많은 CI 통합 사례
perf와 플레임그래프(Flamegraph)를 이용한 프로파일링
벤치마크가 얼마나 빠른지 알려준다면, 프로파일링은 시간이 어디에 쓰이는지 알려줍니다.
# 1단계: 디버그 정보와 함께 빌드 (릴리스 속도 유지, 디버그 심볼 포함)
cargo build --release
# 디버그 정보가 포함되도록 일시적으로 설정:
# [profile.release]
# debug = true # 프로파일링을 위해 임시로 추가
# 2단계: perf로 기록
perf record --call-graph=dwarf ./target/release/diag_tool --run-diagnostics
# 3단계: 플레임그래프 생성
# 설치: cargo install flamegraph
# 설치: cargo install addr2line --features=bin (선택 사항, 속도 향상용)
cargo flamegraph --root -- --run-diagnostics
# 대화형 SVG 플레임그래프가 생성됩니다.
플레임그래프 읽는 법:
- 너비(Width) = 해당 함수에서 소비된 시간 (넓을수록 느림)
- 높이(Height) = 콜 스택 깊이 (높다고 느린 것이 아니라 호출 단계가 깊은 것임)
- 하단(Bottom) = 엔트리 포인트(시작점), 상단(Top) = 실제 작업을 수행하는 리프(leaf) 함수
- 상단에서 넓게 퍼진 평평한 부분(plateaus)을 찾으세요 — 그곳이 바로 핫스팟입니다.
프로필 기반 최적화 (Profile-Guided Optimization, PGO):
# 1단계: 계측(instrumentation)과 함께 빌드
RUSTFLAGS="-Cprofile-generate=/tmp/pgo-data" cargo build --release
# 2단계: 대표적인 워크로드 실행
./target/release/diag_tool --run-full # 프로파일링 데이터 생성
# 3단계: 프로파일링 데이터 병합
# rustc의 LLVM 버전과 일치하는 llvm-profdata 사용:
# $(rustc --print sysroot)/lib/rustlib/x86_64-unknown-linux-gnu/bin/llvm-profdata
# 또는 llvm-tools 설치: rustup component add llvm-tools
llvm-profdata merge -o /tmp/pgo-data/merged.profdata /tmp/pgo-data/
# 4단계: 프로파일링 피드백과 함께 재빌드
RUSTFLAGS="-Cprofile-use=/tmp/pgo-data/merged.profdata" cargo build --release
# 연산 집약적인 코드(파싱, 암호화, 코드 생성 등)에서 보통 5-20% 성능 향상을 보입니다.
# I/O 위주나 시스템 콜이 많은 코드에서는 큰 효과를 보기 어려울 수 있습니다.
팁: PGO에 시간을 들이기 전에, 릴리스 프로필에서 LTO가 활성화되어 있는지 먼저 확인하세요. 보통 LTO가 노력 대비 더 큰 성능 향상을 제공합니다.
hyperfine — 빠른 전체 실행 시간 측정
hyperfine은 개별 함수가 아닌 전체 명령의 실행 시간을 벤치마킹합니다. 바이너리의 전체적인 성능을 측정할 때 완벽합니다.
# 설치
cargo install hyperfine
# 기본 벤치마크
hyperfine './target/release/diag_tool --run-diagnostics'
# 두 가지 구현 비교
hyperfine './target/release/diag_tool_v1 --run-diagnostics' \
'./target/release/diag_tool_v2 --run-diagnostics'
# 워밍업 실행 + 최소 반복 횟수 지정
hyperfine --warmup 3 --min-runs 10 './target/release/diag_tool --run-all'
# CI 비교를 위해 결과를 JSON으로 내보내기
hyperfine --export-json bench.json './target/release/diag_tool --run-all'
Criterion vs hyperfine 사용 시점:
hyperfine: 전체 바이너리 실행 시간, 리팩토링 전후 비교, I/O 위주 워크로드- Criterion: 개별 함수의 마이크로 벤치마킹, 통계적 회귀 감지
CI에서의 지속적인 벤치마킹
배포 전에 성능 저하를 자동으로 감지하세요:
# .github/workflows/bench.yml
name: Benchmarks
on:
pull_request:
paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock']
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run benchmarks
run: cargo bench -- --output-format bencher | tee bench_output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: bench_output.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
alert-threshold: '120%' # 20% 느려지면 알림
comment-on-alert: true
fail-on-alert: true # 회귀 감지 시 PR 차단
CI 설정 시 주의사항:
- 일관된 결과를 위해 공유 CI가 아닌 전용 벤치마크 러너를 사용하세요.
- 클라우드 CI를 사용한다면 특정 머신 타입을 고정하세요.
- 과거 데이터를 저장하여 점진적인 성능 저하를 감지하세요.
- 워크로드의 허용 오차에 따라 임계값(threshold)을 설정하세요 (실행 경로에 따라 5~20%).
적용 사례: 파싱 성능
이 프로젝트에는 벤치마킹을 통해 성능을 개선할 수 있는 여러 파싱 경로가 있습니다:
| 파싱 핫스팟 | 크레이트 | 중요 이유 |
|---|---|---|
| 가속기 쿼리 CSV/XML 출력 | device_diag | GPU당 호출됨, 실행당 최대 8회 |
| 센서 이벤트 파싱 | event_log | 바쁜 서버에서 수천 개의 레코드 처리 |
| PCIe 토폴로지 JSON | topology_lib | 복잡한 중첩 구조, 골든 파일(golden-file)로 검증됨 |
| 보고서 JSON 직렬화 | diag_framework | 최종 보고서 출력, 크기에 민감함 |
| 설정 JSON 로딩 | config_loader | 시작 지연 시간(startup latency) |
첫 번째 권장 벤치마크 — 이미 골든 파일 테스트 데이터가 있는 토폴로지 파서:
#![allow(unused)] fn main() { // topology_lib/benches/parse_bench.rs (제안) use criterion::{criterion_group, criterion_main, Criterion, Throughput}; use std::fs; fn bench_topology_parse(c: &mut Criterion) { let mut group = c.benchmark_group("topology_parse"); for golden_file in ["S2001", "S1015", "S1035", "S1080"] { let path = format!("tests/test_data/{golden_file}.json"); let data = fs::read_to_string(&path).expect("골든 파일을 찾을 수 없습니다"); group.throughput(Throughput::Bytes(data.len() as u64)); group.bench_function(golden_file, |b| { b.iter(|| { topology_lib::TopologyProfile::from_json_str( criterion::black_box(&data) ) }); }); } group.finish(); } criterion_group!(benches, bench_topology_parse); criterion_main!(benches); }
직접 해보기
-
Criterion 벤치마크 작성: 코드베이스에서 파싱 함수를 하나 골라
benches/디렉토리를 만들고, 초당 바이트 수 처리량을 측정하는 Criterion 벤치마크를 설정해 보세요.cargo bench를 실행하고 HTML 보고서를 확인해 보세요. -
플레임그래프 생성:
[profile.release]에debug = true를 추가하고 프로젝트를 빌드한 후,cargo flamegraph -- <인자>를 실행해 보세요. 플레임그래프 상단에서 가장 넓은 스택 3개를 찾아보세요. 그곳이 바로 여러분의 핫스팟입니다. -
hyperfine으로 비교:hyperfine을 설치하고 바이너리의 전체 실행 시간을 다양한 플래그와 함께 측정해 보세요. 이를 Criterion의 함수 단위 시간과 비교해 보세요. Criterion이 보지 못하는 시간(I/O, 시스템 콜, 프로세스 시작 등)은 어디서 소모되나요?
벤치마킹 도구 선택 가이드
flowchart TD
START["성능을 측정하고 싶나요?"] --> WHAT{"어떤 수준인가요?"}
WHAT -->|"개별 함수"| CRITERION["Criterion.rs\n통계적, 회귀 감지"]
WHAT -->|"빠른 함수 확인"| DIVAN["Divan\n가벼움, 속성 매크로"]
WHAT -->|"전체 바이너리"| HYPERFINE["hyperfine\n엔드투엔드, 실제 시간"]
WHAT -->|"핫스팟 찾기"| PERF["perf + flamegraph\nCPU 샘플링 프로파일러"]
CRITERION --> CI_BENCH["GitHub Actions에서의\n지속적인 벤치마킹"]
PERF --> OPTIMIZE["프로필 기반\n최적화 (PGO)"]
style CRITERION fill:#91e5a3,color:#000
style DIVAN fill:#91e5a3,color:#000
style HYPERFINE fill:#e3f2fd,color:#000
style PERF fill:#ffd43b,color:#000
style CI_BENCH fill:#e3f2fd,color:#000
style OPTIMIZE fill:#ffd43b,color:#000
🏋️ 실습
🟢 실습 1: 첫 번째 Criterion 벤치마크
10,000개의 무작위 요소를 가진 Vec<u64>를 정렬하는 함수를 가진 크레이트를 만드세요. 이에 대한 Criterion 벤치마크를 작성한 후, .sort_unstable()로 바꾸고 HTML 보고서에서 성능 차이를 관찰해 보세요.
솔루션
# Cargo.toml
[[bench]]
name = "sort_bench"
harness = false
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand = "0.8"
#![allow(unused)] fn main() { // benches/sort_bench.rs use criterion::{black_box, criterion_group, criterion_main, Criterion}; use rand::Rng; fn generate_data(n: usize) -> Vec<u64> { let mut rng = rand::thread_rng(); (0..n).map(|_| rng.gen()).collect() } fn bench_sort(c: &mut Criterion) { let mut group = c.benchmark_group("sort-10k"); group.bench_function("stable", |b| { b.iter_batched( || generate_data(10_000), |mut data| { data.sort(); black_box(&data); }, criterion::BatchSize::SmallInput, ) }); group.bench_function("unstable", |b| { b.iter_batched( || generate_data(10_000), |mut data| { data.sort_unstable(); black_box(&data); }, criterion::BatchSize::SmallInput, ) }); group.finish(); } criterion_group!(benches, bench_sort); criterion_main!(benches); }
cargo bench
open target/criterion/sort-10k/report/index.html
🟡 실습 2: 플레임그래프 핫스팟
[profile.release]에 debug = true를 설정하고 빌드한 후 플레임그래프를 생성하세요. 가장 너비가 넓은 스택 3개를 식별해 보세요.
솔루션
# Cargo.toml
[profile.release]
debug = true # 플레임그래프를 위해 심볼 유지
cargo install flamegraph
cargo flamegraph --release -- <인자>
# 브라우저에서 flamegraph.svg가 열립니다.
# 상단에서 가장 넓은 스택들이 바로 핫스팟입니다.
핵심 요약
- 절대로
Instant::now()로 벤치마킹하지 마세요. 통계적 엄밀함과 회귀 감지를 위해 Criterion.rs를 사용하세요. black_box()는 컴파일러가 벤치마크 대상을 최적화로 제거하는 것을 방지합니다.hyperfine은 전체 바이너리의 실행 시간을 측정하고, Criterion은 개별 함수를 측정합니다. 두 도구를 적절히 병용하세요.- 플레임그래프는 시간이 어디서 쓰이는지 보여주고, 벤치마크는 시간이 얼마나 쓰이는지 보여줍니다.
- CI에서의 지속적인 벤치마킹은 성능 저하가 배포되기 전에 잡아낼 수 있게 해줍니다.
코드 커버리지 — 테스트가 놓치는 부분 확인하기 🟢
학습 내용:
cargo-llvm-cov를 이용한 소스 기반 커버리지 (가장 정확한 Rust 커버리지 도구)cargo-tarpaulin및 Mozilla의grcov를 이용한 빠른 커버리지 확인- Codecov 및 Coveralls를 이용한 CI 커버리지 게이트(coverage gates) 설정
- 고위험 사각지대를 우선시하는 커버리지 기반 테스트 전략
참조: Miri 및 새니타이저 — 커버리지는 테스트되지 않은 코드를 찾고, Miri는 테스트된 코드 내의 미정의 동작(UB)을 찾습니다 · 벤치마킹 — 커버리지는 무엇이 테스트되었는지 보여주고, 벤치마크는 무엇이 빠른지 보여줍니다 · CI/CD 파이프라인 — 파이프라인 내의 커버리지 게이트
코드 커버리지는 테스트가 실제로 어떤 라인, 브랜치 또는 함수를 실행하는지 측정합니다. 커버리지가 높다고 해서 코드의 정답률이 보장되는 것은 아니지만(실행된 라인에도 버그는 있을 수 있음), 테스트가 전혀 닿지 않는 **사각지대(blind spots)**를 확실하게 드러내 줍니다.
수많은 크레이트에 걸쳐 1,000개가 넘는 테스트가 있는 이 프로젝트에서 커버리지 분석은 다음 질문에 답해줍니다: "테스트에 대한 투자가 정말 중요한 코드에 도달하고 있는가?"
llvm-cov를 이용한 소스 기반 커버리지
Rust는 LLVM을 사용하며, LLVM은 가장 정확한 커버리지 측정 방식인 소스 기반 커버리지 계측(instrumentation)을 지원합니다. 권장되는 도구는 cargo-llvm-cov입니다.
# 설치
cargo install cargo-llvm-cov
# 또는 rustup 컴포넌트를 통해 (로우 LLVM 도구용)
rustup component add llvm-tools-preview
기본 사용법:
# 테스트를 실행하고 파일별 커버리지 요약 표시
cargo llvm-cov
# HTML 보고서 생성 (라인별 하이라이트가 포함된 브라우저용 보고서)
cargo llvm-cov --html
# 결과물 위치: target/llvm-cov/html/index.html
# LCOV 형식 생성 (CI 통합용)
cargo llvm-cov --lcov --output-path lcov.info
# 워크스페이스 전체 커버리지 (모든 크레이트)
cargo llvm-cov --workspace
# 특정 패키지만 포함
cargo llvm-cov --package accel_diag --package topology_lib
# 문서 테스트(doc tests) 포함 커버리지
cargo llvm-cov --doctests
HTML 보고서 읽기:
target/llvm-cov/html/index.html
├── 파일명 │ 함수 │ 라인 │ 브랜치 │ 영역
├─ accel_diag/src/lib.rs │ 78.5% │ 82.3% │ 61.2% │ 74.1%
├─ sel_mgr/src/parse.rs│ 95.2% │ 96.8% │ 88.0% │ 93.5%
├─ topology_lib/src/.. │ 91.0% │ 93.4% │ 79.5% │ 89.2%
└─ ...
초록색 = 커버됨 빨간색 = 커버 안 됨 노란색 = 부분적으로 커버됨 (브랜치)
커버리지 유형 설명:
| 유형 | 측정 대상 | 의미 |
|---|---|---|
| 라인 커버리지 | 어떤 소스 라인이 실행되었는가 | "이 코드에 도달했는가?"라는 기본 측정 |
| 브랜치 커버리지 | if/match의 어떤 분기가 실행되었는가 | 테스트되지 않은 조건문을 찾아냄 |
| 함수 커버리지 | 어떤 함수가 호출되었는가 | 데드 코드(사용되지 않는 코드) 식별 |
| 영역 커버리지 | 어떤 코드 영역(하위 표현식)이 실행되었는가 | 가장 세밀한 단위의 측정 |
cargo-tarpaulin — 빠른 확인을 위한 대안
cargo-tarpaulin은 설정이 더 간단한(LLVM 컴포넌트 불필요) Linux 전용 커버리지 도구입니다.
# 설치
cargo install cargo-tarpaulin
# 기본 커버리지 보고서
cargo tarpaulin
# HTML 출력
cargo tarpaulin --out Html
# 상세 옵션 사용
cargo tarpaulin \
--workspace \
--timeout 120 \
--out Xml Html \
--output-dir coverage/ \
--exclude-files "*/tests/*" "*/benches/*" \
--ignore-panics
# 특정 크레이트 제외
cargo tarpaulin --workspace --exclude diag_tool # 바이너리 크레이트 제외
tarpaulin vs llvm-cov 비교:
| 기능 | cargo-llvm-cov | cargo-tarpaulin |
|---|---|---|
| 정확도 | 소스 기반 (가장 정확함) | Ptrace 기반 (때때로 과다 측정) |
| 플랫폼 | 모든 플랫폼 (LLVM 기반) | Linux 전용 |
| 브랜치 커버리지 | 지원함 | 제한적 지원 |
| 문서 테스트 | 지원함 | 지원 안 함 |
| 설정 | llvm-tools-preview 필요 | 단독 실행 가능 |
| 속도 | 빠름 (컴파일 타임 계측) | 느림 (ptrace 오버헤드) |
| 안정성 | 매우 안정적임 | 때때로 가양성(false positives) 발생 |
권장 사항: 정확도가 중요하다면 cargo-llvm-cov를 사용하세요. LLVM 도구 설치 없이 빠르게 확인하고 싶을 때는 cargo-tarpaulin을 사용하세요.
grcov — Mozilla의 커버리지 도구
grcov는 Mozilla에서 만든 커버리지 집계 도구입니다. 로우(raw) LLVM 프로파일링 데이터를 읽어 다양한 형식의 보고서를 생성합니다.
# 설치
cargo install grcov
# 1단계: 커버리지 계측과 함께 빌드
export RUSTFLAGS="-Cinstrument-coverage"
export LLVM_PROFILE_FILE="target/coverage/%p-%m.profraw"
cargo build --tests
# 2단계: 테스트 실행 (.profraw 파일 생성됨)
cargo test
# 3단계: grcov로 집계
grcov target/coverage/ \
--binary-path target/debug/ \
--source-dir . \
--output-types html,lcov \
--output-path target/coverage/report \
--branch \
--ignore-not-existing \
--ignore "*/tests/*" \
--ignore "*/.cargo/*"
# 4단계: 보고서 확인
open target/coverage/report/html/index.html
grcov를 사용하는 경우: 유닛 테스트, 통합 테스트, 퍼즈(fuzz) 테스트 등 여러 테스트 실행 결과를 하나의 보고서로 합쳐야 할 때 가장 유용합니다.
CI 환경의 커버리지: Codecov 및 Coveralls
커버리지 데이터를 추적 서비스에 업로드하여 이력 관리 및 PR 주석 기능을 활용해 보세요.
# .github/workflows/coverage.yml
name: Code Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: cargo-llvm-cov 설치
uses: taiki-e/install-action@cargo-llvm-cov
- name: 커버리지 생성
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: Codecov에 업로드
uses: codecov/codecov-action@v4
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
# 선택 사항: 최소 커버리지 강제
- name: 커버리지 임계값 확인
run: |
cargo llvm-cov --workspace --fail-under-lines 80
# 라인 커버리지가 80% 미만이면 빌드 실패 처리
커버리지 게이트 (Coverage gates) — JSON 출력을 읽어 크레이트별 최소치를 강제할 수 있습니다.
# 크레이트별 커버리지를 JSON으로 가져오기
cargo llvm-cov --workspace --json | jq '.data[0].totals.lines.percent'
# 임계값 미달 시 실패 처리
cargo llvm-cov --workspace --fail-under-lines 80
cargo llvm-cov --workspace --fail-under-functions 70
cargo llvm-cov --workspace --fail-under-regions 60
커버리지 기반 테스트 전략 (Coverage-Guided Testing Strategy)
전략 없는 커버리지 수치는 무의미합니다. 커버리지 데이터를 효과적으로 활용하는 방법은 다음과 같습니다:
1단계: 위험도에 따른 분류
높은 커버리지, 높은 위험도 → ✅ 양호 — 상태 유지
높은 커버리지, 낮은 위험도 → 🔄 과잉 테스트 가능성 — 느리다면 테스트 축소 고려
낮은 커버리지, 높은 위험도 → 🔴 즉시 테스트 작성 — 버그가 숨어있을 확률 높음
낮은 커버리지, 낮은 위험도 → 🟡 추적은 하되 당장 조급해할 필요 없음
2단계: 라인이 아닌 브랜치 커버리지에 집중하기
#![allow(unused)] fn main() { // 라인 커버리지 100%, 브랜치 커버리지 50% — 여전히 위험합니다! pub fn classify_temperature(temp_c: i32) -> ThermalState { if temp_c > 105 { // ← temp=110으로 테스트됨 → Critical ThermalState::Critical } else if temp_c > 85 { // ← temp=90으로 테스트됨 → Warning ThermalState::Warning } else if temp_c < -10 { // ← 테스트된 적 없음 → 센서 오류 케이스 누락 ThermalState::SensorError } else { ThermalState::Normal // ← temp=25로 테스트됨 → Normal } } }
3단계: 노이즈 제거
# 테스트 코드를 커버리지에서 제외 (테스트 코드는 항상 "커버"되므로)
cargo llvm-cov --workspace --ignore-filename-regex 'tests?\.rs$|benches/'
# 생성된 코드 제외
cargo llvm-cov --workspace --ignore-filename-regex 'target/'
코드 내에서 테스트 불가능한 섹션 표시하기:
#![allow(unused)] fn main() { // 커버리지 도구가 이 패턴을 인식합니다. #[cfg(not(tarpaulin_include))] // tarpaulin용 fn unreachable_hardware_path() { // 이 경로는 실제 GPU 하드웨어가 있어야만 실행 가능합니다. } // llvm-cov의 경우 더 정교한 접근 방식을 사용하세요: // 일부 경로는 유닛 테스트가 아닌 통합/하드웨어 테스트가 필요함을 인정하고, // 커버리지 예외 목록으로 관리하십시오. }
상호 보완적인 테스트 도구
proptest — 속성 기반 테스트 (Property-Based Testing) 는 수동으로 작성한 테스트가 놓치기 쉬운 에지 케이스를 찾아줍니다.
[dev-dependencies]
proptest = "1"
#![allow(unused)] fn main() { use proptest::prelude::*; proptest! { #[test] fn parse_never_panics(input in "\\PC*") { // proptest가 수천 개의 무작위 문자열을 생성합니다. // 어떤 입력에 대해서라도 parse_gpu_csv가 패닉을 일으키면 테스트는 실패하며, // proptest는 실패 원인이 된 최소한의 입력을 찾아줍니다. let _ = parse_gpu_csv(&input); } #[test] fn temperature_roundtrip(raw in 0u16..4096) { let temp = Temperature::from_raw(raw); let md = temp.millidegrees_c(); // 속성: 밀리섭씨 온도는 항상 raw 값으로부터 유도 가능해야 함 assert_eq!(md, (raw as i32) * 625 / 10); } } }
insta — 스냅샷 테스트 (Snapshot Testing) 는 대규모 구조화된 출력(JSON, 텍스트 보고서 등)을 테스트할 때 유용합니다.
[dev-dependencies]
insta = { version = "1", features = ["json"] }
#![allow(unused)] fn main() { #[test] fn test_der_report_format() { let report = generate_der_report(&test_results); // 첫 실행 시 스냅샷 파일을 생성하고, 이후 실행 시 결과와 비교합니다. // 변경 사항은 `cargo insta review`를 통해 대화형으로 승인할 수 있습니다. insta::assert_json_snapshot!(report); } }
proptest/insta 도입 시기: 유닛 테스트가 모두 "정상적인 상황"만 다루고 있다면 proptest가 놓친 에지 케이스를 찾아줄 것입니다. JSON 보고서와 같이 큰 출력 형식을 테스트한다면, 직접 단언문(assertion)을 작성하는 것보다 insta 스냅샷이 작성과 유지보수가 훨씬 빠릅니다.
적용 사례: 1,000개 이상의 테스트 커버리지 맵
이 프로젝트에는 1,000개 이상의 테스트가 있지만 커버리지 추적은 이루어지지 않고 있습니다. 커버리지를 추가하면 테스트 투자가 어디에 집중되어 있는지 알 수 있습니다. 커버되지 않은 경로는 Miri 및 새니타이저 검증의 주요 대상이 됩니다.
권장 커버리지 설정:
# 워크스페이스 빠른 커버리지 확인 (제안된 CI 명령어)
cargo llvm-cov --workspace \
--ignore-filename-regex 'tests?\.rs$' \
--fail-under-lines 75 \
--html
# 특정 크레이트의 집중 개선을 위한 커버리지 확인
for crate in accel_diag event_log topology_lib network_diag compute_diag fan_diag; do
echo "=== $crate ==="
cargo llvm-cov --package "$crate" --json 2>/dev/null | \
jq -r '.data[0].totals | "라인: \(.lines.percent | round)% 브랜치: \(.branches.percent | round)%"'
done
커버리지가 높을 것으로 예상되는 크레이트 (테스트 밀도 기반):
topology_lib— 922라인의 골든 파일(golden-file) 테스트 스위트 보유event_log—create_test_record()헬퍼를 활용한 레지스트리 테스트cable_diag—make_test_event()/make_test_context()패턴 활용
커버리지 사각지대로 예상되는 부분 (코드 검토 기반):
- IPMI 통신 경로의 오류 처리 분기
- 특정 GPU 하드웨어 전용 브랜치 (실제 GPU 필요)
dmesg파싱의 에지 케이스 (플랫폼별 출력 차이)
커버리지의 80/20 법칙: 0%에서 80%까지 커버리지를 올리는 것은 직관적입니다. 하지만 80%에서 95%로 올리려면 점점 더 작위적인 테스트 시나리오가 필요해집니다. 95%에서 100%를 달성하는 것은
#[cfg(not(...))]제외 처리가 필요하며, 그 노력만큼의 가치를 얻기 힘듭니다. 실무적으로는 **라인 커버리지 80%, 브랜치 커버리지 70%**를 최소 기준으로 삼는 것이 적절합니다.
커버리지 문제 해결 (Troubleshooting)
| 증상 | 원인 | 해결책 |
|---|---|---|
llvm-cov 결과가 모든 파일에서 0%로 나옴 | 계측이 적용되지 않음 | cargo test 후에 llvm-cov를 따로 실행하지 말고 cargo llvm-cov 명령어를 사용하세요. |
unreachable!()이 커버되지 않은 것으로 나옴 | 컴파일된 코드에는 해당 분기가 존재함 | #[cfg(not(tarpaulin_include))]를 사용하거나 제외 정규표현식에 추가하세요. |
| 커버리지 측정 중 테스트 바이너리가 충돌함 | 계측과 새니타이저의 충돌 | cargo llvm-cov와 -Zsanitizer=address를 동시에 실행하지 말고 따로 실행하세요. |
llvm-cov와 tarpaulin의 결과가 다름 | 계측 기술의 차이 | 컴파일러 네이티브 방식인 llvm-cov를 기준으로 삼으세요. 차이가 크다면 이슈를 보고하세요. |
error: profraw file is malformed 오류 | 테스트 바이너리가 실행 도중 예기치 않게 종료됨 | 먼저 테스트 실패를 해결하세요. 프로세스가 비정상 종료되면 프로파일링 데이터가 손상됩니다. |
| 브랜치 커버리지가 비정상적으로 낮게 나옴 | 최적화 과정에서 매치 팔, unwrap 등에 브랜치가 생성됨 | 실무적인 지표로는 라인 커버리지에 집중하세요. 브랜치 커버리지는 구조상 낮게 측정되는 경향이 있습니다. |
직접 해보기
-
내 프로젝트 커버리지 측정:
cargo llvm-cov --workspace --html을 실행하고 보고서를 열어보세요. 커버리지가 가장 낮은 파일 3개를 찾아보세요. 단순히 테스트가 없는 것인가요, 아니면 하드웨어 의존성 때문에 테스트하기 어려운 코드인가요? -
커버리지 게이트 설정: CI에
cargo llvm-cov --workspace --fail-under-lines 60을 추가해 보세요. 의도적으로 테스트 하나를 주석 처리하고 CI가 실패하는지 확인해 보세요. 그 후 임계값을 실제 커버리지보다 2% 낮은 수준으로 높여보세요. -
브랜치 vs 라인 커버리지: 3개의 분기가 있는
match함수를 작성하고 2개 분기만 테스트해 보세요. 라인 커버리지(약 66% 예상)와 브랜치 커버리지(약 50% 예상)를 비교해 보세요. 프로젝트에 어떤 지표가 더 유용한가요?
커버리지 도구 선택 가이드
flowchart TD
START["코드 커버리지가 필요한가?"] --> ACCURACY{"우선순위는?"}
ACCURACY -->|"정확도 최우선"| LLVM["cargo-llvm-cov\n소스 기반, 컴파일러 네이티브"]
ACCURACY -->|"빠른 확인"| TARP["cargo-tarpaulin\nLinux 전용, 간편함"]
ACCURACY -->|"여러 실행 결과 집계"| GRCOV["grcov\nMozilla 개발, 프로필 통합"]
LLVM --> CI_GATE["CI 커버리지 게이트\n--fail-under-lines 80"]
TARP --> CI_GATE
CI_GATE --> UPLOAD{"업로드 대상?"}
UPLOAD -->|"Codecov"| CODECOV["codecov/codecov-action"]
UPLOAD -->|"Coveralls"| COVERALLS["coverallsapp/github-action"]
style LLVM fill:#91e5a3,color:#000
style TARP fill:#e3f2fd,color:#000
style GRCOV fill:#e3f2fd,color:#000
style CI_GATE fill:#ffd43b,color:#000
🏋️ 실습
🟢 실습 1: 첫 번째 커버리지 보고서
cargo-llvm-cov를 설치하고 아무 Rust 프로젝트에서나 실행한 후 HTML 보고서를 열어보세요. 라인 커버리지가 가장 낮은 파일 3개를 찾아보세요.
솔루션
cargo install cargo-llvm-cov
cargo llvm-cov --workspace --html --open
# 보고서는 커버리지 순으로 정렬됩니다 — 낮은 것이 아래쪽에 위치합니다.
# 50% 미만인 파일들을 살펴보세요 — 그곳이 여러분의 사각지대입니다.
🟡 실습 2: CI 커버리지 게이트
라인 커버리지가 60% 미만으로 떨어지면 실패하는 커버리지 게이트를 GitHub Actions 워크플로에 추가해 보세요. 테스트 하나를 주석 처리하여 제대로 작동하는지 확인하세요.
솔루션
# .github/workflows/coverage.yml
name: Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- run: cargo install cargo-llvm-cov
- run: cargo llvm-cov --workspace --fail-under-lines 60
테스트를 주석 처리하고 푸시한 뒤 워크플로가 실패하는지 확인하세요.
핵심 요약
cargo-llvm-cov는 컴파일러 고유의 계측 기능을 사용하는 가장 정확한 Rust 커버리지 도구입니다.- 커버리지가 높다고 정답을 보장하지는 않지만, 커버리지가 0%라는 것은 테스트가 전혀 안 되었다는 증거입니다. 사각지대를 찾는 데 활용하세요.
- CI에 커버리지 게이트(예:
--fail-under-lines 80)를 설정하여 성능 저하를 방지하세요. - 100% 커버리지에 집착하지 마세요. 고위험 코드 경로(오류 처리, unsafe, 파싱)에 집중하세요.
- 커버리지 계측과 새니타이저를 같은 실행 주기에서 혼용하지 마세요.
Miri, Valgrind 및 새니타이저 — Unsafe 코드 검증 🔴
학습 내용:
- MIR 인터프리터로서의 Miri — 감지 가능한 항목(에일리어싱, UB, 누수)과 한계(FFI, 시스템 콜)
- Valgrind memcheck, Helgrind(데이터 레이스), Callgrind(프로파일링), Massif(힙 메모리)
- LLVM 새니타이저: 나이틀리(nightly)
-Zbuild-std를 이용한 ASan, MSan, TSan, LSan- 크래시 발견을 위한
cargo-fuzz와 동시성 모델 체킹을 위한loom- 올바른 검증 도구 선택을 위한 의사결정 트리
참조: 코드 커버리지 — 커버리지는 테스트되지 않은 경로를 찾고, Miri는 테스트된 경로를 검증합니다 ·
no_std및 기능 —no_std코드는 종종 Miri로 검증 가능한unsafe를 필요로 합니다 · CI/CD 파이프라인 — 파이프라인 내의 Miri 작업
안전한 Rust(Safe Rust)는 컴파일 타임에 메모리 안전성과 데이터 레이스(data-race) 부재를 보장합니다. 하지만 FFI, 직접 구현한 데이터 구조 또는 성능 최적화를 위해 unsafe 블록을 작성하는 순간, 이러한 보장은 온전히 개발자의 책임이 됩니다. 이 장에서는 여러분의 unsafe 코드가 주장하는 안전성 계약(safety contracts)을 실제로 준수하는지 검증하는 도구들을 다룹니다.
Miri — Unsafe Rust를 위한 인터프리터
Miri는 Rust의 중간 표현인 MIR(Mid-level Intermediate Representation)을 위한 인터프리터입니다. 프로그램을 기계어로 컴파일하는 대신, Miri는 모든 작업에서 미정의 동작(undefined behavior)을 철저히 검사하며 프로그램을 단계별로 실행합니다.
# Miri 설치 (나이틀리 전용 컴포넌트)
rustup +nightly component add miri
# Miri 환경에서 테스트 스위트 실행
cargo +nightly miri test
# Miri 환경에서 특정 바이너리 실행
cargo +nightly miri run
# 특정 테스트 실행
cargo +nightly miri test -- 테스트_이름
Miri의 작동 원리:
소스 코드 → rustc → MIR → Miri가 MIR을 해석 및 실행
│
├─ 모든 포인터의 프로버넌스(provenance, 출처) 추적
├─ 모든 메모리 접근 유효성 검사
├─ 모든 역참조 시 정렬(alignment) 상태 확인
├─ Use-after-free(해제 후 사용) 감지
├─ Double free(이중 해제) 감지
├─ 데이터 레이스 감지 (스레드 사용 시)
└─ Stacked Borrows / Tree Borrows 규칙 강제
Miri가 감지하는 것 (그리고 감지하지 못하는 것)
Miri가 감지하는 항목:
| 카테고리 | 예시 | 런타임에 크래시가 발생하는가? |
|---|---|---|
| 범위를 벗어난 접근 | 할당 범위를 넘어 ptr.add(100).read() 실행 | 때에 따라 다름 (페이지 레이아웃에 의존) |
| 해제 후 사용 (UAF) | 드롭된 Box를 생포인터(raw pointer)로 읽기 | 때에 따라 다름 (할당기에 의존) |
| 이중 해제 | drop_in_place를 두 번 호출 | 보통 발생함 |
| 정렬되지 않은 접근 | 홀수 주소에서 (ptr as *const u32).read() 실행 | 일부 아키텍처에서 발생함 |
| 잘못된 값 | transmute::<u8, bool>(2) 실행 | 조용히 잘못된 결과 산출 |
| 댕글링 참조 | 해제된 위치를 가리키는 &*ptr 생성 | 아니요 (조용한 데이터 오염 발생) |
| 데이터 레이스 | 동기화 없이 두 스레드가 동일 위치에 쓰기 | 간헐적 발생, 재현하기 어려움 |
| Stacked Borrows 위반 | &mut 참조의 에일리어싱(중복 참조) | 아니요 (조용한 데이터 오염 발생) |
Miri가 감지하지 못하는 한계:
| 한계점 | 이유 |
|---|---|
| 로직 버그 | Miri는 메모리 안전성을 검사하며, 논리적 정확성은 검사하지 않음 |
| 동시성 데드락 | Miri는 데이터 레이스를 검사하며, 라이브락(livelocks)은 검사하지 않음 |
| 성능 문제 | 인터프리터 방식은 네이티브 실행보다 10~100배 느림 |
| OS/하드웨어 상호작용 | Miri는 시스템 콜이나 장치 I/O를 에뮬레이션할 수 없음 |
| 모든 FFI 호출 | C 코드를 해석할 수 없음 (Rust MIR만 가능) |
| 모든 경로 커버 | 테스트 스위트가 도달하는 경로만 테스트함 |
구체적인 예시 — 실무에서는 "작동"하지만 불건전한(unsound) 코드 잡아내기:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { #[test] fn test_miri_catches_ub() { // 이 코드는 릴리스 빌드에서 "작동"할 수 있지만 미정의 동작입니다. let mut v = vec![1, 2, 3]; let ptr = v.as_ptr(); // push 작업은 재할당을 유발하여 ptr을 무효화할 수 있습니다. v.push(4); // ❌ UB: 재할당 후 ptr은 댕글링 포인터가 될 수 있습니다. // 할당기가 운 좋게 버퍼를 이동시키지 않았더라도 Miri는 이를 잡아냅니다. // let _val = unsafe { *ptr }; // 에러: Miri는 다음과 같이 보고합니다: // "pointer to alloc1234 was dereferenced after this // allocation got freed" // ✅ 올바른 방법: 변경 작업 후 새 포인터를 가져옵니다. let ptr = v.as_ptr(); let val = unsafe { *ptr }; assert_eq!(val, 1); } } }
실제 크레이트에서 Miri 실행하기
unsafe가 포함된 크레이트를 위한 실무 Miri 워크플로:
# 1단계: 모든 테스트를 Miri 환경에서 실행
cargo +nightly miri test 2>&1 | tee miri_output.txt
# 2단계: 에러가 발생하면 해당 테스트만 격리해서 실행
cargo +nightly miri test -- 실패한_테스트_이름
# 3단계: 진단을 위해 Miri의 백트레이스 활용
MIRIFLAGS="-Zmiri-backtrace=full" cargo +nightly miri test
# 4단계: 빌림(borrow) 모델 선택
# Stacked Borrows (기본값, 더 엄격함):
cargo +nightly miri test
# Tree Borrows (실험적, 더 관대함):
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test
일반적인 상황을 위한 Miri 플래그:
# 격리 해제 (파일 시스템 접근, 환경 변수 허용)
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test
# Miri에서는 메모리 누수 감지가 기본으로 켜져 있습니다.
# 의도적인 누수 등에 대해 이를 무시하려면:
# MIRIFLAGS="-Zmiri-ignore-leaks" cargo +nightly miri test
# 무작위 테스트의 재현성을 위해 RNG 시드 고정
MIRIFLAGS="-Zmiri-seed=42" cargo +nightly miri test
# 엄격한 프로버넌스(provenance) 검사 활성화
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
# 여러 플래그 동시에 사용
MIRIFLAGS="-Zmiri-disable-isolation -Zmiri-backtrace=full -Zmiri-strict-provenance" \
cargo +nightly miri test
CI 환경의 Miri:
# .github/workflows/miri.yml
name: Miri
on: [push, pull_request]
jobs:
miri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- name: Miri 실행
run: cargo miri test --workspace
env:
MIRIFLAGS: "-Zmiri-backtrace=full"
# 누수 검사는 기본적으로 활성화되어 있습니다.
# Miri가 처리할 수 없는 시스템 콜(파일 I/O, 네트워크 등)을
# 사용하는 테스트는 건너뛰도록 설정하세요.
성능 참고: Miri는 네이티브 실행보다 10~100배 느립니다. 네이티브에서 5초 걸리는 테스트가 Miri에서는 5분 이상 걸릴 수 있습니다. CI에서는
unsafe코드가 포함된 크레이트에 집중해서 실행하세요.
Valgrind와 Rust 통합
Valgrind는 고전적인 C/C++ 메모리 검사 도구입니다. 컴파일된 Rust 바이너리에서도 작동하며 기계어 수준에서 메모리 오류를 검사합니다.
# Valgrind 설치
sudo apt install valgrind # Debian/Ubuntu
sudo dnf install valgrind # Fedora
# 디버그 정보와 함께 빌드 (Valgrind는 심볼이 필요함)
cargo build --tests
# 또는 디버그 정보를 포함한 릴리스 빌드:
# cargo build --release
# [profile.release]
# debug = true
# Valgrind 환경에서 특정 테스트 바이너리 실행
valgrind --tool=memcheck \
--leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
./target/debug/deps/my_crate-abc123 --test-threads=1
# 메인 바이너리 실행
valgrind --tool=memcheck \
--leak-check=full \
--error-exitcode=1 \
./target/debug/diag_tool --run-diagnostics
memcheck 이외의 Valgrind 도구들:
| 도구 | 명령어 | 감지 대상 |
|---|---|---|
| Memcheck | --tool=memcheck | 메모리 누수, 해제 후 사용, 버퍼 오버플로 |
| Helgrind | --tool=helgrind | 데이터 레이스 및 락 순서 위반(데드락 위험) |
| DRD | --tool=drd | 데이터 레이스 (다른 감지 알고리즘 사용) |
| Callgrind | --tool=callgrind | CPU 명령 단위 프로파일링 (경로 수준) |
| Massif | --tool=massif | 시간에 따른 힙 메모리 사용량 프로파일링 |
| Cachegrind | --tool=cachegrind | 캐시 미스 분석 |
명령어 수준 프로파일링을 위한 Callgrind 사용:
# 명령 횟수 기록 (실제 시간보다 결과가 안정적임)
valgrind --tool=callgrind \
--callgrind-out-file=callgrind.out \
./target/release/diag_tool --run-diagnostics
# KCachegrind로 시각화
kcachegrind callgrind.out
# 또는 텍스트 기반 대안:
callgrind_annotate callgrind.out | head -100
Miri vs Valgrind — 어떤 도구를 사용할까?
| 비교 항목 | Miri | Valgrind |
|---|---|---|
| Rust 고유 UB 검사 | ✅ Stacked/Tree Borrows | ❌ Rust 규칙 인지 못 함 |
| C FFI 코드 검사 | ❌ C 코드 해석 불가 | ✅ 모든 기계어 검사 |
| 나이틀리 필요 여부 | ✅ 필요함 | ❌ 필요 없음 |
| 속도 | 10~100배 느림 | 10~50배 느림 |
| 플랫폼 | 모든 플랫폼 (MIR 해석) | Linux, macOS (네이티브 실행) |
| 데이터 레이스 감지 | ✅ 가능 | ✅ 가능 (Helgrind/DRD) |
| 누수 감지 | ✅ 가능 | ✅ 가능 (더 철저함) |
| 가양성(False Positives) | 매우 드묾 | 가끔 발생 (특히 할당기 관련) |
둘 다 활용하세요:
- Miri는 순수 Rust
unsafe코드(Stacked Borrows, 프로버넌스) 검증에 사용합니다. - Valgrind는 FFI 비중이 높은 코드와 전체 프로그램의 누수 분석에 사용합니다.
AddressSanitizer, MemorySanitizer, ThreadSanitizer
LLVM 새니타이저(sanitizers)는 컴파일 타임에 런타임 검사 코드를 삽입하는 방식입니다. Valgrind보다 빠르며(2~5배 오버헤드 vs 10~50배) 서로 다른 클래스의 버그를 잡아냅니다.
# 필수: 새니타이저 계측을 포함해 std를 재빌드하기 위해 Rust 소스 설치
rustup component add rust-src --toolchain nightly
# AddressSanitizer (ASan) — 버퍼 오버플로, 해제 후 사용, 스택 오버플로
RUSTFLAGS="-Zsanitizer=address" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# MemorySanitizer (MSan) — 초기화되지 않은 메모리 읽기
RUSTFLAGS="-Zsanitizer=memory" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# ThreadSanitizer (TSan) — 데이터 레이스
RUSTFLAGS="-Zsanitizer=thread" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# LeakSanitizer (LSan) — 메모리 누수 (ASan에 기본 포함됨)
RUSTFLAGS="-Zsanitizer=leak" \
cargo +nightly test --target x86_64-unknown-linux-gnu
참고: ASan, MSan, TSan은
-Zbuild-std를 통해 표준 라이브러리를 새니타이저 계측과 함께 재빌드해야 합니다. LSan은 필요하지 않습니다.
새니타이저 비교:
| 새니타이저 | 오버헤드 | 감지 대상 | 나이틀리 필요? | -Zbuild-std 필요? |
|---|---|---|---|---|
| ASan | 메모리 2배, CPU 2배 | 버퍼 오버플로, 해제 후 사용, 스택 오버플로 | 예 | 예 |
| MSan | 메모리 3배, CPU 3배 | 초기화되지 않은 읽기 | 예 | 예 |
| TSan | 메모리 5~10배, CPU 5배 | 데이터 레이스 | 예 | 예 |
| LSan | 매우 적음 | 메모리 누수 | 예 | 아니요 |
실전 예제 — TSan으로 데이터 레이스 잡아내기:
#![allow(unused)] fn main() { use std::sync::Arc; use std::thread; fn racy_counter() -> u64 { // ❌ UB: 동기화되지 않은 공유 가변 상태 let data = Arc::new(std::cell::UnsafeCell::new(0u64)); let mut handles = vec![]; for _ in 0..4 { let data = Arc::clone(&data); handles.push(thread::spawn(move || { for _ in 0..1000 { // SAFETY: 불건전함(UNSOUND) — 데이터 레이스 발생! unsafe { *data.get() += 1; } } })); } for h in handles { h.join().unwrap(); } // 값은 4000이어야 하지만 레이스로 인해 어떤 값이라도 나올 수 있음 unsafe { *data.get() } } // Miri와 TSan 모두 이를 잡아냅니다: // Miri: "Data race detected between (1) write and (2) write" // TSan: "WARNING: ThreadSanitizer: data race" // // 해결책: AtomicU64 또는 Mutex<u64> 사용 }
관련 도구: 퍼징(Fuzzing) 및 동시성 검증
cargo-fuzz — 커버리지 기반 퍼징 (파서 및 디코더의 크래시 발견):
# 설치
cargo install cargo-fuzz
# 퍼즈 타겟 초기화
cargo fuzz init
cargo fuzz add parse_gpu_csv
#![allow(unused)] fn main() { // fuzz/fuzz_targets/parse_gpu_csv.rs #![no_main] use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if let Ok(s) = std::str::from_utf8(data) { // 퍼저가 수백만 개의 입력을 생성하며 패닉이나 크래시를 찾습니다. let _ = diag_tool::parse_gpu_csv(s); } }); }
# 퍼저 실행 (중단되거나 크래시를 찾을 때까지 실행)
cargo +nightly fuzz run parse_gpu_csv -- -max_total_time=300 # 5분 동안 실행
# 크래시 유발 사례 최소화(minimize)
cargo +nightly fuzz tmin parse_gpu_csv artifacts/parse_gpu_csv/crash-...
퍼징 도입 시기: 신뢰할 수 없는 입력(센서 출력, 설정 파일, 네트워크 데이터, JSON/CSV 등)을 파싱하는 모든 함수에 도입하세요. 퍼징은 serde, regex, image 등 거의 모든 주요 Rust 파서 크레이트에서 실제 버그를 찾아낸 바 있습니다.
loom — 동시성 모델 체커 (원자적 연산 순서를 철저히 테스트):
[dev-dependencies]
loom = "0.7"
#![allow(unused)] fn main() { #[cfg(loom)] mod tests { use loom::sync::atomic::{AtomicUsize, Ordering}; use loom::thread; #[test] fn test_counter_is_atomic() { loom::model(|| { let counter = loom::sync::Arc::new(AtomicUsize::new(0)); let c1 = counter.clone(); let c2 = counter.clone(); let t1 = thread::spawn(move || { c1.fetch_add(1, Ordering::SeqCst); }); let t2 = thread::spawn(move || { c2.fetch_add(1, Ordering::SeqCst); }); t1.join().unwrap(); t2.join().unwrap(); // loom은 가능한 모든 스레드 인터리빙(interleaving)을 탐색합니다. assert_eq!(counter.load(Ordering::SeqCst), 2); }); } } }
loom 도입 시기: 락 프리(lock-free) 데이터 구조나 커스텀 동기화 프리미티브를 구현할 때 사용하세요. loom은 모든 스레드 실행 경로를 샅샅이 뒤지는 모델 체커이지, 단순한 스트레스 테스트 도구가 아닙니다.
Mutex나RwLock위주의 코드에는 필요하지 않습니다.
상황별 도구 선택 가이드
Unsafe 검증을 위한 의사결정 트리:
FFI 호출이 없는 순수 Rust 코드인가?
├─ 예 → Miri 사용 (Rust 고유 UB, Stacked Borrows 감지)
│ 심층 방어를 위해 CI에서 ASan도 함께 실행
└─ 아니오 (FFI를 통해 C/C++ 코드 호출)
├─ 메모리 안전성이 걱정되는가?
│ └─ 예 → Valgrind memcheck 및 ASan 병행 사용
├─ 동시성 문제가 걱정되는가?
│ └─ 예 → TSan(빠름) 또는 Helgrind(더 철저함) 사용
└─ 메모리 누수가 걱정되는가?
└─ 예 → Valgrind --leak-check=full 사용
권장 CI 매트릭스:
# 빠른 피드백을 위해 모든 도구를 병렬로 실행
jobs:
miri:
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@nightly
with: { components: miri }
- run: cargo miri test --workspace
asan:
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@nightly
- run: |
RUSTFLAGS="-Zsanitizer=address" \
cargo test -Zbuild-std --target x86_64-unknown-linux-gnu
valgrind:
runs-on: ubuntu-latest
steps:
- run: sudo apt-get install -y valgrind
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --tests
- run: |
for test_bin in $(find target/debug/deps -maxdepth 1 -executable -type f ! -name '*.d'); do
valgrind --error-exitcode=1 --leak-check=full "$test_bin" --test-threads=1
done
적용 사례: Unsafe 제로와 도입 시점
이 프로젝트는 9만 라인이 넘는 Rust 코드 전반에 걸쳐 unsafe 블록이 단 하나도 없습니다. 이는 시스템 수준의 진단 도구로서 놀라운 성과이며, 다음과 같은 작업에 Safe Rust만으로 충분함을 보여줍니다:
- IPMI 통신 (
ipmitool서브프로세스를 통한std::process::Command사용) - GPU 쿼리 (
accel-query서브프로세스 사용) - PCIe 토폴로지 파싱 (순수 JSON/텍스트 파싱)
- SEL 레코드 관리 (순수 데이터 구조)
- DER 보고서 생성 (JSON 직렬화)
그렇다면 언제 unsafe가 필요할까요?
도입이 예상되는 시나리오는 다음과 같습니다:
| 시나리오 | unsafe 도입 이유 | 권장 검증 도구 |
|---|---|---|
| ioctl 기반 직접 IPMI | ipmitool 우회, libc::ioctl() 직접 호출 | Miri + Valgrind |
| 직접적인 GPU 드라이버 쿼리 | 서브프로세스 대신 accel-mgmt FFI 사용 | Valgrind (C 라이브러리 검사) |
| 메모리 맵 기반 PCIe 설정 | 직접적인 설정 공간 읽기를 위한 mmap | ASan + Valgrind |
| 락 프리 SEL 버퍼 | 동시 이벤트 수집을 위한 AtomicPtr 활용 | Miri + TSan |
| 임베디드/no_std 변종 | 베어 메탈을 위한 생포인터 조작 | Miri |
준비 사항: unsafe를 도입하기 전에 CI에 검증 도구를 추가하세요.
# Cargo.toml — unsafe 최적화를 위한 기능 플래그 추가
[features]
default = []
direct-ipmi = [] # ipmitool 대신 ioctl 기반 직접 IPMI 활성화
direct-accel-api = [] # 서브프로세스 대신 accel-mgmt FFI 활성화
#![allow(unused)] fn main() { // src/ipmi.rs — 기능 플래그로 보호 #[cfg(feature = "direct-ipmi")] mod direct { //! /dev/ipmi0 ioctl을 이용한 직접 IPMI 장치 접근. //! //! # 안전성 (Safety) //! 이 모듈은 ioctl 시스템 콜을 위해 `unsafe`를 사용합니다. //! 검증 도구: Miri(가능한 경우), Valgrind memcheck, ASan. use std::os::unix::io::RawFd; // ... unsafe ioctl 구현 ... } #[cfg(not(feature = "direct-ipmi"))] mod subprocess { //! ipmitool 서브프로세스를 통한 IPMI (기본값, 완전 안전). // ... 현재 구현 내용 ... } }
핵심 통찰:
unsafe코드는 기능 플래그 뒤에 숨겨서 독립적으로 검증할 수 있게 하세요. CI에서cargo +nightly miri test --features direct-ipmi를 실행하여 안전한 기본 빌드에 영향을 주지 않고 지속적으로 unsafe 경로를 검증하십시오.
cargo-careful — 안정 버전에서의 추가 UB 검사
cargo-careful은 표준 라이브러리의 추가 검사 기능을 활성화하여 코드를 실행합니다. 나이틀리나 Miri의 극심한 속도 저하 없이 일반 빌드에서 놓치는 일부 미정의 동작을 잡아낼 수 있습니다.
# 설치 (나이틀리가 필요하지만 실행 속도는 네이티브에 가까움)
cargo install cargo-careful
# 추가 UB 검사와 함께 테스트 실행 (초기화되지 않은 메모리, 잘못된 값 감지)
cargo +nightly careful test
# 추가 검사와 함께 바이너리 실행
cargo +nightly careful run -- --run-diagnostics
cargo-careful이 잡아내는 항목:
MaybeUninit및zeroed()에서의 초기화되지 않은 메모리 읽기- transmute를 통한 잘못된
bool,char, 열거형 값 생성 - 정렬되지 않은 포인터 읽기/쓰기
- 겹치는 범위에서의
copy_nonoverlapping실행
검증 단계에서의 위치:
낮은 오버헤드 철저한 검증
├─ cargo test ──► cargo careful test ──► Miri ──► ASan ──► Valgrind ─┤
│ (오버헤드 0) (~1.5배 오버헤드) (10~100배) (2배) (10~50배) │
│ Safe Rust만 일부 UB 감지 순수 Rust FFI+Rust FFI+Rust │
권장 사항: CI에
cargo +nightly careful test를 빠른 안전 검사 단계로 추가하세요. Miri와 달리 네이티브에 가까운 속도로 실행되면서 Safe Rust 추상화가 가리고 있는 실제 버그들을 찾아낼 수 있습니다.
Miri 및 새니타이저 문제 해결 (Troubleshooting)
| 증상 | 원인 | 해결책 |
|---|---|---|
Miri does not support FFI | Miri는 Rust 인터프리터이며 C 코드를 실행할 수 없음 | FFI 코드에는 대신 Valgrind나 ASan을 사용하세요. |
error: unsupported operation: can't call foreign function | Miri가 extern "C" 호출을 만남 | FFI 경계를 모킹(mock)하거나 #[cfg(miri)]로 보호하세요. |
Stacked Borrows violation | 에일리어싱 규칙 위반 — 코드가 "작동"하더라도 발생함 | Miri가 옳습니다. &mut와 &가 겹치지 않게 리팩토링하세요. |
새니타이저가 DEADLYSIGNAL 보고 | ASan이 버퍼 오버플로 감지 | 배열 인덱싱, 슬라이스 작업, 포인터 연산을 점검하세요. |
LeakSanitizer: detected memory leaks | Box::leak(), forget() 또는 drop() 누락 | 의도적이라면 __lsan_disable()로 억제하고, 아니라면 누수를 수정하세요. |
| Miri가 너무 느림 | 컴파일이 아닌 해석 방식의 한계 (10~100배 느림) | --lib 테스트만 실행하거나, 무거운 테스트에 #[cfg_attr(miri, ignore)]를 사용하세요. |
| 원자적 연산에서 TSan 가양성 발생 | TSan이 Rust의 원자적 순서 모델을 완벽히 이해하지 못함 | 특정 억제 규칙을 담은 tsan.supp 파일을 만들어 TSAN_OPTIONS로 지정하세요. |
직접 해보기
-
Miri UB 감지 유도: 동일한
i32값에 대해 두 개의&mut참조를 만드는unsafe함수를 작성해 보세요(에일리어싱 위반).cargo +nightly miri test를 실행하고 "Stacked Borrows" 에러를 관찰해 보세요. 이를UnsafeCell을 사용하거나 할당을 분리하여 수정해 보세요. -
ASan으로 버그 확인: 배열 범위를 벗어난
unsafe접근을 시도하는 테스트를 만드세요.RUSTFLAGS="-Zsanitizer=address"로 빌드하고 ASan의 보고서를 확인해 보세요. 정확히 어떤 라인을 지목하는지 확인하세요. -
Miri 오버헤드 측정: 동일한 테스트 스위트에 대해
cargo test --lib와cargo +nightly miri test --lib의 실행 시간을 비교해 보세요. 감속 비율을 계산하고, 이를 바탕으로 CI에서 어떤 테스트를 Miri로 돌리고 어떤 테스트를#[cfg_attr(miri, ignore)]로 건너뛸지 결정해 보세요.
안전성 검증 의사결정 트리
flowchart TD
START["Unsafe 코드가 있는가?"] -->|아니요| SAFE["Safe Rust — 별도\n검증 도구 불필요"]
START -->|예| KIND{"어떤 종류인가?"}
KIND -->|"순수 Rust unsafe"| MIRI["Miri\nMIR 인터프리터\n에일리어싱, UB, 누수 감지"]
KIND -->|"FFI / C 연동"| VALGRIND["Valgrind memcheck\n또는 ASan"]
KIND -->|"동시성 unsafe"| CONC{"락 프리인가?"}
CONC -->|"원자적/락 프리"| LOOM["loom\n원자적 연산 모델 체커"]
CONC -->|"Mutex/공유 상태"| TSAN["TSan 또는\nMiri -Zmiri-check-number-validity"]
MIRI --> CI_MIRI["CI: cargo +nightly miri test"]
VALGRIND --> CI_VALGRIND["CI: valgrind --leak-check=full"]
style SAFE fill:#91e5a3,color:#000
style MIRI fill:#e3f2fd,color:#000
style VALGRIND fill:#ffd43b,color:#000
style LOOM fill:#ff6b6b,color:#000
style TSAN fill:#ffd43b,color:#000
🏋️ 실습
🟡 실습 1: Miri UB 감지 유도
동일한 i32 값에 대해 두 개의 &mut 참조를 만드는 unsafe 함수를 작성하세요(에일리어싱 위반). cargo +nightly miri test를 실행하고 Stacked Borrows 에러를 확인한 후 수정하세요.
솔루션
#![allow(unused)] fn main() { #[cfg(test)] mod tests { #[test] fn aliasing_ub() { let mut x: i32 = 42; let ptr = &mut x as *mut i32; unsafe { // 버그: 동일한 위치에 두 개의 &mut 참조 생성 let _a = &mut *ptr; let _b = &mut *ptr; // Miri: Stacked Borrows 위반 감지! } } } }
수정 방법: 별도의 할당을 사용하거나 UnsafeCell을 활용합니다.
#![allow(unused)] fn main() { use std::cell::UnsafeCell; #[test] fn no_aliasing_ub() { let x = UnsafeCell::new(42); unsafe { let a = &mut *x.get(); *a = 100; } } }
🔴 실습 2: ASan 범위 초과 감지
unsafe를 사용하여 배열 범위를 벗어나는 접근을 시도하는 테스트를 작성하세요. 나이틀리에서 RUSTFLAGS="-Zsanitizer=address"로 빌드하고 ASan의 보고서를 관찰하세요.
솔루션
#![allow(unused)] fn main() { #[test] fn oob_access() { let arr = [1u8, 2, 3, 4, 5]; let ptr = arr.as_ptr(); unsafe { let _val = *ptr.add(10); // 범위를 벗어남! } } }
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std \
--target x86_64-unknown-linux-gnu -- oob_access
# ASan 보고서: <정확한 주소>에서 stack-buffer-overflow 발생 지적
핵심 요약
- Miri는 순수 Rust
unsafe를 위한 필수 도구입니다. 에일리어싱 위반, 해제 후 사용, 그리고 테스트를 통과하더라도 숨어있는 누수를 잡아냅니다. - Valgrind는 FFI/C 연동을 위한 도구입니다. 재컴파일 없이 최종 바이너리에서 작동합니다.
- 새니타이저(ASan, TSan, MSan)는 나이틀리가 필요하지만 네이티브에 가까운 속도로 실행되므로 대규모 테스트 스위트에 적합합니다.
- **
loom**은 락 프리 동시성 데이터 구조의 원자적 연산 순서를 검증하기 위해 특화된 도구입니다. - CI에서 매 푸시마다 Miri를 실행하고, 메인 파이프라인의 속도 저하를 막기 위해 새니타이저는 야간 빌드(nightly schedule) 등에 배치하세요.
의존성 관리 및 공급망 보안 🟢
학습 내용:
cargo-audit을 이용한 알려진 취약점 스캔cargo-deny를 이용한 라이선스, 어드바이저리, 소스 정책 강제- Mozilla의
cargo-vet을 이용한 공급망 신뢰 검증- 오래된 의존성 추적 및 API의 파괴적 변경(breaking changes) 감지
- 의존성 그래프 시각화 및 중복 버전 제거
참조: 릴리스 프로필 — 여기서 찾은 미사용 의존성을
cargo-udeps로 제거합니다 · CI/CD 파이프라인 — 파이프라인 내의 audit 및 deny 작업 · 빌드 스크립트 —build-dependencies또한 공급망의 일부입니다.
Rust 바이너리는 여러분이 작성한 코드만 포함하는 것이 아니라, Cargo.lock에 명시된 모든 전이 의존성(transitive dependency)을 포함합니다. 이 의존성 트리 어디에라도 취약점, 라이선스 위반 또는 악의적인 크레이트가 있다면 그것은 곧 여러분의 문제가 됩니다. 이 장에서는 의존성 관리를 자동화하고 감사 가능하게 만드는 도구와 기술을 다룹니다.
cargo-audit — 알려진 취약점 스캔
cargo-audit은 공개된 크레이트의 취약점을 추적하는 RustSec Advisory Database를 바탕으로 여러분의 Cargo.lock을 검사합니다.
# 설치
cargo install cargo-audit
# 알려진 취약점 스캔
cargo audit
# 출력 예시:
# Crate: chrono
# Version: 0.4.19
# Title: Potential segfault in localtime_r invocations
# Date: 2020-11-10
# ID: RUSTSEC-2020-0159
# URL: https://rustsec.org/advisories/RUSTSEC-2020-0159
# Solution: Upgrade to >= 0.4.20
# 취약점이 발견되면 CI를 실패하게 설정
cargo audit --deny warnings
# 자동화 처리를 위해 JSON 형식으로 출력
cargo audit --json
# Cargo.lock 업데이트를 통해 취약점 수정
cargo audit fix
CI 통합:
# .github/workflows/audit.yml
name: Security Audit
on:
schedule:
- cron: '0 0 * * *' # 매일 확인 — 새로운 취약점 정보는 수시로 업데이트됨
push:
paths: ['Cargo.lock']
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
cargo-deny — 종합 정책 강제
cargo-deny는 단순한 취약점 스캔을 넘어 다음 네 가지 차원에서 정책을 강제합니다:
- Advisories (어드바이저리) — 알려진 취약점 (cargo-audit과 유사)
- Licenses (라이선스) — 허용되거나 금지된 라이선스 목록 관리
- Bans (금지) — 특정 크레이트 사용 금지 또는 중복 버전 방지
- Sources (소스) — 허용된 레지스트리 및 git 소스 관리
# 설치
cargo install cargo-deny
# 설정 초기화
cargo deny init
# 기본값이 설명된 deny.toml 파일이 생성됨
# 모든 검사 실행
cargo deny check
# 특정 항목만 검사
cargo deny check advisories
cargo deny check licenses
cargo deny check bans
cargo deny check sources
deny.toml 설정 예시:
# deny.toml
[advisories]
vulnerability = "deny" # 알려진 취약점 발견 시 실패 처리
unmaintained = "warn" # 유지보수되지 않는 크레이트 경고
yanked = "deny" # 배포 취소(yanked)된 크레이트 실패 처리
notice = "warn" # 정보성 어드바이저리 경고
[licenses]
unlicensed = "deny" # 라이선스가 없는 크레이트 불허
allow = [
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
]
copyleft = "deny" # 이 프로젝트에서는 GPL/LGPL/AGPL 불허
default = "deny" # 명시적으로 허용되지 않은 모든 라이선스 불허
[bans]
multiple-versions = "warn" # 동일 크레이트가 두 버전 이상 존재할 경우 경고
wildcards = "deny" # 의존성에 path = "*" 사용 불허
highlight = "all" # 첫 번째가 아닌 모든 중복 항목 표시
# 특정 문제 크레이트 금지
deny = [
# openssl-sys는 C 기반 OpenSSL을 끌어옴 — rustls 권장
{ name = "openssl-sys", wrappers = ["native-tls"] },
]
# 피할 수 없는 특정 중복 버전 허용
[[bans.skip]]
name = "syn"
version = "1.0" # syn 1.x와 2.x는 흔히 공존함
[sources]
unknown-registry = "deny" # crates.io만 허용
unknown-git = "deny" # 임의의 git 의존성 불허
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
라이선스 강제 기능은 상업용 프로젝트에서 특히 가치가 높습니다:
# 의존성 트리에 포함된 라이선스 목록 확인
cargo deny list
# 출력 결과 예시:
# MIT — 127 crates
# Apache-2.0 — 89 crates
# BSD-3-Clause — 12 crates
# MPL-2.0 — 3 crates ← 법무 검토가 필요할 수 있음
# Unicode-DFS — 1 crate
cargo-vet — 공급망 신뢰 검증
Mozilla에서 개발한 cargo-vet은 "이 크레이트에 알려진 버그가 있는가?"가 아니라 **"신뢰할 수 있는 사람이 이 코드를 실제로 검토했는가?"**라는 다른 관점의 질문을 다룹니다.
# 설치
cargo install cargo-vet
# 초기화 (supply-chain/ 디렉토리 생성)
cargo vet init
# 검토가 필요한 크레이트 확인
cargo vet
# 크레이트 검토 후 인증(certify) 처리:
cargo vet certify serde 1.0.203
# serde 1.0.203 버전을 우리 팀의 기준에 따라 감사했음을 기록함
# 신뢰할 수 있는 기관의 감사 결과 가져오기
cargo vet import mozilla
cargo vet import google
cargo vet import bytecode-alliance
작동 구조:
supply-chain/
├── audits.toml ← 우리 팀의 감사 인증 기록
├── config.toml ← 신뢰 설정 및 기준
└── imports.lock ← 타 기관에서 가져온 인증 데이터
cargo-vet은 정부, 금융, 인프라 등 공급망 요구사항이 매우 엄격한 조직에 가장 유용합니다. 대부분의 팀에게는 cargo-deny만으로도 충분한 보호가 가능합니다.
cargo-outdated 및 cargo-semver-checks
cargo-outdated — 더 최신 버전이 있는 의존성 찾기:
cargo install cargo-outdated
cargo outdated --workspace
# 출력 결과:
# Name Project Compat Latest Kind
# serde 1.0.193 1.0.203 1.0.203 Normal
# regex 1.9.6 1.10.4 1.10.4 Normal
# thiserror 1.0.50 1.0.61 2.0.3 Normal ← 메이저 버전 업데이트 가능
cargo-semver-checks — 배포 전 API의 파괴적 변경 감지. 라이브러리 크레이트 제작 시 필수 도구입니다:
cargo install cargo-semver-checks
# 변경 사항이 유의적 버전(semver)을 준수하는지 확인
cargo semver-checks
# 출력 결과 예시:
# ✗ Function `parse_gpu_csv` is now private (was public)
# → 이것은 파괴적 변경(BREAKING change)입니다. MAJOR 버전을 올리세요.
#
# ✗ Struct `GpuInfo` has a new required field `power_limit_w`
# → 이것은 파괴적 변경(BREAKING change)입니다. MAJOR 버전을 올리세요.
#
# ✓ Function `parse_gpu_csv_v2` was added (non-breaking)
cargo-tree — 의존성 시각화 및 중복 제거
cargo tree는 Cargo에 내장된 도구(별도 설치 불필요)로, 의존성 그래프를 파악하는 데 매우 유용합니다.
# 전체 의존성 트리 표시
cargo tree
# 특정 크레이트가 포함된 이유 찾기
cargo tree --invert --package openssl-sys
# 우리 크레이트에서 openssl-sys에 이르는 모든 경로 표시
# 중복 버전 찾기
cargo tree --duplicates
# 출력 예시:
# syn v1.0.109
# └── serde_derive v1.0.193
#
# syn v2.0.48
# ├── thiserror-impl v1.0.56
# └── tokio-macros v2.2.0
# 직접적인 의존성만 표시
cargo tree --depth 1
# 의존성 기능(features) 표시
cargo tree --format "{p} {f}"
# 전체 의존성 개수 확인
cargo tree | wc -l
중복 제거 전략: cargo tree --duplicates가 동일한 크레이트의 두 가지 메이저 버전을 보여준다면, 의존성 체인을 업데이트하여 하나로 합칠 수 있는지 확인하세요. 모든 중복 항목은 컴파일 시간과 바이너리 크기를 증가시킵니다.
적용 사례: 멀티 크레이트 의존성 관리
이 워크스페이스는 버전 관리를 중앙에서 수행하기 위해 [workspace.dependencies]를 사용하고 있으며, 이는 매우 훌륭한 관행입니다. 크기 분석을 위한 cargo tree --duplicates와 결합하면 버전 파편화를 방지하고 바이너리 비대화를 줄일 수 있습니다.
# 루트 Cargo.toml — 모든 버전을 한 곳에서 고정 관리
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
regex = "1.10"
thiserror = "1.0"
anyhow = "1.0"
rayon = "1.8"
프로젝트 권장 추가 사항:
# CI 파이프라인에 추가:
cargo deny init # 최초 1회 설정
cargo deny check # 모든 PR 시 실행 — 라이선스, 어드바이저리, 금지 항목 검사
cargo audit --deny warnings # 모든 푸시 시 실행 — 취약점 스캔
cargo outdated --workspace # 매주 실행 — 업데이트 가능한 항목 추적
프로젝트 권장 deny.toml 설정:
[advisories]
vulnerability = "deny"
yanked = "deny"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-DFS-2016"]
copyleft = "deny" # 하드웨어 진단 도구 — copyleft 라이선스 불허
[bans]
multiple-versions = "warn" # 중복 추적은 하되, 아직 차단은 하지 않음
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
공급망 감사 파이프라인
flowchart LR
PR["Pull Request"] --> AUDIT["cargo audit\n알려진 CVE 스캔"]
AUDIT --> DENY["cargo deny check\n라이선스 + 금지 + 소스 검사"]
DENY --> OUTDATED["cargo outdated\n주간 스케줄 실행"]
OUTDATED --> SEMVER["cargo semver-checks\n라이브러리 크레이트 전용"]
AUDIT -->|"실패 시"| BLOCK["❌ 머지 차단"]
DENY -->|"실패 시"| BLOCK
SEMVER -->|"파괴적 변경"| BUMP["메이저 버전 상향"]
style BLOCK fill:#ff6b6b,color:#000
style BUMP fill:#ffd43b,color:#000
style PR fill:#e3f2fd,color:#000
🏋️ 실습
🟢 실습 1: 의존성 감사해보기
아무 Rust 프로젝트에서 cargo audit과 cargo deny init && cargo deny check를 실행해 보세요. 몇 개의 어드바이저리가 발견되나요? 의존성 트리에 몇 개의 라이선스 카테고리가 있나요?
솔루션
cargo audit
# 어드바이저리 확인 — 주로 chrono, time 또는 오래된 크레이트에서 발견됨
cargo deny init
cargo deny list
# 라이선스 분포 확인: MIT (N개), Apache-2.0 (N개) 등
cargo deny check
# 네 가지 모든 차원에 대한 전체 감사 결과 확인
🟡 실습 2: 중복 의존성 찾아 제거하기
워크스페이스에서 cargo tree --duplicates를 실행하세요. 두 가지 버전으로 존재하는 크레이트를 찾아보세요. Cargo.toml을 업데이트하여 버전을 하나로 합칠 수 있나요? 컴파일 시간과 바이너리 크기에 어떤 변화가 있는지 측정해 보세요.
솔루션
cargo tree --duplicates
# 흔한 예: syn 1.x와 syn 2.x
# 이전 버전을 사용하는 크레이트 찾기:
cargo tree --invert --package syn@1.0.109
# 출력 결과: serde_derive 1.0.xxx -> syn 1.0.109
# 최신 버전의 serde_derive가 syn 2.x를 사용하는지 확인:
cargo update -p serde_derive
cargo tree --duplicates
# syn 1.x가 사라졌다면 중복 버전 제거에 성공한 것입니다.
# 영향도 측정:
time cargo build --release # 전후 비교
cargo bloat --release --crates | head -20
핵심 요약
cargo audit은 알려진 CVE를 잡아냅니다. 모든 푸시 시점과 매일 정기적으로 실행하세요.cargo deny는 어드바이저리, 라이선스, 금지 항목, 소스의 네 가지 정책을 강제합니다.- 멀티 크레이트 워크스페이스에서는
[workspace.dependencies]를 사용하여 버전을 중앙 집중식으로 관리하세요. cargo tree --duplicates는 불필요하게 늘어난 항목을 보여줍니다. 모든 중복은 컴파일 시간과 바이너리 크기를 늘립니다.cargo-vet은 고도의 보안이 필요한 환경을 위한 것이며, 일반적인 팀에게는cargo-deny로도 충분합니다.
릴리스 프로필 및 바이너리 크기 🟡
학습 내용:
- 릴리스 프로필 분석: LTO, codegen-units, panic 전략, strip, opt-level
- Thin vs Fat vs 교차 언어 LTO 트레이드오프
cargo-bloat을 이용한 바이너리 크기 분석cargo-udeps,cargo-machete,cargo-shear를 이용한 의존성 정리교차 참조: 컴파일 타임 도구 — 최적화의 나머지 절반 · 벤치마킹 — 최적화 전 실행 시간 측정 · 의존성 — 의존성 정리는 크기와 컴파일 시간을 모두 줄임
기본적인 cargo build --release 설정도 이미 훌륭합니다. 하지만 운영 환경 배포 — 특히 수천 대의 서버에 배포되는 단일 바이너리 도구의 경우 — "좋음"과 "최적화됨" 사이에는 상당한 차이가 있습니다. 이 장에서는 프로필의 설정값들과 바이너리 크기를 측정하는 도구들을 다룹니다.
릴리스 프로필 분석
Cargo 프로필은 rustc가 코드를 컴파일하는 방식을 제어합니다. 기본값은 보수적으로 설정되어 있어, 최대 성능보다는 광범위한 호환성을 위해 설계되었습니다.
# Cargo.toml — Cargo의 내장 기본값 (아무것도 지정하지 않았을 때의 상태)
[profile.release]
opt-level = 3 # 최적화 수준 (0=없음, 1=기본, 2=좋음, 3=공격적)
lto = false # 링크 타임 최적화(LTO) 꺼짐
codegen-units = 16 # 병렬 컴파일 유닛 (컴파일은 빠르지만 최적화 기회는 적음)
panic = "unwind" # 패닉 시 스택 되감기 (바이너리는 커지지만 catch_unwind 작동 가능)
strip = "none" # 모든 심볼 및 디버그 정보 유지
overflow-checks = false # 릴리스 빌드에서 정수 오버플로 검사 안 함
debug = false # 릴리스 빌드에서 디버그 정보 제외
운영 환경에 최적화된 프로필 (이 프로젝트에서 이미 사용 중인 설정):
[profile.release]
lto = true # 전체 크레이트 간 최적화 활성화
codegen-units = 1 # 단일 코드 생성 유닛 — 최적화 기회 최대화
panic = "abort" # 되감기 오버헤드 제거 — 더 작고 빠름
strip = true # 모든 심볼 제거 — 바이너리 크기 감소
각 설정의 영향:
| 설정 | 기본값 → 최적화 | 바이너리 크기 | 실행 속도 | 컴파일 시간 |
|---|---|---|---|---|
lto = false → true | — | -10 ~ -20% | +5 ~ +20% | 2-5배 느림 |
codegen-units = 16 → 1 | — | -5 ~ -10% | +5 ~ +10% | 1.5-2배 느림 |
panic = "unwind" → "abort" | — | -5 ~ -10% | 무시할 수 있음 | 무시할 수 있음 |
strip = "none" → true | — | -50 ~ -70% | 영향 없음 | 영향 없음 |
opt-level = 3 → "s" | — | -10 ~ -30% | -5 ~ -10% | 비슷함 |
opt-level = 3 → "z" | — | -15 ~ -40% | -10 ~ -20% | 비슷함 |
추가적인 프로필 미세 조정:
[profile.release]
# 위의 모든 설정에 더해:
overflow-checks = true # 릴리스에서도 오버플로 검사 유지 (속도보다 안전)
debug = "line-tables-only" # 전체 DWARF 없이 백트레이스를 위한 최소한의 디버그 정보
rpath = false # 런타임 라이브러리 경로를 포함하지 않음
incremental = false # 증분 컴파일 비활성화 (더 깨끗한 빌드)
# 크기 최적화 빌드용 (임베디드, WASM):
# opt-level = "z" # 공격적인 크기 최적화
# strip = "symbols" # 심볼은 제거하되 디버그 섹션은 유지
크레이트별 프로필 오버라이드 — 중요한 크레이트만 최적화하고 나머지는 그대로 두기:
# 개발 빌드: 의존성은 최적화하되 내 코드는 빠르게 리컴파일
[profile.dev.package."*"]
opt-level = 2 # 개발 모드에서도 모든 의존성 최적화
# 릴리스 빌드: 특정 크레이트의 최적화 설정 변경
[profile.release.package.serde_json]
opt-level = 3 # JSON 파싱 최적화 최대화
codegen-units = 1
# 테스트 프로필: 정확한 통합 테스트를 위해 릴리스 동작과 일치시킴
[profile.test]
opt-level = 1 # 느린 테스트에서 타임아웃을 방지하기 위한 최소한의 최적화
LTO 심층 분석 — Thin vs Fat vs 교차 언어
링크 타임 최적화(LTO)를 사용하면 LLVM이 크레이트 경계를 넘어 최적화를 수행할 수 있습니다. 예를 들어 serde_json의 함수를 파싱 코드에 인라이닝하거나, regex에서 사용되지 않는 코드를 제거하는 등의 작업이 가능합니다. LTO가 없으면 각 크레이트는 독립된 최적화 섬과 같습니다.
[profile.release]
# 옵션 1: Fat LTO (lto = true일 때의 기본값)
lto = true
# 모든 코드를 하나의 LLVM 모듈로 병합 → 최적화 극대화
# 컴파일은 가장 느리지만, 가장 작고 빠른 바이너리 생성
# 옵션 2: Thin LTO
lto = "thin"
# 크레이트는 분리된 상태를 유지하되 LLVM이 모듈 간 최적화 수행
# Fat LTO보다 컴파일이 빠르며 최적화 효과도 거의 비슷함
# 대부분의 프로젝트에 가장 권장되는 트레이드오프
# 옵션 3: LTO 없음
lto = false
# 크레이트 내부에서만 최적화 수행
# 컴파일이 가장 빠르지만 바이너리가 커짐
# 옵션 4: Off (명시적)
lto = "off"
# false와 동일함
Fat LTO vs Thin LTO:
| 요인 | Fat LTO (true) | Thin LTO ("thin") |
|---|---|---|
| 최적화 품질 | 최고 | Fat의 약 95% 수준 |
| 컴파일 시간 | 느림 (모든 코드가 한 모듈에 있음) | 보통 (병렬 모듈 처리) |
| 메모리 사용량 | 높음 (모든 LLVM IR을 메모리에 로드) | 낮음 (스트리밍 방식) |
| 병렬성 | 없음 (단일 모듈) | 좋음 (모듈별 처리) |
| 권장 용도 | 최종 릴리스 빌드 | CI 빌드, 개발 단계 |
교차 언어 LTO — Rust와 C 경계를 넘나드는 최적화:
[profile.release]
lto = true
# cc 크레이트를 사용하는 크레이트의 Cargo.toml
[build-dependencies]
cc = "1.0"
// build.rs — 교차 언어(linker-plugin) LTO 활성화 fn main() { // cc 크레이트는 환경 변수의 CFLAGS를 따릅니다. // 교차 언어 LTO를 위해 C 코드를 다음 설정으로 컴파일합니다: // -flto=thin -O2 cc::Build::new() .file("csrc/fast_parser.c") .flag("-flto=thin") .opt_level(2) .compile("fast_parser"); }
# linker-plugin LTO 활성화 (호환 가능한 LLD 또는 gold 링커 필요)
RUSTFLAGS="-Clinker-plugin-lto -Clinker=clang -Clink-arg=-fuse-ld=lld" \
cargo build --release
교차 언어 LTO를 통해 LLVM은 C 함수를 Rust 호출부로 인라이닝하거나 그 반대의 작업을 수행할 수 있습니다. 이는 작은 C 함수가 빈번하게 호출되는 FFI 비중이 높은 코드(예: IPMI ioctl 래퍼)에서 가장 큰 효과를 발휘합니다.
cargo-bloat을 이용한 바이너리 크기 분석
cargo-bloat은 다음 질문에 답해줍니다:
"내 바이너리에서 어떤 함수와 크레이트가 가장 많은 공간을 차지하고 있는가?"
# 설치
cargo install cargo-bloat
# 가장 큰 함수들 표시
cargo bloat --release -n 20
# 출력 예시:
# File .text Size Crate Name
# 2.8% 5.1% 78.5KiB serde_json serde_json::de::Deserializer::parse_...
# 2.1% 3.8% 58.2KiB regex_syntax regex_syntax::ast::parse::ParserI::p...
# 1.5% 2.7% 42.1KiB accel_diag accel_diag::vendor::parse_smi_output
# ...
# 크레이트별 표시 (어떤 의존성이 가장 큰지 확인)
cargo bloat --release --crates
# 출력 예시:
# File .text Size Crate
# 12.3% 22.1% 340KiB serde_json
# 8.7% 15.6% 240KiB regex
# 6.2% 11.1% 170KiB std
# 5.1% 9.2% 141KiB accel_diag
# ...
# 두 빌드 결과 비교 (최적화 전후)
cargo bloat --release --crates > before.txt
# ... 변경 작업 수행 ...
cargo bloat --release --crates > after.txt
diff before.txt after.txt
흔한 비대화 원인 및 해결책:
| 원인 | 일반적인 크기 | 해결책 |
|---|---|---|
regex (전체 엔진) | 200-400 KB | 유니코드가 필요 없다면 regex-lite 사용 |
serde_json (전체) | 200-350 KB | 성능이 중요하다면 simd-json 또는 sonic-rs 고려 |
| 제네릭 단일화 (Monomorphization) | 다양함 | API 경계에서 dyn Trait 사용 |
포맷팅 엔진 (Display, Debug) | 50-150 KB | 거대한 enum의 #[derive(Debug)]는 크기를 키움 |
| 패닉 메시지 문자열 | 20-80 KB | panic = "abort"는 되감기를 제거하고, strip은 문자열 제거 |
| 미사용 기능(Feature) | 다양함 | 기본 기능 비활성화: serde = { version = "1", default-features = false } |
cargo-udeps를 이용한 의존성 정리
cargo-udeps는 Cargo.toml에는 선언되어 있지만 실제 코드에서는 사용되지 않는 의존성을 찾아줍니다.
# 설치 (nightly 채널 필요)
cargo install cargo-udeps
# 사용되지 않는 의존성 찾기
cargo +nightly udeps --workspace
# 출력 예시:
# unused dependencies:
# `diag_tool v0.1.0`
# └── "tempfile" (dev-dependency)
#
# `accel_diag v0.1.0`
# └── "once_cell" ← LazyLock 도입 전에는 필요했으나 지금은 사용 안 함
사용되지 않는 의존성은 다음과 같은 문제를 일으킵니다:
- 컴파일 시간 증가
- 바이너리 크기 증가
- 공급망 보안 위험 증가
- 잠재적인 라이선스 복잡성 추가
대안: cargo-machete — 휴리스틱 기반의 빠른 방식:
cargo install cargo-machete
cargo machete
# 빠르지만 휴리스틱 방식이라 오탐(false positive)이 있을 수 있음
대안: cargo-shear — cargo-udeps와 cargo-machete 사이의 절충안:
cargo install cargo-shear
cargo shear --fix
# cargo-machete보다 느리지만 cargo-udeps보다는 훨씬 빠름
# cargo-machete보다 오탐이 훨씬 적음
크기 최적화 의사결정 트리
flowchart TD
START["바이너리가 너무 큰가?"] --> STRIP{"strip = true 설정됨?"}
STRIP -->|"아니요"| DO_STRIP["strip = true 추가\n크기 -50 ~ -70%"]
STRIP -->|"예"| LTO{"LTO 활성화됨?"}
LTO -->|"아니요"| DO_LTO["lto = true 및\ncodegen-units = 1 추가"]
LTO -->|"예"| BLOAT["cargo-bloat --crates 실행"]
BLOAT --> BIG_DEP{"거대한 의존성이 있는가?"}
BIG_DEP -->|"예"| REPLACE["더 가벼운 대안으로 교체하거나\n기본 기능(feature) 비활성화"]
BIG_DEP -->|"아니요"| UDEPS["cargo-udeps 실행\n미사용 의존성 제거"]
UDEPS --> OPT_LEVEL{"더 줄여야 하는가?"}
OPT_LEVEL -->|"예"| SIZE_OPT["opt-level = 's' 또는 'z' 설정"]
style DO_STRIP fill:#91e5a3,color:#000
style DO_LTO fill:#e3f2fd,color:#000
style REPLACE fill:#ffd43b,color:#000
style SIZE_OPT fill:#ff6b6b,color:#000
🏋️ 실습
🟢 실습 1: LTO 영향력 측정
기본 릴리스 설정으로 프로젝트를 빌드한 후, lto = true + codegen-units = 1 + strip = true 설정을 적용하여 다시 빌드해 보세요. 바이너리 크기와 컴파일 시간을 비교해 봅니다.
솔루션
# 기본 릴리스 빌드
cargo build --release
ls -lh target/release/my-binary
time cargo build --release # 시간 기록
# 최적화된 릴리스 — Cargo.toml에 추가:
# [profile.release]
# lto = true
# codegen-units = 1
# strip = true
# panic = "abort"
cargo clean
cargo build --release
ls -lh target/release/my-binary # 보통 30-50% 작아짐
time cargo build --release # 보통 컴파일이 2-3배 느려짐
🟡 실습 2: 가장 큰 크레이트 찾기
프로젝트에서 cargo bloat --release --crates를 실행해 보세요. 가장 큰 의존성을 확인합니다. 기본 기능을 비활성화하거나 더 가벼운 대안으로 교체하여 크기를 줄일 수 있을까요?
솔루션
cargo install cargo-bloat
cargo bloat --release --crates
# 출력 예시:
# File .text Size Crate
# 12.3% 22.1% 340KiB serde_json
# 8.7% 15.6% 240KiB regex
# regex의 경우 — 유니코드가 필요 없다면 regex-lite 시도:
# regex-lite = "0.1" # 전체 regex보다 약 10배 작음
# serde의 경우 — std가 필요 없다면 기본 기능 비활성화:
# serde = { version = "1", default-features = false, features = ["derive"] }
cargo bloat --release --crates # 변경 후 결과 비교
핵심 요약
lto = true+codegen-units = 1+strip = true+panic = "abort"는 운영 환경용 릴리스 프로필의 표준입니다.- Thin LTO (
lto = "thin")는 Fat LTO의 장점의 80%를 제공하면서도 컴파일 비용은 훨씬 적습니다. cargo-bloat --crates는 어떤 의존성이 바이너리 공간을 차지하는지 정확히 알려줍니다.cargo-udeps,cargo-machete,cargo-shear는 컴파일 시간과 바이너리 크기를 낭비하는 죽은 의존성을 찾아줍니다.- 크레이트별 프로필 오버라이드를 통해 전체 빌드를 느리게 하지 않고도 중요한 크레이트만 최적화할 수 있습니다.
컴파일 시간 및 개발자 도구 🟡
학습 내용:
- 로컬 및 CI 빌드를 위한
sccache컴파일 캐싱- 기본 링커보다 3~10배 빠른
mold를 이용한 고속 링크cargo-nextest: 더 빠르고 정보가 풍부한 테스트 러너- 개발자 가독성 도구:
cargo-expand,cargo-geiger,cargo-watch- 워크스페이스 린트, MSRV 정책, 문서화를 통한 CI 검증
교차 참조: 릴리스 프로필 — LTO 및 바이너리 크기 최적화 · CI/CD 파이프라인 — 이 도구들을 파이프라인에 통합하는 방법 · 의존성 — 의존성 감소가 컴파일 속도 향상의 지름길
컴파일 시간 최적화: sccache, mold, cargo-nextest
긴 컴파일 시간은 Rust 개발자들이 겪는 가장 큰 고충입니다. 아래 도구들을 조합하면 반복적인 빌드 시간을 50~80%까지 단축할 수 있습니다.
sccache — 공유 컴파일 캐시:
# 설치
cargo install sccache
# Rust 래퍼로 설정
export RUSTC_WRAPPER=sccache
# 또는 .cargo/config.toml에 영구적으로 설정:
# [build]
# rustc-wrapper = "sccache"
# 첫 빌드: 정상 속도 (캐시 생성 중)
cargo build --release # 3분 소요
# Clean 후 재빌드: 변경되지 않은 크레이트는 캐시 적중(hit)
cargo clean && cargo build --release # 45초 소요
# 캐시 통계 확인
sccache --show-stats
# Compile requests 1,234
# Cache hits 987 (80%)
# Cache misses 247
sccache는 팀 전체 및 CI에서의 캐시 공유를 위해 클라우드 스토리지(S3, GCS, Azure Blob)를 지원합니다.
mold — 고속 링커:
링크 단계는 흔히 가장 느린 구간입니다. mold는 lld보다 3~5배, 기본 GNU ld보다는 10~20배 빠릅니다.
# 설치
sudo apt install mold # Ubuntu 22.04+
# 참고: mold는 ELF 타겟(Linux)용입니다. macOS는 ELF가 아닌 Mach-O를 사용합니다.
# macOS 기본 링커(ld64)는 이미 상당히 빠릅니다. 더 빠른 속도가 필요하다면:
# brew install sold # sold = Mach-O용 mold (실험적, 덜 성숙함)
# 실제 프로젝트에서 macOS의 링크 시간이 병목이 되는 경우는 드뭅니다.
# 링크에 mold 사용 설정
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
# 참고: https://github.com/rui314/mold/blob/main/docs/mold.md#environment-variables
export MOLD_JOBS=1
# mold가 사용되고 있는지 확인
cargo build -v 2>&1 | grep mold
cargo-nextest — 고속 테스트 러너:
# 설치
cargo install cargo-nextest
# 테스트 실행 (기본적으로 병렬 실행, 테스트별 타임아웃, 재시도 지원)
cargo nextest run
# cargo test 대비 주요 장점:
# - 각 테스트가 별도의 프로세스에서 실행됨 → 격리성 우수
# - 스마트 스케줄링을 통한 병렬 실행
# - 테스트별 타임아웃 (CI 중단 방지)
# - CI용 JUnit XML 출력 지원
# - 실패한 테스트 재시도 가능
# 설정 예시
cargo nextest run --retries 2 --fail-fast
# 테스트 바이너리 아카이브 (CI에서 유용: 한 곳에서 빌드하고 여러 머신에서 테스트)
cargo nextest archive --archive-file tests.tar.zst
cargo nextest run --archive-file tests.tar.zst
# .config/nextest.toml
[profile.default]
retries = 0
slow-timeout = { period = "60s", terminate-after = 3 }
fail-fast = true
[profile.ci]
retries = 2
fail-fast = false
junit = { path = "test-results.xml" }
통합 개발 환경 설정:
# .cargo/config.toml — 개발 반복 주기 최적화
[build]
rustc-wrapper = "sccache" # 컴파일 결과물 캐싱
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"] # 고속 링크
# 개발용 프로필: 의존성은 최적화하되 내 코드는 그대로
# (Cargo.toml에 작성)
# [profile.dev.package."*"]
# opt-level = 2
cargo-expand 및 cargo-geiger — 가시성 도구
cargo-expand — 매크로가 생성하는 코드 확인:
cargo install cargo-expand
# 특정 모듈의 모든 매크로 확장
cargo expand --lib accel_diag::vendor
# 특정 derive 확장 확인
# 예: #[derive(Debug, Serialize, Deserialize)]
# cargo expand는 생성된 impl 블록들을 보여줍니다.
cargo expand --lib --tests
#[derive] 매크로 출력, macro_rules! 확장, serde가 타입을 위해 생성하는 코드를 이해하고 디버깅하는 데 매우 유용합니다.
cargo-expand 외에도 rust-analyzer를 사용하여 매크로를 확장할 수 있습니다:
- 확인하려는 매크로 위로 커서를 옮깁니다.
- 커맨드 팔레트를 엽니다 (VSCode의 경우
F1). rust-analyzer: Expand macro recursively at caret을 검색하여 실행합니다.
cargo-geiger — 의존성 트리 전체에서 unsafe 사용량 집계:
cargo install cargo-geiger
cargo geiger
# 출력 예시:
# Metric output format: x/y
# x = 빌드 시 사용된 unsafe 코드
# y = 크레이트 내에서 발견된 전체 unsafe 코드
#
# Functions Expressions Impls Traits Methods
# 0/0 0/0 0/0 0/0 0/0 ✅ my_crate
# 0/5 0/23 0/2 0/0 0/3 ✅ serde
# 3/3 14/14 0/0 0/0 2/2 ❗ libc
# 15/15 142/142 4/4 0/0 12/12 ☢️ ring
# 기호 설명:
# ✅ = unsafe 사용 안 함
# ❗ = 일부 unsafe 사용됨
# ☢️ = unsafe 집중 사용됨
프로젝트의 zero-unsafe 정책을 위해, cargo geiger는 의존성이 제공하는 기능 중 실제 호출 그래프 상에서 unsafe 코드가 유입되지 않는지 검증합니다.
워크스페이스 린트 — [workspace.lints]
Rust 1.74부터 Clippy와 컴파일러 린트를 Cargo.toml에서 중앙 집중식으로 관리할 수 있습니다. 더 이상 모든 크레이트 상단에 #![deny(...)]를 적지 않아도 됩니다.
# 루트 Cargo.toml — 모든 크레이트에 대한 린트 설정
[workspace.lints.clippy]
unwrap_used = "warn" # ? 또는 expect("사유") 선호
dbg_macro = "deny" # 커밋되는 코드에 dbg!() 금지
todo = "warn" # 미완성 구현 추적
large_enum_variant = "warn" # 예기치 않은 크기 비대화 방지
[workspace.lints.rust]
unsafe_code = "deny" # zero-unsafe 정책 강제
missing_docs = "warn" # 문서화 장려
# 각 크레이트의 Cargo.toml — 워크스페이스 린트 적용
[lints]
workspace = true
이 방식은 여기저기 흩어져 있던 #![deny(clippy::unwrap_used)] 속성을 대체하며, 워크스페이스 전체에 일관된 정책을 보장합니다.
Clippy 경고 자동 수정:
# Clippy가 자동으로 수정 가능한 제안들을 적용하게 함
cargo clippy --fix --workspace --all-targets --allow-dirty
# 동작을 변경할 수 있는 제안까지 적용 (주의 깊게 검토 필요!)
cargo clippy --fix --workspace --all-targets --allow-dirty -- -W clippy::pedantic
팁: 커밋하기 전에
cargo clippy --fix를 실행하세요. 일일이 고치기 번거로운 사소한 문제들(미사용 import, 불필요한 clone, 타입 단순화 등)을 알아서 처리해 줍니다.
MSRV 정책과 rust-version
최소 지원 Rust 버전(MSRV)은 크레이트가 오래된 툴체인에서도 컴파일되도록 보장합니다. 이는 Rust 버전이 고정된 시스템에 배포할 때 중요합니다.
# Cargo.toml
[package]
name = "diag_tool"
version = "0.1.0"
rust-version = "1.75" # 필요한 최소 Rust 버전
# MSRV 준수 여부 확인
cargo +1.75.0 check --workspace
# 자동 MSRV 탐색
cargo install cargo-msrv
cargo msrv find
# 출력: Minimum Supported Rust Version is 1.75.0
# CI에서 검증
cargo msrv verify
CI에서의 MSRV:
jobs:
msrv:
name: Check MSRV
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.75.0" # Cargo.toml의 rust-version과 일치시킴
- run: cargo check --workspace
MSRV 전략:
- 바이너리 애플리케이션 (이 프로젝트와 같은 경우): 최신 안정 버전을 사용합니다. 특별한 MSRV가 필요하지 않을 수 있습니다.
- 라이브러리 크레이트 (crates.io 라이브러리): 사용 중인 모든 기능을 지원하는 가장 오래된 Rust 버전으로 설정합니다. 보통
N-2(현재 버전보다 2단계 낮음) 방식을 많이 사용합니다. - 엔터프라이즈 배포: 보유한 서버들에 설치된 가장 오래된 Rust 버전에 맞춥니다.
적용 사례: 운영용 바이너리 프로필
이 프로젝트는 이미 훌륭한 릴리스 프로필을 갖추고 있습니다:
# 현재 워크스페이스 Cargo.toml
[profile.release]
lto = true # ✅ 전체 크레이트 간 최적화
codegen-units = 1 # ✅ 최적화 기회 극대화
panic = "abort" # ✅ 되감기 오버헤드 제거
strip = true # ✅ 배포를 위한 심볼 제거
[profile.dev]
opt-level = 0 # ✅ 빠른 컴파일
debug = true # ✅ 전체 디버그 정보 포함
추천 추가 설정:
# 개발 모드에서 의존성 최적화 (테스트 실행 속도 향상)
[profile.dev.package."*"]
opt-level = 2
# 테스트 프로필: 느린 테스트에서 타임아웃을 방지하기 위한 약간의 최적화
[profile.test]
opt-level = 1
# 릴리스 빌드에서 오버플로 검사 유지 (안전성)
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
overflow-checks = true # ← 추가: 정수 오버플로 감지
debug = "line-tables-only" # ← 추가: 전체 DWARF 없이 백트레이스 지원
추천 개발자 도구 설정:
# .cargo/config.toml (제안)
[build]
rustc-wrapper = "sccache" # 첫 빌드 이후 80% 이상의 캐시 적중률 기대
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"] # 3~5배 빠른 링크
프로젝트에 기대되는 효과:
| 지표 | 현재 | 제안 적용 후 |
|---|---|---|
| 릴리스 바이너리 | 약 10 MB (stripped, LTO) | 동일 |
| 개발 빌드 시간 | 약 45초 | 약 25초 (sccache + mold) |
| 재빌드 (파일 1개 변경) | 약 15초 | 약 5초 (sccache + mold) |
| 테스트 실행 | cargo test | cargo nextest — 2배 빠름 |
| 의존성 취약점 스캔 | 없음 | CI에서 cargo audit 실행 |
| 라이선스 준수 | 수동 확인 | cargo deny 자동화 |
| 미사용 의존성 감지 | 수동 확인 | CI에서 cargo udeps 실행 |
cargo-watch — 파일 변경 시 자동 재빌드
cargo-watch는 소스 파일이 변경될 때마다 명령어를 다시 실행하여 빠른 피드백 루프를 만들어 줍니다.
# 설치
cargo install cargo-watch
# 저장할 때마다 즉시 check 실행 (빠른 피드백)
cargo watch -x check
# 변경 시 clippy 및 테스트 실행
cargo watch -x 'clippy --workspace --all-targets' -x 'test --workspace --lib'
# 특정 크레이트만 감시 (대규모 워크스페이스에서 유리)
cargo watch -w accel_diag/src -x 'test -p accel_diag'
# 매 실행 사이 화면 지우기
cargo watch -c -x check
팁: 앞서 소개한
mold+sccache와 조합하면 증분 변경에 대해 1초 미만의 재확인 시간을 확보할 수 있습니다.
cargo doc 및 워크스페이스 문서화
대규모 워크스페이스에서 생성된 문서는 코드 탐색에 필수적입니다. cargo doc은 doc-comment와 타입 시그니처를 바탕으로 HTML 문서를 만듭니다.
# 워크스페이스의 모든 크레이트에 대한 문서 생성 (브라우저에서 열기)
cargo doc --workspace --no-deps --open
# 프라이빗 항목 포함 (개발 중 유용)
cargo doc --workspace --no-deps --document-private-items
# HTML 생성 없이 문서 링크만 확인 (빠른 CI 검사)
cargo doc --workspace --no-deps 2>&1 | grep -E 'warning|error'
문서 내부 링크 (Intra-doc links) — URL 없이 크레이트 간 타입을 연결:
#![allow(unused)] fn main() { /// [`GpuConfig`] 설정을 사용하여 GPU 진단을 실행합니다. /// /// 구현 상세는 [`crate::accel_diag::run_diagnostics`]를 참조하세요. /// [`DiagResult`]를 반환하며, 이는 [`DerReport`](crate::core_lib::DerReport) /// 형식으로 직렬화될 수 있습니다. pub fn run_accel_diag(config: &GpuConfig) -> DiagResult { // ... } }
문서에 플랫폼 전용 API 표시:
#![allow(unused)] fn main() { // Cargo.toml: [package.metadata.docs.rs] // all-features = true // rustdoc-args = ["--cfg", "docsrs"] /// Windows 전용: Win32 API를 통해 배터리 상태를 읽어옵니다. /// /// `cfg(windows)` 빌드에서만 사용 가능합니다. #[cfg(windows)] #[doc(cfg(windows))] // 문서에 "Available on Windows only" 배지 표시 pub fn get_battery_status() -> Option<u8> { // ... } }
CI 문서 검사:
# CI 워크플로에 추가
- name: Check documentation
run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
# 끊어진 문서 링크를 에러로 처리
프로젝트를 위해: 크레이트가 많으므로
cargo doc --workspace는 새 팀원이 API 구성을 파악하는 가장 좋은 방법입니다. CI에RUSTDOCFLAGS="-D warnings"를 추가하여 머지 전에 끊어진 링크를 잡아내세요.
컴파일 시간 의사결정 트리
flowchart TD
START["컴파일이 너무 느린가?"] --> WHERE{"어디서 시간이 걸리는가?"}
WHERE -->|"변경되지 않은 크레이트의\n반복 컴파일"| SCCACHE["sccache\n공유 컴파일 캐시"]
WHERE -->|"링크 단계"| MOLD["mold 링커\n3~10배 빠른 링크"]
WHERE -->|"테스트 실행"| NEXTEST["cargo-nextest\n병렬 테스트 러너"]
WHERE -->|"전반적인 과정"| COMBO["위의 모든 도구 + \ncargo-udeps 의존성 정리"]
SCCACHE --> CI_CACHE{"CI 환경인가 로컬인가?"}
CI_CACHE -->|"CI"| S3["S3/GCS 공유 캐시"]
CI_CACHE -->|"로컬"| LOCAL["로컬 디스크 캐시\n자동 설정"]
style SCCACHE fill:#91e5a3,color:#000
style MOLD fill:#e3f2fd,color:#000
style NEXTEST fill:#ffd43b,color:#000
style COMBO fill:#b39ddb,color:#000
🏋️ 실습
🟢 실습 1: sccache + mold 설정하기
sccache와 mold를 설치하고 .cargo/config.toml에 설정한 뒤, 전체 재빌드 시 컴파일 시간이 얼마나 단축되는지 측정해 보세요.
솔루션
# 설치
cargo install sccache
sudo apt install mold # Ubuntu 22.04+
# .cargo/config.toml 설정:
cat > .cargo/config.toml << 'EOF'
[build]
rustc-wrapper = "sccache"
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
EOF
# 첫 빌드 (캐시 생성)
time cargo build --release # 예: 180초
# Clean 후 재빌드 (캐시 활용)
cargo clean
time cargo build --release # 예: 45초
sccache --show-stats
# 캐시 적중률이 60~80% 이상인지 확인
🟡 실습 2: cargo-nextest로 전환하기
cargo-nextest를 설치하고 테스트 스위트를 실행해 보세요. cargo test와 전체 실행 시간을 비교했을 때 얼마나 빨라졌나요?
솔루션
cargo install cargo-nextest
# 표준 테스트 러너
time cargo test --workspace 2>&1 | tail -5
# nextest (테스트 바이너리별 병렬 실행)
time cargo nextest run --workspace 2>&1 | tail -5
# 대규모 워크스페이스의 경우 보통 2~5배 빨라집니다.
# nextest는 다음 기능도 제공합니다:
# - 테스트별 실행 시간 표시
# - 불안정한(flaky) 테스트 재시도
# - CI용 JUnit XML 출력
cargo nextest run --workspace --retries 2
핵심 요약
- S3/GCS 백엔드를 사용하는
sccache는 팀 전체와 CI 간에 컴파일 캐시를 공유합니다. mold는 가장 빠른 ELF 링커로, 링크 시간을 초 단위에서 밀리초 단위로 줄여줍니다.cargo-nextest는 바이너리별로 테스트를 병렬 실행하며, 향상된 출력 정보와 재시도를 지원합니다.cargo-geiger는unsafe사용량을 집계합니다. 새로운 의존성을 추가하기 전에 실행해 보세요.[workspace.lints]는 다중 크레이트 워크스페이스에서 Clippy와 rustc 린트 설정을 중앙 집중화합니다.
no_std 및 기능(Feature) 검증 🔴
학습 내용:
cargo-hack을 이용한 기능 조합의 체계적 검증- Rust의 세 가지 레이어:
corevsallocvsstd및 각각의 사용 시점- 커스텀 패닉 핸들러와 할당자를 포함한
no_std크레이트 빌드- 호스트 및 QEMU에서
no_std코드 테스트하기교차 참조: Windows 및 조건부 컴파일 — 이 주제의 플랫폼 측면 · 교차 컴파일 — ARM 및 임베디드 타겟으로의 교차 빌드 설정 · Miri 및 새니타이저 —
no_std환경에서의unsafe코드 검증 · 빌드 스크립트 —build.rs에서 내보내는cfg플래그
Rust는 8비트 마이크로컨트롤러부터 클라우드 서버까지 어디서나 실행됩니다. 이 장에서는 그 기초가 되는 내용을 다룹니다: #![no_std]를 사용하여 표준 라이브러리를 제거하고, 설정한 기능(feature) 조합들이 실제로 올바르게 컴파일되는지 검증하는 방법입니다.
cargo-hack을 이용한 기능 조합 검증
cargo-hack은 모든 기능 조합을 체계적으로 테스트합니다. 이는 #[cfg(...)] 코드가 포함된 크레이트에 필수적입니다.
# 설치
cargo install cargo-hack
# 모든 기능이 개별적으로 컴파일되는지 확인
cargo hack check --each-feature --workspace
# 모든 기능 조합 테스트 (지수적으로 증가하므로 주의!)
# 기능이 8개 미만인 크레이트에만 현실적입니다.
cargo hack check --feature-powerset --workspace
# 현실적인 타협안: 각 기능을 개별적으로 테스트 + 모든 기능 활성화 + 기능 모두 비활성화
cargo hack check --each-feature --workspace --no-dev-deps
cargo check --workspace --all-features
cargo check --workspace --no-default-features
이 프로젝트에서 중요한 이유:
플랫폼 기능(linux, windows, direct-ipmi, direct-accel-api)을 추가할 때, cargo-hack은 컴파일을 깨뜨리는 조합을 잡아냅니다.
# 예: 플랫폼 코드를 제어하는 기능들
[features]
default = ["linux"]
linux = [] # Linux 전용 하드웨어 접근
windows = ["dep:windows-sys"] # Windows 전용 API
direct-ipmi = [] # unsafe IPMI ioctl (5장)
direct-accel-api = [] # unsafe accel-mgmt FFI (5장)
# 모든 기능이 단독으로 그리고 함께 올바르게 컴파일되는지 확인
cargo hack check --each-feature -p diag_tool
# 발견 예시: "feature 'windows'가 'direct-ipmi' 없이 컴파일되지 않음"
# 발견 예시: "#[cfg(feature = \"linux\")]에 오타가 있음 — 'lnux'로 되어 있음"
CI 통합:
# CI 파이프라인에 추가 (단순 컴파일 확인이므로 빠름)
- name: Feature matrix check
run: cargo hack check --each-feature --workspace --no-dev-deps
권장 사항: 기능이 2개 이상인 크레이트는 CI에서
cargo hack check --each-feature를 실행하세요.--feature-powerset은 기능이 8개 미만인 핵심 라이브러리 크레이트에만 사용하세요 ($2^n$ 조합이므로 지수적으로 늘어납니다).
no_std — 언제 그리고 왜 사용하는가
#![no_std]는 컴파일러에게 "표준 라이브러리를 링크하지 마라"고 지시합니다. 이 경우 크레이트는 core(그리고 선택적으로 alloc)만 사용할 수 있습니다. 왜 이런 작업이 필요할까요?
| 시나리오 | no_std를 사용하는 이유 |
|---|---|
| 임베디드 펌웨어 (ARM Cortex-M, RISC-V) | OS 없음, 힙(heap) 없음, 파일 시스템 없음 |
| UEFI 진단 도구 | OS API가 없는 부팅 전 환경 |
| 커널 모듈 | 커널 공간에서는 유저 공간의 std를 사용할 수 없음 |
| WebAssembly (WASM) | 바이너리 크기 최소화, OS 의존성 제거 |
| 부트로더 | OS가 존재하기 전에 실행되어야 함 |
| C 인터페이스를 가진 공유 라이브러리 | 호출자에게 Rust 런타임 유입 방지 |
하드웨어 진단 분야에서 no_std는 다음과 같은 결과물을 빌드할 때 필요합니다:
- UEFI 기반 부팅 전 진단 도구 (OS 로드 전)
- BMC 펌웨어 진단 (자원이 제한된 ARM SoC)
- 커널 레벨 PCIe 진단 (커널 모듈 또는 eBPF 프로브)
core vs alloc vs std — 세 가지 레이어
┌─────────────────────────────────────────────────────────────┐
│ std │
│ core + alloc의 모든 기능을 포함하며, 추가로 다음을 제공: │
│ • 파일 I/O (std::fs, std::io) │
│ • 네트워킹 (std::net) │
│ • 스레드 (std::thread) │
│ • 시간 (std::time) │
│ • 환경 변수 (std::env) │
│ • 프로세스 (std::process) │
│ • OS 전용 (std::os::unix, std::os::windows) │
├─────────────────────────────────────────────────────────────┤
│ alloc (#! [no_std] + extern crate alloc로 사용 가능, │
│ 글로벌 할당자가 있는 경우) │
│ • String, Vec, Box, Rc, Arc │
│ • BTreeMap, BTreeSet │
│ • format!() 매크로 │
│ • 힙 메모리가 필요한 컬렉션 및 스마트 포인터 │
├─────────────────────────────────────────────────────────────┤
│ core (#! [no_std]에서도 항상 사용 가능) │
│ • 기본 타입 (u8, bool, char 등) │
│ • Option, Result │
│ • Iterator, slice, array, str (String이 아닌 슬라이스) │
│ • 트레이트: Clone, Copy, Debug, Display, From, Into │
│ • 원자적 연산 (core::sync::atomic) │
│ • Cell, RefCell (core::cell) — Pin (core::pin) │
│ • core::fmt (할당 없는 포맷팅) │
│ • core::mem, core::ptr (저수준 메모리 조작) │
│ • 수학 연산: core::num, 기본 산술 연산 │
└─────────────────────────────────────────────────────────────┘
std 없이 잃게 되는 것들:
HashMap없음 (해셔 필요 —alloc의BTreeMap이나hashbrown사용)println!()없음 (stdout 필요 — 버퍼에 쓰는core::fmt::Write사용)std::error::Error없음 (Rust 1.81부터core에 편입되었으나, 아직 많은 생태계가 마이너레이션 전임)- 파일 I/O, 네트워킹, 스레드 없음 (플랫폼 HAL에서 제공하지 않는 한)
Mutex없음 (spin::Mutex나 플랫폼 전용 락 사용)
no_std 크레이트 구축하기
#![allow(unused)] fn main() { // src/lib.rs — no_std 라이브러리 크레이트 #![no_std] // 선택적으로 힙 할당 사용 extern crate alloc; use alloc::string::String; use alloc::vec::Vec; use core::fmt; /// 온도 센서의 읽기 값. /// 이 구조체는 베어메탈부터 Linux까지 모든 환경에서 작동합니다. #[derive(Clone, Copy, Debug)] pub struct Temperature { /// 원시 센서 값 (일반적인 I2C 센서의 경우 LSB당 0.0625°C) raw: u16, } impl Temperature { pub const fn from_raw(raw: u16) -> Self { Self { raw } } /// 섭씨 온도로 변환 (고정 소수점, FPU 불필요) pub const fn millidegrees_c(&self) -> i32 { (self.raw as i32) * 625 / 10 // 0.0625°C 해상도 } pub fn degrees_c(&self) -> f32 { self.raw as f32 * 0.0625 } } impl fmt::Display for Temperature { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let md = self.millidegrees_c(); // -0.999°C ~ -0.001°C 사이의 값 처리 (md / 1000이 0이지만 값은 음수인 경우) if md < 0 && md > -1000 { write!(f, "-0.{:03}°C", (-md) % 1000) } else { write!(f, "{}.{:03}°C", md / 1000, (md % 1000).abs()) } } } /// 공백으로 구분된 온도 값들을 파싱합니다. /// alloc 사용 — 글로벌 할당자가 필요합니다. pub fn parse_temperatures(input: &str) -> Vec<Temperature> { input .split_whitespace() .filter_map(|s| s.parse::<u16>().ok()) .map(Temperature::from_raw) .collect() } /// 할당 없이 포맷팅 — 버퍼에 직접 씁니다. /// core 전용 환경(alloc 없음, 힙 없음)에서 작동합니다. pub fn format_temp_into(temp: &Temperature, buf: &mut [u8]) -> usize { use core::fmt::Write; struct SliceWriter<'a> { buf: &'a mut [u8], pos: usize, } impl<'a> Write for SliceWriter<'a> { fn write_str(&mut self, s: &str) -> fmt::Result { let bytes = s.as_bytes(); let remaining = self.buf.len() - self.pos; if bytes.len() > remaining { // 버퍼 꽉 참 — 자동 절단 대신 에러 신호 전달 return Err(fmt::Error); } self.buf[self.pos..self.pos + bytes.len()].copy_from_slice(bytes); self.pos += bytes.len(); Ok(()) } } let mut w = SliceWriter { buf, pos: 0 }; let _ = write!(w, "{}", temp); w.pos } }
# no_std 크레이트의 Cargo.toml
[package]
name = "thermal-sensor"
version = "0.1.0"
edition = "2021"
[features]
default = ["alloc"]
alloc = [] # Vec, String 등 활성화
std = [] # 전체 std 활성화 (alloc 포함)
[dependencies]
# no_std 호환 크레이트 사용
serde = { version = "1.0", default-features = false, features = ["derive"] }
# ↑ default-features = false로 설정하면 std 의존성이 제거됩니다!
주요 크레이트 패턴: 많은 인기 크레이트(serde, log, rand, embedded-hal)가
default-features = false를 통해no_std를 지원합니다.no_std환경에서 사용하기 전에 해당 의존성이std를 요구하는지 항상 확인하세요. 일부 크레이트(예:regex)는 최소한alloc이 필요하며core전용 환경에서는 작동하지 않습니다.
커스텀 패닉 핸들러 및 할당자
라이브러리가 아닌 #![no_std] 바이너리에서는 패닉 핸들러를 직접 제공해야 하며, 선택적으로 글로벌 할당자도 설정해야 합니다.
// src/main.rs — no_std 바이너리 (예: UEFI 진단 도구) #![no_std] #![no_main] extern crate alloc; use core::panic::PanicInfo; // 필수: 패닉 발생 시 수행할 작업 (스택 되감기 사용 불가) #[panic_handler] fn panic(info: &PanicInfo) -> ! { // 임베디드: LED 깜빡이기, UART에 쓰기, 중단(hang) // UEFI: 콘솔에 출력, 중지(halt) // 최소 구현: 무한 루프 loop { core::hint::spin_loop(); } } // alloc 사용 시 필수: 글로벌 할당자 제공 use alloc::alloc::{GlobalAlloc, Layout}; struct BumpAllocator { // 임베디드/UEFI를 위한 단순한 범프 할당자 // 실전에서는 linked_list_allocator나 embedded-alloc 크레이트 사용 권장 } // 주의: 아래는 작동하지 않는 플레이스홀더입니다! alloc() 호출 시 null을 반환하며, // 이는 즉각적인 정의되지 않은 동작(UB)을 유발합니다 (할당자 규약상 0이 아닌 크기 할당 시 // null이 아닌 값을 반환해야 함). 실제 코드에서는 검증된 할당자 크레이트를 사용하세요: // - embedded-alloc (임베디드 타겟) // - linked_list_allocator (UEFI / OS 커널) // - talc (범용 no_std) unsafe impl GlobalAlloc for BumpAllocator { /// # Safety /// Layout의 size는 0이 아니어야 합니다. null을 반환합니다 (플레이스홀더 — 크래시 유발). unsafe fn alloc(&self, _layout: Layout) -> *mut u8 { // 플레이스홀더 — 실제 할당 로직으로 교체 필요! core::ptr::null_mut() } /// # Safety /// `_ptr`은 이전에 `alloc`이 호환되는 Layout으로 반환한 값이어야 합니다. unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) { // 범프 할당자에서는 아무 작업도 하지 않음 } } #[global_allocator] static ALLOCATOR: BumpAllocator = BumpAllocator {}; // 진입점 (플랫폼 전용, fn main 아님) // UEFI의 경우: #[entry] 또는 efi_main // 임베디드의 경우: #[cortex_m_rt::entry]
no_std 코드 테스트하기
테스트는 std가 있는 호스트 머신에서 실행됩니다. 비결은 라이브러리는 no_std이지만, 테스트 하네스는 std를 사용하게 하는 것입니다.
#![allow(unused)] fn main() { // 크레이트: src/lib.rs에 #! [no_std] 선언 // 하지만 테스트는 자동으로 std 환경에서 실행됩니다: #[cfg(test)] mod tests { use super::*; // 여기서는 std를 사용할 수 있습니다 — println!, assert!, Vec 등이 모두 작동합니다. #[test] fn test_temperature_conversion() { let temp = Temperature::from_raw(800); // 50.0°C assert_eq!(temp.millidegrees_c(), 50000); assert!((temp.degrees_c() - 50.0).abs() < 0.01); } #[test] fn test_format_into_buffer() { let temp = Temperature::from_raw(800); let mut buf = [0u8; 32]; let len = format_temp_into(&temp, &mut buf); let s = core::str::from_utf8(&buf[..len]).unwrap(); assert_eq!(s, "50.000°C"); } } }
실제 타겟에서 테스트하기 (std를 전혀 사용할 수 없는 경우):
# 디바이스 테스트를 위해 defmt-test 사용 (임베디드 ARM)
# UEFI 타겟을 위해 uefi-test-runner 사용
# 하드웨어 없이 교차 아키텍처 테스트를 위해 QEMU 사용
# 호스트에서 no_std 라이브러리 테스트 실행 (항상 작동):
cargo test --lib
# no_std 타겟에 대해 컴파일 확인:
cargo check --target thumbv7em-none-eabihf # ARM Cortex-M
cargo check --target riscv32imac-unknown-none-elf # RISC-V
no_std 의사결정 트리
flowchart TD
START["코드가 라이브러리 표준\n라이브러리를 필요로 하는가?"] --> NEED_FS{"파일 시스템,\n네트워크, 스레드?"}
NEED_FS -->|"예"| USE_STD["std 사용\n일반 애플리케이션"]
NEED_FS -->|"아니요"| NEED_HEAP{"힙 할당이 필요한가?\nVec, String, Box"}
NEED_HEAP -->|"예"| USE_ALLOC["#![no_std]\nextern crate alloc"]
NEED_HEAP -->|"아니요"| USE_CORE["#![no_std]\ncore 전용"]
USE_ALLOC --> VERIFY["cargo-hack\n--each-feature"]
USE_CORE --> VERIFY
USE_STD --> VERIFY
VERIFY --> TARGET{"타겟에 OS가 있는가?"}
TARGET -->|"예"| HOST_TEST["cargo test --lib\n표준 테스트 방식"]
TARGET -->|"아니요"| CROSS_TEST["QEMU / defmt-test\n디바이스 테스트"]
style USE_STD fill:#91e5a3,color:#000
style USE_ALLOC fill:#ffd43b,color:#000
style USE_CORE fill:#ff6b6b,color:#000
🏋️ 실습
🟡 실습 1: 기능 조합 검증
cargo-hack을 설치하고 여러 기능이 포함된 프로젝트에서 cargo hack check --each-feature --workspace를 실행해 보세요. 잘못된 조합을 찾아내나요?
솔루션
cargo install cargo-hack
# 각 기능을 개별적으로 체크
cargo hack check --each-feature --workspace --no-dev-deps
# 만약 특정 기능 조합이 실패한다면:
# error[E0433]: failed to resolve: use of undeclared crate or module `std`
# → 이는 특정 #[cfg] 가드에 기능 게이트가 누락되었음을 의미합니다.
# 모든 기능 + 기능 없음 + 각 개별 기능을 체크:
cargo hack check --each-feature --workspace
cargo check --workspace --all-features
cargo check --workspace --no-default-features
🔴 실습 2: no_std 라이브러리 만들기
#![no_std] 환경에서 컴파일되는 라이브러리 크레이트를 만들어 보세요. 간단한 스택 기반의 링 버퍼(Ring Buffer)를 구현합니다. thumbv7em-none-eabihf (ARM Cortex-M) 타겟으로 올바르게 컴파일되는지 확인하세요.
솔루션
#![allow(unused)] fn main() { // lib.rs #![no_std] pub struct RingBuffer<const N: usize> { data: [u8; N], head: usize, len: usize, } impl<const N: usize> RingBuffer<N> { pub const fn new() -> Self { Self { data: [0; N], head: 0, len: 0 } } pub fn push(&mut self, byte: u8) -> bool { if self.len == N { return false; } let idx = (self.head + self.len) % N; self.data[idx] = byte; self.len += 1; true } pub fn pop(&mut self) -> Option<u8> { if self.len == 0 { return None; } let byte = self.data[self.head]; self.head = (self.head + 1) % N; self.len -= 1; Some(byte) } } #[cfg(test)] mod tests { use super::*; #[test] fn push_pop() { let mut rb = RingBuffer::<4>::new(); assert!(rb.push(1)); assert!(rb.push(2)); assert_eq!(rb.pop(), Some(1)); assert_eq!(rb.pop(), Some(2)); assert_eq!(rb.pop(), None); } } }
rustup target add thumbv7em-none-eabihf
cargo check --target thumbv7em-none-eabihf
# ✅ 베어메탈 ARM용으로 컴파일 성공
핵심 요약
- 조건부 컴파일을 사용하는 모든 크레이트에는
cargo-hack --each-feature가 필수적입니다 (CI에서 실행 권장). core→alloc→std순으로 계층화되어 있으며, 각 단계마다 기능이 추가되지만 요구되는 런타임 지원도 늘어납니다.- 베어메탈
no_std바이너리에는 커스텀 패닉 핸들러와 할당자가 필요합니다. no_std라이브러리는 특별한 하드웨어 없이도 호스트에서cargo test --lib로 테스트할 수 있습니다.--feature-powerset은 기능이 8개 미만인 핵심 라이브러리에만 사용하세요 ($2^n$ 조합).
Windows 및 조건부 컴파일 🟡
학습 내용:
- Windows 지원 패턴:
windows-sys/windows크레이트,cargo-xwin#[cfg]를 이용한 조건부 컴파일 — 전처리기가 아닌 컴파일러가 검증함- 플랫폼 추상화 아키텍처:
#[cfg]블록으로 충분한 경우 vs 트레이트를 사용해야 하는 경우- Linux에서 Windows용으로 교차 컴파일하기
교차 참조:
no_std및 기능 —cargo-hack및 기능 검증 · 교차 컴파일 — 일반적인 교차 빌드 설정 · 빌드 스크립트 —build.rs에서 내보내는cfg플래그
Windows 지원 — 플랫폼 추상화
Rust의 #[cfg()] 속성과 Cargo 기능을 사용하면 단일 코드베이스로 Linux와 Windows를 모두 깔끔하게 지원할 수 있습니다. 이 프로젝트의 platform::run_command 모듈은 이미 이 패턴을 보여주고 있습니다.
#![allow(unused)] fn main() { // 프로젝트의 실제 패턴 — 플랫폼별 쉘(shell) 호출 pub fn exec_cmd(cmd: &str, timeout_secs: Option<u64>) -> Result<CommandResult, CommandError> { #[cfg(windows)] let mut child = Command::new("cmd") .args(["/C", cmd]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; #[cfg(not(windows))] let mut child = Command::new("sh") .args(["-c", cmd]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; // ... 이후 로직은 플랫폼과 독립적입니다 ... } }
사용 가능한 cfg 조건:
#![allow(unused)] fn main() { // 운영체제 #[cfg(target_os = "linux")] // Linux 전용 #[cfg(target_os = "windows")] // Windows 전용 #[cfg(target_os = "macos")] // macOS 전용 #[cfg(unix)] // Linux, macOS, BSD 등 #[cfg(windows)] // Windows (단축형) // 아키텍처 #[cfg(target_arch = "x86_64")] // x86 64비트 #[cfg(target_arch = "aarch64")] // ARM 64비트 #[cfg(target_arch = "x86")] // x86 32비트 // 포인터 너비 (아키텍처 대신 활용 가능한 호환성 옵션) #[cfg(target_pointer_width = "64")] // 모든 64비트 플랫폼 #[cfg(target_pointer_width = "32")] // 모든 32비트 플랫폼 // 환경 / C 라이브러리 #[cfg(target_env = "gnu")] // glibc #[cfg(target_env = "musl")] // musl libc #[cfg(target_env = "msvc")] // Windows의 MSVC 환경 // 엔디안(Endianness) #[cfg(target_endian = "little")] #[cfg(target_endian = "big")] // any(), all(), not() 조합 #[cfg(all(target_os = "linux", target_arch = "x86_64"))] #[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(not(windows))] }
windows-sys 및 windows 크레이트
Windows API를 직접 호출해야 하는 경우:
# Cargo.toml — 로우(raw) FFI를 위해 windows-sys 사용 (가볍고 추상화 없음)
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Services",
"Win32_System_Registry",
"Win32_System_Power",
] }
# 참고: windows-sys는 유의적 버전(semver) 호환되지 않는 릴리스를 사용합니다 (0.48 → 0.52 → 0.59).
# 특정 마이너 버전에 고정하세요 — 각 릴리스마다 API 바인딩이 제거되거나 이름이 변경될 수 있습니다.
# 새 프로젝트를 시작하기 전에 https://github.com/microsoft/windows-rs 에서 최신 버전을 확인하세요.
# 또는 안전한 래퍼를 위해 windows 크레이트 사용 (더 무겁지만 인체공학적임)
# windows = { version = "0.59", features = [...] }
#![allow(unused)] fn main() { // src/platform/windows.rs #[cfg(windows)] mod win { use windows_sys::Win32::System::Power::{ GetSystemPowerStatus, SYSTEM_POWER_STATUS, }; pub fn get_battery_status() -> Option<u8> { let mut status = SYSTEM_POWER_STATUS::default(); // SAFETY: GetSystemPowerStatus는 제공된 버퍼에 기록합니다. // 버퍼의 크기와 정렬이 올바르게 설정되어 있습니다. let ok = unsafe { GetSystemPowerStatus(&mut status) }; if ok != 0 { Some(status.BatteryLifePercent) } else { None } } } }
windows-sys vs windows 크레이트:
| 비교 항목 | windows-sys | windows |
|---|---|---|
| API 스타일 | 로우 FFI (unsafe 호출) | 안전한 (Safe) Rust 래퍼 |
| 바이너리 크기 | 최소화 (extern 선언만 포함) | 더 큼 (래퍼 코드 포함) |
| 컴파일 시간 | 빠름 | 느림 |
| 사용성 | C-스타일, 수동 안전성 보장 필요 | Rust다운(idiomatic) 방식 |
| 에러 처리 | 원시 BOOL / HRESULT | Result<T, windows::core::Error> |
| 사용 추천 시점 | 성능이 중요하거나 얇은 래퍼 필요 시 | 일반 애플리케이션 코드, 사용 편의성 중시 시 |
Linux에서 Windows용으로 교차 컴파일하기
# 방법 1: MinGW (GNU ABI)
rustup target add x86_64-pc-windows-gnu
sudo apt install gcc-mingw-w64-x86-64
cargo build --target x86_64-pc-windows-gnu
# .exe 생성 — Windows에서 실행되며 msvcrt와 링크됨
# 방법 2: xwin을 통한 MSVC ABI (완전한 MSVC 호환성)
cargo install cargo-xwin
cargo xwin build --target x86_64-pc-windows-msvc
# Microsoft의 CRT 및 SDK 헤더를 자동으로 다운로드하여 사용함
# 방법 3: Zig 기반 교차 컴파일
cargo zigbuild --target x86_64-pc-windows-gnu
Windows에서의 GNU vs MSVC ABI:
| 비교 항목 | x86_64-pc-windows-gnu | x86_64-pc-windows-msvc |
|---|---|---|
| 링커 | MinGW ld | MSVC link.exe 또는 lld-link |
| C 런타임 | msvcrt.dll (보편적) | ucrtbase.dll (최신형) |
| C++ 상호 운용성 | GCC ABI | MSVC ABI |
| Linux에서 교차 빌드 | 쉬움 (MinGW 활용) | 가능 (cargo-xwin 활용) |
| Windows API 지원 | 전체 지원 | 전체 지원 |
| 디버그 정보 형식 | DWARF | PDB |
| 권장 용도 | 단순 도구, CI 빌드 | 완전한 Windows 통합 |
조건부 컴파일 패턴
패턴 1: 플랫폼별 모듈 선택
#![allow(unused)] fn main() { // src/platform/mod.rs — 운영체제별로 다른 모듈 컴파일 #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] pub use linux::*; #[cfg(target_os = "windows")] mod windows; #[cfg(target_os = "windows")] pub use windows::*; // 두 모듈 모두 동일한 공개(public) API를 구현해야 함: // pub fn get_cpu_temperature() -> Result<f64, PlatformError> // pub fn list_pci_devices() -> Result<Vec<PciDevice>, PlatformError> }
패턴 2: 기능을 이용한 플랫폼 지원 제어
# Cargo.toml
[features]
default = ["linux"]
linux = [] # Linux 전용 하드웨어 접근
windows = ["dep:windows-sys"] # Windows 전용 API
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [...], optional = true }
#![allow(unused)] fn main() { // 기능(feature) 없이 Windows용으로 빌드하려고 하면 컴파일 에러 발생: #[cfg(all(target_os = "windows", not(feature = "windows")))] compile_error!("Windows용으로 빌드하려면 'windows' 기능을 활성화하세요"); }
패턴 3: 트레이트 기반 플랫폼 추상화
#![allow(unused)] fn main() { /// 하드웨어 접근을 위한 플랫폼 독립적 인터페이스. pub trait HardwareAccess { type Error: std::error::Error; fn read_cpu_temperature(&self) -> Result<f64, Self::Error>; fn read_gpu_temperature(&self, gpu_index: u32) -> Result<f64, Self::Error>; fn list_pci_devices(&self) -> Result<Vec<PciDevice>, Self::Error>; fn send_ipmi_command(&self, cmd: &IpmiCmd) -> Result<IpmiResponse, Self::Error>; } #[cfg(target_os = "linux")] pub struct LinuxHardware; #[cfg(target_os = "linux")] impl HardwareAccess for LinuxHardware { type Error = LinuxHwError; fn read_cpu_temperature(&self) -> Result<f64, Self::Error> { // /sys/class/thermal/thermal_zone0/temp 읽기 let raw = std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")?; Ok(raw.trim().parse::<f64>()? / 1000.0) } // ... } #[cfg(target_os = "windows")] pub struct WindowsHardware; #[cfg(target_os = "windows")] impl HardwareAccess for WindowsHardware { type Error = WindowsHwError; fn read_cpu_temperature(&self) -> Result<f64, Self::Error> { // WMI (Win32_TemperatureProbe) 또는 Open Hardware Monitor를 통해 읽기 todo!("WMI 온도 쿼리 구현 필요") } // ... } /// 플랫폼에 맞는 적절한 구현체 생성 pub fn create_hardware() -> impl HardwareAccess { #[cfg(target_os = "linux")] { LinuxHardware } #[cfg(target_os = "windows")] { WindowsHardware } } }
플랫폼 추상화 아키텍처
여러 플랫폼을 타겟으로 하는 프로젝트의 경우, 코드를 다음의 세 계층으로 구성하세요:
┌──────────────────────────────────────────────────┐
│ 애플리케이션 로직 (플랫폼 독립적) │
│ diag_tool, accel_diag, network_diag, event_log 등 │
│ 플랫폼 추상화 트레이트만 사용함 │
├──────────────────────────────────────────────────┤
│ 플랫폼 추상화 계층 (트레이트 정의) │
│ trait HardwareAccess { ... } │
│ trait CommandRunner { ... } │
│ trait FileSystem { ... } │
├──────────────────────────────────────────────────┤
│ 플랫폼별 구현 (cfg 게이트 적용) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Linux 구현 │ │ Windows 구현 │ │
│ │ /sys, /proc │ │ WMI, 레지스트리│ │
│ │ ipmitool │ │ ipmiutil │ │
│ │ lspci │ │ devcon │ │
│ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────┘
추상화 테스트: 유닛 테스트를 위해 플랫폼 트레이트를 모킹(Mock)합니다:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; struct MockHardware { cpu_temp: f64, gpu_temps: Vec<f64>, } impl HardwareAccess for MockHardware { type Error = std::io::Error; fn read_cpu_temperature(&self) -> Result<f64, Self::Error> { Ok(self.cpu_temp) } fn read_gpu_temperature(&self, index: u32) -> Result<f64, Self::Error> { self.gpu_temps.get(index as usize) .copied() .ok_or_else(|| std::io::Error::new( std::io::ErrorKind::NotFound, format!("GPU {index}를 찾을 수 없음") )) } fn list_pci_devices(&self) -> Result<Vec<PciDevice>, Self::Error> { Ok(vec![]) // 모킹된 값 반환 } fn send_ipmi_command(&self, _cmd: &IpmiCmd) -> Result<IpmiResponse, Self::Error> { Ok(IpmiResponse::default()) } } #[test] fn test_thermal_check_with_mock() { let hw = MockHardware { cpu_temp: 75.0, gpu_temps: vec![82.0, 84.0], }; let result = run_thermal_diagnostic(&hw); assert!(result.is_ok()); } } }
적용 사례: Linux 우선, Windows 대응 완료
이 프로젝트는 이미 부분적으로 Windows를 지원할 준비가 되어 있습니다. cargo-hack을 사용하여 모든 기능 조합을 검증하고, Linux에서 Windows용으로 교차 컴파일하여 테스트해 보세요.
이미 완료된 작업:
platform::run_command에서 쉘 선택을 위해#[cfg(windows)]사용- 테스트 코드에서 플랫폼별 적합한 명령어를 실행하기 위해
#[cfg(windows)]/#[cfg(not(windows))]사용
Windows 지원 강화를 위한 권장 단계:
1단계: 플랫폼 추상화 트레이트 추출 (현재 → 2주 소요)
├─ core_lib에 HardwareAccess 트레이트 정의
├─ 기존 Linux 코드를 LinuxHardware 구현체로 래핑
└─ 모든 진단 모듈이 Linux 전용 코드 대신 트레이트에 의존하도록 수정
2단계: Windows 스텁(Stub) 추가 (2주 소요)
├─ TODO 주석이 포함된 WindowsHardware 구현
├─ x86_64-pc-windows-msvc 타겟에 대한 CI 빌드 추가 (컴파일 확인용)
└─ 모든 플랫폼에서 MockHardware를 사용해 테스트 통과 확인
3단계: 실제 Windows 기능 구현 (진행 중)
├─ ipmiutil.exe 또는 OpenIPMI Windows 드라이버를 통한 IPMI 지원
├─ accel-mgmt (accel-api.dll)를 통한 GPU 지원 — Linux와 동일한 API 사용
├─ Windows Setup API (SetupDiEnumDeviceInfo)를 통한 PCIe 지원
└─ WMI (Win32_NetworkAdapter)를 통한 NIC 지원
교차 플랫폼 CI 추가:
# CI 매트릭스에 추가
- target: x86_64-pc-windows-msvc
os: windows-latest
name: windows-x86_64
이를 통해 실제 Windows 기능 구현이 완료되기 전에도 코드베이스가 Windows에서 컴파일되는지 확인할 수 있으며, cfg 관련 실수를 조기에 발견할 수 있습니다.
핵심 통찰: 추상화가 처음부터 완벽할 필요는 없습니다. 먼저 (이미
exec_cmd가 하는 것처럼) 말단 함수에서#[cfg]블록으로 시작하고, 지원하는 플랫폼이 3개 이상으로 늘어나거나 플랫폼별 차이가 커질 때 트레이트로 리팩토링하세요. 성급한 추상화는 단순한#[cfg]블록보다 해로울 수 있습니다.
조건부 컴파일 의사결정 트리
flowchart TD
START["플랫폼 전용 코드가 필요한가?"] --> HOW_MANY{"지원 대상 플랫폼이 몇 개인가?"}
HOW_MANY -->|"2개 (Linux + Windows)"| CFG_BLOCKS["말단 함수에서\n#[cfg] 블록 사용"]
HOW_MANY -->|"3개 이상"| TRAIT_APPROACH["플랫폼 트레이트\n+ 플랫폼별 구현"]
CFG_BLOCKS --> WINAPI{"Windows API 호출이 필요한가?"}
WINAPI -->|"최소한의 호출"| WIN_SYS["windows-sys\n로우 FFI 바인딩"]
WINAPI -->|"다양한 기능 (COM 등)"| WIN_RS["windows 크레이트\n안전하고 Rust다운 래퍼"]
WINAPI -->|"없음\n(단순 #[cfg]만 필요)"| NATIVE["cfg(windows)\ncfg(unix)"]
TRAIT_APPROACH --> CI_CHECK["cargo-hack\n--each-feature"]
CFG_BLOCKS --> CI_CHECK
CI_CHECK --> XCOMPILE["CI에서 교차 컴파일\ncargo-xwin 또는\n네이티브 러너 활용"]
style CFG_BLOCKS fill:#91e5a3,color:#000
style TRAIT_APPROACH fill:#ffd43b,color:#000
style WIN_SYS fill:#e3f2fd,color:#000
style WIN_RS fill:#e3f2fd,color:#000
🏋️ 실습
🟢 실습 1: 플랫폼별 조건부 모듈
#[cfg(unix)]와 #[cfg(windows)] 환경에서 각각 작동하는 get_hostname() 함수를 구현해 보세요. cargo check와 cargo check --target x86_64-pc-windows-msvc를 통해 양쪽 환경 모두에서 컴파일되는지 확인합니다.
솔루션
#![allow(unused)] fn main() { // src/hostname.rs #[cfg(unix)] pub fn get_hostname() -> String { use std::fs; fs::read_to_string("/etc/hostname") .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string() } #[cfg(windows)] pub fn get_hostname() -> String { use std::env; env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown".to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn hostname_is_not_empty() { let name = get_hostname(); assert!(!name.is_empty()); } } }
# Linux 컴파일 확인
cargo check
# Windows 교차 컴파일 확인
rustup target add x86_64-pc-windows-msvc
cargo check --target x86_64-pc-windows-msvc
🟡 실습 2: cargo-xwin으로 Windows용 교차 컴파일
cargo-xwin을 설치하고 Linux에서 x86_64-pc-windows-msvc용 간단한 바이너리를 빌드해 보세요. 출력 결과물이 .exe 파일인지 확인합니다.
솔루션
cargo install cargo-xwin
rustup target add x86_64-pc-windows-msvc
cargo xwin build --release --target x86_64-pc-windows-msvc
# Windows SDK 헤더 및 라이브러리를 자동으로 다운로드함
file target/x86_64-pc-windows-msvc/release/my-binary.exe
# 출력 예시: PE32+ executable (console) x86-64, for MS Windows
# Wine이 설치되어 있다면 테스트 가능:
wine target/x86_64-pc-windows-msvc/release/my-binary.exe
핵심 요약
- 말단 함수에서는
#[cfg]블록으로 시작하고, 플랫폼 간 차이가 커질 때 트레이트로 리팩토링하세요. windows-sys는 로우 FFI용이며,windows크레이트는 안전하고 관용적인 래퍼를 제공합니다.cargo-xwin을 사용하면 Windows 머신 없이도 Linux에서 Windows MSVC ABI로 교차 컴파일할 수 있습니다.- Linux 서비스만 제공하더라도 CI에서
--target x86_64-pc-windows-msvc를 주기적으로 체크하여cfg관련 실수를 유의하세요. #[cfg]를 Cargo 기능과 조합하여 선택적인 플랫폼 지원(예:feature = "windows")을 구현하세요.
11. 종합 정리 — 운영 환경용 CI/CD 파이프라인 🟡
학습 내용:
- 다단계 GitHub Actions CI 워크플로 구성 (검사 → 테스트 → 커버리지 → 보안 → 교차 빌드 → 릴리스)
rust-cache와save-if설정을 이용한 캐싱 전략- Nightly 스케줄에 따른 Miri 및 새니타이저 실행
Makefile.toml과 pre-commit 훅을 이용한 작업 자동화cargo-dist를 이용한 자동 릴리스참조: 빌드 스크립트 · 교차 컴파일 · 벤치마킹 · 코드 커버리지 · Miri/새니타이저 · 의존성 관리 · 릴리스 프로필 · 컴파일 타임 도구 ·
no_std· Windows
개별 도구들도 유용하지만, 모든 푸시마다 이들을 자동으로 조율하는 파이프라인은 개발 경험을 혁신적으로 바꿔놓습니다. 이 장에서는 1~10장에서 다룬 도구들을 하나의 응집력 있는 CI/CD 워크플로로 통합합니다.
1. 전체 GitHub Actions 워크플로
모든 검증 단계를 병렬로 실행하는 단일 워크플로 파일 예시입니다.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
CARGO_ENCODED_RUSTFLAGS: "-Dwarnings" # 경고를 에러로 처리 (최상위 크레이트 전용)
jobs:
# ─── 1단계: 빠른 피드백 (< 2분) ───
check:
name: Check + Clippy + Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Check compilation
run: cargo check --workspace --all-targets --all-features
- name: Clippy lints
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: Formatting
run: cargo fmt --all -- --check
# ─── 2단계: 테스트 (< 5분) ───
test:
name: Test (${{ matrix.os }})
needs: check
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --workspace
# ─── 3단계: 교차 컴파일 (< 10분) ───
cross:
name: Cross (${{ matrix.target }})
needs: check
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
use_cross: true
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: taiki-e/install-action@cross
- name: Build
run: cross build --release --target ${{ matrix.target }}
# ─── 4단계: 커버리지 (< 10분) ───
coverage:
name: Code Coverage
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage
run: cargo llvm-cov --workspace --fail-under-lines 75
2. CI 캐싱 전략
Swatinem/rust-cache@v2는 Rust CI의 표준입니다. 대규모 워크스페이스에서는 다음과 같은 튜닝이 필요합니다.
save-if활용:main브랜치에서만 캐시를 저장하고, PR에서는 읽기만 하도록 설정하여 캐시가 오염되는 것을 방지합니다.prefix-key: 캐시 크기가 너무 커지면(>5GB) 접두사를 변경하여 캐시를 초기화하세요.- 타겟별 분리: 교차 컴파일 타겟마다 별도의 캐시 키를 사용하세요.
3. cargo-make를 이용한 작업 자동화
cargo-make는 플랫폼에 독립적인 작업 실행 도구입니다.
# Makefile.toml 예시
[tasks.dev]
description = "CI와 동일한 로컬 검증 실행"
dependencies = ["check", "test", "clippy", "fmt-check"]
[tasks.coverage]
description = "HTML 커버리지 보고서 생성 및 열기"
install_crate = "cargo-llvm-cov"
command = "cargo"
args = ["llvm-cov", "--workspace", "--html", "--open"]
이제 cargo make dev 한 번으로 모든 로컬 검증을 마칠 수 있습니다.
4. 자동 릴리스: cargo-release 및 cargo-dist
cargo-release: 버전 번호 올리기, 태그 생성,Cargo.lock업데이트를 자동화합니다.cargo-dist: GitHub Releases에 업로드할 바이너리 아카이브와 설치 스크립트를 생성합니다.cargo dist init명령어로 손쉽게 시작할 수 있습니다.
핵심 요약
- 병렬 실행 — 빠른 검증(check)을 통과한 후 무거운 작업들(test, cross, coverage)을 병렬로 돌려 시간을 단축하세요.
- 캐시 관리 —
main브랜치 위주의 캐시 저장 정책으로 PR 빌드 속도를 높이세요. - 로컬 자동화 — CI와 동일한 환경을 로컬에서
cargo-make로 재현하여 커밋 전 실수를 방지하세요. - 배포 자동화 — 버전 관리와 바이너리 배포를 도구화하여 실수를 줄이고 고품질의 아티팩트를 제공하세요.
12. 실전 팁과 요령 (Tricks from the Trenches) 🟡
학습 내용:
- 한 장에 담기 힘든 실전에서 검증된 패턴들
- CI 불안정성(flake)이나 바이너리 비대화와 같은 흔한 문제들에 대한 해결책
- 오늘 바로 적용할 수 있는 빠른 성과를 내는 기술들
참조: 이 책의 모든 장 — 이 팁들은 모든 주제에 걸쳐 적용됩니다.
이 장에서는 실제 운영 환경의 Rust 코드베이스에서 반복적으로 나타나는 엔지니어링 패턴들을 모았습니다. 각 팁은 독립적이므로 순서와 상관없이 읽으셔도 좋습니다.
1. deny(warnings) 함정 피하기
문제: 소스 코드에 #![deny(warnings)]를 직접 넣으면 Clippy가 새로운 린트를 추가할 때 어제는 컴파일되던 코드가 갑자기 실패할 수 있습니다.
해결: 소스 코드 대신 CI 환경 변수(CARGO_ENCODED_RUSTFLAGS)를 사용하세요.
# CI: 소스 수정 없이 경고를 에러로 처리
env:
CARGO_ENCODED_RUSTFLAGS: "-Dwarnings"
2. 빌드는 한 번, 테스트는 어디서나
문제: cargo test는 --lib, --doc, --test 사이를 전환할 때마다 프로필이 다르기 때문에 재컴파일이 발생합니다.
해결: 유닛/통합 테스트에는 cargo nextest를 사용하고 문서 테스트는 따로 실행하세요.
cargo nextest run --workspace # 빠름: 병렬, 캐싱됨
cargo test --workspace --doc # 문서 테스트 (nextest는 지원 안 함)
3. 기능 플래그(Feature Flag) 관리
문제: 라이브러리가 기본값으로 std를 사용하지만 아무도 --no-default-features를 테스트하지 않으면, 언젠가 임베디드 사용자가 컴파일 오류를 보고하게 됩니다.
해결: CI에 cargo-hack을 추가하여 모든 기능 조합을 검사하세요.
- name: Feature matrix
run: |
cargo hack check --each-feature --no-dev-deps
4. Lock 파일: 커밋할 것인가, 무시할 것인가?
권장 규칙:
- 바이너리/애플리케이션: 예 (재현 가능한 빌드를 위해
Cargo.lock커밋) - 라이브러리: 아니요 (사용자가 버전을 선택할 수 있도록
.gitignore에 추가) - 둘 다 있는 워크스페이스: 예 (바이너리 규칙이 우선함)
5. 최적화된 의존성을 이용한 디버그 빌드
문제: serde, regex 같은 무거운 의존성들이 최적화되지 않아 디버그 빌드의 실행 속도가 너무 느립니다.
해결: 개발용 프로필에서 의존성만 최적화하도록 설정하세요.
# Cargo.toml
[profile.dev.package."*"]
opt-level = 2 # 개발 모드에서 모든 의존성 최적화
6. RUSTFLAGS vs CARGO_ENCODED_RUSTFLAGS
문제: RUSTFLAGS="-Dwarnings"는 빌드 스크립트와 proc-macro까지 모두 에러로 처리하여 외부 라이브러리 경고 때문에 빌드가 깨질 수 있습니다.
해결: **CARGO_ENCODED_RUSTFLAGS**를 사용하면 최상위 크레이트에만 적용되어 더 안전합니다.
7. cargo tree를 이용한 중복 제거 루틴
문제: syn 버전이 5개, tokio-util 버전이 3개나 있어서 컴파일이 너무 오래 걸립니다.
해결: cargo tree --duplicates로 중복을 찾고 cargo update -p <패키지>로 버전을 통합하세요. 정기적으로 이 작업을 수행하면 컴파일 시간을 5~15% 단축할 수 있습니다.
핵심 요약
- 에러 처리의 분리 — 소스 코드보다는 CI 환경에서 경고 에러 여부를 관리하세요.
- 개발 생산성 —
opt-level = 2설정을 통해 의존성 실행 속도를 높이고 개발 주기를 단축하세요. - 가벼운 의존성 —
cargo tree와cargo update를 습관화하여 컴파일 시간과 바이너리 크기를 최적화하세요. - 로컬 검증 —
cargo make나 pre-push 훅을 이용해 실수 없는 커밋 문화를 만드세요.
빠른 참조 카드 (Quick Reference Card)
명령어 요약: 한눈에 보는 가이드
# ─── 빌드 스크립트 (Build Scripts) ───
cargo build # build.rs를 먼저 컴파일한 후 크레이트 빌드
cargo build -vv # 상세 출력 — build.rs의 모든 출력을 표시
# ─── 교차 컴파일 (Cross-Compilation) ───
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17
cross build --release --target aarch64-unknown-linux-gnu
# ─── 벤치마킹 (Benchmarking) ───
cargo bench # 모든 벤치마크 실행
cargo bench -- parse # "parse"가 포함된 벤치마크만 실행
cargo flamegraph -- --args # 바이너리에서 플레임그래프 생성
perf record -g ./target/release/bin # perf 데이터 기록
perf report # 대화형으로 perf 결과 확인
# ─── 커버리지 (Coverage) ───
cargo llvm-cov --html # HTML 보고서 생성
cargo llvm-cov --lcov --output-path lcov.info
cargo llvm-cov --workspace --fail-under-lines 80
cargo tarpaulin --out Html # 대안 도구 (Linux용)
# ─── 안전성 검증 (Safety Verification) ───
cargo +nightly miri test # Miri에서 테스트 실행 (UB 감지)
valgrind --leak-check=full ./target/debug/binary # 메모리 누수 확인
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# ─── 감사 및 공급망 (Audit & Supply Chain) ───
cargo audit # 알려진 취약점 스캔
cargo audit --deny warnings # 취약점 발견 시 빌드 실패
cargo deny check # 라이선스 + 보안 + 소스 검증
cargo vet # 공급망 신뢰성 검증
cargo outdated --workspace # 오래된 의존성 찾기
cargo semver-checks # 하위 호환성 깨짐 감지
cargo geiger # 의존성 트리의 unsafe 코드 개수 확인
# ─── 바이너리 최적화 (Binary Optimization) ───
cargo bloat --release --crates # 크레이트별 바이너리 크기 기여도 확인
cargo bloat --release -n 20 # 가장 큰 함수 20개 확인
cargo +nightly udeps --workspace # 사용되지 않는 의존성 찾기
cargo machete # 빠른 사용 중단 의존성 감지
cargo expand --lib module::name # 매크로 확장 결과 확인
cargo msrv find # 최소 지원 Rust 버전(MSRV) 확인
cargo clippy --fix --workspace --allow-dirty # 린트 경고 자동 수정
# ─── 컴파일 타임 최적화 (Compile-Time) ───
export RUSTC_WRAPPER=sccache # 공용 컴파일 캐시 설정
sccache --show-stats # 캐시 적중 통계 표시
cargo nextest run # 더 빠른 병렬 테스트 실행 도구
# ─── 플랫폼 엔지니어링 (Platform Engineering) ───
cargo check --target thumbv7em-none-eabihf # no_std 빌드 확인
cargo build --target x86_64-pc-windows-gnu # Linux에서 Windows용 빌드
cargo xwin build --target x86_64-pc-windows-msvc # MSVC ABI 기반 교차 빌드
# ─── 릴리스 (Release) ───
cargo release patch --dry-run # 릴리스 예행 연습
cargo release patch --execute # 버전 업, 커밋, 태그, 게시 실행
cargo dist plan # 배포용 아티팩트(바이너리) 생성 계획 확인
의사결정 테이블: 언제 어떤 도구를 사용할 것인가?
| 목표 | 도구 | 사용 시점 |
|---|---|---|
| git 해시 / 빌드 정보 포함 | build.rs | 바이너리에 추적성이 필요할 때 |
| Rust와 C 코드 함께 컴파일 | cc 크레이트 | 소규모 C 라이브러리와 연결할 때 |
| 스키마에서 코드 생성 | prost-build | Protobuf, gRPC 스텁 생성이 필요할 때 |
| 시스템 라이브러리 링크 | pkg-config | OpenSSL, libpci, systemd와 연결할 때 |
| 정적 Linux 바이너리 | musl 타겟 | 컨테이너나 클라우드 배포 시 |
| 고령화된 glibc 타겟 | cargo-zigbuild | CentOS 7, RHEL 7 호환성이 필요할 때 |
| ARM 서버용 빌드 | cross / zig | Graviton, Ampere 서버용 배포 시 |
| 통계적 벤치마킹 | Criterion.rs | 정밀한 성능 회귀 감지가 필요할 때 |
| 빠른 성능 확인 | Divan | 개발 중 프로파일링 전 가볍게 확인 시 |
| 핫스팟 식별 | cargo flamegraph | 어느 함수가 느린지 시각화할 때 |
| 라인/브랜치 커버리지 | cargo-llvm-cov | CI 게이트 설정 및 사각지대 분석 시 |
| Rust UB 감지 | Miri | 순수 Rust unsafe 코드를 검증할 때 |
| C FFI 메모리 안전성 | Valgrind | Rust와 C가 혼합된 코드베이스 검증 시 |
| 데이터 레이스 감지 | TSan / Miri | 동시성 기반 코드의 안전성을 확인할 때 |
| 로컬 자동화 | cargo make | 다단계 로컬 검증 과정을 하나로 묶을 때 |
| 자동 릴리스 | cargo-dist | 멀티 플랫폼 바이너리 배포를 자동화할 때 |
| 의존성 취약점 점검 | cargo-audit | 보안 감사가 필수인 프로젝트일 때 |
| 라이선스 준수 | cargo-deny | 상용 프로젝트의 라이선스 정책을 관리할 때 |
| 바이너리 크기 분석 | cargo-bloat | 임베디드나 용량 제한 환경용 빌드 시 |
| 하위 호환성 체크 | cargo-semver | 라이브러리 퍼블리싱 전 브레이킹 체인지 확인 시 |