Rust CLI 도구 만들기 | clap, 파일 처리, 에러 처리
이 글의 핵심
Rust CLI 도구 만들기에 대한 실전 가이드입니다. clap, 파일 처리, 에러 처리 등을 예제와 함께 설명합니다.
들어가며
단일 바이너리로 배포하기 쉽고, clap 등으로 인자 파싱·도움말을 정리하기 좋아 CLI 도구에 자주 쓰입니다. 파일·표준 입출력과 함께 Result로 오류를 전파하는 패턴이 일반적입니다.
1. 프로젝트 설정
Cargo.toml
[package]
name = "my-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.0", features = ["derive"] }
2. clap으로 인자 파싱
기본 사용
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);
}
}
서브커맨드
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!("목록 출력");
}
}
}
3. 파일 처리
파일 읽기/쓰기
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(())
}
4. 실전 예제: 단어 카운터
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(())
}
실전 심화 보강
실전 예제: 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"
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를 쓰지 않고unwrap만 남발해 사용자에게 스택 트레이스가 그대로 노출되는 경우.- 상대 경로를 현재 작업 디렉터리에만 의존해 스크립트·CI에서 엉뚱한 파일을 읽는 경우.
- 바이너리 이름과 패키지 이름을 혼동해
cargo install후 실행 파일 이름을 문서에 잘못 안내하는 경우.
주의사항
- Windows·macOS·Linux에서 경로 구분자와 줄바꿈이 다릅니다. 가능하면
std::path::Path와read_to_string을 사용하세요. - CLI는 종료 코드 규약(성공 0, 실패 비0)을 지키는 것이 스크립트 연동에 필수입니다.
- 민감한 정보는 환경 변수나 설정 파일로 분리하고,
--help예시에 시크릿을 넣지 마세요.
실무에서는 이렇게
- **
tracing+RUST_LOG**로 디버그 로그를 켜고, 릴리스에서는 기본을info이상으로 둡니다. clap의env속성으로API_KEY같은 값을 플래그와 환경 변수 양쪽에서 받을 수 있게 하면 운영이 편합니다.- 배포는
cargo build --release후 단일 바이너리를 GitHub Releases나 패키지 매니저에 올리고, 버전은clap의version과Cargo.toml을 맞춥니다.
비교 및 대안
| 접근 | 장점 | 언제 쓸까 |
|---|---|---|
clap derive | 빠른 개발, 서브커맨드·검증 풍부 | 대부분의 팀 CLI |
clap builder API | 동적 인자 구성 | 플러그인형 도구 |
std::env::args만 | 의존성 제로 | 초소형 스크립트 대체 |
Python argparse 호출 래핑 | 기존 스크립트 재사용 | 점진적 Rust 이전 |
추가 리소스
정리
핵심 요약
- clap: CLI 인자 파싱, 서브커맨드
- std::fs: 파일 읽기/쓰기
- BufReader: 효율적인 파일 읽기
- Result: 에러 처리
- ?: 에러 전파
다음 단계
- Rust C++ 비교
관련 글
- Rust 시작하기 | 메모리 안전한 시스템 프로그래밍 언어
- Rust 소유권 | Ownership, Borrowing, Lifetime