본문 바로가기

모듈성과 에러 처리 향상을 위한 리팩터링

minigrep의 인수 파싱·설정·검색 로직을 main과 라이브러리로 분리하고 panic 대신 Result로 오류를 전달합니다.

프로그램을 개선하기 위해서 프로그램의 구조 및 잠재적 에러를 처리하는 방식과 관련된 네 가지 문제를 고치려고 합니다.

첫 번째로는 main 함수가 지금 두 가지 일을 수행한다는 것입니다.

인수 파싱, 파일을 읽는 작업 말입니다.

이 프로그램이 커짐에 따라 main 함수에서 처리하는 개별 작업의 개수는 증가할 것입니다.

어떤 함수가 책임 범위를 계속 넓혀 나가면, 이 함수는 어떤 기능인지 추론하기 어려워지고, 테스트하기도 힘들어지고, 기능 일부분을 깨트리지 않으면서 고치기도 어려워집니다.

기능을 나누어 각각의 함수가 하나의 작업에 대한 책임만 지는 것이 최선입니다.

이 주제는 두 번째 문제와도 엮입니다.

query와 file_path가 프로그램의 설정 변수이지만, contents 같은 변수는 프로그램 로직을 수행하기 위해 사용됩니다.

main이 점점 길어질수록 필요한 변수들이 더 많이 스코프 안에 있게 되고, 스코프 안에 더 많은 변수가 있을수록 각 변수의 목적을 추적하는 것이 더 어려워집니다.

설정 변수들을 하나의 구조체로 묶어서 목적을 분명히 하는 것이 가장 좋습니다.

세 번째 문제는 파일 읽기 실패를 expect로 즉시 패닉 처리하여, 사용자에게 보여줄 진단과 실패 후 동작을 직접 선택하지 못한다는 것입니다. 고정 문맥인 Should have been able to read the file에 실제 오류 정보도 붙지만, 아직 호출자가 Result를 받아 처리하는 구조는 아닙니다.

파일을 읽는 작업은 여러 가지 방식으로 실패할 수 있습니다.

이를테면 파일을 못 찾았거나, 파일을 열 권한이 없었다든가 하는 식이죠.

expect는 고정 문맥과 오류의 Debug 표현을 함께 출력합니다. 다만 패닉 출력을 그대로 사용자 진단으로 쓰는 대신, 실패 원인에 맞는 메시지와 종료 정책을 정하는 편이 좋습니다.

네 번째로, 파일 읽기 오류는 expect로 처리하지만 인수 개수는 검사하지 않습니다. 사용자가 충분한 인수를 지정하지 않으면 args[1] 또는 args[2]의 인덱스 접근에서 index out of bounds 패닉이 나며, 파일 읽기와 expect에는 도달하지 않습니다. 이 메시지만으로는 사용자가 어떤 인수를 더 줘야 하는지 알기 어렵습니다.

모든 에러 처리 코드가 한 곳에 있어서 미래에 코드를 유지보수할 사람이 에러 처리 로직을 변경하기를 원할 경우 찾아봐야 하는 코드가 한 군데에만 있는 것이 가장 좋을 것입니다.

모든 에러 처리 코드를 한 곳에 모아두면 최종 사용자에게 의미 있는 메시지를 출력할 수 있습니다.

이 프로젝트를 리팩터링하여 위의 네 가지 문제를 해결해봅시다.

minigrep 책임 분리와 오류 전파 흐름

main→Config build Result→Ok일 때 main→run Result→main 진단/종료의 명시적 두 호출이다. Config가 run을 호출하지 않는다.

RUST · MINIGREP

Config와 run의 Result는 main으로 돌아온다

라이브러리는 설정 검증과 파일 작업을 담당하고 main은 호출 순서와 실패 정책을 정합니다. Config가 run을 직접 호출하지 않습니다.

main이 Config와 run을 각각 호출한다Config build와 run은 서로 직접 호출하지 않는다. 두 Result는 main으로 돌아오고 main은 진단과 종료 정책을 결정한다.main인수 수집·호출 순서진단·종료 정책Config::build(&args)누락 검사·소유한 설정 생성Result<Config, &str>run(config)파일 읽기·텍스트 출력Result<(), Box<dyn Error>>① build 호출설정 또는 오류② Ok 설정으로 run성공 또는 오류
호출과 오류 반환은 main으로 돌아온다세노드네호출/반환관계. main→build의호출과build→main의설정또는오류반환을구분한다. 설정이Ok일때main이run을호출하고그Result를main으로돌려받는다. build→run직접호출이나build에서프로세스종료하는관계는없다. 세로배치는main→build→run의무조건실행연쇄가아니며각바깥레일이실제호출자와반환대상을연결한다.main인수 수집·호출 순서진단·종료 정책Config::build누락 검사·설정 소유Result<Config, &str>run(config)파일 읽기·텍스트 출력Result<(),Box<dyn Error>>① build 호출설정·오류② Ok 설정으로 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를 호출한다.

  • 호출: main → Config::build
  • 반환: Result<Config, ...>
  • 인수 검증 실패를 먼저 호출자에게 돌려준다.
run 호출

main은 Config 성공 때만 run을 부른다.

  • 호출자: main
  • 호출: run(config)
  • Config가 run을 호출하는 연결을 만들지 않는다.
최종 정책

run Result를 main이 진단하고 종료 처리한다.

  • 실패: main 진단/종료
  • 성공: 정상 완료
  • 상위 main이 실패의 표시와 종료 상태를 담당한다.

minigrep 책임 분리와 오류 전파 흐름

main→Config build Result→Ok일 때 main→run Result→main 진단/종료의 명시적 두 호출이다. Config가 run을 호출하지 않는다.

RUST · MINIGREP

Config와 run의 Result는 main으로 돌아온다

라이브러리는 설정 검증과 파일 작업을 담당하고 main은 호출 순서와 실패 정책을 정합니다. Config가 run을 직접 호출하지 않습니다.

main이 Config와 run을 각각 호출한다Config build와 run은 서로 직접 호출하지 않는다. 두 Result는 main으로 돌아오고 main은 진단과 종료 정책을 결정한다.main인수 수집·호출 순서진단·종료 정책Config::build(&args)누락 검사·소유한 설정 생성Result<Config, &str>run(config)파일 읽기·텍스트 출력Result<(), Box<dyn Error>>① build 호출설정 또는 오류② Ok 설정으로 run성공 또는 오류
호출과 오류 반환은 main으로 돌아온다세노드네호출/반환관계. main→build의호출과build→main의설정또는오류반환을구분한다. 설정이Ok일때main이run을호출하고그Result를main으로돌려받는다. build→run직접호출이나build에서프로세스종료하는관계는없다. 세로배치는main→build→run의무조건실행연쇄가아니며각바깥레일이실제호출자와반환대상을연결한다.main인수 수집·호출 순서진단·종료 정책Config::build누락 검사·설정 소유Result<Config, &str>run(config)파일 읽기·텍스트 출력Result<(),Box<dyn Error>>① build 호출설정·오류② Ok 설정으로 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를 호출한다.

  • 호출: main → Config::build
  • 반환: Result<Config, ...>
  • 인수 검증 실패를 먼저 호출자에게 돌려준다.
run 호출

main은 Config 성공 때만 run을 부른다.

  • 호출자: main
  • 호출: run(config)
  • Config가 run을 호출하는 연결을 만들지 않는다.
최종 정책

run Result를 main이 진단하고 종료 처리한다.

  • 실패: main 진단/종료
  • 성공: 정상 완료
  • 상위 main이 실패의 표시와 종료 상태를 담당한다.

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

여러 작업에 대한 책임을 main 함수에 떠넘기는 조직화 문제는 많은 바이너리 프로젝트에서 흔한 일입니다.

이에 따라 러스트 커뮤니티는 main이 커지기 시작할 때 이 바이너리 프로그램의 별도 관심사를 나누기 위한 가이드라인을 개발했습니다.

이 프로세스는 다음의 단계로 구성되어 있습니다.

  • 프로그램을 main.rs와 lib.rs로 분리하고 프로그램 로직을 lib.rs로 옮기세요.
  • 커맨드 라인 파싱 로직이 작은 동안에는 main.rs에 남을 수 있습니다.
  • 커맨드 라인 파싱 로직이 복잡해지기 시작하면, main.rs로부터 추출하여 lib.rs로 옮기세요.

이 과정을 거친 후 main 함수에 남아 있는 책임 범위는 다음으로 한정되어야 합니다.

  • 인수 값을 가지고 커맨드 라인 파싱 로직 호출하기
  • 그 밖의 설정
  • lib.rs의 run 함수 호출
  • run이 에러를 반환할 때 에러 처리하기

이 패턴은 관심사 분리에 관한 것입니다.

main.rs는 프로그램의 실행을 다루고, lib.rs는 당면한 작업의 모든 로직을 처리합니다.

이 구조는 lib.rs 함수를 직접 단위 테스트하기 쉽게 만듭니다. 실행 파일의 인수·출력·종료 상태는 별도의 자식 프로세스 통합 테스트로 확인할 수 있습니다.

main.rs에 남겨진 코드는 정확한지 검증할 때 읽는 것만으로도 충분할 정도로 작아질 것입니다.

이 프로세스를 따르는 것으로 프로그램 작업을 다시 해봅시다.

인수 파서 추출

커맨드 라인 파싱 로직을 src/lib.rs로 옮기기 위한 준비 단계로 인수를 파싱하기 위한 기능을 main이 호출할 함수로 추출하겠습니다.

예제 11-5는 새로 시작하는 main과 호출되는 새로운 함수 parse_config를 보여주는데, 여기서는 잠깐 src/main.rs에 정의하겠습니다.

예제 11-5: 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에게 다시 넘겨줍니다.

여전히 query와 file_path 변수는 main 안에서 만들지만, main은 더 이상 커맨드 라인 인수와 변수들이 어떻게 대응되는지를 결정할 책임이 없습니다.

이러한 재작업은 우리의 작은 프로그램에 대해서는 지나쳐 보일지도 모르겠으나, 우리는 작게, 점진적인 단계로 리팩터링을 하는 중입니다.

이 변경 후에 프로그램을 다시 실행하여 인수 파싱이 여전히 동작하는지 검증하세요.

프로그램의 동작을 자주 확인하는 것은 좋은 일이며, 문제가 발생했을 때 그 원인을 식별하는 데 도움이 됩니다.

설정 값 묶기

parse_config 함수를 더욱 개선하기 위해 작은 단계를 하나 더 진행할 수 있습니다.

현재는 튜플을 반환하는 중인데, 그런 다음 이 튜플을 개별 부분으로 즉시 다시 쪼개고 있습니다.

이는 아직 적절한 추상화가 이루어지지 않았다는 신호일 수 있습니다.

개선의 여지가 남아있음을 보여주는 또 다른 지표는 parse_config의 config 부분인데, 이는 반환하는 두 값이 연관되어 있고 둘 모두 하나의 설정 값을 이루는 부분임을 의미합니다.

현재 두 값을 튜플로 묶는 것 말고는 데이터의 구조에서 이러한 의미를 전달하지 못하고 있습니다; 그래서 이 두 값을 하나의 구조체에 넣고 구조체 필드에 각각 의미가 있는 이름을 부여하려고 합니다.

그렇게 하는 것이 미래에 이 코드를 유지보수하는 사람에게 이 서로 다른 값들이 어떻게 연관되어 있고 이 값들의 목적은 무엇인지를 더 쉽게 이해하도록 만들어 줄 것입니다.

예제 11-6은 parse_config 함수에 대한 개선을 보여줍니다.

예제 11-6: Config 구조체의 인스턴스를 반환하도록 하는 parse_config 리팩터링
src/main.rs
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 값들을 참조하는 문자열 슬라이스를 반환했는데, 이제는 String 값을 소유한 Config를 정의했습니다.

main 안에 있는 args 변수는 인수 값들의 소유자이고 parse_config 함수에게는 이 값을 빌려주고 있을 뿐인데, 이는 즉 Config가 args의 값에 대한 소유권을 가져가려고 하면 러스트의 대여 규칙을 위반하게 된다는 의미입니다.

String 데이터를 관리하는 방법은 다양하며, 가장 쉬운 방법은 (다소 비효율적이지만) 그 값에서 clone 메서드를 호출하는 것입니다.

이는 데이터의 전체 복사본을 만들어 Config 인스턴스가 소유할 수 있게 해주는데, 이는 문자열 데이터에 대한 참조자를 저장하는 것에 비해 더 많은 시간과 메모리를 소비합니다.

그러나 값의 복제는 참조자의 라이프타임을 관리할 필요가 없어지기 때문에 코드를 매우 직관적으로 만들어 주기도 하므로, 이러한 환경에서 약간의 성능을 포기하고 단순함을 얻는 것은 가치 있는 절충안입니다.


clone을 사용한 절충안

러스타시안들 중에서 많은 이들이 런타임 비용의 이유로 clone을 사용한 소유권 문제 해결을 회피하는 경향을 가지고 있습니다. 12장에서 이러한 종류의 상황에서 더 효율적인 메서드를 사용하는 방법을 배울 것입니다. 하지만 프로젝트를 계속 진행하기 위해 지금으로서는 약간의 문자열을 복사하는 정도는 괜찮은데, 이 복사가 딱 한 번만 일어나고 파일 경로와 질의 문자열이 매우 작기 때문입니다. 한 번에 매우 최적화된 코드 작성을 시도하기보다는 다소 비효율적이라도 동작하는 프로그램을 만드는 편이 좋습니다. 여러분이 러스트에 더 경험을 쌓게 되면 가장 효율적인 해답을 가지고 시작하기 더 쉽겠으나, 지금으로선 clone을 호출하는 것도 충분히 허용될 만합니다.

main을 업데이트하여 parse_config가 반환한 Config 인스턴스를 config라는 이름의 변수에 위치시켰고, 이전에 별개로 사용된 query와 file_path 대신 이제는 Config 구조체의 필드를 이용합니다.

이제 코드가 query와 file_path가 서로 연관되어 있고 이들의 목적이 프로그램의 동작 방법을 설정하기 위함임을 더 명료하게 전달합니다.

이러한 값을 사용하는 모든 코드는 config 인스턴스에서 목적에 맞게 명명된 필드 값을 찾을 수 있습니다.

Config를 위한 생성자 만들기

여기까지 해서 main으로부터 커맨드 라인 인수 파싱을 책임지는 로직을 추출하여 parse_config 함수에 위치시켰습니다.

그렇게 하면 query와 file_path 값이 연관되어 있고 이 관계가 코드로부터 전달된다는 것을 알기 쉽게 해주었습니다.

그다음 query와 file_path의 목적에 연관된 이름을 갖고 parse_config 함수로부터 반환되는 값을 구조체 필드 값이 되도록 하기 위해 Config 구조체를 추가하였습니다.

따라서 이제 parse_config 함수의 목적이 Config 인스턴스를 생성하는 것이 되었으므로, parse_config를 일반 함수에서 Config 구조체와 연관된 new라는 이름의 함수로 바꿀 수 있습니다.

이러한 변경이 코드를 더 자연스럽게 만들어 줄 것입니다.

String 같은 표준 라이브러리 타입의 인스턴스 생성은 String::new를 호출하는 것으로 할 수 있습니다.

비슷하게 parse_config를 Config와 연관된 함수 new로 변경함으로써 Config 인스턴스의 생성을 Config::new의 호출로 할 수 있을 것입니다.

예제 11-7은 이를 위한 변경점을 보여줍니다.

예제 11-7: parse_config를 Config::new로 변경하기
src/main.rs
fn main() {
    let args: Vec<String> = env::args().collect();

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

    // --생략--
}

// --생략--

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

        Config { query, file_path }
    }
}

원래 parse_config를 호출하고 있던 main 부분을 Config::new 호출로 바꿨습니다.

parse_config의 이름은 new로 변경되었고 impl 블록에 옮겨졌는데, 이것이 Config와 new 함수를 연관시켜 줍니다.

이 코드를 다시 한번 컴파일하여 잘 동작하는지 확인하세요.


에러 처리 수정

이제부터는 에러 처리 기능을 수정할 겁니다.

args 벡터에 3개보다 적은 아이템이 들어있는 경우에는 인덱스 1이나 2의 값에 접근을 시도하는 것이 프로그램의 패닉을 일으킬 것이라는 점을 상기합시다.

아무런 인수 없이 프로그램을 실행해 보세요; 아래처럼 나올 것입니다.

$ 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

index out of bounds: the len is 1 but the index is 1 줄은 프로그래머를 위한 에러 메시지입니다.

최종 사용자들에게는 대신 어떤 행동을 해야 하는지 알려 주는 데 도움이 되지 않을 것입니다.

이제 수정해봅시다.

에러 메시지 개선

예제 11-8에서는 인덱스 1과 2에 접근하기 전에 슬라이스의 길이가 충분한지 검증하는 기능을 new 함수에 추가했습니다.

만일 슬라이스가 충분히 길지 않다면, 프로그램은 패닉을 일으키고 더 나은 에러 메시지를 보여줍니다.

예제 11-8: 인수의 개수 검사 추가
src/main.rs
    // --생략--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --생략--

이 코드는 예제 8-13에서 작성했었던 Guess::new 함수와 비슷한데, 거기서는 value 인수가 유효한 값의 범위 밖인 경우 panic!을 호출했었지요.

여기서는 값의 범위를 검사하는 대신, args의 길이가 최소 3이고 이 조건을 만족하는 가정 아래에서 함수의 나머지 부분이 동작할 수 있음을 검사하고 있습니다.

만일 args가 아이템을 세 개보다 적게 가지고 있다면 이 조건은 참이 되고, panic! 매크로를 호출하여 프로그램을 즉시 종료시킵니다.

new에 이렇게 몇 줄을 추가한 다음, 다시 한번 아무 인수 없이 프로그램을 실행하여 이제 에러가 어떤 식으로 보이는지 살펴봅시다.

$ 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

이번 출력이 더 좋습니다.

이제는 적절한 에러 메시지가 되었습니다.

하지만 사용자들에게 제공할 필요 없는 추가적인 정보도 제공하고 있습니다.

어쩌면 예제 8-13에서 사용했던 기술을 여기에 써먹는 것이 최선은 아닌가 봅니다.

8장에서 얘기한 것처럼 panic!을 호출하는 것은 사용의 문제보다는 프로그램의 문제에 더 적합합니다.

대신에 여러분이 8장에서 배웠던 다른 기술, 즉 성공인지 혹은 에러인지를 나타내는 Result를 반환하는 기술을 사용해보겠습니다.

panic! 호출 대신 Result 반환하기

성공한 경우에는 Config를 담고 있고 에러가 난 경우에는 문제를 설명해줄 Result 값을 반환시킬 수 있습니다.

또한 new라는 함수 이름은 build로 변경할 것인데, 이는 많은 프로그래머가 new 함수가 절대 실패하지 않으리라 예상하기 때문입니다.

Config::build가 main과 소통하고 있을 때 Result 타입을 사용하여 문제에 대한 신호를 줄 수 있습니다.

그러면 main을 수정하여 Err 배리언트를 사용자에게 더 실용적인 에러 메시지로 변경할 수 있고, 이는 panic!의 호출로 인한 thread 'main'과 RUST_BACKTRACE에 대해 감싸져 있는 텍스트를 없앨 수 있습니다.

예제 11-9는 이제 Config::build라고 하는 함수의 반환 값과 Result를 반환할 필요가 있는 함수 본문을 위해서 필요한 변경점을 보여줍니다.

main도 마찬가지로 수정하지 않으면 컴파일되지 않는다는 점을 유의하세요.

이건 다음에 하겠습니다.

예제 11-9: Config::build로부터 Result 반환하기
src/main.rs
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를, 에러가 난 경우 &'static str을 갖는 Result를 반환합니다.

에러 값은 언제나 'static 라이프타임을 갖는 문자열 리터럴일 것입니다.

함수 본문에는 두 가지 변경점이 있었습니다.

사용자가 충분한 인수를 넘기지 않았을 때 panic!을 호출하는 대신 이제 Err 값을 반환하며, 반환 값 Config를 Ok로 감쌌습니다.

이러한 변경으로 함수의 본문이 새로운 타입 시그니처에 맞게 됩니다.

Config::build로부터 Err 값을 반환하는 것은 main 함수가 build 함수로부터 반환된 Result 값을 처리하여 에러가 난 경우 프로세스를 더 깔끔하게 종료하도록 해줍니다.

Config::build 호출과 에러 처리

에러가 발생한 경우를 처리하여 사용자 친화적인 메시지를 출력하기 위해서는, 예제 11-10처럼 main을 수정하여 Config::build에 의해 반환되는 Result를 처리할 필요가 있습니다.

또한 panic!으로부터 벗어나서 직접 0이 아닌 에러 코드로 커맨드 라인 도구를 종료하도록 구현할 것입니다.

0이 아닌 종료 상태값은 프로그램을 호출한 프로세스에게 에러 상태값과 함께 종료되었음을 알려주는 관례입니다.

예제 11-10: Config 생성이 실패했을 경우 에러 코드와 함께 종료하기
src/main.rs
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의 인수로 넘겨준 우리가 정의한 익명 함수입니다.

클로저에 대해서는 12장에서 더 자세히 다루겠습니다.

지금은 그저 unwrap_or_else가 Err의 내부 값을 클로저의 세로 파이프(|) 사이에 있는 err 인수로 넘겨주는데, 이번 경우 그 값은 예제 11-9에 추가한 정적 문자열 "not enough arguments"이라는 정도만 알면 됩니다.

그러면 실행했을 때 클로저 내의 코드가 err 값을 사용할 수 있게 됩니다.

새로 추가된 use 줄은 표준 라이브러리로부터 process를 스코프 안으로 가져옵니다.

에러가 난 경우 실행될 클로저 내의 코드는 딱 두 줄입니다.

err 값을 출력한 다음 process::exit를 호출하는 것이지요.

process::exit 함수는 프로그램을 즉시 멈추고 넘겨진 숫자를 종료 상태 코드로서 반환하게 될 것입니다.

이는 예제 11-8에서 사용했던 panic! 기반의 처리와 비슷하지만, 이제는 추가 출력문들이 사라지게 됩니다.

unwrap_or_else는 성공하면 Config를 꺼내고, 오류라면 클로저를 호출합니다. 보통 이 클로저도 같은 타입의 대체값을 내놓아야 하지만, 여기서는 process::exit(1)이 호출자에게 돌아오지 않아 대체 Config를 만들지 않습니다. 이 함수의 반환 타입 !는 ‘반환하지 않음’을 뜻하며, 뒤의 코드가 오류 경로에서 실행되는 일은 없습니다. 종료 상태 1은 러스트 호출자에게 반환되는 Config 값이 아니라 프로세스를 실행한 쪽에 전달되는 상태입니다.

한번 시도해봅시다.

$ 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 함수에 있는 로직 중 설정 값이나 에러 처리와는 관련되지 않은 모든 로직을 run이라는 함수로 추출하도록 하겠습니다.

그렇게 하고 나면 main은 간결하고 검사하기 쉬워질 것이며, 나머지 모든 로직에 대한 테스트를 작성할 수 있게 될 것입니다.

예제 11-11은 추출된 run 함수를 보여줍니다.

지금은 함수 추출에 대한 작고 점진적인 개선만 하고 있습니다.

여전히 함수는 src/main.rs에 정의되어 있습니다.

예제 11-11: 나머지 프로그램 로직을 담는 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 함수로 분리된 남은 프로그램 로직에 대하여, 예제 11-9에서 Config::build에 했던 것처럼 에러 처리 기능을 개선할 수 있습니다.

run 함수는 뭔가 잘못되면 expect를 호출하여 프로그램이 패닉이 되도록 하는 대신 Result<T, E>를 반환할 것입니다.

이를 통해 에러 처리에 관한 로직을 사용자 친화적인 방식으로 main 안에 더욱 통합시킬 수 있습니다.

예제 11-12는 run의 시그니처와 본문에 필요한 변경점을 보여줍니다.

예제 11-12: run 함수가 Result를 반환하도록 변경하기
src/main.rs
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인 경우에 반환될 값으로서 계속 유지하고 있습니다.

에러 타입에 대해서는 트레이트 객체 Box<dyn Error>를 사용했습니다(그리고 상단에 use 구문을 사용하여 std::error::Error를 스코프로 가져왔습니다).

트레이트 객체에 대해서는 16장에서 다룰 예정입니다.

함수 전체의 반환 타입은 여전히 Result<(), Box<dyn Error>>입니다. 성공하면 Ok(())를 반환하고, 실패하면 Err 안에 Error 트레이트를 구현하는 구체 오류를 담은 Box<dyn Error>가 들어갑니다. dyn Error는 그 오류의 구체 타입 이름 대신 공통 트레이트를 통해 다루게 합니다.

이는 서로 다른 에러의 경우에서 서로 다른 타입이 될지도 모를 에러값을 반환하는 유연성을 제공합니다.

dyn 키워드는 ‘동적(dynamic)’의 줄임말입니다.

두 번째로 8장에서 이야기했던 ? 연산자를 활용하여 expect의 호출을 제거했습니다.

?는 에러 상황에서 panic! 대신 호출하는 쪽이 처리할 수 있도록 현재의 함수로부터 에러 값을 반환할 것입니다.

이 호출에서 Ok(String)이면 안의 문자열을 contents에 넣고 다음 줄로 진행합니다. Err(std::io::Error)이면 ?가 오류를 run의 오류 타입인 Box<dyn Error>로 변환하여 즉시 반환합니다. 따라서 실패 경로에서는 With text: 출력과 마지막 Ok(())가 실행되지 않습니다.

세 번째로 run 함수는 이제부터 성공한 경우 Ok 값을 반환합니다.

run 함수의 성공 타입은 시그니처 상에서 ()로 선언되었는데, 이는 유닛 타입 값을 Ok 값으로 감쌀 필요가 있다는 의미입니다.

이 Ok(()) 문법은 처음엔 좀 이상해 보일지도 모릅니다만, 이렇게 ()를 사용하는 것은 run을 호출하여 부작용에 대해서만 처리하겠다는 것을 가리키는 자연스러운 방식입니다; 즉 반환 값이 필요 없는 경우입니다.

이 코드를 실행시키면, 컴파일은 되지만 다음과 같은 경고가 나타날 것입니다.

$ 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

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished dev [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으로부터 반환된 에러 처리하기

이제 예제 11-10의 Config::build에 사용했던 것과 비슷한 기술을 사용하여 에러를 검사하고 이를 처리해 볼 것인데, 약간 다른 점이 있습니다.

src/main.rs
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);
    }
}

run이 Err 값을 반환했는지 검사하고 만일 그렇다면 process::exit(1)을 호출하기 위해 사용한 unwrap_or_else 대신 if let이 사용되었습니다.

run 함수가 반환한 값은 Config 인스턴스를 반환하는 Config::build와 동일한 방식대로 unwrap을 하지 않아도 됩니다.

run이 성공한 경우의 반환값은 Ok(())입니다. 여기서는 안쪽의 ()를 꺼내 쓸 필요 없이 오류 유무만 확인하면 되므로, unwrap_or_else 대신 if let Err(e)로 실패 경로만 처리합니다.

if let과 unwrap_or_else 함수의 본문은 둘 모두 동일합니다.

즉, 에러를 출력하고 종료합니다.


라이브러리 크레이트로 코드 쪼개기

여기까지의 minigrep 프로젝트는 괜찮아 보이는군요!

이제 src/main.rs 파일을 쪼개어 코드 일부를 src/lib.rs 파일에 넣을 것입니다.

그렇게 하여 코드를 테스트할 수 있고 src/main.rs 파일의 책임 범위를 줄일 수 있습니다.

main 함수가 아닌 모든 코드를 src/main.rs에서 src/lib.rs로 옮깁시다.

  • run 함수 정의 부분
  • 이와 관련된 use 구문들
  • Config 정의 부분
  • Config::build 함수 정의 부분

src/lib.rs의 내용은 예제 11-13과 같은 시그니처를 가지고 있어야 합니다. (간결성을 위해 함수의 본문은 생략하였습니다.)

src/main.rs를 예제 11-14처럼 수정하기 전까지는 컴파일이 되지 않음을 유의하세요.

예제 11-13: Config와 run을 src/lib.rs 안으로 옮기기
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> {
        // --생략--
    }
}

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

pub 키워드를 자유롭게 사용했습니다.

Config와 이 구조체의 각 필드 및 build 메서드, 그리고 run 함수에 대해 사용했지요.

이제 우리는 테스트해 볼 수 있는 공개 API를 갖춘 라이브러리 크레이트를 가지게 되었습니다!

이제는 예제 11-14처럼 src/lib.rs로 옮겨진 코드를 src/main.rs 내의 바이너리 크레이트 스코프 쪽으로 가져올 필요가 생겼습니다.

예제 11-14: src/main.rs에서 minigrep 라이브러리 크레이트 사용하기
src/main.rs
use std::env;
use std::process;

use minigrep::Config;

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

use minigrep::Config 줄을 추가하여 라이브러리 크레이트로부터 바이너리 크레이트 스코프로 Config 타입을 가져오고, run 함수 앞에는 크레이트 이름을 붙였습니다.

이제 모든 기능이 연결되어 동작해야 합니다.

cargo run으로 프로그램을 실행하여 모든 것이 정상적으로 동작하는지 확인하세요.

작업량은 많았지만, 이제 에러 처리와 모듈화의 토대가 마련되었습니다.

이제 에러를 처리하기도 훨씬 쉽고, 코드도 훨씬 모듈화되었습니다.

이제부터는 거의 모든 작업이 src/lib.rs 내에서 완료될 것입니다.

이전 코드에서는 어려웠지만 새 코드에서는 쉬운 작업을 수행하여 이 새로운 모듈성의 이점을 활용해봅시다.

테스트를 작성해보겠습니다!

예시 발췌들을 연결할 때는 main이 입력과 종료 정책을 맡고, 라이브러리는 값을 만들거나 오류를 반환한다는 경계를 유지합니다. 이 절 끝의 상태만 재현하려면 이름이 minigrep인 패키지의 두 파일을 다음과 같이 둘 수 있습니다. 다음 절의 검색과 그다음 절의 ignore_case 필드는 아직 넣지 않습니다.

이 절 끝의 완결된 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");
        }
        Ok(Config {
            query: args[1].clone(),
            file_path: args[2].clone(),
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;
    println!("With text:\n{contents}");
    Ok(())
}
이 절 끝의 완결된 src/main.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(err) = minigrep::run(config) {
        println!("Application error: {err}");
        process::exit(1);
    }
}

Config::build는 슬라이스를 빌려 두 문자열을 복제하므로 설정이 인수 벡터의 임시 참조를 담지 않습니다. 여기서 길이 조건은 최소 개수만 확인하여 여분의 인수는 허용합니다. run의 ?는 파일 읽기 오류를 반환하며, 라이브러리 자체가 종료 상태 1을 정하지 않습니다. 이 main이 오류를 출력하고 종료를 선택합니다. 오류도 아직 println!으로 출력하며 표준 에러로 옮기는 일은 마지막 절의 별도 변경입니다.

독립 연습: 인수 벡터가 실행 이름·needle·존재하지 않는 경로·extra의 네 문자열이라고 합시다. Config::build가 성공하는지, run이 어디서 반환하는지, 위 main에서 어느 메시지가 어느 스트림으로 나가는지와 종료 상태를 순서대로 적어 보세요. 같은 run을 다른 호출자가 쓰면 오류에서 반드시 프로세스를 종료해야 하는지도 판단하세요.

오류 반환과 프로그램 종료의 담당 구별하기

길이 4는 최소 조건을 통과하며 검색어와 경로만 설정에 담습니다. main이 검색어·경로를 먼저 출력한 뒤, run의 파일 읽기가 Err를 반환하여 With text:에는 도달하지 않습니다. 호출자가 Application error를 출력하고 상태 1로 종료합니다. 이 단계의 세 메시지는 모두 표준 출력입니다. 라이브러리의 반환값을 받은 다른 호출자는 다시 시도하거나 사용자에게 경로를 요청할 수 있으며 종료 정책은 그 호출자의 책임입니다. 실행 이름만 있는 입력이라면 설정 생성에서 먼저 실패하여 검색어·경로 출력에도 도달하지 않습니다.


미니 grep 리팩터링 경계

I/O 프로젝트 리팩터링은 인자 파싱, 설정 생성, 파일 읽기, 검색 실행, 출력 책임을 분리할수록 테스트하기 쉬워집니다.


모듈성과 에러 처리 향상을 위한 리팩터링 학습 판단 기준표

이 다이어그램은 모듈성과 에러 처리 향상을 위한 리팩터링 내용을 예제 코드에 적용할 때 먼저 볼 조건과 나중에 검증할 조건을 나눕니다.