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 도구 작성 요약
- clap: CLI 인자 파싱, 서브커맨드
- std::fs: 파일 읽기/쓰기
- BufReader: 효율적인 파일 읽기
- Result: 에러 처리
- ?: 에러 전파
다음 단계
같이 보면 좋은 글
- Rust 시작하기 | 메모리 안전한 시스템 프로그래밍 언어
- Rust 소유권 | Ownership, Borrowing, Lifetime
- Rust 테스팅 | 단위 테스트, 통합 테스트, 벤치마크
- C++ 개발자를 위한 Rust | 차이점과 전환 가이드
자주 묻는 질문 (FAQ)
Q. CLI의 main에서 unwrap을 쓰면 무엇이 문제인가요?
A. unwrap이 실패하면 panic 메시지와 스택 정보가 그대로 사용자에게 노출되고, 스크립트가 구분할 수 있는 의미 있는 종료 코드도 남지 않습니다. main이 Result를 반환하게 하고 ?로 에러를 전파하면 사람이 읽을 수 있는 메시지와 함께 비0 종료 코드로 끝나므로 CI나 셸 스크립트에서 실패를 감지하기 쉽습니다. API 키 같은 값은 clap의 env 속성으로 플래그와 환경 변수 양쪽에서 받도록 하면 운영이 편해집니다.