본문 바로가기

커맨드 라인 인수 받기

표준 라이브러리의 args 반복자로 검색어와 파일 경로를 읽어 변수에 저장하며 minigrep 명령줄 인터페이스를 시작합니다.

이번 장에서는 지금까지 배운 여러 기술을 다시 써 보고, 표준 라이브러리의 기능을 몇 가지 더 살펴봅니다. 파일과 명령줄 입출력을 다루는 명령줄 도구를 만들면서, 이제 손에 익었을 러스트의 개념들을 연습합니다.

러스트는 속도와 안전성, 실행 파일 하나로 배포할 수 있다는 점, 여러 플랫폼을 지원한다는 점 덕분에 명령줄 도구를 만들기에 알맞은 언어입니다. 그래서 이번 장에서는 고전적인 명령줄 검색 도구 grep(globally search a regular expression and print)의 간단한 판을 직접 만듭니다. 가장 단순하게 쓰면 grep은 지정한 파일에서 지정한 문자열을 찾습니다. 그러려면 파일 경로와 문자열을 인수로 받아, 파일을 읽고, 그 문자열이 들어 있는 줄을 찾아 출력해야 합니다.

그 과정에서 많은 명령줄 도구가 쓰는 터미널 기능을 프로그램에서 쓰는 방법도 봅니다. 사용자가 환경 변수로 도구의 동작을 바꿀 수 있게 하고, 오류 메시지를 표준 출력(stdout) 대신 표준 에러(stderr)로 출력해, 사용자가 성공한 결과를 파일로 보내더라도 오류 메시지는 화면에서 볼 수 있게 합니다. 러스트 커뮤니티의 앤드루 갈랜트(Andrew Gallant)는 grep의 기능을 모두 갖추고 훨씬 빠른 ripgrep을 이미 만들었습니다. 그에 비하면 이번 장의 도구는 아주 단순하지만, ripgrep 같은 실제 프로젝트를 이해하는 데 필요한 배경지식을 얻을 수 있습니다.

이 grep 프로젝트에는 지금까지 배운 여러 개념이 함께 쓰입니다.

개념배운 곳
코드 정리패키지와 크레이트부터 이어지는 6장
벡터와 문자열벡터와 값 목록, UTF-8 문자열
오류 처리Result와 복구 가능한 오류
트레이트와 라이프타임9장
테스트 작성테스트 작성 방법

클로저와 반복자, 트레이트 객체도 잠깐 쓰는데, 이것들은 클로저와 트레이트 객체와 다형성에서 자세히 다룹니다.

언제나처럼 cargo new로 새 프로젝트를 만듭니다. 이미 시스템에 있는 grep 도구와 구분하기 위해 프로젝트 이름을 minigrep이라고 짓습니다.

cargo new minigrep
cd minigrep

첫 번째 할 일은 minigrep이 두 명령줄 인수, 곧 파일 경로와 검색할 문자열을 받게 하는 것입니다. 다 만들고 나면 다음처럼 cargo run과 두 개의 하이픈으로 뒤의 인수가 Cargo가 아니라 우리 프로그램의 것임을 알리고, 검색할 문자열과 검색할 파일의 경로를 차례로 주어 프로그램을 실행할 수 있어야 합니다.

cargo run -- searchstring example-filename.txt

지금은 cargo new가 만든 프로그램이 받은 인수를 처리하지 못합니다. crates.io에는 명령줄 인수를 받는 프로그램을 쓰는 데 도움이 되는 라이브러리가 있지만, 이 개념을 이제 막 배우는 중이므로 직접 구현해 봅니다.

인수 값 읽기

minigrep이 받은 명령줄 인수의 값을 읽으려면 러스트 표준 라이브러리의 std::env::args 함수가 필요합니다. 이 함수는 minigrep에 넘긴 명령줄 인수의 반복자(iterator)를 돌려줍니다. 반복자는 반복자 기반 데이터 처리에서 자세히 다룹니다. 지금은 반복자가 일련의 값을 만들어 내고, 반복자에 collect 메서드를 부르면 반복자가 만드는 모든 원소를 담은 벡터 같은 컬렉션으로 바꿀 수 있다는 것만 알면 됩니다. 아래 그림은 명령줄에 적은 인수가 프로그램 안의 벡터에 들어가기까지를 단계별로 보여 줍니다.

Cargo 인수와 minigrep 입력 계약의 경계

cargo run -- needle haystack→실행 인수→Vec<String> 소유→query/file_path 두 대여의 구체 값 전달이다. --는 원소가 아니고 길이 검사는 미구현이다.

RUST · MINIGREP

Cargo 경계를 지나 세 인수가 Vec에 모인다

이 절에서는 env::args().collect()로 인수를 모은 뒤 인덱스로 빌립니다. 설정 검증과 Config 생성은 11장 3절에서 도입합니다.

Cargo 인수 전달·소유한 벡터·두 참조이예의네노드세흐름. Cargo구분자뒤두값이실행프로그램에전달되고env::args가호출이름과그값을String으로모아Vec을소유한다. 현재본문은args1과args2를빌리며개수를검증하지않는다. Cargo의--자체는원소가아니고args0은신뢰할실행파일전체경로가아니다. 아래원세단계카드와입력한계를보조설명으로유지한다.
Cargo에 전달cargo run --needle haystack
연결 관계Cargo에 전달 · cargo run -- · needle haystack → 실행 프로그램 · env::args().collect()
실행 프로그램env::args().collect()
호출 이름 + 전달 값호출 이름 + 전달 값실행 프로그램 · env::args().collect() → Vec<String> 소유 · args[0]: 호출 이름 · args[1]: needle · args[2]: haystack
Vec<String> 소유args[0]: 호출 이름args[1]: needleargs[2]: haystack
연결 관계Vec<String> 소유 · args[0]: 호출 이름 · args[1]: needle · args[2]: haystack → 현재 코드의 두 참조 · &args[1]: query · &args[2]: file_path · 길이 검증은 다음 절
현재 코드의 두 참조&args[1]: query&args[2]: file_path길이 검증은 다음 절
Cargo 인수 전달·소유한 벡터·두 참조이예의네노드세흐름. Cargo구분자뒤두값이실행프로그램에전달되고env::args가호출이름과그값을String으로모아Vec을소유한다. 현재본문은args1과args2를빌리며개수를검증하지않는다. Cargo의--자체는원소가아니고args0은신뢰할실행파일전체경로가아니다. 아래원세단계카드와입력한계를보조설명으로유지한다.
Cargo에 전달cargo run --needle haystack
연결 관계Cargo에 전달 · cargo run -- · needle haystack → 실행 프로그램 · env::args() · collect()
실행 프로그램env::args()collect()
이름도 수집이름도 수집실행 프로그램 · env::args() · collect() → Vec<String> 소유 · args[0]: 호출 이름 · args[1]: needle · args[2]: haystack
Vec<String> 소유args[0]: 호출 이름args[1]: needleargs[2]: haystack
연결 관계Vec<String> 소유 · args[0]: 호출 이름 · args[1]: needle · args[2]: haystack → 현재 코드의 참조 · &args[1]: query · &args[2]: file_path · 길이 검증은 다음 절
현재 코드의 참조&args[1]: query&args[2]: file_path길이 검증은 다음 절

1 · Cargo에 입력

cargo run -- needle haystack

--는 Cargo의 구분자입니다. 뒤의 두 값이 실행되는 minigrep에 전달되며 구분자 자체는 argv에 들어가지 않습니다.

2 · 프로그램에서 수집

let args: Vec<String> =
    env::args().collect();

반복자는 호출 이름과 두 사용자 인수를 소유한 String으로 내놓습니다. 공백을 다시 나누는 작업은 이미 셸이 처리했습니다.

3 · 현재 본문의 위치 계약

args[0]  // 호출 이름
args[1]  // "needle": query
args[2]  // "haystack": file_path

&args[1]과 &args[2]를 빌립니다. 아직 길이를 검증하지 않아 누락된 인덱스에 접근하면 패닉이 납니다.

다음 리팩터링과 입력 한계

11장 3절에서는 누락을 Result로 검사합니다. 12장 3절에서는 인수 반복자를 직접 소비해 복사를 줄입니다. args[0]은 관례적 호출 이름이며 신뢰할 경로가 아닙니다.

env::args()는 비유니코드 인수에서 순회 중 패닉이 날 수 있습니다. 해당 입력을 보존해야 한다면 args_os()와 OsString 계약을 사용합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명이 예의 네 노드와 세 흐름이다. Cargo 구분자 뒤 두 값이 실행 프로그램에 전달되고 env::args가 호출 이름과 그 값을 String으로 모아 Vec을 소유한다. 현재 본문은 args[1]과 args[2]를 빌리며 개수를 검증하지 않는다. Cargo의 -- 자체는 원소가 아니고 args[0]은 신뢰할 실행 파일 전체 경로가 아니다. 아래 원 세 단계 카드와 입력 한계를 보조 설명으로 유지한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Cargo 경계

명령의 -- 뒤가 프로그램 인수다.

  • 명령: cargo run -- needle haystack
  • --: 프로그램 원소 아님
  • Cargo 옵션과 실행 프로그램 인수의 경계를 나눈다.
인수 소유

env::args를 Vec<String>으로 모은다.

  • 소유: Vec<String>
  • 인수: needle / haystack
  • 문자열 원본은 인수 벡터가 소유한다.
대여 전달

query와 file_path가 두 문자열을 빌린다.

  • query: needle
  • file_path: haystack
  • 이 초기 버전의 인수 길이 검사는 아직 구현되지 않았다.

Cargo 인수와 minigrep 입력 계약의 경계

cargo run -- needle haystack→실행 인수→Vec<String> 소유→query/file_path 두 대여의 구체 값 전달이다. --는 원소가 아니고 길이 검사는 미구현이다.

RUST · MINIGREP

Cargo 경계를 지나 세 인수가 Vec에 모인다

이 절에서는 env::args().collect()로 인수를 모은 뒤 인덱스로 빌립니다. 설정 검증과 Config 생성은 11장 3절에서 도입합니다.

Cargo 인수 전달·소유한 벡터·두 참조이예의네노드세흐름. Cargo구분자뒤두값이실행프로그램에전달되고env::args가호출이름과그값을String으로모아Vec을소유한다. 현재본문은args1과args2를빌리며개수를검증하지않는다. Cargo의--자체는원소가아니고args0은신뢰할실행파일전체경로가아니다. 아래원세단계카드와입력한계를보조설명으로유지한다.
Cargo에 전달cargo run --needle haystack
연결 관계Cargo에 전달 · cargo run -- · needle haystack → 실행 프로그램 · env::args().collect()
실행 프로그램env::args().collect()
호출 이름 + 전달 값호출 이름 + 전달 값실행 프로그램 · env::args().collect() → Vec<String> 소유 · args[0]: 호출 이름 · args[1]: needle · args[2]: haystack
Vec<String> 소유args[0]: 호출 이름args[1]: needleargs[2]: haystack
연결 관계Vec<String> 소유 · args[0]: 호출 이름 · args[1]: needle · args[2]: haystack → 현재 코드의 두 참조 · &args[1]: query · &args[2]: file_path · 길이 검증은 다음 절
현재 코드의 두 참조&args[1]: query&args[2]: file_path길이 검증은 다음 절
Cargo 인수 전달·소유한 벡터·두 참조이예의네노드세흐름. Cargo구분자뒤두값이실행프로그램에전달되고env::args가호출이름과그값을String으로모아Vec을소유한다. 현재본문은args1과args2를빌리며개수를검증하지않는다. Cargo의--자체는원소가아니고args0은신뢰할실행파일전체경로가아니다. 아래원세단계카드와입력한계를보조설명으로유지한다.
Cargo에 전달cargo run --needle haystack
연결 관계Cargo에 전달 · cargo run -- · needle haystack → 실행 프로그램 · env::args() · collect()
실행 프로그램env::args()collect()
이름도 수집이름도 수집실행 프로그램 · env::args() · collect() → Vec<String> 소유 · args[0]: 호출 이름 · args[1]: needle · args[2]: haystack
Vec<String> 소유args[0]: 호출 이름args[1]: needleargs[2]: haystack
연결 관계Vec<String> 소유 · args[0]: 호출 이름 · args[1]: needle · args[2]: haystack → 현재 코드의 참조 · &args[1]: query · &args[2]: file_path · 길이 검증은 다음 절
현재 코드의 참조&args[1]: query&args[2]: file_path길이 검증은 다음 절

1 · Cargo에 입력

cargo run -- needle haystack

--는 Cargo의 구분자입니다. 뒤의 두 값이 실행되는 minigrep에 전달되며 구분자 자체는 argv에 들어가지 않습니다.

2 · 프로그램에서 수집

let args: Vec<String> =
    env::args().collect();

반복자는 호출 이름과 두 사용자 인수를 소유한 String으로 내놓습니다. 공백을 다시 나누는 작업은 이미 셸이 처리했습니다.

3 · 현재 본문의 위치 계약

args[0]  // 호출 이름
args[1]  // "needle": query
args[2]  // "haystack": file_path

&args[1]과 &args[2]를 빌립니다. 아직 길이를 검증하지 않아 누락된 인덱스에 접근하면 패닉이 납니다.

다음 리팩터링과 입력 한계

11장 3절에서는 누락을 Result로 검사합니다. 12장 3절에서는 인수 반복자를 직접 소비해 복사를 줄입니다. args[0]은 관례적 호출 이름이며 신뢰할 경로가 아닙니다.

env::args()는 비유니코드 인수에서 순회 중 패닉이 날 수 있습니다. 해당 입력을 보존해야 한다면 args_os()와 OsString 계약을 사용합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명이 예의 네 노드와 세 흐름이다. Cargo 구분자 뒤 두 값이 실행 프로그램에 전달되고 env::args가 호출 이름과 그 값을 String으로 모아 Vec을 소유한다. 현재 본문은 args[1]과 args[2]를 빌리며 개수를 검증하지 않는다. Cargo의 -- 자체는 원소가 아니고 args[0]은 신뢰할 실행 파일 전체 경로가 아니다. 아래 원 세 단계 카드와 입력 한계를 보조 설명으로 유지한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Cargo 경계

명령의 -- 뒤가 프로그램 인수다.

  • 명령: cargo run -- needle haystack
  • --: 프로그램 원소 아님
  • Cargo 옵션과 실행 프로그램 인수의 경계를 나눈다.
인수 소유

env::args를 Vec<String>으로 모은다.

  • 소유: Vec<String>
  • 인수: needle / haystack
  • 문자열 원본은 인수 벡터가 소유한다.
대여 전달

query와 file_path가 두 문자열을 빌린다.

  • query: needle
  • file_path: haystack
  • 이 초기 버전의 인수 길이 검사는 아직 구현되지 않았다.

cargo run -- needle haystack에서 --는 Cargo의 구분자이며, 그 뒤의 두 값이 실행되는 minigrep에 넘어갑니다. 구분자 -- 자체는 프로그램이 받는 인수에 들어가지 않습니다. 프로그램은 env::args().collect()로 호출 이름과 넘겨받은 값을 String으로 모아 Vec<String>에 담아 소유하고, 지금 코드는 그 가운데 args[1]과 args[2]를 빌려 씁니다. 인수가 몇 개인지 확인하는 일은 아직 하지 않으며, 모듈성과 에러 처리 향상을 위한 리팩터링에서 더합니다. 다음 코드로 minigrep이 받은 명령줄 인수를 모두 읽어 벡터로 모읍니다.

src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}

먼저 use 문으로 std::env 모듈을 스코프로 가져와 args 함수를 쓸 수 있게 했습니다. std::env::args 함수는 모듈 두 단계 안에 있습니다. use 키워드로 경로를 스코프 안으로 가져오기에서 이야기했듯, 원하는 함수가 모듈 여러 단계 안에 있을 때는 함수 대신 그 부모 모듈을 스코프로 가져오는 것이 관례입니다. 그러면 std::env의 다른 함수도 쉽게 쓸 수 있고, args()만 적는 것보다 env::args()가 덜 모호합니다. 현재 모듈에 정의한 함수로 잘못 볼 수 있기 때문입니다.

인수에 유니코드로 올바르지 않은 값이 들어 있으면 std::env::args는 패닉을 냅니다. 프로그램이 올바르지 않은 유니코드가 든 인수도 받아야 한다면 std::env::args_os를 쓰면 됩니다. 이 함수는 String 대신 OsString 값을 만드는 반복자를 돌려줍니다. 여기서는 단순하게 하려고 std::env::args를 썼습니다. OsString 값은 플랫폼마다 다르고 String보다 다루기 복잡하기 때문입니다.

main의 첫 줄에서 env::args를 부르고, 곧바로 collect로 반복자를 반복자가 만드는 값을 모두 담은 벡터로 바꿉니다. collect로 여러 종류의 컬렉션을 만들 수 있으므로, 문자열의 벡터를 원한다고 args의 타입을 명시했습니다. 러스트에서 타입을 명시해야 하는 일은 드물지만, 러스트가 어떤 컬렉션을 원하는지 추론하지 못하므로 collect는 타입을 명시해야 하는 경우가 많은 함수입니다.

마지막으로 디버그 매크로로 벡터를 출력합니다. 먼저 인수 없이 실행하고, 그다음 인수 두 개를 주어 실행합니다.

cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

벡터의 첫 값이 바이너리의 이름인 "target/debug/minigrep"이라는 점을 눈여겨보세요. 이것은 C의 인수 목록과 같은 동작으로, 프로그램이 실행될 때 불린 이름을 쓸 수 있게 해 줍니다. 메시지에 프로그램 이름을 출력하거나, 프로그램을 부른 별칭에 따라 동작을 바꿀 때 쓸모가 있습니다. 다만 이 값은 실행한 방법과 운영체제에 따라 달라지며, Windows에서는 target\debug\minigrep.exe처럼 나옵니다. 또 실행하는 쪽이 마음대로 정할 수 있으므로, 실행 파일의 실제 경로로 믿고 쓰면 안 됩니다. 이번 장의 목적에서는 이 값을 무시하고 필요한 두 인수만 저장합니다.

인수 값들을 변수에 저장하기

이제 프로그램이 명령줄 인수로 받은 값에 접근할 수 있습니다. 두 인수의 값을 변수에 저장해 프로그램의 나머지 부분에서 쓸 수 있게 합니다.

src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}

벡터를 출력할 때 보았듯, 프로그램의 이름이 벡터의 첫 값 args[0]을 차지하므로 인수는 인덱스 1부터 시작합니다. minigrep의 첫 인수는 검색할 문자열이므로 첫 인수의 참조를 변수 query에 넣고, 둘째 인수는 파일 경로이므로 둘째 인수의 참조를 변수 file_path에 넣습니다. 코드가 의도대로 동작하는지 확인하기 위해 두 변수의 값을 잠시 출력합니다. test와 sample.txt를 인수로 주어 다시 실행합니다.

cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

프로그램이 동작합니다. 필요한 인수의 값이 알맞은 변수에 저장되었습니다. 나중에 사용자가 인수를 주지 않은 경우처럼 잘못된 상황을 다루는 오류 처리를 더할 것입니다. 지금 인수 없이 실행하면 args[1]이 벡터의 범위를 벗어나 패닉이 납니다. 당장은 이 상황을 무시하고 파일을 읽는 기능을 먼저 더합니다.

정리

  • std::env::args는 명령줄 인수의 반복자를 돌려주며, collect로 Vec<String>에 모읍니다. 이때 collect의 결과 타입을 명시합니다.
  • cargo run -- 값...에서 -- 뒤의 값이 프로그램에 넘어가며, -- 자체는 인수에 들어가지 않습니다.
  • args[0]은 프로그램이 불린 이름이고 실제 인수는 인덱스 1부터 시작합니다. 이 이름은 실행 방법에 따라 달라지므로 실행 파일의 경로로 믿으면 안 됩니다.
  • 함수가 모듈 여러 단계 안에 있으면 부모 모듈을 use로 가져와 env::args()처럼 부르는 것이 관례입니다.
  • 유니코드로 올바르지 않은 인수까지 받아야 하면 std::env::args_os를 씁니다.

연습 문제

1. 벡터에 무엇이 들어갈까

cargo run -- -i "to be" poem.txt로 실행하면 args의 길이는 얼마이고, args[1]과 args[2]는 무엇인가요? 지금 코드에서 query와 file_path는 무엇이 되나요?

해설 보기

-- 뒤의 값 -i, to be, poem.txt 세 개와 프로그램 이름까지 네 개이므로 길이는 4입니다. 따옴표로 묶은 to be는 셸이 하나의 인수로 넘깁니다. args[1]은 "-i", args[2]는 "to be"이므로, 지금 코드는 query를 -i로, file_path를 to be로 잘못 읽습니다. 옵션을 받으려면 인수를 해석하는 규칙을 따로 정해야 합니다.

2. 인수 하나만 주었을 때

cargo run -- test처럼 인수를 하나만 주면 지금 코드는 어떻게 되나요?

해설 보기

벡터에는 프로그램 이름과 test 두 값만 있으므로 &args[1]은 성공하지만 &args[2]가 범위를 벗어나 패닉이 납니다. 패닉 메시지는 인덱스 2가 길이 2를 벗어났다는 내용이라 사용자에게 무엇이 잘못되었는지 잘 알려 주지 못하며, 이 문제는 모듈성과 에러 처리 향상을 위한 리팩터링에서 고칩니다.

다음 글 파일 읽기에서는 file_path로 받은 파일을 읽어 그 내용을 출력합니다.