- 들어가며 — 조용한 CLI 르네상스
- 첫 CLI — clap의 derive API
- 서브커맨드 — git 스타일 도구
- 왜 지금 Rust인가 — GC 없는 성능
- 배포 — 단일 정적 바이너리
- 있으면 좋은 크레이트들
- 좋은 CLI가 지키는 관행
- 마치며
- 참고 자료
들어가며 — 조용한 CLI 르네상스
지난 몇 년 사이, 여러분이 매일 쓰는 명령줄 도구들의 상당수가 조용히 Rust로 다시 쓰였습니다. grep은 ripgrep(rg)으로, find는 fd로, cat은 bat으로, ls는 eza로, cd는 zoxide(z)로. 여기에 파일 변경을 감지해 테스트를 다시 돌려 주는 bacon, 디스크 사용량을 보는 dust, 프로세스 뷰어 bottom(btm)까지 목록은 계속 늘어납니다.
이건 우연이 아닙니다. 명령줄 도구는 Rust의 강점이 거의 완벽하게 맞아떨어지는 영역입니다. 시작이 빨라야 하고(GC 워밍업이 없다), 예측 가능하게 빨라야 하고(GC 일시정지가 없다), 배포가 쉬워야 하고(런타임 의존성 없는 단일 바이너리), 안전해야 합니다(수많은 파일과 인코딩을 다루면서도 크래시하지 않아야 한다). 이 글은 그 르네상스의 배경을 짚고, 여러분이 직접 CLI를 만들 때 실제로 무엇이 필요한지를 코드로 보여 줍니다.
Rust 문법이 낯설다면, 글을 읽으면서 이 사이트의 Rust 학습 실습실에서 소유권·트레잇 같은 기초를 병행해 보세요.
첫 CLI — clap의 derive API
Rust 생태계에서 인자 파싱의 사실상 표준은 clap입니다. 예전에는 빌더 스타일로 인자를 하나씩 등록했지만, 요즘 권장되는 방식은 derive API입니다. 구조체를 하나 정의하고 #[derive(Parser)]를 붙이면, 필드가 곧 명령줄 인자가 됩니다.
Cargo.toml에 의존성부터 추가합니다.
[dependencies]
clap = { version = "4", features = ["derive"] }
이제 파일 안의 패턴을 찾는 아주 작은 grep 클론을 만들어 봅니다.
use clap::Parser;
use std::fs;
/// 파일에서 패턴을 찾는 작은 도구
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// 찾을 패턴
pattern: String,
/// 검색할 파일 경로
path: std::path::PathBuf,
/// 대소문자를 무시할지 여부
#[arg(short, long)]
ignore_case: bool,
}
fn main() {
let cli = Cli::parse();
let content = fs::read_to_string(&cli.path).expect("파일을 읽을 수 없습니다");
for (num, line) in content.lines().enumerate() {
let found = if cli.ignore_case {
line.to_lowercase().contains(&cli.pattern.to_lowercase())
} else {
line.contains(&cli.pattern)
};
if found {
println!("{}: {}", num + 1, line);
}
}
}
여기서 눈여겨볼 점이 여럿입니다. 필드 pattern과 path는 위치 인자(positional argument)가 됩니다. #[arg(short, long)]가 붙은 ignore_case는 -i 또는 --ignore-case 플래그가 됩니다. 필드 위의 /// 문서 주석은 그대로 도움말 텍스트가 됩니다. 그리고 #[command(version, about)] 덕분에 --version과 --help가 공짜로 생깁니다.
빌드해서 --help를 실행하면, 직접 한 줄도 쓰지 않은 깔끔한 사용법 화면이 나옵니다.
$ minigrep --help
파일에서 패턴을 찾는 작은 도구
Usage: minigrep [OPTIONS] <PATTERN> <PATH>
Arguments:
<PATTERN> 찾을 패턴
<PATH> 검색할 파일 경로
Options:
-i, --ignore-case 대소문자를 무시할지 여부
-h, --help Print help
-V, --version Print version
타입이 곧 계약이라는 점이 핵심입니다. path를 PathBuf로 선언했으니 clap이 문자열을 경로로 변환해 주고, ignore_case를 bool로 선언했으니 플래그의 유무가 자동으로 참·거짓이 됩니다. 인자가 빠지거나 형식이 틀리면 clap이 알아서 오류 메시지를 내고 종료합니다.
서브커맨드 — git 스타일 도구
git commit, git push처럼 하나의 도구가 여러 하위 명령을 갖는 구조는 아주 흔합니다. clap에서는 열거형(enum)으로 표현합니다. 각 변형(variant)이 하나의 서브커맨드가 됩니다.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// 새 항목을 추가한다
Add {
/// 추가할 내용
text: String,
},
/// 항목 목록을 보여 준다
List {
/// 완료된 항목도 포함할지
#[arg(short, long)]
all: bool,
},
/// 항목을 완료 처리한다
Done {
/// 완료할 항목 번호
id: u32,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { text } => {
println!("추가: {}", text);
}
Commands::List { all } => {
println!("목록 (완료 포함: {})", all);
}
Commands::Done { id } => {
println!("완료 처리: 항목 {}", id);
}
}
}
이제 이 도구는 todo add "우유 사기", todo list --all, todo done 3처럼 호출됩니다. 각 서브커맨드는 자기만의 인자와 플래그를 가지며, match로 분기하면 컴파일러가 모든 변형을 빠짐없이 처리했는지 검사해 줍니다. 서브커맨드를 하나 추가하면 match도 함께 고쳐야 하므로, 처리를 빠뜨릴 수가 없습니다.
왜 지금 Rust인가 — GC 없는 성능
이 도구들이 빠른 데는 여러 이유가 있지만, 가장 근본적인 것은 가비지 컬렉터가 없다는 점입니다.
Go, Java, Node 같은 런타임은 편리하지만 두 가지 세금을 물립니다. 첫째, 프로그램 시작 시 런타임을 초기화하는 지연이 있습니다. 명령줄 도구는 수천 번 반복 호출되기 때문에, 매 호출의 시작 지연이 쌓이면 체감이 됩니다. 둘째, 실행 중 GC가 개입해 예측 불가능한 일시정지를 일으킵니다. Rust는 컴파일 시점의 소유권 규칙으로 메모리를 관리하므로 런타임 GC 자체가 없습니다. 프로그램이 즉시 시작하고, 멈춤 없이 일정한 속도로 돌아갑니다.
여기에 두 가지 저수준 무기가 더해집니다.
- SIMD — 현대 CPU는 한 명령으로 여러 바이트를 동시에 처리하는 벡터 명령을 제공합니다. ripgrep이 의존하는
memchr크레이트는 SIMD로 한 번에 16·32·64바이트를 훑으며 특정 바이트를 찾습니다. 순진한 바이트별 루프보다 훨씬 빠릅니다. - mmap — 파일을 커널 버퍼에서 복사해 오는 대신, 프로세스의 가상 주소 공간에 직접 매핑해 거대한 바이트 배열처럼 다룹니다. 큰 파일 하나를 훑을 때 복사 단계가 사라집니다.
다만 오해는 말아야 합니다. "Rust라서 빠르다"는 절반만 맞습니다. Rust는 무겁지 않은 추상화와 안전한 동시성이라는 토대를 줄 뿐, 속도의 본질은 알고리즘과 설계에 있습니다. ripgrep이 빠른 진짜 이유가 궁금하다면 이 블로그의 ripgrep은 왜 그렇게 빠른가 글에서 정규식 엔진 선택부터 병렬 순회까지 자세히 다뤘습니다.
배포 — 단일 정적 바이너리
CLI 도구의 성패는 성능만이 아니라 배포의 편의성에서 갈립니다. 여기서 Rust는 결정적인 이점을 가집니다.
cargo build --release를 실행하면 하나의 실행 파일이 나옵니다. 이 파일은 인터프리터도, 런타임도, node_modules도 필요로 하지 않습니다. 사용자는 파이썬 버전을 맞추거나 JVM을 설치할 필요 없이, 그냥 바이너리 하나를 내려받아 실행 권한을 주고 실행하면 됩니다.
# 릴리스 빌드 (최적화 켜짐)
cargo build --release
# 결과물은 여기 하나뿐
./target/release/mytool
# 완전 정적 링크가 필요하면 musl 타깃을 쓴다
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
특히 musl 타깃으로 빌드하면 C 표준 라이브러리까지 정적으로 포함되어, 배포 대상의 시스템 라이브러리 버전에 전혀 의존하지 않는 완전 정적 바이너리가 나옵니다. 이런 바이너리는 최소 도커 이미지(scratch나 alpine)에 그대로 넣어도 동작합니다. 배포가 "파일 하나 복사"로 끝나는 것, 이것이 CLI 저자와 사용자 모두에게 큰 매력입니다.
있으면 좋은 크레이트들
인자 파싱이 clap이라면, 나머지 흔한 필요를 채워 주는 크레이트들이 있습니다. 좋은 CLI는 대개 이 조합으로 만들어집니다.
- anyhow — 애플리케이션 레벨의 오류 처리를 편하게 해 줍니다. 다양한 오류 타입을 하나로 묶고,
?연산자로 전파하며, 실패 경로에 맥락을 덧붙일 수 있습니다. - indicatif — 진행 표시줄(progress bar)과 스피너를 그려 줍니다. 파일을 많이 처리하는 도구라면 사용자에게 진행 상황을 보여 주는 것이 큰 차이를 만듭니다.
- crossterm — 터미널을 크로스 플랫폼으로 제어합니다. 색상, 커서 이동, 화면 지우기, 키 입력 감지 등을 윈도우·맥·리눅스에서 동일하게 다룰 수 있습니다.
anyhow로 오류에 맥락을 붙이는 예를 보겠습니다.
use anyhow::{Context, Result};
use std::fs;
fn load_config(path: &str) -> Result<String> {
let content = fs::read_to_string(path)
.with_context(|| format!("설정 파일을 읽지 못했습니다: {}", path))?;
Ok(content)
}
fn main() -> Result<()> {
let config = load_config("config.toml")?;
println!("설정 {} 바이트를 읽었습니다", config.len());
Ok(())
}
main이 Result를 반환하도록 하면, 오류가 발생했을 때 Rust가 알아서 메시지를 출력하고 0이 아닌 종료 코드로 끝냅니다. with_context로 붙인 설명 덕분에, 사용자는 "파일 없음" 같은 저수준 오류가 아니라 "설정 파일을 읽지 못했습니다"라는 이해 가능한 메시지를 봅니다.
indicatif로 진행 표시줄을 그리는 예도 간단합니다.
use indicatif::ProgressBar;
use std::thread;
use std::time::Duration;
fn main() {
let total = 1000;
let bar = ProgressBar::new(total);
for _ in 0..total {
// 실제로는 여기서 파일 하나를 처리한다
thread::sleep(Duration::from_millis(2));
bar.inc(1);
}
bar.finish_with_message("완료");
}
좋은 CLI가 지키는 관행
빠르고 안전한 것만으로는 부족합니다. 앞서 본 도구들이 사랑받는 이유는 사용자 경험을 세심하게 챙기기 때문입니다. 여러분이 도구를 만들 때도 다음을 기억하면 좋습니다.
- 표준 스트림을 존중하라. 정상 출력은 stdout으로, 오류와 진단은 stderr로 보냅니다. 그래야 사용자가
mytool | other처럼 파이프로 연결할 때 진단 메시지가 데이터를 오염시키지 않습니다. - 파이프를 감지하라. 출력이 터미널이 아니라 파이프로 연결되면 색상 코드를 끄는 것이 예의입니다. 대부분의 도구가 이렇게 동작하고, 필요하면
--color always같은 옵션으로 강제하게 둡니다. - 종료 코드를 지켜라. 성공은 0, 실패는 0이 아닌 값. 셸 스크립트가 여러분의 도구를 조건문에 넣을 수 있어야 합니다.
- 좋은 기본값을 골라라. 사용자가 대개 원하는 동작을 설정 없이 기본으로 삼고, 예외적 상황만 플래그로 열어 줍니다. 이것이 ripgrep이
.gitignore를 기본으로 존중하는 이유입니다. - 셸 자동완성을 제공하라. clap은 bash·zsh·fish용 자동완성 스크립트를 생성해 줍니다. 사소해 보여도 사용성을 크게 끌어올립니다.
버전 관리 도구를 다뤄 보며 CLI 감각을 익히고 싶다면, 이 사이트의 Git 플레이그라운드에서 명령들의 동작을 직접 실험해 볼 수 있습니다.
마치며
CLI 르네상스는 언어 팬덤이 아니라 적합성의 결과입니다. 빠른 시작, 예측 가능한 성능, 단일 바이너리 배포, 메모리 안전. 이 네 가지가 명령줄 도구라는 문제에 정확히 들어맞았고, 그 위에서 ripgrep·fd·bat 같은 도구들이 태어났습니다.
여러분의 첫 도구는 거창할 필요가 없습니다. #[derive(Parser)]를 붙인 구조체 하나로 시작해, 서브커맨드를 붙이고, anyhow로 오류를 다듬고, 필요하면 indicatif로 진행을 보여 주세요. 그리고 마지막으로 표준 스트림과 종료 코드 같은 작은 관행을 지키면, 여러분의 도구도 사람들이 매일 손에 익혀 쓰는 그 목록에 들어갈 수 있습니다.
참고 자료
- clap 공식 문서 — https://docs.rs/clap/
- clap derive 튜토리얼 — https://docs.rs/clap/latest/clap/_derive/_tutorial/index.html
- The Rust Programming Language, 12장 (minigrep 만들기) — https://doc.rust-lang.org/book/ch12-00-an-io-project.html
- Command Line Applications in Rust (책) — https://rust-cli.github.io/book/
- anyhow 크레이트 — https://docs.rs/anyhow/
- indicatif 크레이트 — https://docs.rs/indicatif/
- crossterm 크레이트 — https://docs.rs/crossterm/
- ripgrep 저장소 — https://github.com/BurntSushi/ripgrep
- fd 저장소 — https://github.com/sharkdp/fd
- bat 저장소 — https://github.com/sharkdp/bat
현재 단락 (1/145)
지난 몇 년 사이, 여러분이 매일 쓰는 명령줄 도구들의 상당수가 조용히 Rust로 다시 쓰였습니다. `grep`은 `ripgrep`(rg)으로, `find`는 `fd`로, `cat...