Clap v4 서브커맨드 아키텍처, config-rs 설정 관리, tracing 구조화 로깅, indicatif 진행 표시, 크로스 플랫폼 빌드와 배포까지 다룹니다.
Rust로 만든 CLI 도구는 세 가지 장점이 있습니다.
ripgrep, fd, bat, exa 등 유명 CLI 도구들이 모두 Rust로 작성된 이유입니다.
Clap은 Rust의 CLI 인자 파싱 라이브러리로, derive 매크로를 통해 선언적으로 CLI를 정의할 수 있습니다.
[package]
name = "myctl"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.4", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
config = "0.14"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
indicatif = "0.17"
anyhow = "1"use clap::{Parser, Subcommand};
use anyhow::Result;
#[derive(Parser)]
#[command(
name = "myctl",
version,
about = "백엔드 관리 CLI 도구",
long_about = "데이터베이스 마이그레이션, 시드 데이터 생성, 서버 관리 등을 수행하는 CLI 도구입니다."
)]
struct Cli {
/// 설정 파일 경로
#[arg(short, long, default_value = "config.toml")]
config: String,
/// 로그 레벨 (trace, debug, info, warn, error)
#[arg(short, long, default_value = "info", env = "LOG_LEVEL")]
log_level: String,
/// 출력 형식
#[arg(long, default_value = "text")]
format: OutputFormat,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// 데이터베이스 관련 명령
Db {
#[command(subcommand)]
action: DbCommands,
},
/// 서버 시작
Serve {
/// 포트 번호
#[arg(short, long, default_value = "3000")]
port: u16,
/// 바인딩 주소
#[arg(long, default_value = "0.0.0.0")]
host: String,
},
/// 사용자 관리
User {
#[command(subcommand)]
action: UserCommands,
},
}
#[derive(Subcommand)]
enum DbCommands {
/// 마이그레이션 실행
Migrate,
/// 마이그레이션 되돌리기
Rollback {
/// 되돌릴 단계 수
#[arg(short, long, default_value = "1")]
steps: u32,
},
/// 시드 데이터 생성
Seed {
/// 생성할 레코드 수
#[arg(short, long, default_value = "100")]
count: u32,
},
}
#[derive(Subcommand)]
enum UserCommands {
/// 사용자 목록 조회
List {
#[arg(long, default_value = "20")]
limit: u32,
},
/// 관리자 생성
CreateAdmin {
/// 관리자 이름
#[arg(short, long)]
name: String,
/// 관리자 이메일
#[arg(short, long)]
email: String,
},
}
#[derive(Clone, clap::ValueEnum)]
enum OutputFormat {
Text,
Json,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// 로깅 초기화
setup_logging(&cli.log_level);
// 설정 로드
let config = load_config(&cli.config)?;
// 서브커맨드 분기
match cli.command {
Commands::Db { action } => handle_db(action, &config).await?,
Commands::Serve { port, host } => handle_serve(port, &host, &config).await?,
Commands::User { action } => handle_user(action, &config).await?,
}
Ok(())
}이렇게 정의하면 다음과 같은 CLI 인터페이스가 자동 생성됩니다.
# 도움말
myctl --help
myctl db --help
myctl db seed --help
# 사용 예시
myctl db migrate
myctl db seed --count 500
myctl db rollback --steps 2
myctl serve --port 8080
myctl user list --limit 50
myctl user create-admin --name "Admin" --email "admin@example.com"config-rs는 여러 소스의 설정을 계층적으로 병합하는 라이브러리입니다.
use config::{Config, ConfigError, File, Environment};
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub database: DatabaseConfig,
pub server: ServerConfig,
pub auth: AuthConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
pub min_connections: u32,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
pub jwt_secret: String,
pub token_expiry_hours: u64,
}
pub fn load_config(path: &str) -> Result<AppConfig, ConfigError> {
let config = Config::builder()
// 1. 기본값
.set_default("server.host", "0.0.0.0")?
.set_default("server.port", 3000)?
.set_default("database.max_connections", 20)?
.set_default("database.min_connections", 5)?
// 2. 설정 파일 (선택적)
.add_source(File::with_name(path).required(false))
// 3. 환경 변수 (APP_ 접두사, __ 로 중첩 구분)
// APP_DATABASE__URL → database.url
.add_source(
Environment::with_prefix("APP")
.separator("__")
)
.build()?;
config.try_deserialize()
}[database]
url = "postgresql://localhost/mydb"
max_connections = 20
min_connections = 5
[server]
host = "0.0.0.0"
port = 3000
[auth]
jwt_secret = "change-me-in-production"
token_expiry_hours = 24설정의 우선순위는 환경 변수 > 설정 파일 > 기본값 순입니다. 프로덕션에서는 JWT 시크릿 등 민감한 값을 환경 변수로 주입하고, 개발 환경에서는 설정 파일을 사용하는 패턴이 일반적입니다.
tracing은 Rust의 구조화 로깅 및 분산 추적 프레임워크입니다. 단순한 텍스트 로그가 아닌 구조화된 이벤트를 기록합니다.
use tracing::{info, warn, error, debug, instrument};
use tracing_subscriber::{EnvFilter, fmt};
fn setup_logging(level: &str) {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(level));
fmt()
.with_env_filter(filter)
.with_target(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.init();
}
// #[instrument]는 함수 진입/퇴장을 자동 추적
#[instrument(skip(pool), fields(user_count))]
async fn seed_users(pool: &sqlx::PgPool, count: u32) -> anyhow::Result<()> {
info!(count, "시드 데이터 생성 시작");
for i in 0..count {
let name = format!("User {}", i);
let email = format!("user{}@example.com", i);
match create_user(pool, &name, &email).await {
Ok(_) => debug!(index = i, name, "사용자 생성 성공"),
Err(e) => warn!(index = i, error = %e, "사용자 생성 실패"),
}
}
info!("시드 데이터 생성 완료");
Ok(())
}2026-03-31T10:00:00.000Z INFO myctl::db: 시드 데이터 생성 시작 count=100
2026-03-31T10:00:00.005Z DEBUG myctl::db: 사용자 생성 성공 index=0 name="User 0"
2026-03-31T10:00:00.010Z DEBUG myctl::db: 사용자 생성 성공 index=1 name="User 1"
...
2026-03-31T10:00:01.234Z INFO myctl::db: 시드 데이터 생성 완료indicatif으로 사용자에게 진행 상황을 시각적으로 보여줍니다.
use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
use std::time::Duration;
async fn seed_with_progress(pool: &sqlx::PgPool, count: u32) -> anyhow::Result<()> {
let pb = ProgressBar::new(count as u64);
pb.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})"
)?
.progress_chars("=>-"),
);
for i in 0..count {
let name = format!("User {}", i);
let email = format!("user{}@example.com", i);
create_user(pool, &name, &email).await?;
pb.inc(1);
}
pb.finish_with_message("시드 데이터 생성 완료");
Ok(())
}
// 여러 작업의 동시 진행 표시
async fn parallel_seed(pool: &sqlx::PgPool) -> anyhow::Result<()> {
let multi = MultiProgress::new();
let pb_users = multi.add(ProgressBar::new(100));
pb_users.set_prefix("사용자");
pb_users.set_style(ProgressStyle::with_template(
"{prefix:.bold} [{bar:30}] {pos}/{len}",
)?);
let pb_posts = multi.add(ProgressBar::new(500));
pb_posts.set_prefix("게시물");
pb_posts.set_style(ProgressStyle::with_template(
"{prefix:.bold} [{bar:30}] {pos}/{len}",
)?);
let pool2 = pool.clone();
let users_task = tokio::spawn(async move {
for i in 0..100 {
// 사용자 생성...
pb_users.inc(1);
tokio::time::sleep(Duration::from_millis(10)).await;
}
pb_users.finish();
});
let posts_task = tokio::spawn(async move {
for i in 0..500 {
// 게시물 생성...
pb_posts.inc(1);
tokio::time::sleep(Duration::from_millis(2)).await;
}
pb_posts.finish();
});
tokio::join!(users_task, posts_task);
Ok(())
}async fn handle_db(action: DbCommands, config: &AppConfig) -> Result<()> {
let pool = create_pool(&config.database).await?;
match action {
DbCommands::Migrate => {
info!("마이그레이션 실행");
sqlx::migrate!("./migrations")
.run(&pool)
.await?;
println!("마이그레이션이 성공적으로 적용되었습니다.");
}
DbCommands::Rollback { steps } => {
info!(steps, "마이그레이션 되돌리기");
for _ in 0..steps {
sqlx::migrate!("./migrations")
.undo(&pool, 1)
.await?;
}
println!("{}단계 롤백이 완료되었습니다.", steps);
}
DbCommands::Seed { count } => {
seed_with_progress(&pool, count).await?;
}
}
Ok(())
}
async fn handle_serve(port: u16, host: &str, config: &AppConfig) -> Result<()> {
let pool = create_pool(&config.database).await?;
let state = Arc::new(AppState { db: pool, config: config.clone() });
let app = create_router(state);
let addr = format!("{}:{}", host, port);
info!(addr, "서버 시작");
println!("서버가 {}에서 실행 중입니다", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("Ctrl+C 시그널 수신 실패");
info!("종료 신호 수신");
}# 타겟 추가
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-apple-darwin
rustup target add x86_64-pc-windows-msvc
# Linux (정적 링크)
cargo build --release --target x86_64-unknown-linux-musl
# macOS ARM
cargo build --release --target aarch64-apple-darwincargo-dist는 릴리스 바이너리 빌드와 배포를 자동화합니다.
# 설치
cargo install cargo-dist
# 초기화 — 대화형으로 타겟 플랫폼 선택
cargo dist init
# 로컬에서 빌드 테스트
cargo dist build[workspace.metadata.dist]
cargo-dist-version = "0.27"
ci = "github"
installers = ["shell", "homebrew"]
targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]GitHub에 태그를 푸시하면 자동으로 모든 플랫폼의 바이너리가 빌드되고 GitHub Releases에 업로드됩니다.
git tag v0.1.0
git push origin v0.1.0
# → GitHub Actions가 자동으로 빌드 및 릴리스 생성cargo-dist는 설치 스크립트도 자동 생성합니다. 사용자는 curl -sSf https://your-repo/install.sh | sh 한 줄로 도구를 설치할 수 있습니다. Homebrew tap도 자동으로 생성되어 brew install your-tool도 가능합니다.
이 장에서는 Rust CLI 도구 개발의 전체 과정을 다루었습니다.
다음 장에서는 WebAssembly 타겟을 다룹니다. Rust 코드를 브라우저와 서버리스 환경에서 실행하는 방법을 알아봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
wasm-pack, wasm-bindgen, wasm32-wasi 타겟, Spin 서버리스, 브라우저 통합, 서버와 Wasm 간 비즈니스 로직 공유, 크기 최적화까지 다룹니다.
Rust의 단위 테스트, 통합 테스트, API 테스트, testcontainers를 활용한 DB 테스트, 프로퍼티 기반 테스트, 벤치마킹까지 다룹니다.
Axum, SQLx, Tokio를 조합한 프로덕션 수준의 REST API를 처음부터 구축합니다. JWT 인증, CRUD, 미들웨어, 테스트, Docker 배포까지 총정리합니다.