[Rust 시리즈 20편] 캡스톤: 안전한 CLI 도구 만들기

English version

이번 글에서 다룰 내용

마지막 편은 지금까지 배운 개념을 하나의 안전한 CLI 도구로 엮습니다. 목표는 간단합니다. 텍스트 파일에 메모를 추가하고 검색하는 프로그램을 만들되, 모듈을 나누고, 에러를 안전하게 처리하며, 테스트까지 갖춥니다.

시나리오와 프로젝트 구조

사용자는 memo add "Rust ownership"처럼 명령을 실행해 메모를 기록하고, memo list로 저장된 항목을 조회합니다. 구조는 다음과 같습니다.

src/
  main.rs        // 인자 파싱과 커맨드 분기
  commands.rs    // add, list 로직
  storage.rs     // 파일 읽기/쓰기 추상화

모듈을 나누면 테스트 범위를 줄이면서도 재사용성을 높일 수 있습니다.

시작하기 전에, 이 글은 특히 아래 편을 복습해 두면 더 잘 읽힙니다.

  • 11편: Result, ?, 에러 전달
  • 12편: 모듈과 파일 분리
  • 17편: 테스트 작성

storage 모듈: 파일 I/O와 에러 처리

// src/storage.rs
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::Path;

pub fn append_line<P: AsRef<Path>>(path: P, line: &str) -> io::Result<()> {
    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
    writeln!(file, "{}", line)?;
    Ok(())
}

pub fn read_all<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
    if !path.as_ref().exists() {
        return Ok(vec![]);
    }
    let content = fs::read_to_string(path)?;
    Ok(content.lines().map(|s| s.to_string()).collect())
}

io::Result?를 사용해 에러를 상위 호출자로 넘깁니다. 파일이 없을 때는 빈 벡터를 반환해 사용자가 첫 실행에서 패닉을 겪지 않도록 합니다.

commands 모듈: 도메인 로직

// src/commands.rs
use crate::storage;
use std::io;

pub enum Command {
    Add { text: String },
    List,
}

impl Command {
    pub fn run(self, path: &str) -> io::Result<()> {
        match self {
            Command::Add { text } => {
                storage::append_line(path, &text)?;
                println!("추가 완료: {text}");
            }
            Command::List => {
                let items = storage::read_all(path)?;
                if items.is_empty() {
                    println!("저장된 메모가 없습니다.");
                } else {
                    for (idx, item) in items.iter().enumerate() {
                        println!("{idx}: {item}");
                    }
                }
            }
        }
        Ok(())
    }
}

명령 추상화를 만들어 두면 이후 search 같은 서브커맨드를 추가하기가 쉬워집니다.

main.rs: 인자 파싱과 에러 보고

mod commands;
mod storage;

use commands::Command;

fn main() {
    if let Err(err) = run() {
        eprintln!("오류: {err}");
        std::process::exit(1);
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let mut args = std::env::args().skip(1);
    let cmd = match args.next().as_deref() {
        Some("add") => {
            let text = args.next().ok_or("추가할 문장을 입력하세요")?;
            Command::Add { text }
        }
        Some("list") => Command::List,
        _ => {
            println!("사용법: memo <add|list> [text]");
            return Ok(());
        }
    };

    cmd.run("memo.txt")?;
    Ok(())
}

run 함수는 Box<dyn Error>를 통해 다양한 에러를 한 번에 감쌀 수 있게 설계했습니다. main에서는 에러 메시지를 표준 오류로 출력하고 적절한 종료 코드를 반환합니다.

테스트: storage 동작 검증

storage 모듈은 파일 시스템과 직접 상호작용하므로, tempfile 크레이트나 tempdir로 임시 파일을 만들어 테스트할 수 있습니다.

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::NamedTempFile;

    #[test]
    fn append_and_read_back() {
        let file = NamedTempFile::new().unwrap();
        let path = file.path();

        append_line(path, "hello").unwrap();
        append_line(path, "rust").unwrap();

        let lines = read_all(path).unwrap();
        assert_eq!(lines, vec!["hello", "rust"]);
    }
}

파일 API가 제대로 동작하면 나머지 로직도 기대한 대로 움직일 가능성이 높습니다. CLI 레이어는 통합 테스트나 수동 테스트로도 검증할 수 있습니다.

완성 후 체크리스트

  • memo add "hello"가 파일에 한 줄을 추가하는가
  • memo list가 저장된 메모를 빠짐없이 출력하는가
  • 파일이 없을 때도 패닉 없이 빈 목록으로 동작하는가
  • 잘못된 인자를 넣었을 때 에러 메시지가 이해하기 쉬운가
  • 테스트가 cargo test에서 통과하는가

안전성 체크리스트

  • Result를 끝까지 전달하고 에러 메시지를 명확히 기록했습니다.
  • 모듈을 나눠 해당 파일의 책임을 단순화했습니다.
  • 테스트로 핵심 I/O 로직을 검증했습니다.
  • 향후 async 처리를 도입하고 싶다면 tokio::fs로 치환하면서 Command 구조는 그대로 유지할 수 있습니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo checkcargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. starter를 fork한 뒤 src/main.rs를 연다
  2. 본문 예제를 붙여 넣고 cargo check와 cargo run을 차례로 실행한다
  3. 타입, 값, 참조 흐름을 바꿔 컴파일 피드백과 출력 차이를 비교한다

Rust 실습은 브라우저 미리보기보다 터미널 피드백이 더 중요합니다. 여러 파일 구조나 추가 crate가 필요한 예제는 파일 배치를 조금 더 손봐야 할 수 있습니다.

연습 과제

  1. search <keyword> 명령을 추가해 해당 키워드가 들어 있는 라인만 출력해 보세요.
  2. memo list --json 옵션을 도입해 JSON 배열로 메모를 표시해 보세요.
  3. tokio 런타임을 도입해 비동기 파일 I/O로 전환하고, 기존 테스트가 통과하는지 확인해 보세요.

마무리

20편에 걸친 Rust 여정을 마무리했습니다. 스마트 포인터, 테스트, 동시성, 비동기, CLI 캡스톤까지 따라왔다면 이제 Rust로 작은 도구를 설계할 때 두려움이 훨씬 줄었을 것입니다. 다음 단계는 관심 있는 도메인(웹, CLI, 시스템 도구)에서 실제 프로젝트를 선택하고, 이번 캡스톤을 기반으로 확장해 보는 것입니다.

💬 댓글

이 글에 대한 의견을 남겨주세요