환경 변수 사용하기
환경 변수 사용하기
minigrep 바이너리에서 사용자가 환경 변수로 켤 수 있는 대소문자 무시 검색 옵션을 추가해서 개선해 볼게요. 이 기능을 명령줄 옵션으로 만들어 사용자가 적용할 때마다 입력하게 할 수도 있지만, 환경 변수로 만들면 사용자는 한 번만 설정해 두면 그 터미널 세션 동안 모든 검색이 대소문자를 무시하게 됩니다.
출처: The Rust Book
대소문자 무시 검색의 실패 테스트 작성
먼저 환경 변수에 값이 있을 때 호출될 search_case_insensitive 함수를 minigrep 라이브러리에 추가할게요. TDD 과정을 계속 따를 테니, 첫 단계는 역시 실패하는 테스트를 쓰는 것입니다. 새 search_case_insensitive 함수에 대한 테스트를 새로 추가하고, 기존 테스트의 이름을 one_result에서 case_sensitive로 바꿔 두 테스트의 차이를 분명히 할게요. Listing 12-20처럼요.
파일명: 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도 수정했다는 점에 주목하세요. 대문자 D로 된 "Duct tape."라는 새 줄을 추가했는데, 대소문자를 구분하는 검색에서는 이 줄이 쿼리 "duct"와 일치하면 안 됩니다. 기존 테스트를 이렇게 바꿔 두면 이미 구현해 둔 대소문자 구분 검색 기능을 실수로 깨뜨리지 않는다는 걸 보장해요. 이 테스트는 지금 통과해야 하고, 대소문자 무시 검색 작업을 하는 동안에도 계속 통과해야 합니다.
대소문자 무시 검색의 새 테스트는 쿼리로 "rUsT"를 사용해요. 곧 추가할 search_case_insensitive 함수에서 쿼리 "rUsT"는 대문자 R이 있는 "Rust:" 줄과 "Trust me." 줄을 일치시켜야 하는데, 둘 다 쿼리와 대소문자가 다릅니다. 이것이 우리의 실패 테스트로, search_case_insensitive 함수를 아직 정의하지 않아서 컴파일도 되지 않을 거예요. Listing 12-16에서 search 함수에 했던 것처럼 항상 빈 벡터를 반환하는 뼈대 구현을 추가해 테스트가 컴파일·실패하는 걸 확인해 봐도 좋습니다.
search_case_insensitive 함수 구현
Listing 12-21의 search_case_insensitive 함수는 search 함수와 거의 같을 거예요. 차이는 query와 각 line을 소문자로 만들어, 입력 인자의 대소문자가 어떻든 줄이 쿼리를 담고 있는지 확인할 때 둘이 같은 대소문자가 되게 한다는 점입니다.
파일명: 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는 to_lowercase를 호출하면 새 데이터를 만들지 기존 데이터를 참조하지 않기 때문에, 이제 문자열 슬라이스가 아니라 String이 됐다는 점에 주목하세요. 예를 들어 쿼리가 "rUsT"라고 해 봅시다. 이 문자열 슬라이스에는 우리가 쓸 수 있는 소문자 u나 t가 없으므로, "rust"를 담은 새 String을 할당해야 해요. 이제 query를 contains 메서드에 인자로 넘길 때 contains의 시그니처가 문자열 슬라이스를 받도록 정의되어 있으므로 앰퍼샌드를 붙여야 합니다.
그다음 각 line에 to_lowercase를 호출해 모든 문자를 소문자로 만듭니다. line과 query를 모두 소문자로 바꿨으니, 쿼리의 대소문자가 어떻든 일치 항목을 찾게 됩니다.
이 구현이 테스트를 통과하는지 볼게요:
$ 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
좋아요! 통과했네요. 이제 run 함수에서 새 search_case_insensitive 함수를 호출해 볼게요. 먼저 대소문자 구분 검색과 대소문자 무시 검색 사이를 전환하는 구성 옵션을 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 함수가 ignore_case 필드의 값을 확인하고, 그 값을 써서 search 함수를 호출할지 search_case_insensitive 함수를 호출할지 결정하게 만들어야 합니다. Listing 12-22처럼요. 아직 컴파일되지는 않아요.
파일명: 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 맨 위에서 이미 스코프에 들어와 있어요. Listing 12-23처럼 env 모듈의 var 함수를 사용해서 IGNORE_CASE라는 이름의 환경 변수에 어떤 값이라도 설정됐는지 확인할게요.
파일명: 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::var 함수는 Result를 반환하는데, 환경 변수에 어떤 값이라도 설정되어 있으면 환경 변수의 값을 담은 Ok 변형이 성공적으로 나오고, 환경 변수가 설정되어 있지 않으면 Err 변형이 나옵니다.
Result의 is_ok 메서드를 사용해서 환경 변수가 설정되어 있는지 확인하는데, 설정되어 있으면 프로그램이 대소문자 무시 검색을 해야 한다는 뜻이에요. IGNORE_CASE 환경 변수에 아무것도 설정되어 있지 않으면 is_ok가 false를 반환하고 프로그램은 대소문자 구분 검색을 수행합니다. 우리는 환경 변수의 값에는 관심이 없고 설정됐는지 여부만 알면 되므로, unwrap, expect, 또는 Result에서 봤던 다른 메서드 대신 is_ok를 확인하고 있어요.
ignore_case 변수의 값을 Config 인스턴스로 넘겨서, Listing 12-22에서 구현한 것처럼 run 함수가 그 값을 읽고 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_CASE를 1로 설정하고 같은 쿼리 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 모듈에는 환경 변수를 다루는 유용한 기능이 훨씬 더 많아요. 무엇이 있는지 문서를 확인해 보세요.