Rust CLI 도구 만들기 | clap, 파일 처리, 에러 처리

이 글의 핵심

Rust는 단일 바이너리 배포와 빠른 실행 속도 덕분에 CLI 도구 제작에 잘 맞습니다. Cargo.toml 설정과 clap 기본 사용에서 시작해 실제 도구를 두 개 만들어 보고, 잘못된 입력이나 없는 파일을 만났을 때 패닉 대신 읽기 좋은 에러와 종료 코드를 돌려주는 방법, 자주 하는 실수와 대안 라이브러리를 정리합니다.

시리즈 안내

#11 | 📋 전체 목차 | 이전: #10 테스팅 · 다음: #12 C++ 비교


들어가며

단일 바이너리로 배포하기 쉽으며, clap 등으로 인자 파싱·도움말을 정리하기 좋아 CLI 도구에 자주 사용됩니다. 파일·표준 입출력과 함께 Result로 오류를 전파하는 패턴이 일반적입니다.

Rust가 CLI에 잘 맞는 이유는 구체적입니다. 런타임이나 인터프리터 없이 실행 파일 하나만 복사하면 되고, 시작 시간이 밀리초 단위라 셸 스크립트 안에서 수천 번 호출해도 부담이 없습니다. ripgrep, fd, bat, exa/eza 같은 도구가 Rust로 만들어져 널리 쓰이는 것도 이 때문입니다. 대신 대가도 있습니다. 컴파일이 느리고, clap 같은 의존성을 넣으면 첫 빌드에 수십 초가 걸리며, 릴리스 바이너리 크기도 수 MB로 C로 만든 도구보다 큽니다. 이 글은 clap으로 인자를 받고, 파일과 stdin을 읽고, 실패했을 때 사용자에게 읽기 좋은 에러와 올바른 종료 코드를 돌려주는 흐름을 두 개의 예제로 따라갑니다.


프로젝트 생성과 의존성 설정

Cargo.toml

[package]
name = "my-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.0", features = ["derive"] }

features = ["derive"]는 #[derive(Parser)] 매크로를 쓰기 위한 설정입니다. TOML에서 배열 원소는 문자열이어야 하므로 따옴표를 빼먹으면 cargo가 invalid TOML value, did you mean to use a quoted string? 같은 에러를 내며 빌드를 시작조차 하지 않습니다. 직접 편집하기보다 cargo add clap --features derive로 추가하면 이런 실수를 피할 수 있습니다. 버전을 "4.0"으로 적으면 Cargo는 ^4.0으로 해석해 4.x 중 최신 버전을 받으므로, 실제 사용 버전은 Cargo.lock에서 확인합니다. 바이너리 프로젝트라면 Cargo.lock을 저장소에 커밋해야 모든 빌드가 같은 의존성 버전을 씁니다.


clap derive로 인자와 서브커맨드 파싱

기본 사용

use clap::Parser;
#[derive(Parser)]
#[command(name = "my-cli")]
#[command(about = "간단한 CLI 도구", long_about = None)]
struct Args {
    #[arg(short, long)]
    name: String,
    
    #[arg(short, long, default_value_t = 1)]
    count: u32,
}
fn main() {
    let args = Args::parse();
    
    for _ in 0..args.count {
        println!("Hello, {}!", args.name);
    }
}

derive 방식에서는 구조체 정의가 곧 CLI 명세입니다. 필드 이름 name에서 --name이, 첫 글자에서 -n이 자동으로 만들어지고, 타입이 String이므로 필수 인자가 됩니다. 선택 인자로 만들려면 Option<String>으로 바꾸면 되고, bool 필드는 값 없이 존재 여부만 보는 플래그가 됩니다. count: u32는 clap이 문자열을 u32로 파싱하므로 --count abc나 --count -1을 넣으면 프로그램 코드에 도달하기 전에 error: invalid value 'abc' for '--count <COUNT>': invalid digit found in string과 함께 종료 코드 2로 끝납니다. 입력 검증의 상당 부분을 타입이 대신해 주는 셈입니다.

--help와 --version도 자동으로 생깁니다. 구조체나 필드 위에 /// 문서 주석을 달면 그 내용이 도움말 설명으로 들어가므로, help = "..." 속성을 따로 쓰지 않아도 됩니다. #[command(version)]을 추가하면 Cargo.toml의 버전을 읽어 --version에 표시합니다. 처음 clap을 쓸 때 흔히 걸리는 것은 짧은 옵션 충돌입니다. name과 number처럼 첫 글자가 같은 필드에 둘 다 short를 붙이면 컴파일은 되지만 실행 시 디버그 빌드에서 Short option names must be unique 패닉이 납니다. #[arg(short = 'N')]처럼 직접 지정하면 해결됩니다.

서브커맨드

use clap::{Parser, Subcommand};
#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}
#[derive(Subcommand)]
enum Commands {
    Add { name: String },
    Remove { id: u32 },
    List,
}
fn main() {
    let cli = Cli::parse();
    
    match cli.command {
        Commands::Add { name } => {
            println!("추가: {}", name);
        }
        Commands::Remove { id } => {
            println!("삭제: {}", id);
        }
        Commands::List => {
            println!("목록 출력");
        }
    }
}

git add, cargo build처럼 동사를 앞에 두는 CLI는 enum으로 표현하는 것이 자연스럽습니다. 변형 이름 Add는 자동으로 소문자 add 서브커맨드가 되고, 변형 안의 필드는 그 서브커맨드의 인자가 됩니다. 여기서는 옵션 속성이 없으므로 name과 id는 위치 인자(my-cli add 홍길동)입니다.

이 구조의 가장 큰 장점은 match가 모든 서브커맨드를 처리했는지 컴파일러가 검사한다는 점입니다. 나중에 Update 변형을 추가하면 match에서 non-exhaustive patterns 에러가 나서 처리 코드를 빠뜨릴 수 없습니다. 서브커맨드가 많아지면 각 변형이 Add(AddArgs)처럼 별도 구조체를 감싸게 하고, 구조체마다 run() 메서드를 두어 main을 얇게 유지하는 것이 흔한 패턴입니다. 서브커맨드 없이 실행했을 때 도움말을 보여 주고 싶다면 command 필드를 Option<Commands>로 바꾸거나 #[command(arg_required_else_help = true)]를 붙입니다.


파일 읽기와 쓰기

use std::fs;
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path;
fn read_file(path: &str) -> io::Result<String> {
    fs::read_to_string(path)
}
fn read_lines(path: &str) -> io::Result<Vec<String>> {
    let file = fs::File::open(path)?;
    let reader = BufReader::new(file);
    
    let mut lines = Vec::new();
    for line in reader.lines() {
        lines.push(line?);
    }
    
    Ok(lines)
}
fn write_file(path: &str, content: &str) -> io::Result<()> {
    fs::write(path, content)
}
fn append_file(path: &str, content: &str) -> io::Result<()> {
    use std::fs::OpenOptions;
    
    let mut file = OpenOptions::new()
        .append(true)
        .create(true)
        .open(path)?;
    
    writeln!(file, "{}", content)?;
    Ok(())
}

네 함수 모두 io::Result를 반환하고 ?로 에러를 호출자에게 넘깁니다. fs::read_to_string은 파일 전체를 메모리에 올리므로 간단하지만, 수 GB짜리 로그 파일을 처리하는 도구라면 메모리가 부족해집니다. 그럴 때는 read_lines처럼 BufReader로 한 줄씩 읽는데, 이 예제는 결국 모든 줄을 Vec에 모으고 있어서 메모리 사용량은 똑같습니다. 진짜 스트리밍이 필요하면 for line in reader.lines() 루프 안에서 바로 처리하고 결과만 누적해야 합니다.

read_to_string과 lines()는 입력이 유효한 UTF-8이 아니면 에러(stream did not contain valid UTF-8)를 냅니다. 사용자가 CP949로 저장한 파일이나 바이너리 파일을 넘기면 도구가 멈추는 것입니다. 텍스트가 아닐 수 있는 입력을 다룬다면 fs::read로 바이트를 읽고 String::from_utf8_lossy로 변환하거나, BufRead::split(b'\n')으로 바이트 단위로 처리합니다. 또 lines()는 줄 끝의 \n과 \r\n을 모두 제거해 주므로 Windows에서 만든 파일도 줄 단위 처리에는 문제가 없습니다.

write_file의 fs::write는 파일이 있으면 내용을 덮어씁니다. 사용자의 원본 파일을 결과로 바꾸는 도구라면, 쓰는 도중 실패하면 원본이 반쯤 잘린 채로 남을 수 있습니다. 같은 디렉터리의 임시 파일에 먼저 쓴 뒤 fs::rename으로 교체하면 대부분의 파일 시스템에서 원자적으로 바뀝니다(tempfile 크레이트의 NamedTempFile::persist가 이 작업을 해 줍니다).


예제: 단어 카운터

use clap::Parser;
use std::fs;
use std::collections::HashMap;
#[derive(Parser)]
struct Args {
    #[arg(help = "파일 경로")]
    file: String,
    
    #[arg(short, long, help = "대소문자 구분 안 함")]
    ignore_case: bool,
}
fn count_words(text: &str, ignore_case: bool) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    
    for word in text.split_whitespace() {
        let word = word.trim_matches(|c: char| !c.is_alphanumeric());
        let word = if ignore_case {
            word.to_lowercase()
        } else {
            word.to_string()
        };
        
        *counts.entry(word).or_insert(0) += 1;
    }
    
    counts
}
fn main() -> std::io::Result<()> {
    let args = Args::parse();
    
    let content = fs::read_to_string(&args.file)?;
    let counts = count_words(&content, args.ignore_case);
    
    let mut words: Vec<_> = counts.iter().collect();
    words.sort_by(|a, b| b.1.cmp(a.1));
    
    println!("단어 빈도:");
    for (word, count) in words.iter().take(10) {
        println!("{}: {}", word, count);
    }
    
    Ok(())
}

count_words의 핵심은 entry(word).or_insert(0)입니다. 키가 없으면 0을 넣고, 있든 없든 값에 대한 가변 참조를 돌려주므로 *... += 1로 한 번의 조회로 개수를 올립니다. if let Some(c) = counts.get_mut(&word)로 확인한 뒤 없으면 insert하는 방식보다 짧고 해시 계산도 한 번으로 끝납니다.

실제로 돌려 보면 드러나는 문제가 몇 가지 있습니다. 첫째, trim_matches로 문장 부호를 떼어 낸 결과가 빈 문자열이 될 수 있습니다(—나 ...처럼 기호만 있는 토큰). 이 빈 문자열이 가장 많이 등장한 “단어”로 출력되는 일이 생기므로 if word.is_empty() { continue; }를 넣는 것이 좋습니다. 둘째, HashMap은 순회 순서가 실행마다 달라서 빈도가 같은 단어들의 출력 순서가 매번 바뀝니다. 테스트나 diff 비교를 할 때 결과가 흔들리므로 b.1.cmp(a.1).then(a.0.cmp(b.0))처럼 두 번째 정렬 기준을 두면 결과가 결정적이 됩니다. 셋째, is_alphanumeric은 유니코드 기준이라 한글도 정상적으로 단어로 인식하지만, 한국어는 조사가 붙어서 “Rust는”과 “Rust를”이 다른 단어로 세어집니다. 언어별 처리가 필요하면 형태소 분석기가 따로 필요합니다.

main이 std::io::Result<()>를 반환하게 한 것도 의미가 있습니다. 파일이 없으면 ?가 에러를 반환하고, Rust 런타임은 Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }를 stderr에 출력한 뒤 종료 코드 1로 끝냅니다. 패닉보다는 낫지만, 이 출력은 Debug 형식이라 사용자에게 친절하지 않고 어떤 파일이 없는지도 알려 주지 않습니다. 다음 예제에서 anyhow로 이 부분을 개선합니다.


예제: JSON 한 줄 포맷터 (stdin·파일·출력 경로)

단계: (1) 입력 소스 선택 (2) serde_json으로 파싱 (3) 예쁜 출력 또는 압축 출력 (4) 실패 시 비제로 종료 코드. Cargo.toml에 다음을 추가합니다.

[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"

이 예제에서는 serde_json::Value만 쓰므로 serde의 derive 기능은 사실 필요 없습니다. 나중에 JSON을 구조체로 역직렬화하도록 확장할 때를 위해 넣어 둔 것입니다. anyhow는 애플리케이션용 에러 처리 크레이트로, 어떤 에러 타입이든 anyhow::Error 하나로 담고 context로 설명을 덧붙일 수 있게 해 줍니다. 라이브러리를 만든다면 호출자가 에러 종류를 구분할 수 있도록 thiserror로 구체적인 에러 enum을 정의하는 편이 맞고, anyhow는 최종 바이너리에서 쓰는 것이 일반적인 구분입니다.

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::fs;
use std::io::{self, Read};
use std::path::PathBuf;
use std::process;
#[derive(Parser)]
#[command(name = "jsonfmt")]
#[command(about = "stdin 또는 파일의 JSON을 포맷합니다.")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}
#[derive(Subcommand)]
enum Commands {
    /// 파일을 읽어 stdout에 출력
    File {
        path: PathBuf,
        #[arg(long, default_value_t = false)]
        compact: bool,
    },
    /// stdin에서 읽기
    Stdin {
        #[arg(long, default_value_t = false)]
        compact: bool,
    },
}
fn format_json(text: &str, compact: bool) -> Result<String> {
    let v: serde_json::Value =
        serde_json::from_str(text).context("JSON 파싱 실패")?;
    Ok(if compact {
        serde_json::to_string(&v)?
    } else {
        serde_json::to_string_pretty(&v)?
    })
}
fn main() {
    let cli = Cli::parse();
    let run = || -> Result<()> {
        match cli.command {
            Commands::File { path, compact } => {
                let s = fs::read_to_string(&path)
                    .with_context(|| format!("파일 읽기 실패: {}", path.display()))?;
                println!("{}", format_json(&s, compact)?);
            }
            Commands::Stdin { compact } => {
                let mut buf = String::new();
                io::stdin().read_to_string(&mut buf)?;
                println!("{}", format_json(&buf.trim(), compact)?);
            }
        }
        Ok(())
    };
    if let Err(e) = run() {
        eprintln!("error: {:#}", e);
        process::exit(1);
    }
}

main이 Result를 반환하는 대신 클로저 run에서 에러를 받아 직접 출력하는 이유는 출력 형식과 종료 코드를 통제하기 위해서입니다. {:#}는 anyhow 에러를 원인 체인까지 한 줄로 출력하는 형식이라, 없는 파일을 넘기면 error: 파일 읽기 실패: data.json: No such file or directory (os error 2)처럼 “무엇을 하다가” “왜” 실패했는지가 함께 나옵니다. with_context에 클로저를 넘긴 것은 format!이 에러가 났을 때만 실행되게 하려는 것입니다. context(format!(...))로 쓰면 성공할 때도 매번 문자열을 만듭니다.

에러를 stdout이 아니라 stderr(eprintln!)로 보내는 것도 중요합니다. jsonfmt file a.json > out.json처럼 결과를 파일로 리다이렉트할 때 에러 메시지가 out.json에 섞이지 않고 터미널에 표시됩니다. 처음 CLI를 만들 때 흔히 하는 실수가 에러를 println!으로 찍는 것인데, 파이프라인 뒤쪽 도구가 에러 문장을 JSON으로 파싱하려다 엉뚱한 곳에서 실패하게 됩니다.

주의할 점이 하나 있습니다. process::exit는 소멸자를 실행하지 않고 즉시 종료하므로, 버퍼에 남은 출력이나 임시 파일 정리 같은 Drop 동작이 건너뛰어집니다. 이 예제는 run()이 끝난 뒤, 즉 모든 지역 값이 이미 정리된 뒤에 호출하므로 안전합니다. 또 stdin 서브커맨드는 입력이 끝날 때까지(Ctrl+D, Windows는 Ctrl+Z 후 Enter) 기다리므로, 사용자가 인자 없이 실행하고 멈춘 것처럼 보인다고 느낄 수 있습니다. std::io::IsTerminal(Rust 1.70+)로 stdin이 터미널인지 확인해 안내 메시지를 띄우는 도구가 많습니다.

unwrap 남발·상대 경로·깨진 파이프 같은 실수

  • main에서 Result를 쓰지 않고 unwrap만 남발해 사용자에게 스택 트레이스가 그대로 노출되는 경우.
  • 상대 경로를 현재 작업 디렉터리에만 의존해 스크립트·CI에서 엉뚱한 파일을 읽는 경우.
  • 바이너리 이름과 패키지 이름을 혼동해 cargo install 후 실행 파일 이름을 문서에 잘못 안내하는 경우.
  • println!을 대량 출력 루프에서 그대로 사용해 느려지는 경우. stdout은 줄 단위로 버퍼링되고 println!마다 잠금을 잡으므로, 수십만 줄을 출력한다면 let mut out = io::BufWriter::new(io::stdout().lock());로 감싸고 writeln!(out, ...)을 쓰면 크게 빨라집니다.
  • 파이프가 닫혔을 때 패닉하는 경우. my-cli | head -1처럼 뒤쪽 프로그램이 먼저 끝나면 println!이 failed printing to stdout: Broken pipe (os error 32) 패닉을 냅니다. writeln!의 에러를 받아 BrokenPipe일 때 조용히 종료하도록 처리하는 것이 관례입니다.

unwrap 문제는 개발 중에는 잘 드러나지 않습니다. 본인은 항상 올바른 인자와 존재하는 파일로 테스트하기 때문입니다. 제 경험상 이 문제가 드러나는 순간은 다른 사람이 도구를 쓰기 시작하거나 CI 스크립트에 넣었을 때인데, thread 'main' panicked at src/main.rs:12:40: called Result::unwrap() on an Err value: ... 같은 메시지를 본 사용자는 자기 입력이 잘못된 건지 도구가 고장 난 건지 구분하지 못합니다. 또 패닉의 종료 코드는 101이라서 “실패 시 1”을 기대하는 스크립트와도 어긋납니다. 사용자가 고칠 수 있는 에러(파일 없음, 형식 오류)는 Result로, 프로그램 버그에서만 패닉이 나게 구분하는 것이 기준입니다.

경로·종료 코드·시크릿 다루기

  • Windows·macOS·Linux에서 경로 구분자와 줄바꿈이 다릅니다. 가능하면 std::path::Path와 read_to_string을 사용하세요.
  • CLI는 종료 코드 규약(성공 0, 실패 비0)을 지키는 것이 스크립트 연동에 필수입니다. clap은 인자 오류에 2를 쓰므로, 도구 자체의 실패는 1을 쓰면 “사용법 오류”와 “실행 실패”를 스크립트가 구분할 수 있습니다. std::process::ExitCode를 main에서 반환하면 process::exit 없이도 종료 코드를 지정할 수 있습니다.
  • 민감한 정보는 환경 변수나 설정 파일로 분리하며, --help 예시에 시크릿을 넣지 마세요.

로깅·환경 변수·배포용 바이너리

  • tracing + RUST_LOG로 디버그 로그를 켜고, 릴리스에서는 기본을 info 이상으로 둡니다.
  • clap의 env 속성으로 API_KEY 같은 값을 플래그와 환경 변수 양쪽에서 받을 수 있게 하면 운영이 편합니다.
  • 배포는 cargo build —release 후 단일 바이너리를 GitHub Releases나 패키지 매니저에 올리고, 버전은 #[command(version)]으로 Cargo.toml에서 읽어 오게 하면 따로 맞출 필요가 없습니다.
  • 바이너리 크기가 신경 쓰이면 [profile.release]에 strip = true, lto = true, codegen-units = 1을 넣습니다. 빌드는 느려지지만 크기와 실행 속도가 함께 개선됩니다. 크로스 컴파일은 cross나 cargo-dist 같은 도구가 타깃별 툴체인 설정을 대신 처리해 줍니다.
  • Linux용 바이너리를 배포할 때는 빌드한 머신의 glibc 버전보다 오래된 배포판에서 GLIBC_2.xx not found 에러가 날 수 있습니다. 넓은 호환성이 필요하면 x86_64-unknown-linux-musl 타깃으로 정적 링크하는 방법이 흔히 쓰입니다.

clap derive, builder, std::env::args 비교

접근장점언제 쓸까
clap derive빠른 개발, 서브커맨드·검증 풍부대부분의 팀 CLI
clap builder API동적 인자 구성플러그인형 도구
std::env::args만의존성 제로초소형 스크립트 대체
Python argparse 호출 래핑기존 스크립트 재사용점진적 Rust 이전

참고 자료


CLI 도구 작성 요약

  1. clap: CLI 인자 파싱, 서브커맨드
  2. std::fs: 파일 읽기/쓰기
  3. BufReader: 효율적인 파일 읽기
  4. Result: 에러 처리
  5. ?: 에러 전파

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. CLI의 main에서 unwrap을 쓰면 무엇이 문제인가요?

A. unwrap이 실패하면 panic 메시지와 스택 정보가 그대로 사용자에게 노출되고, 스크립트가 구분할 수 있는 의미 있는 종료 코드도 남지 않습니다. main이 Result를 반환하게 하고 ?로 에러를 전파하면 사람이 읽을 수 있는 메시지와 함께 비0 종료 코드로 끝나므로 CI나 셸 스크립트에서 실패를 감지하기 쉽습니다. API 키 같은 값은 clap의 env 속성으로 플래그와 환경 변수 양쪽에서 받도록 하면 운영이 편해집니다.