icon

안동민 개발노트

11장 : I/O 프로젝트: 커맨드 라인 프로그램 만들기

커맨드 라인 인수 받기


이번 장에서는 여러분이 지금까지 배운 여러 기술들을 요약하고 표준 라이브러리의 기능을 몇 가지 더 탐색해보겠습니다. 파일 및 커맨드 입출력을 통해 상호작용하는 커맨드 라인 도구를 만들면서 이제는 여러분이 이해하고 있을 러스트 개념 몇 가지를 연습해 볼 것입니다.

러스트의 속도, 안정성, 단일 바이너리 출력, 그리고 크로스 플랫폼 지원은 커맨드 라인 도구를 만들기 위한 이상적인 언어가 되게끔 하므로, 프로젝트를 위해 고전적인 커맨드 라인 검색 도구인 grep(globally search a regular expression and print)의 직접 구현한 버전을 만들어 보려고 합니다. 가장 단순한 사용례에서 grep은 어떤 특정한 파일에서 특정한 문자열을 검색합니다. 이를 위해 grep은 파일 경로와 문자열을 인수로 받습니다. 그다음 파일을 읽고, 그 파일에서 중 문자열 인수를 포함하고 있는 라인을 찾고, 그 라인들을 출력합니다.

그러는 와중에 수많은 다른 커맨드 라인 도구들이 사용하는 터미널의 기능을 우리의 커맨드 라인 도구도 사용할 수 있게 하는 방법을 알아보겠습니다. 먼저 환경 변수의 값을 읽어서 사용자가 커맨드 라인 도구의 동작을 설정하도록 할 것입니다. 또한 표준 출력 콘솔 스트림(stdout) 대신 표준 에러 콘솔 스트림(stderr)에 에러 메시지를 출력하여, 예를 들자면 사용자가 화면을 통해 에러 메시지를 보는 동안에도 성공적인 출력을 파일로 리디렉션할 수 있게끔 할 것입니다.

러스트 커뮤니티 멤버 일원인 앤드루 갈란트(Andrew Gallant)가 이미 ripgrep이라는 이름의, 모든 기능을 가진 grep의 매우 빠른 버전을 만들었습니다. 그에 비해서 지금 만들어 볼 버전은 꽤 단순할 예정이지만, 이 장은 여러분에게 ripgrep과 같은 실제 프로젝트를 이해하는 데 필요한 배경 지식을 제공할 것입니다.

grep 프로젝트는 지금까지 배운 여러 개념을 조합할 것입니다.

  • 코드 조직화하기(6장에서 모듈에 대해 배운 것들을 사용)
  • 벡터와 문자열 사용하기(7장의 컬렉션)
  • 에러 처리하기(8장)
  • 적절한 곳에 트레이트와 라이프타임 사용하기(9장)
  • 테스트 작성하기(10장)

아울러 12장16장에서 자세히 다루게 될 클로저(closure), 반복자(iterator), 그리고 트레이트 객체(trait object)에 대해서도 간략히 소개하겠습니다.

이제 요구사항을 코드로 옮기는 순서부터 살펴보겠습니다.

언제나처럼 cargo new로 새 프로젝트를 만들어 봅시다. 여러분의 시스템에 이미 설치되어 있을지도 모를 grep 도구와 구분하기 위하여, 우리 프로젝트 이름은 minigrep으로 하겠습니다.

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

minigrep을 만들기 위한 첫 과제는 두 개의 커맨드 라인 인수를 받는 것입니다. 바로 검색할 파일 경로와 문자열이지요. 그 말은즉슨, 다음과 같이 프로그램을 실행하기 위해 cargo run, cargo 대신 우리 프로그램을 위한 인수가 나올 것임을 알려주는 두 개의 하이픈, 검색을 위한 문자열, 그리고 검색하길 원하는 파일을 사용할 수 있도록 하고 싶다는 것입니다.

$ cargo run -- searchstring example-filename.txt

현재 cargo new로 생성된 프로그램은 입력된 인수를 처리할 수 없습니다. crates.io에 있는 몇 가지 라이브러리가 커맨드 라인 인수를 받는 프로그램 작성에 도움 되겠지만, 지금은 이 개념을 막 배우는 중이므로 직접 이 기능을 구현해봅시다.

인수 값 읽기

minigrep이 커맨드 라인 인수로 전달된 값들을 읽을 수 있도록 하기 위해서는 러스트의 표준 라이브러리가 제공하는 std::env::args 함수를 사용할 필요가 있습니다. 이 함수는 minigrep으로 넘겨진 커맨드 라인 인수의 반복자(iterator)를 반환합니다. 반복자에 대한 모든 것은 12장에서 다룰 예정입니다. 지금은 반복자에 대한 두 가지 세부 사항만 알면 됩니다. 반복자는 일련의 값들을 생성하고, 반복자의 collect 메서드를 호출하여 반복자가 생성하는 모든 요소를 담고 있는 벡터 같은 컬렉션으로 바꿀 수 있다는 것입니다.

예제 12-1의 코드는 minigrep 프로그램이 넘겨진 어떤 커맨드 라인 인수들을 읽은 후, 그 값들을 벡터로 모아주도록 해 줍니다.

예제 12-1: 커맨드 라인 인수들을 벡터로 모으고 출력하기
src/main.rs
use std::env;

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

먼저 use를 사용하여 std::env 모듈을 스코프로 가져와서 args 함수를 사용할 수 있게 합니다. std::env::args 함수는 두 단계로 중첩된 모듈에 있는 점을 주목하세요. 6장에서 논의한 것처럼, 하나 이상의 모듈로 중첩된 곳에 원하는 함수가 있는 경우에는, 함수가 아닌 그 부모 모듈을 스코프로 가져오는 선택을 했습니다. 이렇게 하면 std::env의 다른 함수들도 쉽게 사용할 수 있습니다. 또한 이렇게 하는 것이 use std::env::args를 추가하고 args 만으로 함수를 호출하는 것보다 덜 모호한데, 이는 args가 현재의 모듈 내에 정의된 다른 함수로 쉽게 오해받을 수 있기 때문입니다.

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

벡터의 첫 번째 값이 "target/debug/minigrep", 즉 이 바이너리 파일의 이름인 점을 주목하세요. 이는 C에서의 인수 리스트의 동작과 일치하며, 프로그램이 실행될 때 호출된 이름을 사용할 수 있게 해 줍니다. 프로그램의 이름에 접근할 수 있는 것은 메시지에 이름을 출력하고 싶을 때라던가 프로그램을 호출할 때 사용된 커맨드 라인 별칭이 무엇이었는지에 기반하여 프로그램의 동작을 바꾸고 싶을 때 종종 편리하게 이용됩니다. 하지만 이 장의 목적을 위해서 지금은 이를 무시하고 현재 필요한 두 인수만 저장하겠습니다.

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

이제 프로그램은 커맨드 라인 인수로 지정된 값들에 접근할 수 있습니다. 이제는 두 인수의 값을 변수에 저장할 필요가 있는데, 그렇게 하면 프로그램의 나머지 부분에서 이 값들을 사용할 수 있습니다. 예제 12-2에서 이 동작을 수행합니다.

예제 12-2: 질의 (query) 인수와 파일 경로 인수를 담은 변수 생성하기
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에 집어넣습니다.

우리 의도대로 코드가 동작하는지 검증하기 위해 이 변수의 값들을 임시로 출력하겠습니다. testsample.txt를 인수로 하여 이 프로그램을 다시 실행해봅시다.

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

프로그램이 훌륭하게 동작하네요! 필요로 하는 인수 값들이 올바른 변수에 저장되고 있습니다. 나중에는 사용자가 아무런 인수를 제공하지 않았을 때처럼 에러가 발생할 수 있는 특정한 경우를 처리하기 위한 에러 처리 기능을 몇 가지 추가할 것입니다; 지금은 그런 경우를 무시하고 파일 읽기 기능을 추가하는 작업으로 넘어가겠습니다.

목차