본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 9장: CLI 도구 개발
2026년 2월 27일·프로그래밍·

9장: CLI 도구 개발

Clap v4 서브커맨드 아키텍처, config-rs 설정 관리, tracing 구조화 로깅, indicatif 진행 표시, 크로스 플랫폼 빌드와 배포까지 다룹니다.

12분1,017자9개 섹션
rust
공유
rust-backend9 / 11
1234567891011
이전8장: 테스트와 품질 보증다음10장: WebAssembly 타겟

학습 목표

  • Clap v4로 서브커맨드 기반 CLI를 설계합니다
  • config-rs로 계층적 설정 관리를 구현합니다
  • tracing으로 구조화 로깅을 적용합니다
  • indicatif으로 진행 표시를 구현합니다
  • 크로스 플랫폼 빌드와 배포 파이프라인을 이해합니다

왜 Rust로 CLI를 만드는가

Rust로 만든 CLI 도구는 세 가지 장점이 있습니다.

  1. 단일 바이너리 배포: 런타임이나 인터프리터 없이 바이너리 하나로 배포
  2. 빠른 시작 시간: GC 워밍업이 없어 밀리초 단위로 시작
  3. 크로스 플랫폼: 하나의 코드로 Linux, macOS, Windows 빌드

ripgrep, fd, bat, exa 등 유명 CLI 도구들이 모두 Rust로 작성된 이유입니다.

Clap v4 서브커맨드 아키텍처

Clap은 Rust의 CLI 인자 파싱 라이브러리로, derive 매크로를 통해 선언적으로 CLI를 정의할 수 있습니다.

Cargo.toml
toml
[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"
src/main.rs — CLI 진입점
rust
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 인터페이스가 자동 생성됩니다.

자동 생성되는 CLI 인터페이스
bash
# 도움말
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: 계층적 설정 관리

config-rs는 여러 소스의 설정을 계층적으로 병합하는 라이브러리입니다.

src/config.rs
rust
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()
}
config.toml
toml
[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
Info

설정의 우선순위는 환경 변수 > 설정 파일 > 기본값 순입니다. 프로덕션에서는 JWT 시크릿 등 민감한 값을 환경 변수로 주입하고, 개발 환경에서는 설정 파일을 사용하는 패턴이 일반적입니다.

tracing: 구조화 로깅

tracing은 Rust의 구조화 로깅 및 분산 추적 프레임워크입니다. 단순한 텍스트 로그가 아닌 구조화된 이벤트를 기록합니다.

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(())
}
출력 예시
text
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: 진행 표시

indicatif으로 사용자에게 진행 상황을 시각적으로 보여줍니다.

진행 표시줄 구현
rust
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(())
}

서브커맨드 핸들러 구현

서브커맨드 핸들러
rust
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!("종료 신호 수신");
}

크로스 플랫폼 빌드

크로스 컴파일
bash
# 타겟 추가
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-darwin

cargo-dist로 배포 자동화

cargo-dist는 릴리스 바이너리 빌드와 배포를 자동화합니다.

cargo-dist 설정
bash
# 설치
cargo install cargo-dist
 
# 초기화 — 대화형으로 타겟 플랫폼 선택
cargo dist init
 
# 로컬에서 빌드 테스트
cargo dist build
Cargo.toml (cargo-dist 설정)
toml
[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에 업로드됩니다.

릴리스
bash
git tag v0.1.0
git push origin v0.1.0
# → GitHub Actions가 자동으로 빌드 및 릴리스 생성
Tip

cargo-dist는 설치 스크립트도 자동 생성합니다. 사용자는 curl -sSf https://your-repo/install.sh | sh 한 줄로 도구를 설치할 수 있습니다. Homebrew tap도 자동으로 생성되어 brew install your-tool도 가능합니다.

정리

이 장에서는 Rust CLI 도구 개발의 전체 과정을 다루었습니다.

  • Clap v4의 derive 매크로로 서브커맨드 기반 CLI를 선언적으로 정의합니다
  • config-rs로 기본값, 설정 파일, 환경 변수를 계층적으로 병합합니다
  • tracing으로 구조화 로깅을 적용하여 디버깅과 모니터링을 용이하게 합니다
  • indicatif으로 사용자 친화적인 진행 표시를 제공합니다
  • cargo-dist로 크로스 플랫폼 빌드와 배포를 자동화합니다

다음 장에서는 WebAssembly 타겟을 다룹니다. Rust 코드를 브라우저와 서버리스 환경에서 실행하는 방법을 알아봅니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#rust

관련 글

프로그래밍

10장: WebAssembly 타겟

wasm-pack, wasm-bindgen, wasm32-wasi 타겟, Spin 서버리스, 브라우저 통합, 서버와 Wasm 간 비즈니스 로직 공유, 크기 최적화까지 다룹니다.

2026년 3월 1일·13분
프로그래밍

8장: 테스트와 품질 보증

Rust의 단위 테스트, 통합 테스트, API 테스트, testcontainers를 활용한 DB 테스트, 프로퍼티 기반 테스트, 벤치마킹까지 다룹니다.

2026년 2월 25일·12분
프로그래밍

11장: 실전 프로젝트 -- Rust 백엔드 API 구축

Axum, SQLx, Tokio를 조합한 프로덕션 수준의 REST API를 처음부터 구축합니다. JWT 인증, CRUD, 미들웨어, 테스트, Docker 배포까지 총정리합니다.

2026년 3월 3일·16분
이전 글8장: 테스트와 품질 보증
다음 글10장: WebAssembly 타겟

댓글

목차

약 12분 남음
  • 학습 목표
  • 왜 Rust로 CLI를 만드는가
  • Clap v4 서브커맨드 아키텍처
  • config-rs: 계층적 설정 관리
  • tracing: 구조화 로깅
  • indicatif: 진행 표시
  • 서브커맨드 핸들러 구현
  • 크로스 플랫폼 빌드
    • cargo-dist로 배포 자동화
  • 정리