명령줄 인자를 읽는 코드는 금방 작성할 수 있습니다. 문제는 main 안에서 인자를 꺼내고 URL을 검사한 뒤 곧바로 println!까지 호출할 때 시작됩니다. 입력 하나를 시험하려고 프로세스를 매번 실행해야 하고 오류 문구를 바꾸면 도메인 테스트까지 흔들립니다.
이번 편에서는 외부 크레이트 없이 작은 엔드포인트 등록 CLI를 만듭니다. 완성한 프로그램은 다음 명령을 처리합니다.
cargo run --quiet -- add api https://example.com/health
registered api -> https://example.com/health
기능은 작지만 경계는 선명하게 나눕니다. std::env가 맡는 프로세스 I/O, 문자열을 Command로 바꾸는 파싱, 엔드포인트 규칙을 검사하는 도메인 로직, 결과를 문자열로 바꾸는 출력 포맷을 각각 분리합니다. 이 구조라면 대부분의 테스트가 터미널이나 운영체제에 기대지 않습니다.
1. 첫 버전의 범위
CLI 문법은 하나뿐입니다.
endpoint-registry add <name> <http(s)://url>
add는 이름과 URL을 받아 검증한 뒤 등록 결과를 출력합니다. 아직 파일이나 데이터베이스에는 저장하지 않습니다. 따라서 이번 버전의 '등록'은 입력을 검증해 도메인 값으로 만들고 결과를 돌려주는 한 번의 실행을 뜻합니다. 영속화는 이후 저장소 경계를 배운 뒤 붙이는 편이 낫습니다.
예제는 Rust 2024 에디션이며 의존성 목록은 비어 있습니다.
[package]
name = "endpoint-registry"
version = "0.1.0"
edition = "2024"
publish = false
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
all = "warn"
pedantic = "warn"
각 패키지의 Cargo.toml은 컴파일에 필요한 메타데이터를 담는 매니페스트입니다. 여기서는 edition = "2024"를 명시해 예제가 어느 에디션을 기준으로 하는지 고정했습니다.
1.1. 완성된 예제 실행하기
이 디렉터리의 Cargo.toml, src/lib.rs, src/main.rs, tests/cli.rs가 기준 소스이며 디렉터리 밖의 파일은 필요하지 않습니다. 글에서 다음처럼 실행합니다.
cd fixtures/06-endpoint-registry-cli
cargo run --quiet -- add api https://example.com/health
아래 절에서는 이 파일들의 핵심 부분을 나누어 설명합니다. 프로그램을 재현할 때는 서로 떨어진 코드 조각만으로 정의를 추측하지 말고 위 경로의 완성된 파일을 사용하면 오류 타입, 테스트 도우미, 프로세스 테스트까지 빠짐없이 포함됩니다.
2. 비대한 main의 비용
처음에는 아래처럼 작성하기 쉽습니다.
fn main() {
let args: Vec<String> = std::env::args().collect();
if args[1] == "add" {
println!("registered {} -> {}", args[2], args[3]);
}
}
인자가 부족하면 인덱싱에서 패닉이 납니다. URL 검증을 넣을수록 분기도 main에 쌓입니다. 출력까지 함수 안에 박혀 있어서 결과를 확인하려면 stdout을 가로채거나 바이너리를 실행해야 합니다. main이 인자 파싱과 작업 수행을 함께 맡으면 추론과 테스트, 변경이 어려워집니다.
이 글의 기준은 간단합니다. main은 운영체제와 접촉하되 판단하지 않습니다. 나머지 함수는 값을 받고 값을 돌려줍니다.
3. 원시 문자열을 Command로
파서는 운영체제를 직접 읽지 않습니다. 대신 String 이터레이터를 받습니다.
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Add { name: String, url: String },
}
#[derive(Debug, PartialEq, Eq)]
pub enum ParseError {
MissingCommand,
UnknownCommand(String),
WrongArgumentCount { command: String },
}
pub fn parse_args(
args: impl IntoIterator<Item = String>,
) -> Result<Command, ParseError> {
let mut args = args.into_iter();
let command = args.next().ok_or(ParseError::MissingCommand)?;
match command.as_str() {
"add" => {
let (Some(name), Some(url), None) =
(args.next(), args.next(), args.next())
else {
return Err(ParseError::WrongArgumentCount { command });
};
Ok(Command::Add { name, url })
}
_ => Err(ParseError::UnknownCommand(command)),
}
}
Command를 만들고 나면 뒤쪽 코드는 URL이 몇 번째 원소였는지 알 필요가 없습니다. let ... else 패턴은 이름과 URL이 모두 있고 추가 인자는 없는 경우만 통과시킵니다. 부족한 입력과 남는 입력을 같은 오류로 처리하는 선택도 파서 정책입니다.
std::env::args()는 프로그램을 시작할 때 전달된 인자의 이터레이터를 반환합니다. 첫 원소는 관례상 실행 파일 경로이므로 main에서 skip(1)로 제외합니다. 이 첫 원소를 보안 판단에 사용하면 안 됩니다. 값이 임의 문자열일 수 있고 실제 경로가 아닐 수도 있기 때문입니다.
또 하나의 경계가 있습니다. args()는 인자에 유효하지 않은 Unicode가 들어 있으면 순회 중 패닉할 수 있습니다. 임의의 운영체제 문자열을 손실 없이 받아야 하는 도구라면 args_os()와 OsString을 검토해야 합니다. 이번 CLI는 사람이 입력하는 Unicode 텍스트를 전제로 String 경계를 택했습니다.
4. URL 규칙의 위치
parse_args는 토큰의 개수와 명령 이름만 확인합니다. 이름이 비어 있는지, URL 스킴을 허용할지는 Endpoint::new가 결정합니다.
#[derive(Debug, PartialEq, Eq)]
pub struct Endpoint {
name: String,
url: String,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
Added(Endpoint),
}
#[derive(Debug, PartialEq, Eq)]
pub enum DomainError {
EmptyName,
UnsupportedScheme,
}
impl Endpoint {
pub fn new(name: String, url: String) -> Result<Self, DomainError> {
if name.trim().is_empty() {
return Err(DomainError::EmptyName);
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(DomainError::UnsupportedScheme);
}
Ok(Self { name, url })
}
}
이 검사는 완전한 URL 파서가 아닙니다. 예를 들어 https:// 뒤의 호스트 존재 여부까지 확인하지 않습니다. 표준 라이브러리만 사용하는 이번 단계에서는 허용 스킴이라는 최소 불변식만 둡니다. 더 강한 URL 정규화가 필요해지면 검증 정책과 파서 선택을 함께 바꿔야 합니다.
실행 함수도 I/O를 하지 않습니다.
pub fn execute(command: Command) -> Result<Outcome, DomainError> {
match command {
Command::Add { name, url } => {
Endpoint::new(name, url).map(Outcome::Added)
}
}
}
파싱 오류와 도메인 오류를 나누면 테스트 실패가 어느 경계에서 났는지 바로 드러납니다. 화면에 같은 방식으로 표시하고 싶을 때만 CliError에서 두 오류를 감쌉니다.
5. 출력 데이터를 먼저 만들기
render는 println!을 호출하는 대신 String을 반환합니다.
#[derive(Debug, PartialEq, Eq)]
pub enum CliError {
Parse(ParseError),
Domain(DomainError),
}
impl From<ParseError> for CliError {
fn from(error: ParseError) -> Self {
Self::Parse(error)
}
}
impl From<DomainError> for CliError {
fn from(error: DomainError) -> Self {
Self::Domain(error)
}
}
#[must_use]
pub fn render(outcome: &Outcome) -> String {
match outcome {
Outcome::Added(endpoint) => {
format!("registered {} -> {}", endpoint.name, endpoint.url)
}
}
}
pub fn run(
args: impl IntoIterator<Item = String>,
) -> Result<String, CliError> {
let command = parse_args(args)?;
let outcome = execute(command)?;
Ok(render(&outcome))
}
이제 성공 문구는 문자열 비교만으로 검사할 수 있습니다. 나중에 사람이 읽는 텍스트 대신 JSON 출력이 필요해져도 도메인 규칙을 건드리지 않고 렌더링 계층을 교체할 수 있습니다. 현재 예제에는 JSON 구현이나 호환성 약속이 없다는 점은 구분해야 합니다.
6. main에는 I/O만
use std::{env, process::ExitCode};
use endpoint_registry::{USAGE, run};
fn main() -> ExitCode {
match run(env::args().skip(1)) {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("error: {error}\n\n{USAGE}");
ExitCode::FAILURE
}
}
}
정상 결과는 stdout, 진단과 사용법은 stderr로 보냅니다. 오류와 진행 메시지는 stderr로, 프로그램의 주 출력은 stdout으로 보냅니다. 이 구분은 셸에서 성공 결과만 파일이나 다음 명령으로 넘길 때 유용합니다.
main은 ExitCode를 반환할 수 있습니다. 여기서는 플랫폼의 대표 성공·실패 값을 뜻하는 ExitCode::SUCCESS와 ExitCode::FAILURE를 사용했습니다. 숫자 종료 코드의 의미와 마스킹 방식은 플랫폼마다 다를 수 있으므로, 단순 성공과 실패라면 이 상수가 의도를 더 정확히 드러냅니다.
Cargo 자체 옵션과 프로그램 인자를 가르는 --도 눈여겨볼 부분입니다.
cargo run --quiet -- add api https://example.com/health
첫 번째 --quiet는 Cargo 옵션입니다. -- 뒤의 add, api, URL은 실행된 바이너리로 전달됩니다.
7. 함수 테스트와 프로세스 테스트
파싱, 도메인 규칙, 렌더링은 빠른 단위 테스트로 확인합니다.
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(ToString::to_string).collect()
}
#[test]
fn rejects_unsupported_url_scheme() {
let command = Command::Add {
name: "api".to_owned(),
url: "ftp://example.com".to_owned(),
};
assert_eq!(execute(command), Err(DomainError::UnsupportedScheme));
}
#[test]
fn renders_stable_primary_output() {
let output = run(strings(&[
"add",
"api",
"https://example.com/health",
]));
assert_eq!(
output.as_deref(),
Ok("registered api -> https://example.com/health")
);
}
다만 함수 테스트만으로는 stdout과 stderr, 종료 상태의 배선을 검증하지 못합니다. 통합 테스트 하나는 Cargo가 제공하는 CARGO_BIN_EXE_endpoint-registry 경로로 실제 바이너리를 실행합니다.
#[test]
fn invalid_url_writes_error_and_usage_to_stderr() {
let output = Command::new(env!("CARGO_BIN_EXE_endpoint-registry"))
.args(["add", "api", "ftp://example.com"])
.output()
.expect("binary should run");
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert_eq!(
String::from_utf8_lossy(&output.stderr),
concat!(
"error: endpoint URL must start with http:// or https://\n",
"\n",
"Usage: endpoint-registry add <name> <http(s)://url>\n"
)
);
}
둘 중 하나만 고를 이유는 없습니다. 많은 경우를 함수 수준에서 저렴하게 검사하되 소수의 프로세스 테스트로 I/O 연결을 확인하면 됩니다. Rust의 테스트 함수에는 #[test]를 붙이며 cargo test가 테스트 러너를 빌드하고 실행합니다. 이 명령은 단위 테스트, 통합 테스트, 문서 테스트를 컴파일하고 실행합니다.
8. CLI 검증
이 예제는 stable rustc 1.98.1, Cargo 1.98.1, Rust 2024 에디션을 사용하며 외부 의존성이 없습니다.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
테스트 모음은 라이브러리 단위 테스트 4개와 바이너리 통합 테스트 2개로 구성됩니다. 두 테스트 그룹의 예상 결과는 다음과 같습니다.
running 4 tests
test tests::rejects_extra_arguments ... ok
test tests::parses_add_command ... ok
test tests::rejects_unsupported_url_scheme ... ok
test tests::renders_stable_primary_output ... ok
running 2 tests
test successful_command_writes_only_to_stdout ... ok
test invalid_url_writes_error_and_usage_to_stderr ... ok
오류 경로는 다음 명령으로 따로 확인할 수 있습니다.
cargo run --quiet -- add api ftp://example.com
error: endpoint URL must start with http:// or https://
Usage: endpoint-registry add <name> <http(s)://url>
이 입력은 종료 상태 1을 반환하고 stdout에는 아무것도 쓰지 않습니다.
9. 수제 파서의 한계
직접 만든 파서는 명령이 하나일 때는 읽기 쉽습니다. 플래그 조합, 선택 인자, 자동 도움말, 셸 완성까지 필요해지면 전용 CLI 크레이트가 중복 코드를 줄여줄 수 있습니다. 하지만 크레이트를 바꾸더라도 경계는 그대로 유지할 수 있습니다. 외부 파서가 Command를 만듭니다. 도메인 함수가 실행한 결과는 출력 어댑터가 내보냅니다.
현재 레지스트리는 프로세스가 끝나면 사라지고 URL 검증도 스킴 접두사만 봅니다. 이는 숨겨진 완성 기능이 아니라 의도적으로 남긴 경계입니다. 다음 편에서 소유권과 이동을 다룰 때 Command와 Endpoint의 String이 함수 사이를 어떻게 이동하는지 추적하면, 이 작은 CLI가 바로 실습 재료가 됩니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 06 프로젝트에서 확인할 수 있습니다.
출처
- Rust 표준 라이브러리:
std::env::args - Rust 표준 라이브러리:
std::process::ExitCode - Rust 표준 라이브러리:
eprintln! - The Rust Programming Language: Accepting Command Line Arguments
- The Rust Programming Language: Refactoring to Improve Modularity and Error Handling
- The Rust Programming Language: How to Write Tests
- The Cargo Book:
cargo test - The Cargo Book: The Manifest Format
답글 남기기