모듈성과 에러 처리를 개선하도록 리팩터링하기
프로그램을 개선하기 위해, 우선 프로그램 구조와 잠재적 에러 처리 방식에 관한 네 가지
문제를 고쳐 보겠습니다. 첫째, 현재 main 함수는 두 가지 일을 합니다. 인수를
파싱하고 파일을 읽습니다. 프로그램이 커질수록 main 이 담당하는 작업 수도 늘어납니다.
함수가 책임을 많이 질수록 이해하기 어렵고, 테스트하기 어렵고, 일부만 바꾸려 해도
다른 부분을 깨뜨리지 않기 어렵습니다. 각 함수가 하나의 일만 맡도록 기능을 분리하는
것이 좋습니다.
이 문제는 두 번째 문제와도 연결됩니다. query 와 file_path 는 프로그램의 설정값인데,
contents 같은 변수는 프로그램의 실제 로직을 수행하기 위해 사용됩니다. main 이
길어질수록 더 많은 변수를 스코프로 끌어와야 하고, 스코프 안의 변수가 많아질수록
각 변수의 목적을 추적하기가 어려워집니다. 설정과 관련된 변수들을 하나의 구조체에
묶어 목적을 분명하게 만드는 편이 좋습니다.
세 번째 문제는, 파일 읽기에 실패했을 때 에러 메시지를 출력하기 위해 expect 를
사용했지만, 메시지가 단지 Should have been able to read the file 라고만 말한다는
점입니다. 파일 읽기는 여러 이유로 실패할 수 있습니다. 파일이 없을 수도 있고, 파일을
열 권한이 없을 수도 있습니다. 그런데 현재는 어떤 경우든 똑같은 메시지를 출력하므로,
사용자에게는 아무 도움이 되지 않습니다.
넷째, 에러 처리에 expect 를 사용했기 때문에, 사용자가 인수를 충분히 주지 않고
프로그램을 실행하면 러스트로부터 index out of bounds 같은 에러를 보게 되는데,
이것은 무슨 문제가 있는지 명확하게 설명하지 않습니다. 모든 에러 처리 코드를 한 곳에
모아 두면, 이후 유지보수자는 에러 처리 로직을 바꿔야 할 때 한 곳만 보면 됩니다.
또한 에러 처리 코드가 한곳에 모여 있으면, 최종 사용자에게 의미 있는 메시지만
일관되게 출력하도록 만들 수도 있습니다.
이제 이 네 가지 문제를 리팩터링으로 해결해 보겠습니다.
바이너리 프로젝트에서 관심사 분리하기
여러 작업의 책임을 main 함수 하나에 몰아주는 문제는 많은 바이너리 프로젝트에서
흔하게 나타납니다. 그래서 많은 Rust 프로그래머는 main 함수가 커지기 시작하면
바이너리 프로그램의 서로 다른 관심사를 나누는 패턴을 사용합니다. 그 과정은 보통 다음
단계를 따릅니다.
- 프로그램을 main.rs 와 lib.rs 로 나누고, 프로그램의 실제 로직을 lib.rs 로 옮긴다.
- 명령줄 인수 파싱 로직이 아직 작다면
main함수 안에 그대로 둘 수 있다. - 명령줄 파싱이 복잡해지기 시작하면, 그것도
main에서 떼어내 별도 함수나 타입으로 분리한다.
이 과정을 거친 뒤 main 함수에 남아야 하는 책임은 대체로 다음 정도로 제한됩니다.
- 인수 값을 이용해 명령줄 파싱 로직 호출하기
- 나머지 설정 구성하기
- lib.rs 안의
run함수 호출하기 run이 에러를 반환할 경우 그 에러를 처리하기
이 패턴의 핵심은 관심사 분리입니다. main.rs 는 프로그램을 실행하는 일을 맡고,
lib.rs 는 실제 작업 로직을 맡습니다. main 함수는 직접 테스트할 수 없으므로,
이 구조를 사용하면 main 바깥으로 옮긴 나머지 프로그램 로직을 모두 테스트할 수
있습니다. main 안에 남는 코드는 육안으로도 쉽게 검증할 수 있을 정도로 작아질
것입니다. 이제 이 과정을 따라 프로그램을 다시 정리해 봅시다.
인수 파서 추출하기
먼저 인수 파싱 기능을 main 이 호출하는 별도 함수로 추출합니다. 목록 12-5는
main 초반이 어떻게 바뀌는지를 보여 줍니다. 이 함수는 src/main.rs 안에 정의할
새 parse_config 함수를 호출합니다.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
// --snip--
println!("Searching for {query}");
println!("In file {file_path}");
let contents = fs::read_to_string(file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
main 에서 parse_config 함수 추출하기여전히 명령줄 인수는 벡터로 수집하지만, 이제 main 안에서 인덱스 1의 값을 query,
인덱스 2의 값을 file_path 에 대입하는 대신, 전체 벡터를 parse_config 함수에
넘깁니다. 이제 어떤 인수가 어떤 변수에 들어갈지를 결정하는 로직은 parse_config
함수 안에 있게 되고, 그 값들을 다시 main 으로 돌려줍니다. query 와 file_path
변수 자체는 여전히 main 안에서 만들어지지만, 이제 main 은 명령줄 인수와 변수의
대응 관계를 결정하는 책임을 지지 않습니다.
이 리팩터링은 지금처럼 작은 프로그램에서는 과해 보일 수 있습니다. 하지만 우리는 작고 점진적인 단계로 리팩터링하고 있습니다. 이 변경을 한 뒤 프로그램을 다시 실행해, 인수 파싱이 여전히 제대로 동작하는지 확인하세요. 이렇게 중간중간 자주 확인하는 것은, 문제가 생겼을 때 원인을 빠르게 좁히는 데 큰 도움이 됩니다.
설정값 묶기
parse_config 를 한 단계 더 개선할 수 있습니다. 현재는 튜플을 반환한 뒤, 곧바로
그 튜플을 다시 개별 값으로 분해하고 있습니다. 이것은 아직 적절한 추상화에 도달하지
못했다는 신호입니다.
또 다른 신호는 parse_config 의 이름에 있는 config 입니다. 이 이름은 우리가
반환하는 두 값이 서로 관련되어 있고 하나의 설정값 일부라는 뜻을 암시합니다. 하지만
현재는 그 의미를 단지 튜플로 묶는 것 외에는 데이터 구조에서 드러내지 못하고 있습니다.
대신 두 값을 하나의 구조체에 넣고, 각 필드에 의미 있는 이름을 붙이겠습니다. 그러면
나중에 코드를 보는 사람도 값들이 서로 어떤 관계인지, 각 값의 목적이 무엇인지 더
쉽게 이해할 수 있습니다.
목록 12-6은 parse_config 를 개선한 버전입니다.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
// --snip--
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
parse_config 가 Config 구조체 인스턴스를 반환하도록 리팩터링하기여기서는 query 와 file_path 라는 필드를 가진 Config 구조체를 추가했습니다.
이제 parse_config 시그니처는 Config 값을 반환한다고 말합니다. parse_config
본문에서는 원래 args 안의 String 값을 가리키는 문자열 슬라이스를 반환했지만,
이제 Config 구조체는 소유권을 가진 String 값을 담습니다. main 안의 args
변수가 인수 값들의 소유자이고, parse_config 는 그 값을 잠시 빌려 쓸 뿐이기 때문에,
만약 Config 가 그 값을 그대로 가져가려 하면 러스트의 대여 규칙을 위반하게 됩니다.
이 String 데이터를 다루는 방법은 여러 가지가 있지만, 가장 쉬우면서 다소 비효율적인
방법은 값에 clone 을 호출하는 것입니다. 이렇게 하면 Config 인스턴스가 소유할
완전한 복사본이 만들어지므로 시간과 메모리를 더 씁니다. 하지만 데이터를 복사하면
참조의 라이프타임을 관리할 필요가 없어지므로, 코드가 훨씬 단순해집니다. 이 상황에서는
약간의 성능을 포기하고 단순성을 얻는 것이 합리적인 절충입니다.
clone 사용의 트레이드오프
많은 Rustacean은 소유권 문제를 해결하기 위해 clone 을 쓰는 것을, 런타임 비용이
든다는 이유로 피하고 싶어 합니다. [13장][ch13]에서는 이런 상황에서
더 효율적인 방법을 사용하는 법을 배우게 됩니다. 하지만 지금은 계속 진도를 나가기
위해 문자열 몇 개를 복사하는 정도는 괜찮습니다. 이런 복사는 한 번만 일어나고,
파일 경로와 검색 문자열도 매우 작기 때문입니다. 처음 시도부터 지나치게 최적화하려
하기보다, 약간 비효율적이더라도 동작하는 프로그램을 만드는 편이 낫습니다. 러스트에
더 익숙해질수록 처음부터 더 효율적인 해결책을 택하기도 쉬워지겠지만, 지금은
clone 을 호출하는 것이 충분히 괜찮습니다.
우리는 main 도 업데이트해서, parse_config 가 반환한 Config 인스턴스를
config 라는 변수에 넣고, 이전에 query 와 file_path 개별 변수를 쓰던 코드도
Config 구조체의 필드를 사용하도록 바꿨습니다.
이제 코드는 query 와 file_path 가 서로 관련 있으며, 프로그램 동작 방식을 설정하는
값이라는 사실을 더 분명하게 전달합니다. 이 값들을 사용하는 코드는 이제 목적이 드러나는
필드 이름과 함께 config 인스턴스 안에서 그것들을 찾으면 됩니다.
Config 생성자 만들기
지금까지 우리는 명령줄 인수 파싱 책임을 main 에서 떼어내 parse_config 함수로
옮겼습니다. 이 과정을 통해 query 와 file_path 가 관련된 값이라는 점을 확인했고,
그 관계를 코드 구조에도 반영해야 한다는 것을 알게 되었습니다. 그래서 Config
구조체를 추가해, 이 둘의 공통 목적에 이름을 붙이고, 구조체 필드 이름으로 함께
반환할 수 있게 했습니다.
이제 parse_config 함수의 목적이 Config 인스턴스를 만드는 일이라는 것이 분명해졌으니,
parse_config 를 일반 함수 대신 Config 구조체와 연관된 new 함수로 바꿔도
좋습니다. 이렇게 하면 코드도 더 관용적인 러스트 스타일이 됩니다. 표준 라이브러리의
타입인 String 인스턴스를 String::new 로 만들듯이, Config 와 연관된 new
함수로 바꾸면 Config::new 로 Config 인스턴스를 만들 수 있게 됩니다.
목록 12-7이 필요한 변경을 보여 줍니다.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
// --snip--
}
// --snip--
struct Config {
query: String,
file_path: String,
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
parse_config 를 Config::new 로 바꾸기우리는 main 안에서 parse_config 를 호출하던 부분을 Config::new 호출로
바꾸었고, parse_config 의 이름을 new 로 바꾼 뒤 impl 블록 안으로 옮겨
Config 와 연관되도록 만들었습니다. 다시 컴파일해 잘 동작하는지 확인해 보세요.
에러 처리 개선하기
이제 에러 처리를 손보겠습니다. args 벡터의 인덱스 1이나 2에 접근하려 하면, 벡터
안에 요소가 세 개 미만일 때 프로그램이 패닉을 일으킨다는 점을 기억하세요. 아무 인수 없이
프로그램을 실행해 보면 이런 모습이 됩니다.
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
index out of bounds: the len is 1 but the index is 1 이라는 줄은
프로그래머를 위한 에러 메시지입니다. 하지만 최종 사용자에게는 무엇을 해야 하는지
전혀 알려 주지 않습니다. 지금 이를 고쳐 봅시다.
에러 메시지 개선하기
목록 12-8에서는 new 함수 안에, 인덱스 1과 2에 접근하기 전에 슬라이스 길이가 충분한지
확인하는 검사를 추가합니다. 슬라이스가 충분히 길지 않다면 프로그램은 패닉하고, 더 나은
에러 메시지를 출력합니다.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
// --snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --snip--
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
이 코드는 목록 9-13에서 작성한 Guess::new 와 비슷합니다. 그때는 value
인수가 유효한 범위를 벗어나면 panic! 을 호출했습니다. 여기서는 값의 범위 대신
args 의 길이가 적어도 3 인지를 검사하며, 그 조건이 만족되었다는 가정 아래 나머지
함수가 동작하도록 합니다. args 에 세 개 미만의 항목이 있으면 조건은 true 가 되고,
우리는 panic! 으로 즉시 프로그램을 끝냅니다.
new 에 이 몇 줄의 코드를 더한 뒤, 다시 아무 인수 없이 프로그램을 실행해서 에러가
이제 어떻게 보이는지 확인해 봅시다.
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
이 출력은 전보다는 낫습니다. 이제 적어도 납득할 만한 에러 메시지가 보입니다. 하지만
아직은 최종 사용자에게 주고 싶지 않은 군더더기 정보도 함께 나옵니다. 아마 목록 9-13에서
썼던 방식이 여기에는 최선이 아닌 것 같습니다. [9장에서 논의했듯이]
[ch9-error-guidelines], panic! 은 사용법 실수보다는 프로그래밍 오류에
더 어울립니다. 그래서 이제는 9장에서 배운 다른 기법, 즉 [성공 또는 실패를 나타내는
Result 반환하기][ch9-result]를 사용해 보겠습니다.
panic! 대신 Result 반환하기
이제는 성공한 경우 Config 인스턴스를 담고, 에러인 경우에는 문제를 설명하는
Result 값을 반환하도록 바꿀 수 있습니다. 또한 많은 프로그래머는 new 함수가
실패하지 않는다고 기대하므로, 함수 이름도 new 에서 build 로 바꾸겠습니다.
Config::build 가 main 과 통신할 때 Result 타입을 사용하면, main 에서
Err variant를 감지해 사용자에게 더 친절한 에러를 출력할 수 있고, panic! 이
출력하는 thread 'main' 이나 RUST_BACKTRACE 같은 부가 정보는 숨길 수 있습니다.
목록 12-9는 이제 Config::build 라고 부르는 함수의 반환값과 본문을 어떻게 바꿔야
하는지 보여 줍니다. 물론 다음 목록에서 main 도 함께 고치기 전까지는 이 코드는
컴파일되지 않습니다.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
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 })
}
}
Config::build 에서 Result 반환하기이제 build 함수는 성공하면 Config 인스턴스를 담은 Result 를, 실패하면
문자열 리터럴을 담은 Result 를 반환합니다. 이 에러 값은 언제나 'static
라이프타임을 가진 문자열 리터럴입니다.
함수 본문에는 두 가지 변화가 있습니다. 사용자가 인수를 충분히 주지 않았을 때 더
이상 panic! 을 호출하지 않고 Err 값을 반환합니다. 그리고 성공 시에는
Config 반환값을 Ok 로 감싸서 반환합니다. 이 두 변화 덕분에 함수는 새 타입
시그니처와 맞게 됩니다.
Config::build 에서 Err 를 반환하게 되면, 이제 main 함수는 build 함수가
반환한 Result 값을 직접 처리할 수 있고, 에러일 경우 더 깔끔한 방식으로 프로세스를
종료시킬 수 있습니다.
Config::build 호출하고 에러 처리하기
에러를 처리하고 사용자 친화적인 메시지를 출력하려면, main 을 목록 12-10처럼
업데이트해야 합니다. 또한 명령줄 도구가 0이 아닌 종료 코드로 끝나는 책임을
panic! 에 맡기지 않고, 우리가 직접 구현할 것입니다. 0이 아닌 종료 코드는 프로그램이
에러 상태로 끝났음을 호출한 프로세스에게 알려 주는 일반적인 규약입니다.
use std::env;
use std::fs;
use std::process;
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);
});
// --snip--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
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 })
}
}
Config 생성에 실패하면 에러 코드로 종료하기여기서는 아직 자세히 다루지 않은 메서드 하나를 사용했습니다. 바로 표준 라이브러리의
Result<T, E> 에 정의된 unwrap_or_else 입니다. unwrap_or_else 를 사용하면,
panic! 이 아닌 사용자 정의 에러 처리 로직을 만들 수 있습니다. Result 가 Ok
이면 이 메서드는 unwrap 과 비슷하게 내부 값을 반환합니다. 반대로 값이 Err 이면,
인수로 넘긴 클로저(익명 함수) 안의 코드를 실행합니다. 클로저는 [13장][ch13]
에서 자세히 다루겠지만, 지금은 unwrap_or_else 가 Err 안의 값, 이 경우 목록
12-9에서 넣은 "not enough arguments" 라는 정적 문자열을, 세로줄 사이에 나타난
인자 err 로 클로저에 넘긴다는 점만 알면 충분합니다. 클로저 안 코드는 실행될 때
이 err 값을 사용할 수 있습니다.
표준 라이브러리의 process 를 스코프로 가져오기 위해 use 문도 하나 추가했습니다.
에러일 때 실행되는 클로저 안 코드는 두 줄뿐입니다. 먼저 err 를 출력하고, 그다음
process::exit 를 호출합니다. process::exit 함수는 프로그램을 즉시 중단시키고,
전달받은 숫자를 종료 상태 코드로 반환합니다. 이것은 목록 12-8에서 사용했던
panic! 기반 처리와 비슷하지만, 이제는 원치 않는 추가 출력이 사라집니다. 실행해
봅시다.
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
좋습니다! 이 출력은 사용자에게 훨씬 친절합니다.
main 에서 로직 추출하기
이제 설정 파싱 리팩터링을 마쳤으니, 프로그램의 실제 로직을 보겠습니다. 앞의
“바이너리 프로젝트에서 관심사 분리하기”
절에서 말했듯이, 이제 설정 구성과 에러 처리에 관련되지 않은 main 안의 나머지
로직을 run 이라는 함수로 추출할 것입니다. 이렇게 하면 main 함수는 짧고
검증하기 쉬워지고, 나머지 로직에 대해서는 테스트도 작성할 수 있게 됩니다.
목록 12-11은 run 함수를 추출하는 작은 단계의 리팩터링을 보여 줍니다.
use std::env;
use std::fs;
use std::process;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) {
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
// --snip--
struct Config {
query: String,
file_path: String,
}
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 })
}
}
run 함수 추출하기이제 run 함수는 파일 읽기부터 시작하는, main 에 있던 나머지 로직을 전부 담고
있습니다. run 함수는 Config 인스턴스를 인수로 받습니다.
run 에서 에러 반환하기
이제 나머지 로직이 run 함수로 분리되었으니, 목록 12-9에서 Config::build 를
리팩터링했던 것처럼 run 의 에러 처리도 개선할 수 있습니다. expect 로 프로그램이
패닉하게 두는 대신, 문제가 생기면 run 이 Result<T, E> 를 반환하게 하겠습니다.
그러면 사용자 친화적인 방식으로 에러를 처리하는 로직을 main 에 더 집중시킬 수
있습니다. 목록 12-12는 run 의 시그니처와 본문에 필요한 변경을 보여 줍니다.
use std::env;
use std::fs;
use std::process;
use std::error::Error;
// --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);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
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 })
}
}
run 함수가 Result 를 반환하도록 바꾸기여기에는 세 가지 중요한 변화가 있습니다. 첫째, run 함수의 반환 타입을
Result<(), Box<dyn Error>> 로 바꿨습니다. 이전에는 단위 타입 () 를 반환했지만,
성공한 경우에도 여전히 () 를 사용하고, 이제는 그것을 Ok 안에 감쌉니다.
에러 타입으로는 trait object인 Box<dyn Error> 를 사용했습니다. 이를 위해 파일
위쪽에서 std::error::Error 도 use 로 가져왔습니다. trait object는
[18장][ch18]에서 더 자세히 다루지만, 지금은 Box<dyn Error> 가
“Error 트레이트를 구현하는 어떤 에러 타입이든 반환할 수 있다” 정도로 이해하면
됩니다. 이 방식은 함수가 서로 다른 에러 상황에서 서로 다른 타입의 에러 값을 반환해야
할 수도 있을 때 유연성을 제공합니다.
둘째, [9장][ch9-question-mark]에서 이야기한 것처럼 expect 호출을
? 연산자로 바꿨습니다. 이제 에러가 나면 panic! 하는 대신, ? 가 현재 함수에서
에러 값을 반환해 호출한 쪽이 처리하도록 합니다.
셋째, 성공한 경우 run 함수는 이제 Ok 값을 반환합니다. run 함수 시그니처에서
성공 타입을 () 로 선언했으므로, 단위 타입 값을 Ok 안에 감싸야 합니다. Ok(())
라는 문법은 처음에는 조금 낯설 수 있지만, “이 함수는 부작용을 위해 호출하며, 별도의
값은 반환하지 않는다”는 것을 표현하는 러스트 관용구입니다.
이 코드를 실행하면 컴파일은 되지만 경고가 하나 뜹니다.
$ cargo run -- the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
19 | let _ = run(config);
| +++++++
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
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!
러스트는 우리 코드가 Result 값을 무시했고, 그 값이 에러를 나타낼 수도 있다고
알려 줍니다. 즉, 실제로 에러가 있었는지 아직 확인하지 않고 있다는 뜻입니다.
이제 그 문제도 바로 고쳐 봅시다.
main 에서 run 이 반환한 에러 처리하기
이제 목록 12-10에서 Config::build 를 다룰 때 썼던 것과 비슷한 방식으로, run
이 반환한 에러도 검사하고 처리하겠습니다. 다만 약간의 차이는 있습니다.
파일명: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
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 })
}
}
여기서는 unwrap_or_else 대신 if let 을 사용해 run 이 Err 값을 반환했는지
확인하고, 그렇다면 process::exit(1) 을 호출합니다. run 함수는 성공 시 우리가
unwrap 할 만한 의미 있는 값을 반환하지 않고 () 를 반환하므로,
Config::build 와는 달리 굳이 unwrap_or_else 를 쓸 필요가 없습니다.
두 경우 모두 if let 본문과 unwrap_or_else 안의 본문은 똑같습니다. 에러를 출력하고,
프로세스를 종료합니다.
코드를 라이브러리 크레이트로 분리하기
우리 minigrep 프로젝트는 지금도 꽤 잘 정리되어 있습니다. 이제 src/main.rs 를
더 작게 만들기 위해, 일부 코드를 src/lib.rs 로 옮겨 보겠습니다. 이렇게 하면 그
코드에 대해 테스트를 작성하기도 쉬워집니다.
검색 텍스트를 처리하는 코드는 src/main.rs 가 아니라 src/lib.rs 안에 두겠습니다.
그러면 우리 minigrep 바이너리뿐 아니라, 그 라이브러리를 사용하는 누구든 그 검색
함수를 활용할 수 있게 됩니다.
먼저, 목록 12-13처럼 src/lib.rs 안에 search 함수 시그니처를 정의해 둡니다.
본문은 지금은 unimplemented! 매크로로 두겠습니다. 구현은 곧 채워 넣고, 그 전에
시그니처를 조금 더 설명하겠습니다.
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
search 함수 정의하기함수 정의 앞에 pub 키워드를 붙여 search 를 라이브러리 크레이트의 공개 API 일부로
지정했습니다. 이제 우리는 바이너리 크레이트에서도 사용할 수 있고, 테스트도 할 수 있는
라이브러리 크레이트를 하나 가지게 된 것입니다.
다음으로는 src/lib.rs 에 정의한 코드를 src/main.rs 안의 바이너리 크레이트 스코프로 가져오고, 그것을 호출해야 합니다. 목록 12-14를 보세요.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
// --snip--
use minigrep::search;
fn main() {
// --snip--
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);
}
}
// --snip--
struct Config {
query: String,
file_path: String,
}
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)?;
for line in search(&config.query, &contents) {
println!("{line}");
}
Ok(())
}
minigrep 라이브러리 크레이트의 search 함수 사용하기우리는 use minigrep::search 라는 줄을 추가해 라이브러리 크레이트의 search 함수를
바이너리 크레이트 스코프로 가져왔습니다. 그다음 run 함수 안에서 더 이상 파일 내용을
그대로 출력하는 대신, search 함수를 호출하고 config.query 와 contents 를
인수로 넘깁니다. 그런 뒤 run 은 search 가 반환한, 질의와 매칭된 각 줄을 for
루프로 출력합니다. 이 시점에서 main 함수 안에서 질의와 파일 경로를 출력하던
println! 도 지워, 에러가 없는 경우에는 오직 검색 결과만 출력하게 만드는 것도
좋습니다.
참고로 이 search 함수는 출력하기 전에 모든 결과를 벡터에 모아 반환합니다. 따라서
큰 파일을 검색할 때는 결과를 찾자마자 바로 출력하지 못하므로 느릴 수 있습니다.
13장에서 반복자를 배우면서 이를 개선할 수 있는 방법도 이야기할 것입니다.
후우, 여기까지도 꽤 많은 작업이었습니다. 하지만 덕분에 우리는 앞으로 더 쉽게 작업할 수 있는 구조를 마련했습니다. 에러 처리도 훨씬 다루기 쉬워졌고, 코드도 더 모듈화 되었습니다. 이제부터는 거의 모든 작업이 src/lib.rs 안에서 이루어질 것입니다.
그럼 이 새롭게 얻은 모듈성을 활용해, 이전 구조에서는 하기 어려웠지만 지금은 쉬워진 일을 해 봅시다. 바로 테스트 작성입니다!