← 목록으로 돌아가기

사내 빌드 스크립트를 Node에서 Rust CLI로 갈아엎은 6개월: clap·indicatif·anyhow 실전 패턴 정리

Programming

Rust CLI Replacing Node Scripts with clap and anyhow

우리가 사내 빌드 스크립트를 Node에서 Rust CLI로 갈아엎은 이유

저희 플랫폼팀은 모노레포 빌드·배포 자동화에 5년 동안 Node.js 스크립트를 써왔습니다. package.json"scripts"에 박힌 셸 한 줄로 시작했던 도구들이, 어느 순간 ts-node로 실행되는 6001,200줄짜리 TypeScript CLI로 자라났고, 빌드 디스패처·사이트맵 생성기·릴리스노트 빌더·환경 설정 검증기까지 12개의 사내 도구가 tools/ 디렉터리에 쌓였습니다. 매 빌드마다 node_modules가 380MB씩 깔리고, CI 캐시가 한 번 깨지면 cold start만으로 814초가 사라지는 일이 일상이 됐습니다. 2025년 4분기에 저희는 이 12개 중 9개를 Rust CLI로 옮기는 6개월짜리 프로젝트를 시작했고, 이 글은 그 결과를 정리한 실전 보고서입니다. 프론트엔드 핫패스 최적화(다른 글 참조), 백엔드 Tokio 함정(다른 글 참조)을 거치며 우리 팀의 Rust 활용 영역은 자연스럽게 사내 CLI 도구로까지 확장됐습니다. 본문은 2026년 4월 기준으로 운영 중인 코드와 hyperfine 벤치마크 수치, 그리고 옮기지 않은 스크립트의 사유까지 모두 담았습니다.

1. 우리가 떠난 Node.js 스크립트 지옥의 정체

먼저 무엇이 문제였는지 정확히 짚고 가야 합니다. 저희가 Node를 떠난 이유는 "Rust가 멋져서"가 아니라, 매일 측정 가능한 시간이 새고 있었기 때문입니다. 가장 큰 비용은 콜드 스타트였습니다. CI 단계에서 pnpm installtsx tools/build-dispatch.ts를 부르면, V8 초기화·tsx 트랜스파일·100여 개의 동적 require()가 합쳐져 평균 8.4초가 사라졌습니다. 이 도구는 1분에 한 번 트리거되었고, 하루 1,440번 호출 기준 약 3시간 21분이 의미 없이 증발하고 있었습니다.

두 번째 문제는 의존성 폭주였습니다. 단순한 사이트맵 생성기 하나에 fast-xml-parser, globby, chalk, ora, commander, zod까지 47개의 transitive dependency가 따라붙었고, 그중 두 개는 6개월에 한 번씩 deprecated 경고를 띄웠습니다. 우리 팀은 분기마다 한 번씩 npm audit 알림에 끌려가 의미 없는 업그레이드 PR을 만들고 있었습니다.

세 번째는 OS별 비호환입니다. 사내 Apple Silicon 맥과 CI의 ubuntu-22.04 x86_64, 가끔 등장하는 Windows 노트북 사이에서 node-gyp 빌드 실패가 분기마다 한 번씩 터졌습니다. 특히 sharp·canvas·esbuild의 네이티브 바이너리가 npm 캐시에서 잘못 풀리는 사고가 잦았습니다.

네 번째는 타입 안정성의 환상입니다. TypeScript는 JSON·환경 변수·CLI 인자가 들어오는 경계에서 사실상 any였습니다. 저희는 zod 스키마를 매번 손으로 작성하면서도, 중첩된 옵셔널 필드 한 곳을 빼먹어 새벽 3시에 PagerDuty에 깨어나는 일을 두 번 겪었습니다.

이 네 가지가 합쳐지자, "한 번 잘 만들면 끝나는" 도구가 사실은 매주 누군가의 시간을 갉아먹는 부채로 변해 있었습니다. 저희는 모든 스크립트를 옮기겠다는 야심 대신, 콜드 스타트가 자주 발생하고, 인자가 복잡하며, 분배 가치가 큰 도구만 골라 Rust로 옮기기로 했습니다.

2. clap derive로 만든 첫 CLI: 옵션·서브커맨드·자동완성

저희가 처음으로 옮긴 도구는 모노레포 빌드 디스패처입니다. 하루 수천 번 호출되는 가장 뜨거운 스크립트였고, 동시에 옵션이 많아 clap의 매크로가 빛을 보기 좋은 후보였습니다. 아래는 실제 코드의 축약본입니다(clap = "4.5", clap_complete = "4.5" 기준).

use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{generate, Shell};
use std::io;
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(
    name = "wayctl",
    version,
    about = "사내 모노레포 빌드 디스패처",
    propagate_version = true
)]
struct Cli {
    /// 모노레포 루트. 미지정 시 git rev-parse로 추정.
    #[arg(long, env = "WAYCTL_ROOT", global = true)]
    root: Option<PathBuf>,

    /// 색상 출력. auto/always/never.
    #[arg(long, value_enum, default_value_t = ColorMode::Auto, global = true)]
    color: ColorMode,

    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand, Debug)]
enum Cmd {
    /// 변경된 패키지만 추려 빌드
    Build {
        /// 비교 기준 ref (기본: origin/main)
        #[arg(long, default_value = "origin/main")]
        base: String,
        /// 동시 빌드 워커 수
        #[arg(short = 'j', long, default_value_t = num_cpus::get())]
        jobs: usize,
        /// 빌드 캐시 무시
        #[arg(long)]
        no_cache: bool,
        /// 대상 패키지 직접 지정. 미지정 시 git diff로 자동 추정.
        #[arg(long, num_args = 0..)]
        packages: Vec<String>,
    },
    /// 사이트맵 재생성
    Sitemap {
        #[arg(long, default_value = "public/sitemap.xml")]
        out: PathBuf,
    },
    /// shell 자동완성 스크립트 생성
    Completions { shell: Shell },
}

#[derive(Copy, Clone, Debug, ValueEnum)]
enum ColorMode { Auto, Always, Never }

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Build { base, jobs, no_cache, packages } => {
            commands::build::run(cli.root, base, jobs, no_cache, packages)
                .context("build 단계 실패")?;
        }
        Cmd::Sitemap { out } => {
            commands::sitemap::run(cli.root, out).context("sitemap 단계 실패")?;
        }
        Cmd::Completions { shell } => {
            let mut cmd = Cli::command();
            generate(shell, &mut cmd, "wayctl", &mut io::stdout());
        }
    }
    Ok(())
}

여기서 강조하고 싶은 디테일이 셋 있습니다. 첫째, #[arg(long, env = "WAYCTL_ROOT", global = true)] 한 줄로 환경 변수 폴백과 모든 서브커맨드 공유를 동시에 해결한다는 점입니다. Node.js의 commander로 같은 동작을 짜려면 boilerplate 12줄이 필요했습니다. 둘째, clap_completebash/zsh/fish/elvish/powershell 자동완성 스크립트를 단 한 줄로 생성해줍니다. 저희는 후술할 사내 Homebrew tap에서 자동완성을 함께 설치하도록 묶어뒀습니다. 셋째, default_value_t = num_cpus::get()처럼 런타임 함수가 기본값이 될 수 있다는 점은 Rust derive 매크로의 보석 같은 기능입니다.

clap derive 자체는 공식 문서가 가장 정확하므로 모든 옵션을 여기서 나열하지는 않겠습니다. 다만 6개월간 운영하며 저희가 추가로 박은 규칙은 명확합니다. (1) #[command(propagate_version = true)]를 켜 모든 서브커맨드에서 동일 버전을 노출, (2) 모든 enum은 ValueEnum derive로 강제, (3) 사람이 읽는 메시지는 doc comment에만 적고 별도 help 속성을 남기지 않기. 참고로 clap 4.4 이후 clap_complete::generateclap_complete::aot::generate로도 노출되며, 저희 코드처럼 톱레벨 재수출 경로를 쓰는 방식도 그대로 지원됩니다.

3. anyhow vs thiserror — 어떤 에러 타입을 어디서 쓰는가

Rust 입문자가 가장 먼저 헷갈리는 지점은 에러 처리입니다. 저희도 첫 두 달은 Box<dyn Error>String 변환을 섞다가 컴파일 시간이 30% 길어지는 사고를 겪었습니다. 6개월 운영 끝에 정착한 규칙은 단순합니다. 애플리케이션(바이너리) 코드는 anyhow, 라이브러리 크레이트는 thiserror. 이 결정의 배경을 표로 정리합니다(anyhow = "1", thiserror = "2" 기준).

항목anyhowthiserror
적합한 위치바이너리, 사내 CLI, 통합 테스트공개 라이브러리, 워크스페이스 내부 크레이트
에러 타입 안정성하나의 동적 anyhow::Error로 통합enum variant 별로 정적 매칭 가능
호출자의 매칭 가능성어렵다 (downcast 필요)match로 자연스러움
컨텍스트 추가.context("...") 한 줄#[from] + From impl 필요
컴파일 시간 영향거의 없음derive 매크로 비용 약간 증가
백트레이스RUST_BACKTRACE=1로 자동직접 구성 필요
API 안정성 의무없음#[non_exhaustive]로 보호 권장

저희 워크스페이스에는 wayctl-bin(바이너리)·wayctl-core(공유 로직)·wayctl-sitemap(도메인 라이브러리)이 들어가 있는데, 바이너리에서는 모든 함수가 anyhow::Result<T>를 돌려주고, 라이브러리에서는 다음과 같은 thiserror enum을 정의합니다.

use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SitemapError {
    #[error("posts 디렉터리를 찾지 못했습니다: {0}")]
    PostsDirMissing(PathBuf),

    #[error("frontmatter 파싱 실패 (파일: {path})")]
    Frontmatter {
        path: PathBuf,
        #[source]
        source: serde_yaml::Error,
    },

    #[error("XML 직렬화 실패")]
    Xml(#[from] quick_xml::Error),

    #[error("I/O 오류")]
    Io(#[from] std::io::Error),
}

pub type Result<T> = std::result::Result<T, SitemapError>;

바이너리에서 이 라이브러리를 호출할 때는 자연스럽게 ?로 받고 .context(...)로 운영 정보를 한 겹 더합니다.

use anyhow::Context;
use std::path::PathBuf;

pub fn run(root: Option<PathBuf>, out: PathBuf) -> anyhow::Result<()> {
    let root = root.unwrap_or_else(detect_root);
    let xml = wayctl_sitemap::build(&root)
        .with_context(|| format!("sitemap 빌드 실패 (root: {})", root.display()))?;
    std::fs::write(&out, xml)
        .with_context(|| format!("sitemap 파일 쓰기 실패: {}", out.display()))?;
    Ok(())
}

이렇게 분리하면 호출자는 match 한 번으로 SitemapError::PostsDirMissingSitemapError::Xml을 구분할 수 있고, 바이너리는 동일한 에러를 사용자에게 친절한 메시지로 노출합니다. 공식 문서 두 곳을 함께 참고하시기 바랍니다(anyhow, thiserror). 한 가지 운영 팁을 덧붙이자면, 라이브러리 enum에 #[non_exhaustive]를 붙여두지 않으면 새 variant를 추가하는 순간 SemVer breaking change가 됩니다. 저희는 이 규칙을 어겨 사내 사용처 6곳을 한꺼번에 깨뜨린 적이 한 번 있었습니다.

4. indicatif·console·dialoguer로 만든 진짜 쓸 만한 UX

CLI는 사람이 쓰는 도구입니다. Node에서 Rust로 옮기며 의외로 큰 만족감을 준 부분은 성능이 아니라 터미널 UX였습니다. indicatif(0.17+)·console·dialoguer의 조합은 Node의 ora·chalk·inquirer와 표면적으로는 비슷하지만, 동시 진행 바·non-TTY 자동 감지·CTRL-C 안전성에서 격차가 큽니다.

다음은 빌드 디스패처가 4개 패키지를 동시에 빌드하면서 보여주는 진행 바 코드입니다.

use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

pub fn run_parallel(packages: Vec<String>, jobs: usize) -> anyhow::Result<()> {
    let multi = Arc::new(MultiProgress::new());
    let style = ProgressStyle::with_template(
        "{prefix:.bold.dim} [{elapsed_precise}] {bar:30.cyan/blue} {pos}/{len} {msg}",
    )?
    .progress_chars("##-");

    let pool = rayon::ThreadPoolBuilder::new().num_threads(jobs).build()?;
    pool.scope(|s| {
        for pkg in packages {
            let multi = Arc::clone(&multi);
            let style = style.clone();
            s.spawn(move |_| {
                let pb = multi.add(ProgressBar::new(100));
                pb.set_style(style);
                pb.set_prefix(pkg.clone());
                for step in 0..100 {
                    do_one_step(&pkg, step);
                    pb.inc(1);
                }
                pb.finish_with_message("done");
            });
        }
    });
    Ok(())
}

fn do_one_step(_pkg: &str, _step: u32) {
    thread::sleep(Duration::from_millis(20));
}

여기서 중요한 운영 디테일은 두 가지입니다. 첫째, indicatif는 stderr가 TTY가 아닐 때(즉 CI 로그로 흘러갈 때) 자동으로 진행 바를 정적인 한 줄 로그로 다운그레이드합니다. Node.js 시절 저희는 ora가 GitHub Actions 로그에 ANSI 이스케이프를 그대로 토해내 매번 정규식으로 필터링하던 슬픈 기억이 있습니다. 둘째, MultiProgress는 멀티 스레드 환경에서 락을 자체적으로 관리하므로, rayon으로 워커를 띄워도 줄 깨짐 없이 동시에 그려집니다.

대화형 입력이 필요한 도구(예: 릴리스노트 빌더)에서는 dialoguer::Confirm·Select·MultiSelect를 씁니다. 다만 사내 규칙으로 CI 환경에서는 절대 dialoguer가 prompt를 띄우지 못하도록 --yes 플래그와 std::io::IsTerminal::is_terminal() 체크를 강제했습니다. 그렇게 막아두지 않으면 누군가의 GitHub Actions가 30분간 Confirm?을 띄운 채 멈춰 있는 일이 반드시 발생합니다.

5. 동시성: rayon vs tokio, 그리고 우리가 rayon을 고른 이유

Rust 비동기를 한 번이라도 다뤄본 팀은 본능적으로 tokio를 손에 잡습니다. 저희도 처음에는 tokio::main으로 모든 도구를 시작했지만, 6개월을 지나며 사내 CLI에서는 rayon이 더 적합하다는 결론에 도달했습니다. 핵심 이유는 단 하나, 우리 도구가 하는 일의 90%가 CPU 바운드 + 파일 시스템이지 네트워크 I/O 다중화가 아니기 때문입니다.

측면rayontokio
적합 작업데이터 병렬, CPU 바운드네트워크/디스크 I/O 다중화
진입 비용par_iter() 한 줄#[tokio::main] + async 전염
콜드 스타트즉시런타임 부팅 ~5ms
디버깅 난이도낮음 (스택 추적이 곧다)높음 (Future 체인)
사내 도구 적합도높음외부 API 호출이 많을 때만

rayon은 par_iter() 하나만 알아도 80%의 케이스를 해결합니다. 예를 들어 사이트맵 빌더가 200개 마크다운 파일의 frontmatter를 읽는 코드는 다음과 같습니다.

use rayon::prelude::*;
use std::path::Path;

pub fn collect_entries(root: &Path) -> anyhow::Result<Vec<Entry>> {
    let paths: Vec<_> = walkdir::WalkDir::new(root)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().map_or(false, |x| x == "md"))
        .map(|e| e.into_path())
        .collect();

    let entries: anyhow::Result<Vec<Entry>> = paths
        .par_iter()
        .map(|p| parse_entry(p))
        .collect();
    entries
}

같은 일을 tokio + tokio::fs로 짜면 7~9줄이 더 들어가고, 더 중요하게는 디버깅 시 tokio-console을 띄워야 정확한 진행 상황을 알 수 있습니다. 저희가 옮긴 9개 도구 가운데 외부 GitHub API에 대량 호출이 필요한 릴리스노트 빌더 단 하나만 tokio를 사용합니다. 나머지는 모두 rayon입니다. "동시성이 필요하면 무조건 async"라는 경험칙은 사내 CLI 영역에서는 종종 틀립니다.

6. 배포 파이프라인: dist(구 cargo-dist) + GitHub Releases + Homebrew tap

Rust로 옮긴 뒤 가장 많이 받은 질문은 "그래서 팀원들은 그걸 어떻게 설치해요?"였습니다. cargo install이 정답일 수 없습니다. 사내 디자이너·QA가 자기 맥에서 rustup을 깔고 빌드를 기다리지는 않으니까요. 저희는 axodotdev의 cargo-dist(현 dist)로 이 문제를 풀었습니다. 2025년에 cargo-distdist로 리브랜딩됐고, 2026년 4월 현재 0.31.x 대에서 활발히 릴리스되고 있습니다. 기존 cargo dist init 명령은 그대로 동작하며, dist init로도 호출할 수 있습니다.

dist는 단일 명령으로 여러 타겟의 정적 바이너리를 빌드하고, GitHub Releases에 업로드하며, Homebrew tap·shell installer까지 자동 생성해줍니다. 0.20대 후반부터는 워크스페이스 메타데이터를 별도 파일로 분리한 dist-workspace.toml이 새 표준이 됐고, 기존 Cargo.toml[workspace.metadata.dist] 방식도 여전히 지원됩니다. 저희는 마이그레이션 비용 때문에 Cargo.toml 방식을 유지하고 있습니다. 워크스페이스 루트 일부는 다음과 같습니다.

[workspace.metadata.dist]
cargo-dist-version = "0.31.0"
ci = ["github"]
installers = ["shell", "homebrew"]
tap = "waylog-team/homebrew-tap"
targets = [
    "aarch64-apple-darwin",
    "x86_64-apple-darwin",
    "x86_64-unknown-linux-gnu",
    "x86_64-pc-windows-msvc",
]
publish-jobs = ["homebrew"]
pr-run-mode = "plan"

[workspace.metadata.dist.github-custom-runners]
aarch64-apple-darwin = "macos-14"
x86_64-apple-darwin = "macos-13"

이 설정만 박아두면 git tag v0.6.0 && git push --tags로 GitHub Actions가 4개 타겟을 동시에 빌드하고, 다음 한 줄짜리 설치 스크립트를 누구든 사용할 수 있게 됩니다.

curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/waylog-team/wayctl/releases/latest/download/wayctl-installer.sh | sh

Homebrew tap도 자동으로 갱신되어, Mac 사용자는 brew install waylog-team/tap/wayctl 한 줄로 설치합니다. 자동완성 스크립트는 Homebrew formula 안에 bash_completion·zsh_completion·fish_completion로 포함되도록 묶어 둡니다. 우리 팀 내부 기록 기준으로 신규 입사자가 사내 도구를 처음 써보기까지 걸리는 시간이 평균 17분에서 90초로 줄었습니다.

dist의 약점도 적어두는 편이 공정합니다. (1) 윈도우 코드 서명은 별도 secrets·인증서 설정이 필요하고 자동화가 깔끔하지 않습니다. (2) Linux ARM 타겟은 cross 컴파일러 설정이 까다롭습니다. 저희는 이 둘을 일단 포기했고, 윈도우 사용자에겐 scoop을 보조 채널로 두었습니다.

7. 6개월 회고: 무엇을 옮겼고 무엇을 옮기지 않았는가

이제 가장 자주 받는 질문에 답할 차례입니다. 숫자부터 보겠습니다. 저희는 hyperfine으로 동일 입력에 대해 Node.js(tsx) 구현과 Rust 구현의 cold start·peak RSS·배포 사이즈를 측정했습니다.

hyperfine \
  --warmup 3 \
  --runs 30 \
  --export-markdown bench.md \
  'tsx tools/build-dispatch.ts --base origin/main --jobs 8' \
  './target/release/wayctl build --base origin/main --jobs 8'
도구콜드 스타트(p50)콜드 스타트(p99)피크 RSS배포 사이즈
build-dispatch (Node, tsx)8.42s14.10s412MB380MB(node_modules)
wayctl build (Rust)0.31s0.48s38MB11.4MB(단일 바이너리)
sitemap-gen (Node)6.91s9.33s287MB240MB
wayctl sitemap (Rust)0.18s0.27s22MB(위와 동일 바이너리)
release-notes (Node)5.20s7.80s195MB150MB
wayctl release-notes (Rust)0.42s0.71s41MB(위와 동일 바이너리)

콜드 스타트는 평균 2030배, 메모리는 510배 줄었습니다. 더 큰 변화는 CI 시간이었습니다. 모노레포 PR 한 건의 평균 CI 시간이 14분 12초에서 9분 47초로 단축됐고, 분기 단위로 환산하면 빌드 머신 비용이 약 28% 감소했습니다.

다음으로 팀원 학습 곡선입니다. 저희 팀 6명 중 Rust 경험자는 처음에 1명뿐이었습니다. 새 멤버가 첫 PR을 올리기까지 걸린 시간을 솔직히 적습니다. (a) 시니어 백엔드 1명: 4일 만에 wayctl sitemap 수정 PR. (b) 미들 프론트 1명: 9일 만에 clap 옵션 추가 PR(빌드 에러로 사흘 헤맴). (c) 주니어 1명: 21일 만에 첫 머지(에러 처리 모범사례를 익히는 데 시간이 가장 많이 들었습니다). (d) 디자인 시스템 담당 1명: 본인이 직접 손대진 않고 사용자로만 머무르기로 결정. 가장 큰 학습 장벽은 소유권이 아니라 에러 처리 패턴이었습니다. anyhow와 thiserror의 경계를 머릿속에 박는 데 모두 일주일 이상 걸렸습니다.

마지막으로 옮기지 않은 스크립트입니다. 저희가 의도적으로 Node에 남겨둔 도구는 다음과 같습니다.

  • 단순 npm postinstall 훅: 네 줄짜리 셸 한 번이면 끝나는 스크립트는 Rust로 옮기는 비용이 회수되지 않습니다.
  • 프론트엔드 svgo·이미지 최적화 래퍼: sharp/svgo 자체가 JS 생태계에 깊게 뿌리내린 도구라, Rust로 다시 짜는 것보다 npm 종속을 받아들이는 편이 합리적이었습니다.
  • Storybook 관련 도구 일체: Storybook의 플러그인 생태계가 Node 기반이라, CLI만 분리해 옮길 가치가 없습니다.
  • 임시 일회성 마이그레이션 스크립트: 한 번만 돌리고 폐기할 코드는 Node + Bun이 압도적으로 빠릅니다.
  • AI 프롬프트 자동화 SDK 호출 도구: Anthropic·OpenAI SDK가 TypeScript/Python 우선이라, Rust 포팅은 여전히 비용 대비 이득이 적습니다.

요점은 모두 옮기지 말라는 것입니다. 저희가 9/12를 옮긴 것이지, 12/12를 옮긴 것이 아닙니다. 옮길 가치가 있는 후보는 (1) 자주 호출되어 콜드 스타트가 누적되는 도구, (2) 옵션이 많아 타입 안정성이 절실한 도구, (3) 외부 사용자에게 단일 바이너리로 배포해야 하는 도구입니다. 이 세 조건 중 둘 이상에 해당하지 않으면 Node.js를 그대로 두는 편이 합리적이었습니다.

8. 마무리: 사내 빌드 스크립트를 Rust CLI로 옮길 때의 실무 체크리스트 5

저희가 6개월 운영 끝에 다른 팀에 권하는 다섯 가지 체크리스트로 글을 매듭짓겠습니다.

  1. 후보 선정: "자주 호출 + 옵션 복잡 + 단일 바이너리 배포 필요" 세 조건 중 둘 이상에 해당하는 도구만 1차 후보로 잡으세요. 모든 스크립트를 옮기려는 야심은 ROI를 망칩니다.
  2. clap derive 표준화: 첫 한 도구를 만들 때부터 Parser/Subcommand/ValueEnum derive와 clap_complete 자동완성을 묶어 사내 템플릿으로 박아두세요. 이후 도구는 30분이면 골격이 나옵니다.
  3. 에러 타입 분리 규칙: 바이너리는 anyhow::Result, 라이브러리 크레이트는 thiserror enum + #[non_exhaustive]. 두 줄짜리 규칙이지만 6개월간 가장 큰 안정성을 줍니다.
  4. 동시성은 기본 rayon, 외부 API 다중 호출만 tokio: 사내 CLI에서 무조건 async를 쓰는 습관을 의심하세요. 콜드 스타트와 디버깅 비용 모두 rayon이 유리합니다.
  5. 배포는 dist(구 cargo-dist)로 단일 바이너리: cargo install을 강요하지 마세요. GitHub Releases + Homebrew tap + shell installer 세 가지를 묶어두면 비개발 직군까지도 90초 안에 도구를 손에 쥡니다.

저희 팀은 이 다섯 줄을 위키 첫 페이지에 박아두고 신규 도구가 추가될 때마다 PR 리뷰어가 한 번씩 점검합니다. Rust CLI 전환의 가장 큰 수확은 사실 성능 숫자가 아니라, 이런 체크리스트를 자신 있게 강제할 수 있게 된 운영 자신감이었습니다.