모듈성과 오류 처리를 개선하는 리팩터링

모듈성과 오류 처리를 개선하는 리팩터링

프로그램을 개선하기 위해, 구조와 오류 처리 방식에 관련된 문제 네 가지를 고칠게요. 첫째, 지금 main 함수는 두 가지 일을 해요. 인자를 파싱하고 파일을 읽죠. 프로그램이 커질수록 main이 맡는 별도의 작업 수는 늘어나요. 함수의 책임이 늘어날수록 그 함수를 추론하기 어려워지고, 테스트하기도 어려워지며, 일부만 건드려도 깨뜨리지 않고 바꾸기 어려워집니다. 각 함수가 하나의 일만 담당하도록 기능을 분리하는 게 가장 좋아요.

이 문제는 두 번째 문제와도 연결돼요. queryfile_path는 프로그램의 구성(configuration) 변수인 반면, contents 같은 변수는 프로그램의 로직을 수행하는 데 사용됩니다. main이 길어질수록 스코프로 불러와야 할 변수가 늘어나고, 스코프에 변수가 많아질수록 각각의 목적을 추적하기가 어려워집니다. 구성 변수들을 하나의 구조체로 묶어 그 목적을 분명히 하는 게 가장 좋아요.

세 번째 문제는 파일 읽기가 실패했을 때 오류 메시지를 출력하라고 expect를 썼는데, 그 메시지가 그저 Should have been able to read the file이라고만 출력한다는 거예요. 파일 읽기는 여러 방식으로 실패할 수 있습니다. 예를 들어 파일이 없거나, 열 권한이 없을 수 있죠. 지금은 상황과 무관하게 모든 경우에 똑같은 오류 메시지를 출력하니, 사용자에게 아무 정보도 주지 못해요!

넷째, 오류를 처리하는 데 expect를 쓰는데, 사용자가 충분한 인자를 지정하지 않고 프로그램을 실행하면 Rust가 발생시키는 index out of bounds 오류가 나와서 문제를 명확히 설명하지 못해요. 오류 처리 로직을 바꿔야 할 경우 미래의 유지보수자가 한 곳만 참조하면 되도록, 모든 오류 처리 코드를 한 곳에 모아 두는 게 가장 좋습니다. 오류 처리 코드를 한 곳에 모으면 최종 사용자에게 의미 있는 메시지를 출력한다는 것도 보장돼요.

이 네 가지 문제를 프로젝트 리팩터링으로 해결해 볼게요.

출처: The Rust Book

바이너리 프로젝트의 관심사 분리

main 함수에 여러 작업의 책임을 몰아두는 조직적 문제는 많은 바이너리 프로젝트에서 흔해요. 그래서 많은 Rust 프로그래머는 main 함수가 커지기 시작하면 바이너리 프로그램의 관심사를 분리하는 게 유용하다고 생각합니다. 이 과정은 다음 단계를 따릅니다:

  • 프로그램을 main.rs 파일과 lib.rs 파일로 나누고, 프로그램의 로직을 lib.rs로 옮긴다.
  • 명령줄 파싱 로직이 작은 동안은 main 함수에 남겨 둘 수 있다.
  • 명령줄 파싱 로직이 복잡해지기 시작하면, main 함수에서 꺼내 다른 함수나 타입으로 추출한다.

이 과정을 거친 뒤 main 함수에 남는 책임은 다음으로 제한해야 해요:

  • 인자 값으로 명령줄 파싱 로직을 호출한다
  • 그 밖의 구성을 세팅한다
  • lib.rsrun 함수를 호출한다
  • run이 오류를 반환하면 그 오류를 처리한다

이 패턴은 관심사 분리에 관한 것입니다. main.rs는 프로그램 실행을 담당하고 lib.rs는 당면한 작업의 모든 로직을 담당해요. main 함수는 직접 테스트할 수 없으니, 이 구조를 쓰면 프로그램의 모든 로직을 main에서 꺼내 테스트할 수 있습니다. main에 남는 코드는 읽어서 정확성을 검증할 수 있을 만큼 작아져요. 이 과정을 따라 프로그램을 다시 만들어 볼게요.

인자 파서 추출하기

인자를 파싱하는 기능을 main이 호출할 함수로 추출할게요. Listing 12-5는 src/main.rs에 정의할 새 함수 parse_config를 호출하는 main 함수의 새로운 시작 부분입니다.

파일명: src/main.rs

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)
}

Listing 12-5: main에서 parse_config 함수 추출하기

여전히 명령줄 인자를 벡터로 모으지만, main 함수 안에서 인덱스 1의 값을 query 변수에, 인덱스 2의 값을 file_path 변수에 할당하는 대신 벡터 전체를 parse_config 함수로 넘겨요. 그러면 parse_config 함수가 어떤 인자를 어떤 변수에 넣을지 결정하는 로직을 쥐고, 값을 main으로 돌려줍니다. queryfile_path 변수는 여전히 main에서 만들지만, main은 더 이상 명령줄 인자와 변수가 어떻게 대응하는지 결정할 책임이 없어요.

작은 프로그램치고 과한 것처럼 보일 수도 있는데, 우리는 작은 단계로 조금씩 리팩터링하고 있답니다. 이 변경을 하고 난 뒤 프로그램을 다시 실행해서 인자 파싱이 여전히 동작하는지 확인하세요. 진행 상황을 자주 확인하는 게 문제가 생겼을 때 원인을 짚는 데 도움이 돼요.

구성 값 묶기

parse_config 함수를 더 개선할 작은 걸음을 하나 더 뗄 수 있어요. 지금은 튜플을 반환하고 있는데, 곧바로 그 튜플을 다시 낱개로 쪼개고 있죠. 이는 아직 올바른 추상화를 찾지 못했다는 신호입니다.

개선 여지가 있음을 보여주는 또 다른 신호는 parse_configconfig 부분이에요. 이 이름은 우리가 반환하는 두 값이 서로 연관되어 있고 둘 다 하나의 구성 값의 일부라는 것을 암시합니다. 지금은 두 값을 튜플로 묶은 것 외에는 데이터 구조에 이 의미를 전달하지 않고 있어요. 대신 두 값을 하나의 구조체에 넣고 각 필드에 의미 있는 이름을 붙이면, 이 코드의 미래 유지보수자가 서로 다른 값들이 어떻게 연관되는지, 각각의 목적이 무엇인지 이해하기 쉬워집니다.

Listing 12-6은 parse_config 함수의 개선 사항을 보여줘요.

파일명: src/main.rs

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 }
}

Listing 12-6: parse_configConfig 구조체의 인스턴스를 반환하도록 리팩터링

queryfile_path라는 필드를 가진 Config라는 구조체를 추가했어요. 이제 parse_config의 시그니처는 Config 값을 반환한다고 나타냅니다. parse_config의 본문에서 이전에는 argsString 값을 참조하는 문자열 슬라이스를 반환했지만, 이제는 Config가 소유한 String 값을 담도록 정의했어요. mainargs 변수는 인자 값의 소유자이며 parse_config 함수에 그것을 빌려줄 뿐이라, Configargs의 값을 소유하려고 하면 Rust의 빌림 규칙을 위반하게 됩니다.

String 데이터를 관리하는 방법은 여러 가지가 있는데, 가장 쉬운(다소 비효율적이지만) 길은 값에 clone 메서드를 호출하는 거예요. 이렇게 하면 Config 인스턴스가 소유할 데이터의 전체 복사본이 생겨서, 문자열 데이터의 참조를 저장하는 것보다 시간과 메모리를 더 쓰게 됩니다. 하지만 데이터를 복제하면 참조의 라이프타임을 관리할 필요가 없어져 코드가 아주 단순해져요. 이 상황에서는 성능을 조금 내주는 대신 단순함을 얻는 게 충분히 가치 있는 거래입니다.

clone 사용의 트레이드오프

많은 Rust 사용자가 소유권 문제를 고치려고 clone을 쓰는 걸 꺼리는 경향이 있는데, 실행 시간 비용이 들기 때문이에요. 13장에서 이런 상황에 더 효율적인 방법을 쓰는 법을 배우게 될 거예요. 하지만 지금은 문자열 몇 개를 복사해서 진행하는 것도 괜찮아요. 이 복사는 한 번만 하면 되고, 파일 경로와 쿼리 문자열은 아주 짧으니까요. 첫 시도부터 지나치게 최적화하려고 하기보다는, 조금 비효율적이어도 동작하는 프로그램을 가지는 게 나아요. Rust에 익숙해질수록 처음부터 가장 효율적인 해법으로 시작하는 게 쉬워지겠지만, 지금으로서는 clone을 호출하는 게 전적으로 허용됩니다.

parse_config가 반환한 Config 인스턴스를 config라는 변수에 담도록 main을 갱신했고, 이전에 따로 쓰던 queryfile_path 변수를 사용하던 코드들을 Config 구조체의 필드를 쓰도록 바꿨어요.

이제 코드가 queryfile_path가 서로 연관되어 있고, 그 목적은 프로그램이 어떻게 동작할지 구성하는 것임을 더 분명하게 전달합니다. 이 값들을 쓰는 코드는 어디서든 config 인스턴스에서 목적에 맞는 이름의 필드를 찾으면 됩니다.

Config 생성자 만들기

지금까지 main에서 명령줄 인자를 파싱하는 로직을 꺼내 parse_config 함수에 넣었는데, 덕분에 queryfile_path 값이 연관되어 있고 그 관계를 코드에 전달해야 함을 알게 됐어요. 그다음 Config 구조체를 추가해 queryfile_path의 연관된 목적에 이름을 붙이고, parse_config 함수에서 그 값들의 이름을 구조체 필드 이름으로 반환할 수 있게 했습니다.

이제 parse_config 함수의 목적이 Config 인스턴스를 만드는 것이므로, parse_config를 평범한 함수에서 Config 구조체에 연관된 new라는 함수로 바꿀 수 있어요. 이렇게 바꾸면 코드가 더 관용적(idiomatic)이 됩니다. String 같은 표준 라이브러리의 타입 인스턴스는 String::new를 호출해 만들 수 있어요. 마찬가지로 parse_configConfig에 연관된 new 함수로 바꾸면, Config::new를 호출해 Config 인스턴스를 만들 수 있습니다. Listing 12-7이 필요한 변경 사항을 보여줘요.

파일명: 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}");

    // --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 }
    }
}

Listing 12-7: parse_configConfig::new로 바꾸기

parse_config를 호출하던 main을 대신 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' 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이라는 줄은 프로그래머를 위한 오류 메시지예요. 최종 사용자가 대신 무엇을 해야 할지는 알려주지 못합니다. 지금 고쳐 볼게요.

오류 메시지 개선

Listing 12-8에서는 new 함수에 인덱스 1과 2에 접근하기 전에 슬라이스가 충분히 긴지 확인하는 검사를 추가해요. 슬라이스가 충분히 길지 않으면 프로그램이 패닉을 일으키고 더 나은 오류 메시지를 보여줍니다.

파일명: 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 {
    // --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 }
    }
}

Listing 12-8: 인자 개수 확인 추가하기

이 코드는 Listing 9-13에서 value 인자가 유효한 값의 범위를 벗어났을 때 panic!을 호출했던 Guess::new 함수와 비슷해요. 여기서는 값의 범위를 확인하는 대신 args의 길이가 3 이상인지 확인하고, 함수의 나머지 부분은 이 조건이 충족됐다는 가정 아래 동작합니다. args에 항목이 세 개 미만이면 이 조건이 참이 되고, 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

이 출력은 훨씬 나아요. 이제 그럴듯한 오류 메시지가 나오거든요. 하지만 사용자에게 주고 싶지 않은 부수 정보도 여전히 있습니다. 아마 Listing 9-13에서 쓴 기법이 여기서는 최선이 아닐 거예요. panic! 호출은 9장에서 논의했듯 사용(usage) 문제보다는 프로그래밍 문제에 더 적절합니다. 대신 9장에서 배운 다른 기법, 즉 성공 또는 오류를 나타내는 Result를 반환하는 방법을 쓰겠습니다.

panic! 대신 Result 반환하기

대신 성공한 경우 Config 인스턴스를 담고 오류의 경우 문제를 설명하는 Result 값을 반환할 수 있어요. 또 함수 이름을 new에서 build로 바꿀 건데, 많은 프로그래머가 new 함수는 절대 실패하지 않을 거라 기대하기 때문입니다. Config::buildmain과 소통할 때 Result 타입으로 문제가 있음을 알릴 수 있어요. 그러면 mainpanic! 호출이 일으키는 thread 'main'RUST_BACKTRACE 같은 주변 텍스트 없이, Err 변형을 사용자에게 더 실용적인 오류로 바꿀 수 있습니다.

Listing 12-9는 이제 부르게 될 Config::build 함수의 반환값과, Result를 반환하기 위해 필요한 함수 본문의 변경 사항을 보여줘요. 이건 다음 listing에서 main을 갱신할 때까지는 컴파일되지 않는다는 점을 알아두세요.

파일명: 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 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 })
    }
}

Listing 12-9: Config::build에서 Result 반환하기

build 함수는 성공한 경우 Config 인스턴스를, 오류의 경우 문자열 리터럴을 담은 Result를 반환합니다. 오류 값은 항상 'static 라이프타임을 가진 문자열 리터럴이 될 거예요.

함수 본문에서 변경한 것은 두 가지입니다. 사용자가 충분한 인자를 넘기지 않았을 때 panic!을 호출하는 대신 이제 Err 값을 반환하고, Config 반환값을 Ok로 감쌌어요. 이 변경으로 함수가 새 타입 시그니처에 부합하게 됩니다.

Config::buildErr 값을 반환하면 main 함수가 build가 반환한 Result 값을 처리하고, 오류의 경우 더 깔끔하게 프로세스를 종료할 수 있게 돼요.

Config::build 호출과 오류 처리

오류 케이스를 처리하고 사용자 친화적인 메시지를 출력하려면, Listing 12-10처럼 Config::build가 반환하는 Result를 처리하도록 main을 갱신해야 합니다. 또 명령줄 도구를 0이 아닌 오류 코드로 종료시키는 책임을 panic!에서 떼어내 직접 구현할 거예요. 0이 아닌 종료 상태는 우리 프로그램을 호출한 프로세스에게 "프로그램이 오류 상태로 종료됐다"를 알리는 관례입니다.

파일명: src/main.rs

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 })
    }
}

Listing 12-10: Config 빌드가 실패하면 오류 코드로 종료하기

이 listing에서는 아직 자세히 다루지 않은 메서드인 unwrap_or_else를 사용했는데, 이는 표준 라이브러리가 Result<T, E>에 정의해 둔 것입니다. unwrap_or_else를 쓰면 panic!이 아닌 사용자 정의 오류 처리를 정의할 수 있어요. ResultOk 값이면 이 메서드의 동작은 unwrap과 비슷합니다. Ok가 감싼 내부 값을 반환하죠. 하지만 값이 Err이면, 이 메서드는 클로저 안의 코드를 호출합니다. 클로저는 우리가 정의해서 unwrap_or_else에 인자로 넘기는 익명 함수예요. 클로저에 대해서는 13장에서 더 자세히 다룰 거고, 지금은 unwrap_or_elseErr의 내부 값(이 경우 Listing 12-9에서 추가한 "not enough arguments"라는 정적 문자열)을 세로 막대 사이에 나타나는 err 인자로 우리 클로저에 넘긴다는 것만 알면 돼요. 그러면 클로저 안의 코드는 실행될 때 err 값을 사용할 수 있습니다.

process를 표준 라이브러리에서 스코프로 불러오는 use 줄을 새로 추가했어요. 오류 케이스에서 실행될 클로저 안의 코드는 두 줄뿐입니다. err 값을 출력하고 process::exit를 호출하죠. process::exit 함수는 프로그램을 즉시 멈추고 인자로 넘긴 숫자를 종료 상태 코드로 반환합니다. 이는 Listing 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 함수는 간결해져서 점검만으로도 쉽게 검증할 수 있고, 나머지 모든 로직에 대해 테스트를 작성할 수 있게 됩니다.

Listing 12-11은 run 함수를 추출하는 작고 점진적인 개선을 보여줘요.

파일명: src/main.rs

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 })
    }
}

Listing 12-11: 나머지 프로그램 로직을 담은 run 함수 추출하기

이제 run 함수는 파일을 읽는 것부터 시작하는 main의 나머지 로직을 모두 담아요. run 함수는 Config 인스턴스를 인자로 받습니다.

run에서 오류 반환하기

나머지 프로그램 로직을 run 함수로 분리했으니, Listing 12-9에서 Config::build로 했던 것처럼 오류 처리를 개선할 수 있어요. 프로그램이 expect를 호출해 패닉에 빠지게 두는 대신, run 함수는 뭔가 잘못되면 Result<T, E>를 반환하게 됩니다. 이러면 오류 처리 로직을 main에 사용자 친화적인 방식으로 더 통합할 수 있어요. Listing 12-12는 run의 시그니처와 본문에 필요한 변경 사항을 보여줘요.

파일명: src/main.rs

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 })
    }
}

Listing 12-12: run 함수가 Result를 반환하도록 바꾸기

여기서 크게 세 가지를 바꿨어요. 첫째, run 함수의 반환 타입을 Result<(), Box<dyn Error>>로 바꿨습니다. 이 함수는 이전에 유닛 타입 ()을 반환했는데, Ok 케이스에서 반환하는 값으로 유닛 타입을 유지해요.

오류 타입으로는 트레이트 객체 Box<dyn Error>를 사용했고(그리고 맨 위 use 문으로 std::error::Error를 스코프로 불러왔어요), 트레이트 객체는 18장에서 다룰 거예요. 지금은 Box<dyn Error>가 "함수가 Error 트레이트를 구현하는 어떤 타입을 반환한다"는 뜻이며, 반환값이 구체적으로 어떤 타입인지 지정할 필요는 없다는 것만 알면 돼요. 이렇게 하면 서로 다른 오류 케이스에서 서로 다른 타입의 오류 값을 반환할 수 있는 유연성이 생깁니다. dyn 키워드는 dynamic의 줄임말이에요.

둘째, 9장에서 이야기한 대로 expect 호출을 없애고 ? 연산자를 썼습니다. ?는 오류에 panic!하지 않고 현재 함수에서 오류 값을 반환해 호출자가 처리하게 합니다.

셋째, 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
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!

Rust가 우리 코드가 Result 값을 무시했고, 그 Result 값이 오류가 발생했음을 나타낼 수 있다고 알려줘요. 하지만 우리는 오류가 있었는지 확인하지 않고 있고, 컴파일러가 "여기에 오류 처리 코드가 있어야 하지 않았나요?"를 상기시키는 거예요. 이 문제를 지금 바로잡아 볼게요.

main에서 run이 반환한 오류 처리하기

Listing 12-10에서 Config::build에 썼던 것과 비슷하되 조금 다른 기법으로 오류를 확인하고 처리할게요:

파일명: 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 })
    }
}

runErr 값을 반환하는지 확인하고, 그렇다면 process::exit(1)을 호출하도록 unwrap_or_else 대신 if let을 사용합니다. run 함수는 Config::buildConfig 인스턴스를 반환하는 것처럼 unwrap하고 싶은 값을 반환하지 않아요. run은 성공 케이스에서 ()를 반환하므로 우리는 오류를 감지하는 것만 신경 쓰면 되고, unwrap_or_else가 풀어낸 값을 돌려줄 필요가 없습니다. 그 값은 그저 ()일 테니까요.

if letunwrap_or_else의 본문은 두 경우 모두 같아요. 오류를 출력하고 종료합니다.

코드를 라이브러리 크레이트로 나누기

minigrep 프로젝트가 지금까지 아주 잘 진행되고 있어요! 이제 src/main.rs 파일을 나눠서 일부 코드를 src/lib.rs 파일에 넣을게요. 그렇게 하면 코드를 테스트할 수 있고, 책임이 줄어든 src/main.rs를 가질 수 있습니다.

src/main.rs가 아니라 src/lib.rs에 텍스트 검색을 담당하는 코드를 정의해 두면, 우리(또는 minigrep 라이브러리를 쓰는 다른 누구든)가 검색 함수를 minigrep 바이너리보다 더 다양한 맥락에서 호출할 수 있어요.

먼저 Listing 12-13처럼 src/lib.rssearch 함수 시그니처를 정의하고, 본문은 unimplemented! 매크로를 호출하게 둘게요. 구현을 채울 때 시그니처를 더 자세히 설명할게요.

파일명: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

Listing 12-13: src/lib.rssearch 함수 정의하기

함수 정의에 pub 키워드를 써서 search를 우리 라이브러리 크레이트의 공개 API 일부로 지정했어요. 이제 바이너리 크레이트에서 쓸 수 있고 테스트할 수 있는 라이브러리 크레이트가 생겼습니다!

이제 Listing 12-14처럼 src/lib.rs에 정의된 코드를 src/main.rs의 바이너리 크레이트 스코프로 불러와서 호출해야 합니다.

파일명: src/main.rs

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(())
}

Listing 12-14: src/main.rs에서 minigrep 라이브러리 크레이트의 search 함수 사용하기

minigrep 라이브러리 크레이트의 search 함수를 바이너리 크레이트의 스코프로 불러오는 use minigrep::search 줄을 추가했어요. 그런 다음 run 함수에서 파일 내용을 그대로 출력하는 대신 search 함수를 호출하고 config.query 값과 contents를 인자로 넘깁니다. 그러면 runfor 루프로 search가 반환한 줄 중 쿼리와 일치하는 각 줄을 출력해요. 또한 이 기회에 main 함수 안에서 쿼리와 파일 경로를 출력하던 println! 호출을 제거해서, 우리 프로그램이 (오류가 없다면) 검색 결과만 출력하도록 하는 것도 좋습니다.

search 함수는 출력이 일어나기 전에 모든 결과를 반환할 벡터에 모은다는 점에 주목하세요. 이 구현은 큰 파일을 검색할 때 결과가 발견되는 대로 출력되지 않으므로 결과 표시가 느릴 수 있는데, 13장에서 반복자를 써서 고칠 수 있는 방법을 이야기할 거예요.

휴, 정말 많은 작업을 했지만 앞으로의 성공을 위한 발판을 마련했어요. 이제 오류를 처리하기 훨씬 쉬워졌고, 코드도 더 모듈화됐습니다. 이제부터 우리의 작업 대부분은 src/lib.rs에서 이루어질 거예요.

이 새로 얻은 모듈성을 활용해서, 이전 코드로는 어려웠지만 새 코드로는 쉬운 걸 해 볼게요. 바로 테스트를 작성하는 거예요!

더 알아보기 (Learn more)