Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

환경 변수 다루기

이번에는 minigrep 바이너리에 기능 하나를 더 추가하겠습니다. 사용자가 환경 변수를 통해 켜고 끌 수 있는 대소문자 무시 검색 옵션입니다. 이 기능을 명령줄 옵션으로 만들어 매번 입력하게 할 수도 있지만, 대신 환경 변수로 만들면 사용자는 터미널 세션 동안 환경 변수를 한 번만 설정해 두고 모든 검색을 대소문자 무시 모드로 실행할 수 있습니다.

대소문자 무시 검색 함수에 대한 실패하는 테스트 작성하기

먼저 minigrep 라이브러리에 search_case_insensitive 라는 새 함수를 추가하겠습니다. 이 함수는 환경 변수에 값이 설정되어 있을 때 호출됩니다. 여기서도 계속 TDD 과정을 따르므로, 첫 단계는 역시 실패하는 테스트를 작성하는 것입니다. 새 search_case_insensitive 함수를 위한 테스트를 추가하고, 기존 테스트 이름도 one_result 에서 case_sensitive 로 바꾸어 두 테스트의 차이를 더 분명히 하겠습니다. 목록 12-20이 그 모습을 보여 줍니다.

Filename: src/lib.rs
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
}

#[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)
        );
    }
}
Listing 12-20: 추가하려는 대소문자 무시 함수에 대한 새 실패 테스트 추가하기

기존 테스트의 contents 도 바꾸었다는 점에 주목하세요. "Duct tape." 라는 새 줄을 대문자 D 로 추가했습니다. 대소문자를 구분하는 검색에서는 이것이 질의 "duct" 와 매칭되면 안 됩니다. 기존 테스트를 이렇게 바꾸는 것은, 앞으로 대소문자 무시 검색 기능을 추가하는 동안 이미 구현한 대소문자 구분 검색 기능을 실수로 망가뜨리지 않게 해 줍니다. 이 테스트는 지금 통과해야 하고, 이후 작업 중에도 계속 통과해야 합니다.

새 대소문자 무시 검색 테스트는 "rUsT" 를 질의로 사용합니다. 우리가 곧 만들 search_case_insensitive 함수에서는 이 "rUsT" 질의가 대문자 R 로 시작하는 "Rust:" 줄과, 또 다른 대소문자를 가진 "Trust me." 줄 모두와 매칭되어야 합니다. 이것이 우리가 원하는 실패 테스트이며, 아직 search_case_insensitive 함수를 정의하지 않았기 때문에 현재는 컴파일에도 실패합니다. 필요하다면 목록 12-16에서 search 함수에 대해 했던 것처럼, 항상 빈 벡터를 반환하는 골격 구현을 먼저 넣어 테스트가 “컴파일은 되지만 실패하게” 바꿔 보는 것도 좋습니다.

search_case_insensitive 함수 구현하기

목록 12-21에 나오는 search_case_insensitive 함수는 search 함수와 거의 같습니다. 유일한 차이는 질의 문자열 query 와 각 줄 line 을 모두 소문자로 바꾼 뒤 비교한다는 점입니다. 이렇게 하면 입력 인수의 대소문자 형태가 어떻든, 비교할 때는 동일한 대소문자가 됩니다.

Filename: src/lib.rs
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
}

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
}

#[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)
        );
    }
}
Listing 12-21: 비교 전에 질의와 각 줄을 소문자로 바꾸는 search_case_insensitive 함수 정의하기

먼저 query 문자열을 소문자로 바꾸고, 원래 이름과 같은 새 변수에 저장해 이전 query 를 가립니다. to_lowercase 를 호출해야 하는 이유는, 사용자가 "rust", "RUST", "Rust", "rUsT" 중 무엇을 입력하든 그것을 모두 "rust" 처럼 다루고 대소문자를 무시하기 위해서입니다. to_lowercase 는 기본적인 유니코드를 처리하지만 100퍼센트 완전한 해법은 아닙니다. 만약 실제 애플리케이션을 만든다면 이 부분을 더 신경 써야 하겠지만, 여기서는 환경 변수가 주제이지 유니코드가 주제는 아니므로 이 정도로 두겠습니다.

이제 query 는 문자열 슬라이스가 아니라 String 입니다. to_lowercase 는 기존 데이터를 참조하는 대신 새로운 데이터를 만들기 때문입니다. 예를 들어 질의가 "rUsT" 라면, 그 문자열 슬라이스 안에는 우리가 그대로 사용할 수 있는 소문자 u, t 가 들어 있지 않습니다. 따라서 "rust" 를 담은 새 String 을 할당해야 합니다. 이제 contains 메서드에 query 를 인수로 넘길 때는, contains 시그니처가 문자열 슬라이스를 받도록 정의되어 있으므로 & 를 붙여야 합니다.

다음으로 각 line 에 대해서도 to_lowercase 를 호출해 모든 문자를 소문자로 바꿉니다. 이렇게 linequery 둘 다 소문자로 변환하면, 질의의 대소문자와 상관없이 항상 올바른 매칭을 찾을 수 있습니다.

이 구현이 테스트를 통과하는지 확인해 봅시다.

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (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 src/main.rs (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

좋습니다. 테스트가 통과했습니다! 이제 새 search_case_insensitive 함수를 run 함수에서 실제로 호출해 보겠습니다. 먼저 Config 구조체에 대소문자를 구분할지 무시할지 전환하는 설정 필드를 하나 추가합니다. 아직 이 필드를 어디에서도 초기화하지 않았으므로, 이 변경은 곧바로 컴파일 오류를 일으킬 것입니다.

파일명: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --snip--


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

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

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

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

        Ok(Config { query, file_path })
    }
}

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

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

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

    Ok(())
}

우리는 불리언 값을 담는 ignore_case 필드를 추가했습니다. 다음으로 run 함수가 config.ignore_case 값을 검사해, search 함수를 호출할지 search_case_insensitive 함수를 호출할지 결정하도록 만들어야 합니다. 목록 12-22가 그 코드입니다. 물론 아직은 이것만으로는 컴파일되지 않습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --snip--


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

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

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

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

        Ok(Config { query, file_path })
    }
}

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

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

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

    Ok(())
}
Listing 12-22: config.ignore_case 값에 따라 search 또는 search_case_insensitive 호출하기

마지막으로 환경 변수를 실제로 검사해야 합니다. 환경 변수를 다루는 함수들은 표준 라이브러리의 env 모듈에 있고, 이것은 이미 src/main.rs 맨 위에서 스코프로 가져와져 있습니다. 목록 12-23처럼 env 모듈의 var 함수를 사용해 IGNORE_CASE 라는 이름의 환경 변수에 값이 설정되어 있는지 확인하겠습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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

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

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

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

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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

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

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

    Ok(())
}
Listing 12-23: IGNORE_CASE 라는 환경 변수에 어떤 값이든 들어 있는지 확인하기

여기서는 ignore_case 라는 새 변수를 만들고 값을 설정합니다. 이를 위해 env::var 함수를 호출하고 IGNORE_CASE 환경 변수 이름을 넘깁니다. env::varResult 를 반환하는데, 환경 변수가 어떤 값으로든 설정되어 있다면 그 값을 담은 성공적인 Ok variant를 반환하고, 설정되어 있지 않다면 Err variant를 반환합니다.

우리는 Result 에서 is_ok 메서드를 사용해 환경 변수가 설정되어 있는지만 확인합니다. 즉, 값이 있으면 프로그램은 대소문자를 구분하지 않는 검색을 하게 됩니다. IGNORE_CASE 환경 변수에 아무 값도 없으면 is_okfalse 를 반환하고, 프로그램은 대소문자를 구분하는 검색을 수행합니다. 환경 변수의 구체적인 값 자체는 관심 대상이 아니고, 설정되어 있는지 여부만 중요하므로 unwrap, expect 같은 메서드 대신 is_ok 를 쓰는 것입니다.

우리는 이 ignore_case 변수 값을 Config 인스턴스로 넘겨서, run 함수가 이 값을 읽고 목록 12-22에서 구현한 대로 search_case_insensitive 를 호출할지 search 를 호출할지 결정하게 합니다.

이제 직접 실행해 봅시다! 먼저 환경 변수를 설정하지 않은 상태에서, 소문자 to 를 질의로 넣습니다. 이 경우 to 를 소문자로 포함하는 줄만 매칭되어야 합니다.

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

좋습니다. 여전히 잘 동작합니다! 이제 IGNORE_CASE1 로 설정하고, 같은 질의 to 로 프로그램을 다시 실행해 봅시다.

$ IGNORE_CASE=1 cargo run -- to poem.txt

PowerShell 을 사용 중이라면 환경 변수를 설정하는 명령과 프로그램 실행 명령을 별도로 써야 합니다.

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

이 방식은 셸 세션이 끝날 때까지 IGNORE_CASE 가 유지되게 합니다. 해제하려면 Remove-Item cmdlet을 사용할 수 있습니다.

PS> Remove-Item Env:IGNORE_CASE

이제 대문자를 포함한 To 도 함께 매칭된 줄들이 나와야 합니다.

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

아주 좋습니다. To 를 포함한 줄까지 함께 나왔습니다! 이제 우리의 minigrep 프로그램은 환경 변수로 제어되는 대소문자 무시 검색도 할 수 있습니다. 이렇게 하면 명령줄 인수나 환경 변수로 설정된 옵션을 어떻게 다뤄야 하는지도 익히게 됩니다.

어떤 프로그램은 같은 설정에 대해 명령줄 인수와 환경 변수를 동시에 허용합니다. 그런 경우 어느 쪽이 우선하는지는 프로그램마다 다르게 정합니다. 연습으로, 대소문자 구분 여부를 명령줄 인수 또는 환경 변수 중 어느 것으로도 제어할 수 있게 만들어 보세요. 그리고 둘이 충돌할 때는 어느 쪽을 우선할지 직접 결정해 보세요.

std::env 모듈에는 환경 변수와 관련된 유용한 기능이 더 많이 있습니다. 어떤 기능이 더 있는지는 문서를 직접 살펴보세요.