Rust로 CLI 도구 만들기 — clap + indicatif로 쓸만한 개발자 도구 제작

게시일: 2025년 5월 9일 · 15분 읽기

Python으로 만든 스크립트가 너무 느려서 Rust로 다시 만들었다. 30초→0.3초.

Cargo.toml 의존성

[package]
name = "audio-cli"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4.4", features = ["derive"] }
indicatif = "0.17"
colored = "2.1"
anyhow = "1.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"

[[bin]]
name = "audio-cli"
path = "src/main.rs"

Argument Parsing with clap

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "audio-cli")]
#[command(about = "오디오 처리 도구", long_about = None)]
#[command(version)]
struct Cli {
    #[command(subcommand)]
    command: Commands,

    #[arg(global = true, short, long)]
    verbose: bool,
}

#[derive(Subcommand)]
enum Commands {
    /// 오디오 파일 분석
    Analyze {
        /// 입력 파일 경로
        #[arg(short, long)]
        input: String,

        /// 상세 분석 수행
        #[arg(short, long)]
        detailed: bool,

        /// 출력 형식 (json, csv)
        #[arg(short, long, default_value = "json")]
        format: String,
    },

    /// 배치 처리
    Process {
        /// 입력 디렉토리
        #[arg(short, long)]
        input_dir: String,

        /// 출력 디렉토리
        #[arg(short, long)]
        output_dir: String,

        /// 동시 처리 수
        #[arg(short = 'j', long, default_value_t = 4)]
        jobs: usize,
    },
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Analyze { input, detailed, format } => {
            analyze(&input, detailed, &format).await?;
        }
        Commands::Process { input_dir, output_dir, jobs } => {
            process_batch(&input_dir, &output_dir, jobs).await?;
        }
    }

    Ok(())
}

Progress Bar with indicatif

use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;

async fn process_batch(
    input_dir: &str,
    output_dir: &str,
    jobs: usize,
) -> anyhow::Result<()> {
    let files = std::fs::read_dir(input_dir)?
        .collect::<Result<Vec<_>, _>>()?;

    let pb = ProgressBar::new(files.len() as u64);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}")
            .unwrap()
            .progress_chars("#>-")
    );

    let mut handles = vec![];

    for file in files {
        let output_dir = output_dir.to_string();
        let pb_clone = pb.clone();

        let handle = tokio::spawn(async move {
            match process_file(file.path(), &output_dir).await {
                Ok(_) => {
                    pb_clone.inc(1);
                    Ok(())
                }
                Err(e) => {
                    pb_clone.println(format!("❌ Error: {}", e));
                    Err(e)
                }
            }
        });

        handles.push(handle);

        if handles.len() >= jobs {
            let _ = futures::future::join_all(handles).await;
            handles = vec![];
        }
    }

    let _ = futures::future::join_all(handles).await;
    pb.finish_with_message("✓ 처리 완료!");

    Ok(())
}

Colored Output

use colored::*;

fn print_summary(stats: &Stats) {
    println!("{}", "=== 분석 결과 ===".bold().blue());
    println!("{}: {}", "파일 수".cyan(), stats.file_count);
    println!("{}: {}", "총 크기".green(), format_size(stats.total_size));
    println!("{}: {}", "평균 품질".yellow(), stats.avg_quality);

    if stats.errors > 0 {
        println!("{}: {}", "에러".red().bold(), stats.errors);
    }
}

fn format_size(bytes: u64) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
    let mut size = bytes as f64;
    let mut unit_idx = 0;

    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
        size /= 1024.0;
        unit_idx += 1;
    }

    format!("{:.2} {}", size, UNITS[unit_idx])
}

Config File Support

~/.audio-cli.toml:

[defaults]
jobs = 8
output_format = "json"

[logging]
level = "info"

코드:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Config {
    defaults: Defaults,
    logging: Logging,
}

#[derive(Serialize, Deserialize)]
struct Defaults {
    jobs: usize,
    output_format: String,
}

#[derive(Serialize, Deserialize)]
struct Logging {
    level: String,
}

fn load_config() -> anyhow::Result<Config> {
    let config_path = dirs::home_dir()
        .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?
        .join(".audio-cli.toml");

    if config_path.exists() {
        let content = std::fs::read_to_string(&config_path)?;
        Ok(toml::from_str(&content)?)
    } else {
        Ok(Config::default())
    }
}

Cross-Platform Binary Distribution

GitHub Actions:

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
            artifact: audio-cli
          - os: macos-latest
            target: x86_64-apple-darwin
            artifact: audio-cli
          - os: macos-latest
            target: aarch64-apple-darwin
            artifact: audio-cli
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            artifact: audio-cli.exe

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v3
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - run: cargo build --target ${{ matrix.target }} --release

      - uses: softprops/action-gh-release@v1
        with:
          files: target/${{ matrix.target }}/release/${{ matrix.artifact }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

결론

Rust CLI 도구는:

30초 걸리던 Python 스크립트를 0.3초로 만들었다. 이제 일상적으로 쓸 수 있다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.