나는 Rust를 오래전부터 좋아했습니다. 그 장점은 명확하고 단점도 역시 분명합니다. 메모리 Safe, GC 없이, 零成本抽象, 并发安全, 현재 단계에서 가장 좋은 工具链와 配套外围建设, 넓은覆盖面(웹 云服务器부터 단片기까지), 그리고 오류 처리 설계도 매우 훌륭합니다. 단점은 학습 곡선이 가파르고, Ownership, Borrow 같은 모델 제약을 이해하기 어렵고, 코딩 속도가 느리다는 점입니다?
오랫동안 우리는 이것을 Rust의 단점이라고 생각해 왔지만, 실제로 앞의 두 문제는 사고와 인지를 훈련함으로써 해결할 수 있습니다. 하지만 컴파일 속도가 느린 것은 계속해서 내 작업 흐름을 늦추고 있습니다. 몇 년 전만 해도 괜찮았는데, AI가 퍼오기 전에는 인내심이 있었고 알고리즘이나 기능을 새벽까지 작성하곤 했습니다. 그런데 지금은요? 변하지 않았다고 말하기 어렵고, 전혀 인내심이 사라졌습니다. Rust 코드를 어떻게 쓰는지 고민하고 싶지도 않고, Cargo의 긴 진행 표시줄을 기다릴 수도 없습니다. 특히 ld 단계에서 진행이 전혀 보이지 않고 멈춰버리는 상황은 정말 힘듭니다.
이 모든 것에 정말 방법이 없는 걸까요? 사실 방법은 있습니다. 약 한 달 전, 저는 워크플로 최적화를 포함해 Rust 개발 프로세스를 더 빠르게 만들기 위해 몇 가지 방법을 시도해 보았습니다. 하지만 실제 대기 시간은 매번 Cargo에서 막혔습니다. 이것을 해결하지 않으면 근본적인 해결이 되지 않기에, 이번 기회를 빌려 Rust 개발 중 Cargo.toml 설정 튜닝과 관련된 내용 및 이에 수반되는 주변 설정에 대해 이야기해 보고자 합니다.
백엔드 이론
Rust 설계에서는 LLVM이 다소 게으른 면이 있지만, 선택 자체는 훌륭합니다. Go처럼 자체적으로 머신코드 생성 과정을 유지하는 것과 달리, LLVM의 방대한 생태계에 성숙한 최적화를 맡기는 것이 매우 좋습니다. Rust 전체 컴파일 흐름은 대략 다음과 같습니다. 소스 코드를 먼저 렉시컬 분석과 구문 분석하여 AST를 생성하고, 매크로 전개와 HIR 낮추기를 통해 평탄화합니다. 그 다음 일반적인 타입 검사와 약간의 빌림 검사(borrow checking)를 거쳐 최종적으로 MIR을 생성합니다. 여기까지는 모두 Rust 컴파일러 프론트엔드가 담당합니다. MIR 이후의 작업은 LLVM에 맡기는데, 예를 들어 MIR을 LLVM IR로 변환하고 LLVM이 O0에서 O3까지의 최적화 패스를 실행합니다. 마지막으로 목표 플랫폼의 머신코드를 생성하고, 링커가 이를 결합해 실행 가능한 BIN 파일을 만듭니다.
그렇다면 대체 문제는 어디에 있는 걸까요? 답은 사실 매우 간단합니다. LLVM은 매우 무거운 인프라스트럭처이며, 프로덕션 환경에서 무겁다는 것은 옳습니다. 컴파일 시간을 바쳐 실행 시간을 얻는 것은 훌륭한 철학입니다. LLVM은 상위의 Rust뿐만 아니라 C, C++, Swift 등 수많은 언어를 이어주는 다리 역할을 합니다. 이는 LLVM의 최적화 파이프라인이 '빠른 컴파일 완료'가 아니라 '최적의 머신코드 생성'을 위해 설계되었음을 의미합니다. release 모드에서 수십 개의 최적화 pass를 한 번 돌리는 것은 엄청난 시간이 소요되며, debug 모드의 O0일지라도 LLVM은 IR 생성과 머신코드 생성의 전 과정을 그대로 거쳐야 하고, 이 과정 자체도 결코 가볍지 않습니다. 여기에 더해 Rust의 제네릭 단일화가 컴파일 시점에 엄청난 양의 코드를 전개하기 때문에, LLVM에 전달되는 IR의 양은 소스 코드에서 보이는 것보다 훨씬 커지며, LLVM이 처리해야 할 작업량도 자연스럽게 함께 부풀어 오르게 됩니다.
Cranelift
본질적으로, 컴파일이 느린 시간의 대부분은 Rust 자체 프론트엔드가 아니라 LLVM이라는 “무거운 백엔드”에 소비됩니다. 여기서 질문이 나온다: 이를 없앨 수 있을까? 답은 있다, 냥. 이 요구를 위해 만들어진 백엔드가 Cranelift이다. 이는 원래 Cretonne이라는 이름으로 2016년에 시작돼 Bytecode Alliance 가 개발했으며, 처음에는 Wasmtime 패키지를 위한 코드 생성 백엔드로 설계되었습니다; 이후 Rust 공식 팀이 채택해 선택적 codegen 백엔드로 사용하고 있습니다.

그렇다면 Cranelift는 대체 어디서 빨라야 할까요? 가장 좋은 단계는 바로 개발 시기입니다. 이 시기에는 실행 효율이 극도로 최적화된 거의 완벽한 머신 코드가 아예 필요하지 않을 수도 있습니다. 단지 빠른 피드백만 얻을 수 있다면 충분할지도 모르죠. 이 그다지 훌륭하지 않은 머신 코드가 LLVM이 제대로 뽑아낸 머신 코드와 의미론적으로 동일하기만 하다면 말입니다. 초기 Cranelift는 수많은 Edge Case 때문에 사실 이렇게 하지 못했습니다. 지금도 여전히 존재하긴 하지만 많이 개선되었으며, 현재 Cranelift에서는 잘 돌아가는데 LLVM에서 깨질 확률은 Rust를 작성하다가 rustc ICE를 유발할 확률과 거의 비슷합니다. LLVM은 극도로 최적화된 머신 코드를 생성하기 위해 루프 언롤링, 벡터화, 상수 전파, 데드 코드 제거 등 수십 개의 최적화 pass를 실행합니다... 정말 수많은 과정을 한 땀 한 땀 다듬어 나가는 것이죠. 반면 Cranelift의 설계 철학은 전혀 다릅니다. 이러한 최적화 단계를 대폭 축소하여 가장 기본적인 레지스터 할당과 명령어 선택만 수행하고, 반복적인 이터레이션 없이 단 한 번의 선형 스캔으로 코드 생성을 완료합니다. 동시에 IR 설계 역시 훨씬 가벼우며, 수십 년간의 범용화 부담을 짊어진 LLVM IR과 달리 상위 IR에서 머신 코드로 빠르게 변환되도록 전용으로 설계되었습니다.
그럼 장점에 대해 다 이야기했으니, 대가는 무엇일까요? 우선 가장 명확한 단점은 Cranelift가 생성한 코드의 런타임 성능이 LLVM보다 떨어지며, 시나리오에 따라 약 10%~30% 정도 느리다는 점입니다. 하지만 생각을 바꿔보면 개발 단계에서는 이게 전혀 중요하지 않다는 사실을 깨닫게 됩니다. 왜 그렇게 빨라야 할까요? 벤치마크 점수를 내는 중인가요, 아니면 CI release를 돌리는 중인가요? 나는 그저 cargo build가 좀 더 빨리 돌아가서 로직이 제대로 나오는지 확인하고 싶을 뿐입니다. 그리고 장담하건대, 여러분이 작성하는 대부분의 프로그램은 80% 이상의 시간 동안 계산 리소스를 꽉 채워 쓰지 않습니다. 당연히 비즈니스 요청이 들어와야 동작할 텐데, 개발 중에 비즈니스 요청이 커 봤자 얼마나 크겠습니까? CPU는 아마 내내 농땡이를 치고 있을 겁니다. 오히려 내가 원하는 건 결과를 보고 로직을 검증하는 것이죠. 그렇다면 컴파일 시간과 런타임 효율을 맞바꾸는 셈법은 개발 루프에서 오히려 반대로 계산해야 하는 게 아닐까요?
코드 생성 단위
또한 codegen-units에 대해 설명하겠습니다. 이것은 실제로 컴파일러가 crate를 백엔드가 병렬 처리할 최소 단위로 몇 개로 나눌지를 제어하는 매개변수입니다. 기본값으로 디버그 모드에서는 256개, 릴리스 모드에서는 16개입니다. 숫자가 클수록 병렬도가 높아져 컴파일 속도가 빨라집니다—여러 CPU 코어를 동시에 사용해 백엔드 코드 생성을 수행할 수 있기 때문입니다. 그러나 그 대가로 최적화 효과가 감소합니다. 각 단위가 독립적으로 최적화되므로 LLVM(또는 Cranelift)이 보는 컨텍스트가 작아지고, 단위 간 인라인 및 최적화 기회가 줄어듭니다.
하지만 실제 Release에서는 일반적으로 codegen-units = 1와 같은 극단적인 옵션을 사용해야 합니다. 이렇게 해야만 제품이 최적화를 최대화할 수 있습니다. 결국 Rust를 사용하고 있으니 컴파일 시간을 희생해 런타임 성능을 얻는 것이 당연하지 않나요.
최적화 수준
간과하기 쉽지만 조절할 수 있는 또 다른 설정은 opt-level입니다. 기본값은 "3"이어야 하지만, Release 빌드에서는 "z"로 설정하는 것이 더 일반적입니다. 코드를 수정하지 않고도 얻을 수 있는 무료 아티팩트 크기 최적화인데, 설정하지 않을 이유가 없겠죠. 하지만 개발 모드에서는 최적화를 전혀 하지 않는 "0"으로 설정해야 가장 빠른 속도를 얻을 수 있습니다.
또 하나의 작은 팁은 Cargo.toml 로, 실제로 의존성 및 자신의 코드에 서로 다른 최적화 수준을 설정할 수 있다는 것입니다.
[profile.dev.package."*"]
opt-level = 3이렇게 하면 서드파티 의존성을 O3로 컴파일할 수 있으며, 이는 첫 번째 콜드 컴파일 시간만 늘릴 뿐 이후에는 증분 컴파일됩니다. 외부 의존성은 일반적으로 자주 업데이트되지 않으므로, 의존성에는 O3를 사용하고 본인의 비즈니스 코드에는 O0를 사용하면 일반적인 상황에서 속도와 용량을 적절히 조율한 좋은 dev 모드 환경을 얻을 수 있습니다. 하지만 Release 모드에서는 군말 없이 O3나 Z를 최대로 올리는 것을 추천합니다.
링크 타임 최적화
LTO(Link‑Time Optimization)는 메모리와 컴파일 성능을 많이 잡아먹는 또 다른 요소이며, 대부분의 프로젝트에서 Release 빌드 시 활성화하는 것이 권장됩니다. 일반 컴파일에서는 각 crate가 독립적으로 최적화되고, 컴파일러 백엔드는 crate 간 호출 관계를 알 수 없으므로 일부 크로스‑crate 인라인 및 죽은 코드 제거가 불가능한데, 이런 경우가 매우 많습니다. LTO는 링크 단계에서 모든 crate의 IR을 받아 이 경계를 깨고 전역 최적화를 수행합니다. 하지만 프로파일에 무분별하게 lto = true을 붙이는 것은 절대 금물입니다. 그렇게 하면 Rust 컴파일 속도가 너무 느려져 인생을 회의하게 되니, Release 프로파일을 사용할 때만 붙이도록 하세요.
디버그 심볼 제거
다른 것들과 마찬가지로 Rust의 debuginfo와 symbols는 바로 여기에 존재합니다. release 모드에서 strip을 활성화하면 크기를 상당히 줄일 수 있는데, (보통 50MB vs 5MB) 정도의 엄청난 차이입니다. symbols를 제거하는 것은 보안 측면에서도 더 안전합니다. 결국 대부분의 프론트엔드 source code 유출은 map에서 실수로 npm에 push되어 발생하니까요 :(
그래서 dev profile에서는 별다를 말할 것이 없어요, debuginfo를 strip 할 수는 없잖아요, 그러면 gdb / lldb을 정상적으로 디버깅할 수 없고 backtrace에서도 의미 있는 함수명을 볼 수 없습니다. 하지만 ArchLinux에서 패키징할 때 기본적으로 strip이 적용되는데, 여기서는 우리가 이미 strip을 했기 때문에 약간 충돌이 있습니다. 그 결과 나중에 오류가 발생할 수 있습니다. AUR을 작성할 때는 이 단계를 명시적으로 건너뛰는 것을 고려하십시오.
panic! 포기하기
일반적인 비즈니스 로직에서 panic 은 존재해서는 안 됩니다. 일반적인 비즈니스 코드가 runtime에서 에러를 만났을 때, 결함 허용(fault tolerance)이 설계된 Err라면 폭력적으로 panic을 일으키는 대신 안전하게 반환되어야 합니다. React ErrorBoundary와 마찬가지로 정상적인 에러로 처리되어야 하며, panic은 코드가 복구 불가능한 불가능한 상태에 진입했다고 확신할 때만 발생해야 합니다. 저 개인의 철학은 panic을 포기하는 것인데, 왜일까요? 실제로 비즈니스 코드는 3단계 테스트로 나눌 수 있기 때문입니다. 첫 번째 단계는 happy path, 두 번째 단계는 에러 경로(무한한 가능성 중 어느 하나), 세 번째 단계가 비로소 fuzz나 충돌 테스트로 구한 Edge Case입니다. 공학적으로 볼 때 정상 경로는 100% 테스트할 수 있고, 에러 경로는 한 유형의 n가지 변형 중 1가지를 선택할 수 있습니다. 충돌 테스트는 순전히 시간 문제일 뿐 대부분의 경우 그럴 가치가 없으며, 사용자 규모가 실제로 커지면 직접 언제 해야 할지 알게 될 것입니다. 보통 저는 전혀 하지 않습니다.
좋은 코드라면 올바른 경로에 대한 테스트 1가지와 최소 하나의 오류 경로에 대한 테스트를 포함해야 합니다. 오류 경로는 하나만 커버하면 Err가 반환되는 상황을 테스트할 수 있어 fallback 로직을 작성할 수 있고, 자연스럽게 thiserror 열거하거나 anyhow 로 인터셉트해 로그를 평탄화할 수 있습니다. 요컨대, 코드는 이러한 상황을 처리하는 메커니즘을 강제로 작성하게 합니다. 이 단계까지 구현했다면 panic 은 필요 없게 되는데, 이를 “이론적으로 불가능”이라고 이해할 수 있지만 이것도 이론에 불과합니다. 실제 세계에는 OS 오류, 메모리 오류, 우주선량에 의한 단일 비트 플립, 기이한 오버플로우 등이 존재합니다… 이런 경우를 모두 커버할 수는 없습니다. 그렇다면 왜 panic을 피해야 할까요? 이러한 상황은 대부분 여러분의 코드 문제와는 관계가 없으며, 90 % 정도는 여러분의 코드가 원인이 아닙니다. panic 의 본질은 발생 시 Rust가 호출 스택을 거꾸로 따라가면서 각 프레임의 소멸자(drop)를 호출해 자원을 정리한다는 것입니다. 이 과정에는 컴파일러가 추가적인 unwind 테이블을 생성해야 하는데, 이는 비즈니스 로직 오류 지점을 찾는 데 도움을 줍니다. 오류가 대부분 여러분의 것이 아니라면 그 정보는 가치가 없으며, 바이너리 크기도 늘리고 링커 작업량도 증가시킵니다—비활성화하는 것이 정답입니다. 코드를 충분히 테스트하고 자신감이 있다면 panic = abort 를 설정해 보세요.
실제 벤치마크
그렇게 많이 말해도 소용없다. 실제로 이 조합들에서 얻은 향상을 살펴보자. 여기서는 몇 달 전에 만든 작은 장난감 프로젝트를 예시로 사용한다.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Language Files Lines Code Comments Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CSS 5 152 113 16 23
Go 77 14556 12409 584 1563
JavaScript 23 2506 1999 232 275
JSON 93 1780 1780 0 0
Just 1 408 275 71 62
Makefile 1 15 8 3 4
Shell 16 1440 1075 166 199
SVG 1 9 9 0 0
TOML 20 502 419 3 80
TSX 75 3257 2718 132 407
TypeScript 390 36643 30350 1627 4666
─────────────────────────────────────────────────────────────────────────────────
HTML 2 41 32 7 2
|- CSS 1 37 37 0 0
(Total) 78 69 7 2
─────────────────────────────────────────────────────────────────────────────────
Markdown 95 5344 0 3578 1766
|- BASH 3 8 8 0 0
|- Go 2 21 21 0 0
|- HTML 1 25 15 10 0
|- JSON 6 317 317 0 0
|- Rust 1 10 9 0 1
|- TSX 1 11 9 0 2
(Total) 5736 379 3588 1769
─────────────────────────────────────────────────────────────────────────────────
Rust 181 31654 26951 1032 3671
|- Markdown 86 581 0 564 17
(Total) 32235 26951 1596 3688
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total 980 99317 78554 8025 12738
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━이 프로젝트의 Rust 부분은 의존성 크기를 제외하고 대략 32K SCoL Rust 입니다.
이 저장소는 실제로 많은 하위 프로젝트를 포함한 monorepo이며, 그 안에서 두 가지 전형적인 예를 찾아볼 수 있습니다. 먼저 Skeleton 패키지를 살펴보면 특징이 명확합니다. 코드 양이 많고 의존성이 거의 없어 전체 프로젝트 중 codegen 비중이 가장 높으며, 이는 Cranelift가 뛰어난 성능을 발휘하기에 적합합니다. 또한 이 패키지는 매우 깔끔한데, 여기서 깔끔함은 순수 Safe Rust로 이루어졌다는 뜻입니다.
반면에 아래의 CLI 패키지는 그다지 적절하지 않습니다. 의존성이 많은 것 자체는 문제가 아니며, 이론적으로는 이것이 효율성을 더 잘 보여줄 수 있지만, 여기에는 클래식한 둘 중 하나의 선택이 존재합니다. 왼쪽 상단에서 ring 의존성이 선택 사항으로 표시된 것을 볼 수 있습니다. 이는 프로젝트가 원래 aws-lc-rs을(를) 사용했기 때문이며, 이 두 가지 모두 Rust의 암호화 알고리즘 Backend입니다. 하지만 왜 선택 사항이어야 할까요? 그것은 ring이(가) 순수 Rust로 작성된 반면, 다른 하나는 Asm 어셈블리 FFI로 들어와 x86, arm64 등 주요 cpu 아키텍처를 위한 어셈블리 가속용으로 전용 작성되었기 때문입니다. 하지만 패배의 원인도 바로 여기에 있습니다. Cranelift의 마법은 순수 Rust에만 국한되기 때문에, Unsafe Code나 FFI C, ASM 등을 도입하는 순간 실제로는 Cranelift의 호환성이 극도로 악화됩니다.
하지만 이것이 해결 불가능한 것은 아닙니다. 실제로 cfg로 백엔드를 선택하고 Cranelift 모드에서는 ring을 컴파일러에 전달하면 됩니다. 위에 설명한 대로 dev와 release 프로파일을 설정한 뒤, 간단히 CLI 컴파일 비교를 실행할 수 있습니다. 이 경우 개발 빌드는 릴리즈보다 최소 3배 빠르며, 이는 모두 콜드 컴파일이며 증분 빌드가 없는 상황입니다; 또한 O0를 사용하고 LTO를 비활성화하면, dev 프로파일은 이후 증분 업데이트에서 릴리즈보다 수십 배 더 빠를 것입니다. LTO와 같은 매직은 실제로 각 crate의 경계를 평탄화함으로써 작동하므로, 모두 하나의 전체가 되면 코드 한 곳을 수정하는 것만으로도 전체를 다시 컴파일해야 하며, 올바른 증분 업데이트가 불가능해집니다.
![이 스크린샷은 터미널 창(제목: "~/C/P/seam")에서 "seam" 프로젝트에 대해 `cargo build --release`를 실행한 최종 부분을 보여줍니다. 스크롤에는 walkdir, rand, tungstenite, sha2, toml, notify, rquickjs, clap 등 수십 개의 의존성 크레이트가 순차적으로 컴파일되는 목록이 표시되고, 이어서 seam-codegen, seam-skeleton, seam-injector-wasm, seam-engine-wasm, seam-server, seam-server-axum, seam-cli 등 프로젝트 자체 워크스페이스 구성원과 몇몇 예시 백엔드(demo-server-rust, github-dashboard-axum, markdown-demo-rust, i18n-demo-axum)가 나타납니다. 마지막 줄에는 "Finished `release` profile [optimized] target(s) in 1m 05s" 라는 성공 메시지가 나오고, 그 뒤에 `canmi@xyy ~/C/P/seam (main)>` 라는 대기 중 쉘 프롬프트가 표시됩니다. 이는 WASM 및 Axum 서버 구성 요소를 포함한 다중 크레이트 Rust 모노레포가 깨끗하고 완전하게 릴리즈 빌드된 증거입니다.](https://cdn.ffoni.com/image/e8b530aa153a78f1cdea3d2980f80ecd.jpeg)
![터미널 스크린샷은 Rust 워크스페이스 “seam”의 Cargo 빌드 로그 말미를 보여줍니다. 제목은 `~/C/P/seam`이며, 셸은 사용자 `canmi`가 `main` 브랜치에서 실행하고 있습니다. 수십 개의 의존 크레이트가 순차적으로 컴파일됩니다 (console, reqwest, clap, tokio-tungstenite, notify, indicatif 등). 이어서 프로젝트 자체 크레이트들—seam-codegen, seam-server-axum, seam-skeleton, seam-injector-wasm, seam-engine-wasm, seam-cli—모두 버전 0.5.38이며, 몇몇 예제 백엔드(github-dashboard-axum, i18n-demo-axum, markdown-demo-rust, demo-server-rust)도 포함됩니다. 로그는 `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 19.23s` 로 끝나며, 예제 애플리케이션을 포함한 전체 워크스페이스가 성공적으로 디버그 빌드되었음을 나타냅니다.](https://cdn.ffoni.com/image/71aa2631cd4c616d2ecca6e510cf06f4.jpeg)
종합적인 결과를 보면 이 둘은 사실 차이가 그리 크지 않습니다. 비약적인 변화까지는 아니더라도 확실히 체감할 수 있는 수준인데, 주요 원인은 Apple Silicon이 너무 강력하기 때문입니다. M 시리즈 칩의 싱글 코어 성능과 메모리 대역폭이 말도 안 되게 뛰어나서, 컴파일 같은 고연산+고I/O 작업이 이러한 장점을 그대로 누리게 됩니다. 보통 Linux에서 돌려보면 차이가 더 크게 나겠지만, MacBook에 Asahi Linux 같은 것을 올려서 실행한다면 단연 Linux가 이길 것입니다.
오래된 링커
Linux에서 Rust는 기본적으로 GNU ld(bfd)를 사용합니다. 맞습니다, 가장 오래되고 가장 느린 바로 그것입니다. 싱글 스레드인데다 심볼 해석과 재배치가 모두 직렬 스캔으로 처리되어 프로젝트가 커지면 확연히 발목을 잡게 됩니다. Linux에서는 @Rui Ueyama 님이 작성한 mold를 사용해 볼 수 있으며, 설정도 매우 간단하여 .cargo/config.toml 안에 한 줄만 추가하면 됩니다.
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
성능 향상은 여전히 매우 체감되지만, 아쉽게도 macOS에는 sold라는 상용 프로젝트만 존재합니다. 하지만 다행스러운 소식은 macOS의 lld나 Apple 자체 ld64 속도가 이미 결코 느리지 않다는 점입니다. 특히 Xcode 15 이후의 새 링커는 속도 향상이 엄청나게 눈에 띕니다. 다만 이 녀석의 유일한 단점은 무인 macOS CI 및 업데이트 후에 매번 sudo Accept로 약관에 동의해야 해서, 제 CI 정기 작업이 몇 번이나 뻗었다는 점입니다.
MUSL
저는 개인적으로 MUSL 덕후이고 GNU glibc를 정말 싫어하지만, 개발할 때는 그래도 x86_64-unknown-linux-gnu 사용을 추천합니다. 왜냐하면 빠르기 때문입니다. MUSL의 가장 큰 장점은 완전한 정적 링크 바이너리를 생성할 수 있다는 것입니다. 시스템의 어떠한 동적 라이브러리에도 의존하지 않아, 컴파일된 결과물을 어떤 Linux 머신에든 복사하면 바로 실행할 수 있어서 컨테이너와 임베디드에 특히 적합합니다. 하지만 느린 이유 역시 정적 링크에 있습니다. 링커가 모든 것을 포함하여 패키징해야 하므로, 링크 단계의 작업량이 동적 링크보다 훨씬 큽니다.
하지만 다행히도 macOS를 사용하고 있다면 전혀 걱정할 필요가 없습니다. aarch64-apple-darwin이(가) 현재 유일한 선택지입니다. macOS는 설계상 완전한 정적 링크를 지원하지 않는데, 컴파일 속도 측면에서는 오히려 잘된 일입니다. 따라서 dev profile에서는 Linux에서 gnulibc, macOS에서 libSystem을 사용하고, release profile에서는 macOS와 Linux 모두에서 musl을 사용할 수 있습니다. macOS의 기본 linker 및 ar 선택 문제로 인해 유의할 점은 .cargo/config.toml을(를) 설정해야 할 수도 있다는 것뿐입니다.
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
ar = "x86_64-linux-musl-ar"덧붙여서 zig는 절대 사용하지 마세요. nightly Rust의 zbuild가 현재 큰 문제를 가지고 있습니다. 교차 컴파일이 필요하고 호스트가 Linux x86인 경우에는 cross를 권장합니다. Docker를 활용해 빌드하면 환경이 모두 갖춰져 매우 편리합니다. glibc를 선호한다면 그것도 좋습니다. glibc는 하위 호환만 보장하기 때문에 일반적으로 Debian 주요 버전(예: Debian‑2)의 glibc 버전으로 컴파일합니다. 너무 최신 glibc는 많은 머신이 실행되지 않게 만들 수 있습니다. 이는 새로운 기능을 사용했기 때문이 아니라 단순히 버전 문자열을 판단해 여러분을 화나게 하려는 것입니다. 호스트가 mac인 경우에는 rustup을 원래대로 사용하십시오 target + cargo build --release .
UPX는 나쁜 것 입니다
논의해 볼 만한 또 다른 포인트는 바로 UPX 사용에 관한 것입니다. 제가 musl을 좋아한다고 하면서 동시에 작은 용량을 추구하는 것이 사실 꽤 모순되고 말이 안 되게 들린다는 점은 저도 압니다...
ooooo ooo ooooooooo. ooooooo ooooo
`888' `8' `888 `Y88. `8888 d8'
888 8 888 .d88' Y888..8P
888 8 888ooo88P' `8888'
888 8 888 .8PY888.
`88. .8' 888 d8' `888b
`YbodP' o888o o888o o88888o
The Ultimate Packer for eXecutables
Copyright (c) 1996-2026 Markus Oberhumer, Laszlo Molnar & John Reiser
https://upx.github.io하지만 실제로 upx의 존재 덕분에 이 문제는 해결됩니다. UPX의 원리는 BIN을 압축한 후 해제 stub으로 감싸, 실행 시 먼저 메모리에서 해제한 뒤 실행하는 방식입니다. 이렇게 하면 바이너리 크기를 원래의 30%~50% 정도로 압축할 수 있어 크기 이점이 매우 명확해지지만, 바로 이 점 때문에 UPX + GNU glibc 동적 링크는 큰 문제를 일으킵니다. glibc 동적 링크 바이너리에는 특수한 section과 동적 로딩 메커니즘이 존재하여 UPX 압축 후 이러한 구조가 손상되고 실행 시 동적 라이브러리를 찾지 못하게 될 수 있기 때문입니다 segment fault. 반면 musl의 경우 UPX에 매우 적합합니다. 원래부터 완전 정적이고 내부적으로 안정적이어서 UPX 후의 해제와 압축이 매우 깔끔합니다. 따라서 musl의 release CI에 UPX 단계를 하나 더 추가하는 것은 확실히 큰 플러스 요소입니다.
또한 UPX를 남용해서는 안 됩니다. UPX는 장기 실행 작업(long-running task)이나 저빈도 작업에만 적합합니다. 장기 실행 작업에 적합한 이유는 UPX가 시작할 때 메모리에서 한 번 해제해야 하며, 이 과정은 전자동이지만 약간의 콜드 스타트 시간이 필요하기 때문입니다. 이 영향은 미미하지만 고빈도 경로에서는 치명적일 수 있습니다. 따라서 장기 실행 작업을 한 번 시작해 백그라운드에 띄워 두는 용도에는 매우 적합합니다. 하지만 Docker 이미지와 같은 장기 서비스 패키지에 무작정 UPX를 밀어 넣는 것은 추천하지 않습니다. 왜냐하면 이는 사실상 메모리를 주고 디스크 공간을 바꾸는 행위로, 엄청난 손해이기 때문입니다. 게다가 컨테이너 Layer의 압축도 배포 시 다운로드 용량을 줄여준다는 점에서 실제로는 UPX와 동일한 역할을 합니다. 오히려 추천하는 환경은 특정 CLI 용도입니다. 엄청난 디스크 공간을 절약할 수 있으며 시작 시 해제 시간도 거의 체감되지 않습니다. 게다가 musl은 본래 CLI를 만드는 데에도 적합합니다. 그렇지 않다면 AUR 헬퍼를 명적으로 비판해야 할 뻔했습니다. Go로 작성된 그 녀석은 정적 링크가 되어 있지 않아, 과거 ArchLinux에서 yay가 고장 나 동적 라이브러리를 찾지 못하는 상황을 겪은 적이 있습니다. 본체 자체가 시스템 패키지 관리자라 자체 해결이 불가능해, 결국 LiveISO로 부팅한 뒤 chroot로 복구할 수밖에 없었습니다.
요약
이러한 유용한 설정은 dev와 release 프로파일을 빠르게 구분하는 데 도움이 되지만, 프로젝트 코드가 커질수록 그 효과는 점점 더 뚜렷해집니다. 그렇지만 저는 여전히 Rust Stable LLVM 빌드를 실행하는 CI를 추가하고, 가능한 한 모든 테스트도 함께 실행하도록 권장합니다. 이렇게 하면 로컬에서 nightly Rust로 전환할 필요가 없고, Edge Case도 문제를 일으키지 않으며, 푸시할 때 문제가 있으면 CI가 차단해 줍니다. 현재 이것이 가장 편리한 DX 환경이며, 이번은 여기까지입니다. 다음에 시간이 날 때 다시 살펴보겠습니다.