Rust #12: 12장 I/O 프로젝트: 명령줄 프로그램 구축

·

11 min read

개요

본 장을 통해 지금까지 배운 기술을 적용하고 몇 가지 표준 라이브러리 기능을 활용 할 것입니다. 진행 과정을 통해 점진적으로 모듈화가 되어가는 것을 같이 체험할 수 있으며, 테스트 주도 개발이 무엇인지도 맛보기로 체험할 수 있습니다.

명령 줄 인자 받아들이기

먼저 실행 크레이트를 생성합시다.

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

우리는 본 장을 통해 최종적으로 다음과 같이 특정 텍스트 파일에서 찾고자 하는 문자열이 있는 라인 항목을 출력하도록 만들 것입니다.

$ cargo run searchstring example-filename.txt

인수 값 읽기

먼저 메인 함수에서 명령 줄 인자를 받는 방법을 살펴봅시다.

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

이제 다음처럼 입력된 명령 줄 인자가 잘 출력 됨을 확인할 수 있습니다.

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

변수에 인수 값 저장

이제 필요로 하는 인자를 변수에 담아 봅시다.

use std::env;

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

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

    println!("Searching for {}", query);
    println!("In file {}", filename);
}
$ 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

파일 읽기

먼저 테스트에 필요한 텍스트 파일을 생성해봅시다.

파일명: poem.txt

I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

그리고 다음과 같이 해당 파일이 잘 읽혀 지는지 확인해봅시다.

use std::env;
use std::fs;

fn main() {
    // --snip--
    println!("In file {}", filename);

    let contents = fs::read_to_string(filename)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}
$ cargo run the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

fs::read_to_string() 함수를 통해 파일을 문자열로 읽어올 수 있었습니다.

모듈화 및 오류 처리 개선을 위한 리팩토링

프로그램 코드는 유지/보수를 위해 모듈화 되어 있어야만 합니다. 모듈화 되어 있는 코드는 지역적 수정과 기능 확장이 유리한 구조입니다. 그리고 적절한 오류 처리가 되어야 하는데 오류 처리 역시 모듈화를 해야 합니다. 본 내용을 통해 어떻게 모듈화를 하는지 알아봅시다.

바이너리 프로젝트에 대한 관심사 분리

우리는 다음처럼 모듈화를 진행할 예정입니다.

  • main.rslib.rs로 구조를 분할하고 프로그램 논리를 lib.rs로 이동
  • 명령 줄 구문 분석의 구조가 간단하면 main.rs에 남을 수 있음
  • 명령 줄 구분 분석 로직이 복잡해지면 lib.rs로 이동해야 함

모듈화 후 main에 남아 있는 역할은 다음으로 제한되어야 합니다.

  • 인자 값을 사용하여 명령 줄 구문 분석 논리 호출
  • 기타 구성 설정
  • lib.rs의 run 함수 호출
  • run 함수 반환 오류를 처리

인자 파서 추출

명령 줄 인자를 처리해 봅시다.

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

    let (query, filename) = parse_config(&args);

    // --snip--
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
}

구성 값 그룹화

튜플은 여러 값을 반환할 수 있도록 돕지만 좀 더 명확해야 합니다. 다음의 코드를 봅시다.

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

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");

    // --snip--
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let filename = args[2].clone();

    Config { query, filename }
}

Config에 대한 생성자 생성

이제 Config 구조체에서 명령줄 인자를 파싱하는게 좋습니다.

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

    let config = Config::new(&args);

    // --snip--
}

// --snip--

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let filename = args[2].clone();

        Config { query, filename }
    }
}

오류 처리 수정

적절한 명령 줄 인자가 주어지지 않으면 현재는 배열 인덱스 범위가 벗어났다는 오류가 발생합니다.

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:27:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

이제 이것을 개선해 봅시다.

오류 메시지 개선

    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

이제 범위가 벗어나면 우리의 메시지를 출력하고 프로그램이 종료되는데,

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments', src/main.rs:26:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

원인에 대한 메시지를 보완되었지만 아직도 사용자 친화적 이지는 않습니다.

panic!을 호출하는 대신 새로운 Result 반환

panic!을 호출하는 대신 오류를 호출하는 곳에서 적절히 처리할 수 있도록 Result<T, E>로 반환하는게 좋습니다.

impl Config {
    fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

Config::new 호출과 오류 처리

이제 Config::new() 함수를 호출하는 main함수에서 결과 값을 처리할 수 있습니다.

use std::process;

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

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // --snip--

이제 출력의 결과가 사용자 친화적이 되었습니다.

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

main에서 논리 추출

main함수에서 논리를 분리해 봅시다.

fn main() {
    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}

// --snip--

현재는 이 작업이 코드가 작아 불필요하게 느껴질 수 도 있겠지만, 코드의 양이 방대해진다면 논리를 분리하는 작업은 모듈화에서 반드시 필요한 작업입니다.

run 함수에서 오류 반환

run함수 반환값을 개선해봅시다.

use std::error::Error;

// --snip--

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;

    println!("With text:\n{}", contents);

    Ok(())
}

main에서 run 반환 오류 처리

이제 main함수를 보완해야 합니다.

fn main() {
    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = run(config) {
        println!("Application error: {}", e);

        process::exit(1);
    }
}

코드를 라이브러리 크레이트로 분리

이제 main.rs와 논리 구조인 lib.rs로 구조를 분할하겠습니다.

파일 이름: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub filename: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        // --snip--
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
}

파일 이름: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --snip--
    if let Err(e) = minigrep::run(config) {
        // --snip--
    }
}

테스트 주도 개발로 라이브러리 기능 개발

이제 로직을 src/lib.rs로 이동했으며 그 이외의 명령 줄 인자 및 오류 처리를 src/main.rs에 두었으므로 핵심 코드의 기능 테스트가 수월해졌습니다. 본 내용을 통해 테스트 코드로 기능을 구현하고 확장하는 것을 살펴봅시다.

실패한 테스트 작성하기

코드를 구현하기 앞서서 먼저 실패하는 테스트 코드를 작성해봅시다.

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

이렇게 실패하는 테스트 코드를 작성하는 이유는 테스트가 통과되기 위한 목표 설정을 할 수 있기 때문인데요, 먼저 테스트 코드를 작성한 후 본 코드를 작성 하는 것이 테스트 주도 개발의 중요 핵심입니다.

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `["safe, fast, productive."]`,
 right: `[]`', src/lib.rs:44:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

테스트는 당연히 실패합니다.

테스트 통과를 위한 코드 작성

이제 테스트를 통과하기 위해 코드를 작성해 나가 봅시다.

search 함수를 구현하기 위한 논리는 다음과 같습니다.

  • 콘텐츠의 각 줄을 반복
  • 행에 쿼리 문자열이 포함되어 있는지 확인
  • 있다면 반환 목록에 추가
  • 없다면 아무것도 하지 않기
  • 모든 줄을 반복한 후 결과 목록을 반환

lines 메소드로 라인 순회하기

순회 코드는 다음과 같습니다.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

여기서 중요하게 살펴볼 것은 [&str].lines() 함수입니다. 이 함수를 통해 라인 별로 순회할 수 있습니다.

쿼리로 각 줄 탐색

탐색할 문자열을 각 라인 별로 비교해봅시다.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

매칭된 라인 저장

문자열이 존재한다면 반환 목록에 추가 합니다.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

그런 후 테스트를 진행해 봅시다.

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

테스트가 통과됐습니다!

이제 run함수에 search 함수를 적용합시다.

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;

    for line in search(&config.query, &contents) {
        println!("{}", line);
    }

    Ok(())
}

환경 변수 동작

조금 더 다양한 동작을 실험하기 위해 환경 변수에 따라 다르게 동작하는 코드를 작성해 보도록 합시다.

먼저 대소문자를 구분하지 않는 기능에 대한 실패 테스트를 작성해봅시다.

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

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

search_case_insensitive 기능 구현

search_case_insensitive() 함수를 구현해 봅시다.

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

테스트를 돌리면,

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

정상적으로 기능 테스트가 되었습니다!

이제 Config 구조체에 관련 필드를 추가해봅시다.

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

그런 후 run함수를 변경해야겠죠.

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

이제 Config의 설정에 따라 대소문자를 무시할 지를 결정할 수 있게 되었습니다.

이제 환경 변수를 이용해 대소문자 무시 유무를 걸정 하도록 코드를 보완합니다.

use std::env;
// --snip--

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config {
            query,
            filename,
            case_sensitive,
        })
    }
}

이제 확인해봅시다!

$ cargo run to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

다음 환경 변수를 이용해 봅시다.

$ CASE_INSENSITIVE=1 cargo run to poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

표준 출력 대신 표준 오류에 오류 메시지 쓰기

일반 출력은 일반 내용만 출력 하는 것이 좋습니다. 이제 오류 출력을 표준 오류로 변경해 봅시다.

오류가 쓰여진 위치 확인

(깨끗한) 출력 결과를 파일로 저장하기 위해 다음처럼 사용합니다.

$ cargo run > output.txt

그런데 현재는 오류 출력도 포함되어 있으므로 표준 오류로 변경해봅시다.

오류를 표준 오류로 인쇄

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

    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {}", e);

        process::exit(1);
    }
}

다음처럼 명령 줄 인자를 주지 않으면 표준 파이프라인이 아닌 출력으로 오류가 표시됨을 확인 할 수 있습니다.

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

명령 줄 인자를 잘 주면,

$ cargo run to poem.txt > output.txt

원하는 결과 값만 파일로 잘 저장이 됩니다.

파일 이름: output.txt

Are you nobody, too?
How dreary to be somebody!

정리

이 장으로 통해 비로서 Rust 코드를 이용해 구조적인 모듈을 만들 수 있었습니다. 본 장을 여러 번 반복한다면 Rust 코드를 이용해 구조적으로 코딩할 수 있는 기본 실력이 갖춰지리라 생각합니다.