이번 글에서 다룰 내용
마지막 편은 지금까지 배운 개념을 하나의 안전한 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 check와 cargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요