Rust로 CLI 도구 만들어보기 - clap 라이브러리 사용기

배경

팀 내부에서 사용하던 배포 스크립트가 있었다. Node.js로 작성되어 있었는데, 수백 개의 파일을 처리하다 보니 메모리 사용량이 과도했고 실행 시간도 길었다. Rust를 배워볼 겸 이 도구를 재작성하기로 했다.

clap으로 CLI 구성

use clap::{App, Arg};

fn main() {
    let matches = App::new("deploy-tool")
        .version("1.0.0")
        .about("빌드 파일 배포 도구")
        .arg(
            Arg::with_name("env")
                .short("e")
                .long("environment")
                .value_name("ENV")
                .help("배포 환경 (dev/staging/prod)")
                .required(true)
                .takes_value(true),
        )
        .arg(
            Arg::with_name("dry-run")
                .long("dry-run")
                .help("실제 배포 없이 테스트만 수행")
        )
        .get_matches();

    let env = matches.value_of("env").unwrap();
    let is_dry_run = matches.is_present("dry-run");
}

clap 3.x 버전을 사용했다. derive 매크로도 있지만 builder 패턴이 더 직관적이라 이쪽을 선택했다.

파일 처리 성능

병렬 처리를 위해 rayon을 사용했다.

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

fn process_files(files: Vec<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
    files.par_iter()
        .try_for_each(|file| {
            // 파일 처리 로직
            compress_and_upload(file)
        })?;
    Ok(())
}

Node.js 버전은 300개 파일 처리에 45초, 메모리 800MB를 사용했다. Rust 버전은 12초, 메모리 150MB로 개선되었다.

에러 처리

초반에는 unwrap()을 남발했는데, 실제 사용하면서 에러 메시지가 불친절하다는 피드백을 받았다. anyhow 크레이트로 에러 컨텍스트를 추가했다.

use anyhow::{Context, Result};

fn read_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .context(format!("설정 파일을 읽을 수 없습니다: {}", path))?;
    
    serde_json::from_str(&content)
        .context("JSON 파싱 실패")
}

결과

바이너리 하나로 배포할 수 있어서 팀원들 온보딩이 간단해졌다. 성능 개선으로 CI 시간도 단축되었다. Rust 학습 곡선은 가팔랐지만, 컴파일러가 많은 실수를 잡아줘서 런타임 에러는 거의 없었다.