모듈성과 에러 처리 향상을 위한 리팩터링
minigrep의 인수 파싱·설정·검색 로직을 main과 라이브러리로 분리하고 panic 대신 Result로 오류를 전달합니다.
프로그램을 개선하기 위해, 프로그램의 구조와 오류를 다루는 방식에 관련된 네 가지 문제를 고칩니다.
첫째, 지금 main 함수는 인수를 해석하는 일과 파일을 읽는 일 두 가지를 합니다. 프로그램이 커질수록 main이 하는 일은 늘어나고, 함수가 맡는 일이 많아질수록 이해하기도, 테스트하기도, 한 부분을 망가뜨리지 않고 고치기도 어려워집니다. 기능을 나누어 함수마다 한 가지 일을 맡기는 것이 좋습니다.
둘째, 이 문제와 이어지는 것으로, query와 file_path는 프로그램의 설정 변수이지만 contents 같은 변수는 프로그램의 로직을 실행하는 데 쓰입니다. main이 길어질수록 스코프로 가져와야 하는 변수가 늘어나고, 스코프에 있는 변수가 많을수록 각 변수의 목적을 기억하기 어렵습니다. 설정 변수는 하나의 구조체로 묶어 목적을 분명히 하는 것이 좋습니다.
셋째, 파일을 읽지 못했을 때 expect로 오류 메시지를 출력하지만, 그 메시지는 Should have been able to read the file뿐입니다. 파일을 읽는 일은 여러 까닭으로 실패할 수 있습니다. 파일이 없을 수도 있고, 파일을 열 권한이 없을 수도 있습니다. 지금은 어떤 상황이든 같은 오류 메시지를 출력하므로 사용자에게 아무 정보도 주지 못합니다.
넷째, 서로 다른 오류를 다루는 데 expect를 쓰고 있어서, 사용자가 인수를 충분히 주지 않고 프로그램을 실행하면 문제를 분명히 설명하지 못하는 index out of bounds 오류를 받게 됩니다. 오류를 다루는 코드를 모두 한곳에 두면, 나중에 오류 처리 로직을 고쳐야 할 때 살펴볼 곳이 한 군데뿐이어서 좋습니다. 오류를 다루는 코드를 한곳에 모아 두면 최종 사용자에게 의미 있는 메시지를 출력하기도 쉽습니다. 리팩터링을 마쳤을 때의 구조는 아래 그림과 같습니다.
minigrep 책임 분리와 오류 전파 흐름
main→Config build Result→Ok일 때 main→run Result→main 진단/종료의 명시적 두 호출이다. Config가 run을 호출하지 않는다.
RUST · MINIGREP
Config와 run의 Result는 main으로 돌아온다
라이브러리는 설정 검증과 파일 작업을 담당하고 main은 호출 순서와 실패 정책을 정합니다. Config가 run을 직접 호출하지 않습니다.
main → Config::build
인수를 모아 build에 전달합니다. Ok이면 설정을 얻고 Err이면 진단을 출력한 뒤 process::exit(1)로 끝납니다.
main → run(config)
성공한 설정으로 run을 호출합니다. run은 파일을 읽고 이 절에서는 텍스트를 출력합니다. 검색 함수는 11장 4절에서 추가합니다.
run → main
파일 오류는 ?를 통해 Result로 돌아옵니다. main이 오류를 진단하고 종료 상태를 정합니다.
이 절의 단계
본문의 오류 진단은 아직 println!입니다. 11장 6절에서 eprintln!로 바꿔 stderr와 검색 결과를 분리합니다.
테스트와 오류 경계
Box<dyn Error>에는 Error를 구현하는 구체 오류가 들어갑니다. 라이브러리 함수는 직접 단위 테스트할 수 있고 실행 파일은 자식 프로세스 통합 테스트로 확인할 수 있습니다.
process::exit는 Rust 스택의 소멸자를 실행하지 않고 종료합니다. 정리 작업이 필요한 프로그램은 정상 반환 등의 종료 정책을 선택합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 세 노드와 네 호출/반환 관계다. main→build의 호출과 build→main의 설정 또는 오류 반환을 구분한다. 설정이 Ok일 때 main이 run을 호출하고 그 Result를 main으로 돌려받는다. build→run 직접 호출이나 build에서 프로세스를 종료하는 관계는 없다. 세로 배치는 main→build→run의 무조건 실행 연쇄가 아니며 각 바깥 레일이 실제 호출자와 반환 대상을 연결한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Config 생성 main이 Config::build를 호출한다. |
|
|
| run 호출 main은 Config 성공 때만 run을 부른다. |
|
|
| 최종 정책 run Result를 main이 진단하고 종료 처리한다. |
|
|
minigrep 책임 분리와 오류 전파 흐름
main→Config build Result→Ok일 때 main→run Result→main 진단/종료의 명시적 두 호출이다. Config가 run을 호출하지 않는다.
RUST · MINIGREP
Config와 run의 Result는 main으로 돌아온다
라이브러리는 설정 검증과 파일 작업을 담당하고 main은 호출 순서와 실패 정책을 정합니다. Config가 run을 직접 호출하지 않습니다.
main → Config::build
인수를 모아 build에 전달합니다. Ok이면 설정을 얻고 Err이면 진단을 출력한 뒤 process::exit(1)로 끝납니다.
main → run(config)
성공한 설정으로 run을 호출합니다. run은 파일을 읽고 이 절에서는 텍스트를 출력합니다. 검색 함수는 11장 4절에서 추가합니다.
run → main
파일 오류는 ?를 통해 Result로 돌아옵니다. main이 오류를 진단하고 종료 상태를 정합니다.
이 절의 단계
본문의 오류 진단은 아직 println!입니다. 11장 6절에서 eprintln!로 바꿔 stderr와 검색 결과를 분리합니다.
테스트와 오류 경계
Box<dyn Error>에는 Error를 구현하는 구체 오류가 들어갑니다. 라이브러리 함수는 직접 단위 테스트할 수 있고 실행 파일은 자식 프로세스 통합 테스트로 확인할 수 있습니다.
process::exit는 Rust 스택의 소멸자를 실행하지 않고 종료합니다. 정리 작업이 필요한 프로그램은 정상 반환 등의 종료 정책을 선택합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 세 노드와 네 호출/반환 관계다. main→build의 호출과 build→main의 설정 또는 오류 반환을 구분한다. 설정이 Ok일 때 main이 run을 호출하고 그 Result를 main으로 돌려받는다. build→run 직접 호출이나 build에서 프로세스를 종료하는 관계는 없다. 세로 배치는 main→build→run의 무조건 실행 연쇄가 아니며 각 바깥 레일이 실제 호출자와 반환 대상을 연결한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Config 생성 main이 Config::build를 호출한다. |
|
|
| run 호출 main은 Config 성공 때만 run을 부른다. |
|
|
| 최종 정책 run Result를 main이 진단하고 종료 처리한다. |
|
|
라이브러리의 Config::build는 인수가 모자라는지 검사해 설정을 만들고, run은 파일을 읽고 이 글에서는 그 텍스트를 출력합니다. 검색 함수는 테스트 주도 라이브러리 개발에서 더합니다. main은 인수를 모아 Config::build를 부르고, 성공했을 때만 그 설정으로 run을 부릅니다. Config::build가 run을 직접 부르지는 않으며, 두 함수의 Result는 모두 main으로 돌아옵니다. 그러면 main이 오류를 어떻게 알릴지와 프로그램을 어떤 종료 상태로 끝낼지를 정합니다. 이 글에서는 오류 메시지를 아직 println!으로 출력하며, 표준 에러 출력에서 eprintln!으로 바꿔 검색 결과와 나눕니다. 이 구조를 한 단계씩 만들어 갑니다.
바이너리 프로젝트에 대한 관심사 분리
여러 일을 main 함수에 맡기는 구성 문제는 많은 바이너리 프로젝트에 흔합니다. 그래서 main이 커지기 시작할 때 바이너리 프로그램의 관심사를 나누는 방법이 러스트 커뮤니티에 자리 잡았습니다. 그 과정은 다음과 같습니다.
- 프로그램을
main.rs와lib.rs로 나누고 프로그램의 로직을lib.rs로 옮깁니다. - 명령줄을 해석하는 로직이 작다면
main함수에 둘 수 있습니다. - 명령줄을 해석하는 로직이 복잡해지면
main함수에서 뽑아내 다른 함수나 타입으로 옮깁니다.
이 과정을 거치고 나면 main 함수에 남는 일은 다음 정도로 줄어듭니다.
- 인수 값으로 명령줄 해석 로직을 부릅니다.
- 그 밖의 설정을 준비합니다.
lib.rs의run함수를 부릅니다.run이 오류를 돌려주면 그 오류를 다룹니다.
이 방식은 관심사를 나누는 것입니다. main.rs는 프로그램을 실행하는 일을, lib.rs는 맡은 작업의 로직을 모두 다룹니다. main 함수는 직접 테스트할 수 없으므로, 이 구조로 프로그램의 로직을 모두 main 바깥의 함수로 옮겨 테스트할 수 있게 합니다. main에 남은 코드는 읽어 보는 것만으로 올바른지 확인할 수 있을 만큼 작아집니다. 이 과정을 따라 프로그램을 고쳐 봅니다.
인수 파서 추출
인수를 해석하는 기능을 main이 부를 함수로 뽑아냅니다. 이렇게 해 두면 명령줄을 해석하는 로직을 src/lib.rs로 옮길 준비가 됩니다. 다음은 main의 새 시작 부분과, 새 함수 parse_config입니다. 당분간 이 함수는 src/main.rs에 정의합니다.
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
// --생략--
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}여전히 명령줄 인수를 벡터에 모으지만, main에서 인덱스 1의 값을 query에, 인덱스 2의 값을 file_path에 넣는 대신 벡터 전체를 parse_config에 넘깁니다. 그러면 어느 인수가 어느 변수에 들어가는지 정하는 로직은 parse_config가 맡고, 그 값을 다시 main에 돌려줍니다. main에서 query와 file_path 변수를 만드는 것은 같지만, main은 더 이상 명령줄 인수와 변수가 어떻게 이어지는지 정하지 않습니다.
이 정도는 작은 프로그램에 지나친 일로 보일 수 있지만, 작고 점진적인 단계로 리팩터링하는 중입니다. 이렇게 바꾼 뒤에는 프로그램을 실행해 인수 해석이 여전히 동작하는지 확인합니다. 진행 상황을 자주 확인하면 문제가 생겼을 때 원인을 찾기 쉽습니다.
설정 값 묶기
parse_config를 한 단계 더 고칠 수 있습니다. 지금은 튜플을 돌려주고, 곧바로 그 튜플을 다시 개별 부분으로 나눕니다. 이것은 아직 알맞은 추상화를 찾지 못했다는 신호일 수 있습니다.
개선할 여지가 있다는 또 다른 신호는 parse_config의 config 부분으로, 이 함수가 돌려주는 두 값이 서로 관련되어 있고 둘 다 하나의 설정 값의 일부라는 것을 뜻합니다. 지금은 두 값을 튜플로 묶는 것 말고는 데이터의 구조에서 이 의미를 전하지 못하고 있습니다. 두 값을 하나의 구조체에 넣고 구조체의 각 필드에 의미 있는 이름을 붙이는 것이 좋습니다. 그러면 나중에 이 코드를 유지 보수하는 사람이 서로 다른 값이 어떻게 관련되어 있고 각각의 목적이 무엇인지 이해하기 쉽습니다.
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");
// --생략--
}
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 }
}query와 file_path 필드를 가진 Config 구조체를 정의했습니다. 이제 parse_config의 시그니처는 Config 값을 돌려준다고 알립니다. 전에는 parse_config의 본문에서 args의 String 값을 가리키는 문자열 슬라이스를 돌려주었지만, 이제는 Config가 소유한 String 값을 담도록 정의했습니다. main의 args 변수는 인수 값들의 소유자이고 parse_config에게 빌려줄 뿐이므로, Config가 args의 값의 소유권을 가져가려 하면 러스트의 대여 규칙을 어기게 됩니다.
String 데이터를 다루는 방법은 여러 가지가 있으며, 비효율적이기는 해도 가장 쉬운 방법은 값에 clone 메서드를 부르는 것입니다. 그러면 Config 인스턴스가 소유할 데이터의 복사본을 통째로 만듭니다. 문자열 데이터의 참조를 저장하는 것보다 시간과 메모리가 더 들지만, 참조의 라이프타임을 관리하지 않아도 되므로 코드가 아주 간단해집니다. 이 상황에서는 성능을 조금 내주고 단순함을 얻는 것이 해 볼 만한 거래입니다.
clone을 쓰면 런타임 비용이 들기 때문에, 많은 러스타시안(Rustacean)은 소유권 문제를 clone으로 고치지 않으려는 경향이 있습니다. I/O 프로젝트 개선에서는 이런 상황에서 더 효율적인 방법을 배웁니다. 지금은 문자열 몇 개를 복사해 진행하는 것으로 충분합니다. 이 복사는 한 번만 일어나고 파일 경로와 검색 문자열은 아주 작기 때문입니다. 처음부터 코드를 지나치게 최적화하려고 하기보다, 조금 비효율적이더라도 동작하는 프로그램을 갖는 편이 낫습니다. 러스트에 익숙해질수록 가장 효율적인 방법으로 시작하기가 쉬워지겠지만, 지금은 clone을 불러도 괜찮습니다.
main도 고쳐서, parse_config가 돌려준 Config 인스턴스를 config 변수에 넣고, 전에 따로 쓰던 query와 file_path 변수 대신 Config 구조체의 필드를 씁니다. 이제 query와 file_path가 서로 관련되어 있고, 프로그램이 어떻게 동작할지를 설정하는 값이라는 것이 코드에 더 분명히 드러납니다. 이 값을 쓰는 코드는 그 목적에 맞게 이름 붙은 필드에서 config 인스턴스의 값을 찾으면 됩니다.
Config를 위한 생성자 만들기
지금까지 명령줄 인수를 해석하는 로직을 main에서 뽑아 parse_config 함수에 두었습니다. 그 과정에서 query와 file_path 값이 서로 관련되어 있고, 그 관계가 코드에 드러나야 한다는 것을 알게 되었습니다. 그래서 query와 file_path의 관련된 목적을 나타내는 Config 구조체를 더하고, parse_config가 그 이름의 값을 Config 구조체로 돌려주게 했습니다.
이제 parse_config 함수의 목적은 Config 인스턴스를 만드는 것이므로, 평범한 함수 parse_config를 Config 구조체에 딸린 new 함수로 바꿀 수 있습니다. 그렇게 바꾸면 코드가 더 관용적이 됩니다. 표준 라이브러리의 String 같은 타입의 인스턴스는 String::new를 불러 만들 수 있습니다. 마찬가지로 parse_config를 Config에 딸린 new 함수로 바꾸면, Config::new를 불러 Config의 인스턴스를 만들 수 있습니다. 바꾼 뒤의 src/main.rs 전체는 다음과 같습니다.
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 new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}main에서 parse_config를 부르던 곳을 Config::new를 부르도록 고쳤습니다. parse_config의 이름을 new로 바꾸고 impl 블록 안으로 옮겨, new 함수가 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' (6017887) panicked at src/main.rs:25: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 backtraceindex out of bounds: the len is 1 but the index is 1은 프로그래머를 위한 오류 메시지입니다. 위치 25:21은 Config::new 안에서 args[1]에 접근하는 곳입니다. 최종 사용자가 무엇을 잘못했고 무엇을 해야 하는지 이해하는 데는 도움이 되지 않습니다. 이것을 고쳐 봅니다.
에러 메시지 개선
new 함수에 검사를 더해, 인덱스 1과 2에 접근하기 전에 슬라이스가 충분히 긴지 확인합니다. 슬라이스가 충분히 길지 않으면 프로그램은 패닉을 내고 더 나은 오류 메시지를 출력합니다.
impl Config {
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
let query = args[1].clone();
// --생략--이 코드는 panic! 사용 기준에서 값이 올바른 범위 밖에 있으면 panic!을 부르던 Guess::new 함수와 비슷합니다. 여기서는 값의 범위를 확인하는 대신 args의 길이가 3 이상인지 확인하고, 함수의 나머지 부분은 이 조건이 만족되었다고 보고 동작할 수 있습니다. args의 원소가 세 개보다 적으면 조건이 참이 되어, panic! 매크로를 불러 프로그램을 곧바로 끝냅니다. 앞의 전체 코드에서 Config::new의 첫 줄 앞에 이 검사를 넣었다면, 인수 없이 실행했을 때 다음과 같이 나옵니다.
thread 'main' (6017887) panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace이 출력이 더 낫습니다. 이제 쓸 만한 오류 메시지가 나옵니다. 하지만 사용자에게 보여 주고 싶지 않은 군더더기 정보도 함께 나옵니다. panic! 사용 기준에서 이야기했듯 panic!은 사용법의 문제보다 프로그래밍의 문제에 더 알맞으므로, 이 상황에는 그 기법이 맞지 않을 것입니다. 대신 Result와 복구 가능한 오류에서 배운 다른 기법, 곧 성공인지 오류인지를 나타내는 Result를 돌려주는 방법을 씁니다.
panic! 호출 대신 Result 반환하기
성공하면 Config 인스턴스를, 오류가 나면 문제를 설명하는 값을 담은 Result를 돌려주게 합니다. 함수의 이름도 new에서 build로 바꿉니다. 많은 프로그래머는 new 함수가 실패하지 않는다고 기대하기 때문입니다. Config::build가 main과 소통할 때, Result 타입으로 문제가 있다는 신호를 줄 수 있습니다. 그런 다음 main이 Err 배리언트를 사용자에게 더 실용적인 오류 메시지로 바꾸게 하면, panic!을 부를 때 나오는 thread 'main'이나 RUST_BACKTRACE에 관한 문구를 없앨 수 있습니다.
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 })
}
}build 함수는 성공하면 Config 인스턴스를, 오류가 나면 문자열 리터럴을 담은 Result를 돌려줍니다. 오류 값은 언제나 'static 라이프타임을 가진 문자열 리터럴입니다. 함수의 본문에서 두 가지를 바꾸었습니다. 사용자가 인수를 충분히 주지 않았을 때 panic!을 부르는 대신 Err 값을 돌려주고, 반환하는 Config 값을 Ok로 감쌌습니다. 이렇게 바꾸면 함수가 새 타입 시그니처를 따르게 됩니다.
Config::build에서 Err 값을 돌려주면, main 함수가 build가 돌려준 Result 값을 다루고 오류가 났을 때 프로세스를 더 깔끔하게 끝낼 수 있습니다.
Config::build 호출과 에러 처리
오류가 난 경우를 다루고 사용자 친화적인 메시지를 출력하려면, Config::build가 돌려준 Result를 다루도록 main을 고쳐야 합니다. 또 0이 아닌 오류 코드로 명령줄 도구를 끝내는 일도 panic!에서 가져와 직접 구현합니다. 0이 아닌 종료 상태는 프로그램을 부른 프로세스에게 프로그램이 오류 상태로 끝났다고 알리는 관례입니다.
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);
});
// --생략--여기서 아직 자세히 다루지 않은 메서드 unwrap_or_else를 썼습니다. 이 메서드는 표준 라이브러리가 Result<T, E>에 정의한 것입니다. unwrap_or_else를 쓰면 panic!이 아닌 오류 처리 방법을 직접 정할 수 있습니다. Result가 Ok 값이면 이 메서드는 unwrap과 비슷하게 Ok가 감싼 안쪽 값을 돌려줍니다. Err 값이면 클로저(closure) 안의 코드를 부릅니다. 클로저는 unwrap_or_else에 인수로 넘기는 익명 함수입니다. 클로저는 클로저: 자신의 환경을 캡처하는 익명 함수에서 자세히 다룹니다. 지금은 unwrap_or_else가 Err의 안쪽 값, 이 경우에는 Config::build에 더한 정적 문자열 "not enough arguments"를, 세로 막대 사이에 적은 클로저의 인수 err로 넘긴다는 것만 알면 됩니다. 그러면 클로저 안의 코드가 실행될 때 err 값을 쓸 수 있습니다.
표준 라이브러리의 process를 스코프로 가져오는 use 줄도 새로 더했습니다. 오류가 났을 때 실행되는 클로저의 코드는 두 줄뿐입니다. err 값을 출력하고 process::exit를 부릅니다. process::exit 함수는 프로그램을 곧바로 멈추고 넘긴 숫자를 종료 상태 코드로 돌려줍니다. 이것은 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훨씬 낫습니다. 이제 사용자에게 훨씬 친절한 출력이 나옵니다. 다만 process::exit는 프로세스를 곧바로 끝내므로, 현재 스레드의 스택에 남은 값들의 소멸자(destructor)를 실행하지 않습니다. 끝내기 전에 버퍼를 비우거나 파일을 정리해야 하는 프로그램이라면, 정리를 마친 뒤 main에서 정상적으로 돌아가며 종료 상태를 정하는 방법을 고릅니다. 이 프로그램은 정리할 것이 없으므로 process::exit를 그대로 씁니다.
main으로부터 로직 추출하기
설정을 해석하는 리팩터링을 마쳤으니, 이제 프로그램의 로직으로 넘어갑니다. 바이너리 프로젝트에 대한 관심사 분리에서 말했듯, 설정을 준비하거나 오류를 다루는 일에 관련되지 않은 main의 로직을 모두 담을 run 함수를 뽑아냅니다. 다 마치고 나면 main은 간결해져 읽어 보는 것만으로 확인하기 쉬워지고, 다른 로직은 모두 테스트를 쓸 수 있게 됩니다. 지금은 run 함수를 뽑아내는 작은 개선만 하며, 이 함수는 아직 src/main.rs에 정의합니다.
fn main() {
// --생략--
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}");
}
// --생략--이제 run 함수에 파일을 읽는 것부터 main의 나머지 로직이 모두 담겼습니다. run 함수는 Config 인스턴스를 인수로 받습니다.
run 함수로부터 에러 반환하기
나머지 프로그램 로직을 run 함수로 나눴으니, Config::build에서 그랬듯 오류 처리도 개선할 수 있습니다. expect를 불러 프로그램이 패닉을 내게 하는 대신, 무언가 잘못되면 run이 Result<T, E>를 돌려주게 합니다. 그러면 오류를 다루는 로직을 사용자 친화적인 방식으로 main에 더 모을 수 있습니다.
use std::error::Error;
// --생략--
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}여기서 세 가지를 크게 바꾸었습니다. 첫째, run 함수의 반환 타입을 Result<(), Box<dyn Error>>로 바꾸었습니다. 전에는 이 함수가 유닛 타입 ()를 돌려주었고, Ok의 경우에는 그 값을 그대로 돌려줍니다.
오류 타입에는 트레이트 객체(trait object) Box<dyn Error>를 썼습니다. 그리고 맨 위에 use 문으로 std::error::Error를 스코프로 가져왔습니다. 트레이트 객체는 트레이트 객체와 다형성에서 다룹니다. 지금은 Box<dyn Error>가 이 함수가 Error 트레이트를 구현하는 타입을 돌려주되, 그 값이 구체적으로 어떤 타입인지는 정하지 않아도 된다는 뜻이라는 것만 알면 됩니다. 그래서 서로 다른 오류 상황에서 서로 다른 타입의 오류 값을 돌려줄 수 있는 유연함이 생깁니다. dyn 키워드는 동적(dynamic)을 줄인 말입니다.
둘째, expect 호출을 없애고 Result와 복구 가능한 오류에서 본 ? 연산자를 썼습니다. 오류가 나면 panic!을 내는 대신, ?가 현재 함수에서 오류 값을 돌려주어 호출한 쪽이 다루게 합니다.
셋째, 이제 run 함수는 성공하면 Ok 값을 돌려줍니다. 시그니처에서 run 함수의 성공 타입을 ()로 선언했으므로, 유닛 타입 값을 Ok로 감싸야 합니다. 이 Ok(()) 문법이 처음에는 좀 이상해 보일 수 있지만, 이렇게 ()를 쓰는 것은 run을 부작용 때문에만 부르며 필요한 값을 돌려주지는 않는다는 것을 나타내는 관용적인 방법입니다. 이 코드를 실행하면 컴파일은 되지만 경고가 나옵니다. 지금까지 고친 src/main.rs 전체는 다음과 같습니다.
use std::env;
use std::error::Error;
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);
});
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 })
}
}cargo run -- the poem.txt Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:17:5
|
17 | run(config);
| ^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
17 | 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 값을 무시했으며, 그 Result 값이 오류가 났다는 것을 나타낼 수도 있다고 알려 줍니다. 오류가 났는지 확인하지 않았으므로, 컴파일러는 여기에 오류를 다루는 코드를 두려던 것이 아니냐고 일깨워 줍니다. 이제 이 문제를 고칩니다.
main에서 run으로부터 반환된 에러 처리하기
오류를 확인하고 다루는 방법은 Config::build에서 쓴 것과 비슷하지만, 조금 다릅니다. 아래 코드는 main의 앞부분을 생략하고, 설정을 출력한 뒤 run을 부르는 마지막 부분만 보입니다.
fn main() {
// --생략--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}unwrap_or_else 대신 if let을 써서 run이 Err 값을 돌려주었는지 확인하고, 그렇다면 process::exit(1)을 부릅니다. run 함수는 Config::build가 Config 인스턴스를 돌려주는 것과 달리 꺼내 쓸 값을 돌려주지 않습니다. run은 성공하면 ()를 돌려주므로 오류를 찾는 일에만 관심이 있으며, 안쪽 값은 ()일 뿐이므로 unwrap_or_else로 꺼낼 필요가 없습니다.
if let과 unwrap_or_else 함수의 본문은 두 경우 모두 같습니다. 오류를 출력하고 끝냅니다. 없는 파일을 주어 실행하면, 이제 expect의 패닉 대신 오류 값의 Display 표현이 담긴 메시지가 나오고 종료 상태 1로 끝납니다.
cargo run -- the missing.txtSearching for the
In file missing.txt
Application error: No such file or directory (os error 2)Box<dyn Error>에는 Error를 구현하는 구체적인 오류 값이 들어 있으며, 여기서는 fs::read_to_string이 돌려준 io::Error입니다. 오류 문구는 운영체제마다 다릅니다.
더 깊이: ?가 서로 다른 오류를 Box<dyn Error>로 바꾸는 방법
run의 ?가 io::Error를 Box<dyn Error>로 돌려줄 수 있는 것은, ?가 오류 값에 From::from을 부르고 표준 라이브러리에 그 변환이 정의되어 있기 때문입니다. Error 트레이트 문서에는 Error를 구현한 어떤 타입 E든 Box<dyn Error>로 바꾸는 From 구현이 있고, Send와 Sync도 구현한 오류를 Box<dyn Error + Send + Sync>로 바꾸는 구현도 따로 있습니다. 또 &str과 String을 Box<dyn Error>로 바꾸는 구현이 있어서, 이 반환 타입을 가진 함수에서는 return Err("not enough arguments".into());처럼 문자열로 오류를 만들 수도 있습니다.
Box<dyn Error>로 바꾸면 구체적인 오류 타입은 시그니처에서 사라지지만, 값 안에는 그대로 남아 있습니다. 부른 쪽이 특정 오류만 따로 처리해야 한다면 dyn Error의 downcast_ref::<io::Error>()로 원래 타입인지 확인해 참조를 얻을 수 있으며, 그 타입이 아니면 None이 돌아옵니다. 다만 이렇게 하면 어떤 오류가 올 수 있는지가 시그니처에 드러나지 않으므로, 여러 곳에서 쓰는 라이브러리라면 오류의 종류를 열거형으로 정의해 돌려주는 편이 부르는 쪽에 더 많은 정보를 줍니다. 또 Send가 바운드에 없는 Box<dyn Error>는 다른 스레드로 보낼 수 없으므로, 오류를 스레드 사이로 넘겨야 한다면 Box<dyn Error + Send + Sync>를 씁니다.
라이브러리 크레이트로 코드 쪼개기
minigrep 프로젝트가 괜찮아 보입니다. 이제 src/main.rs를 쪼개 일부 코드를 src/lib.rs에 넣습니다. 그러면 코드를 테스트할 수 있고 src/main.rs가 맡는 일이 줄어듭니다. main 함수가 아닌 코드를 모두 src/main.rs에서 src/lib.rs로 옮깁니다.
| 옮기는 것 | 비고 |
|---|---|
run 함수의 정의 | 오류 타입을 위한 use std::error::Error; 포함 |
Config 구조체의 정의 | 필드도 pub으로 공개 |
Config::build 함수의 정의 | pub fn으로 공개 |
관련된 use 문 | use std::fs; |
src/lib.rs는 다음과 같은 시그니처를 갖습니다.
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub 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 })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}pub 키워드를 넉넉히 썼습니다. Config와 그 필드, build 메서드, 그리고 run 함수에 붙였습니다. 이제 테스트할 수 있는 공개 API를 가진 라이브러리 크레이트가 생겼습니다. src/main.rs의 바이너리 크레이트에서 src/lib.rs로 옮긴 코드를 스코프로 가져와야 합니다.
use std::env;
use std::process;
use minigrep::Config;
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);
if let Err(e) = minigrep::run(config) {
println!("Application error: {e}");
process::exit(1);
}
}use minigrep::Config 줄을 더해 라이브러리 크레이트의 Config 타입을 바이너리 크레이트의 스코프로 가져왔고, run 함수 앞에는 크레이트 이름을 붙였습니다. 패키지 이름이 minigrep이므로 라이브러리 크레이트의 이름도 minigrep입니다. 이제 모든 기능이 이어져 동작할 것입니다. cargo run으로 실행해 모든 것이 제대로 동작하는지 확인합니다.
휴! 일이 많았지만, 앞으로를 위해 성공의 기반을 다졌습니다. 이제 오류를 다루기가 훨씬 쉬워졌고, 코드도 더 모듈화되었습니다. 앞으로 거의 모든 작업은 src/lib.rs에서 하게 됩니다. 라이브러리 함수는 유닛 테스트로 직접 확인할 수 있고, 실행 파일 자체의 동작은 테스트 조직화에서 본 것처럼 별도 프로세스로 실행하는 통합 테스트로 확인할 수 있습니다. 어디까지 라이브러리로 옮길지는 정하기 나름이며, Config와 run은 src/main.rs에 두고 검색 함수만 라이브러리에 두는 구성도 흔히 씁니다. 이 교재에서는 그림처럼 설정과 실행 로직까지 라이브러리에 둡니다.
이렇게 새로 얻은 모듈성을 살려, 예전 코드로는 어려웠지만 새 코드로는 쉬운 일을 해 봅니다. 테스트를 써 봅니다.
정리
main에는 인수 수집, 설정 준비,run호출, 오류 처리만 남기고, 로직은src/lib.rs로 옮겨 테스트할 수 있게 합니다.- 서로 관련된 설정 값은 튜플 대신 이름 붙은 필드를 가진
Config구조체로 묶습니다. 지금은clone으로 단순하게 소유권을 해결합니다. - 실패할 수 있는 생성 함수는
new대신build라고 이름 짓고Result<Config, &'static str>을 돌려줍니다. main은unwrap_or_else와if let Err(e)로 오류를 받아 메시지를 출력하고process::exit(1)로 끝냅니다.process::exit는 스택의 소멸자를 실행하지 않습니다.run은Result<(), Box<dyn Error>>를 돌려주고?로 오류를 호출한 쪽에 넘기며, 그Result를 무시하면unused_must_use경고가 나옵니다.- 라이브러리로 옮긴 아이템은
pub으로 공개하고,src/main.rs에서use minigrep::Config나minigrep::run처럼 크레이트 이름으로 부릅니다.
연습 문제
1. 무엇이 출력될까
마지막 코드에서 (가) cargo run -- the, (나) cargo run -- the missing.txt, (다) cargo run -- the poem.txt extra를 실행하면 각각 무엇이 출력되고, 종료 상태는 무엇인가요?
해설 보기
(가)는 args의 길이가 2라 Config::build가 Err를 돌려주므로 Problem parsing arguments: not enough arguments를 출력하고 종료 상태 1로 끝나며, Searching for 줄은 나오지 않습니다. (나)는 설정은 만들어지므로 Searching for the와 In file missing.txt가 나온 뒤, run이 파일 오류를 돌려주어 Application error:로 시작하는 메시지를 출력하고 종료 상태 1로 끝납니다. (다)는 길이가 3 이상이라 통과하며 셋째 인수는 무시되고, 시의 내용을 출력한 뒤 종료 상태 0으로 끝납니다.
2. 왜 new가 아니라 build일까
Config::new가 Result를 돌려주도록 바꾸지 않고 함수 이름을 build로 바꾼 까닭은 무엇인가요? 오류 타입을 &'static str로 정한 것은 어떤 뜻인가요?
해설 보기
러스트 프로그래머는 대개 new가 실패하지 않고 값을 곧바로 돌려준다고 기대하므로, 실패할 수 있는 생성 함수에는 다른 이름을 붙여 그 차이를 드러냅니다. &'static str은 오류 값이 프로그램이 실행되는 내내 살아 있는 문자열 리터럴이라는 뜻이며, "not enough arguments" 같은 고정된 메시지만 돌려준다는 것을 나타냅니다.
다음 글 테스트 주도 라이브러리 개발에서는 실패하는 테스트부터 써서 src/lib.rs에 검색 기능을 더합니다.