Result와 에러 처리

Rust에서 에러 처리는 단순히 오류를 보고하고 종료하는 것에 그치지 않고, 프로그램 전체의 안정성, 안전성, 복구 가능성 등을 포괄적으로 관리하기 위한 기제로 활용된다. 이때 가장 핵심적인 역할을 하는 것이 바로 표준 라이브러리에 정의된 Result 열거형이다. Result<T, E>는 정상적인 값 T와 오류 정보를 담는 E 중 어느 한 쪽을 표현할 수 있는 열거형으로, 정상 동작이든 오류 상황이든 명시적으로 값을 다루도록 강제함으로써 프로그램의 안정성을 높인다.

Result<T, E>는 두 가지 변형인 Ok(T)와 Err(E)를 가진다. Ok(T)는 T 타입의 정상적인 값이 성공적으로 생성되었음을 나타내며, Err(E)는 오류 상황이 발생했음을 의미한다. 예를 들어 파일을 여는 함수는 성공 시 Ok(File)을 반환하거나, 오류가 발생하면 Err(std::io::Error)와 같은 형태로 오류 정보를 담아 반환한다. 이러한 메커니즘을 통해 함수 호출자는 결과가 정상값인지 오류값인지 확실히 구분할 수 있으며, 이에 따른 분기 처리도 명확해진다.

Result는 함수에서 반환값으로 자주 쓰이며, 특히 표준 라이브러리나 서드 파티 라이브러리의 I/O 함수, 파싱 함수 등이 많이 활용한다. 반환된 Result를 다루는 방법은 크게 두 가지로 나뉜다. 하나는 match 구문을 통해 직접 패턴 매칭하여 Ok일 때와 Err일 때를 구분하는 방법이고, 다른 하나는 ? 연산자를 이용해 에러 전파를 간결하게 처리하는 것이다.

match 구문을 사용하면 직접 Ok와 Err를 매칭할 수 있다. 예시는 다음과 같다.

fn open_and_read_file(path: &str) -> Result<String, std::io::Error> {
    let file = std::fs::File::open(path);
    match file {
        Ok(mut f) => {
            let mut contents = String::new();
            f.read_to_string(&mut contents)?;
            Ok(contents)
        },
        Err(e) => Err(e),
    }
}

match 구문을 쓰면 모든 케이스를 완전히 기술하게 되므로, 정상적 상황과 오류 상황이 어떻게 처리되는지 명확히 드러난다. 하지만 매번 match 구문으로 분기처리를 작성하면 다소 번거롭고, Result를 다른 함수에 전파하고 싶을 때는 패턴 매칭이 장황해질 수 있다. 이런 상황에서 유용하게 쓰이는 연산자가 바로 ?이다.

? 연산자는 Result 타입을 만나면 내부적으로 match 구문과 매우 유사한 처리를 수행한다. Ok(T)를 만나면 T를 추출해서 다음 로직으로 넘기고, Err(E)를 만나면 현재 함수를 즉시 종료하며 Err(E)를 반환한다. 위 예시 코드를 ? 연산자를 사용해 간단히 표현하면 다음과 같다.

fn open_and_read_file(path: &str) -> Result<String, std::io::Error> {
    let mut f = std::fs::File::open(path)?;
    let mut contents = String::new();
    f.read_to_string(&mut contents)?;
    Ok(contents)
}

Result를 다룰 때 자주 사용하는 메서드로 unwrap, expect, map, and_then 등이 있다. unwrap은 Ok(T)에서 T 값을 꺼내지만, 오류 상황이면 즉시 패닉을 발생시킨다. 따라서 사용자가 해당 상황을 어떻게 처리할지 직접 관장해야 하는 경우에는 적절하지 않을 수 있다. 이에 비해 expect는 unwrap과 유사하지만, 에러 메시지를 커스텀하여 좀 더 친절하게 오류 정보를 제공할 수 있다. 둘 다 오류 발생 시 패닉을 유발하므로, 실제 프로덕션 코드에서는 가급적 ? 연산자나 명시적인 match로 처리하는 것이 바람직하다.

map과 and_then은 Result가 가지고 있는 고차 함수 메서드로, 성공적인 Ok 값에 대해서만 연산을 수행하고, Err 상황이면 그대로 Err를 반환한다. 예컨대 Ok 안에 들어 있는 값을 변환하거나 다른 Result와 연쇄적으로 연결할 때 유용하다. map은 T를 다른 타입으로 매핑하는 데 사용하고, and_then은 Ok(T)를 받으면 또 다른 Result로 매핑하는 함수를 호출하여 Result 끼리 연쇄적 처리를 가능하게 한다.

에러의 구체적인 타입 E 역시 다양한 방식으로 정의할 수 있다. std::io::Error나 std::num::ParseIntError 등 표준 라이브러리에 이미 정의된 에러 타입을 사용할 수도 있고, 상황에 따라서는 직접 사용자 정의 에러 타입을 만들 수도 있다. 사용자 정의 에러 타입을 만들 때는 표준 트레이트인 std::error::Error를 구현해주는 것이 일반적이며, 이 과정을 간편하게 해주는 서드 파티 크레이트로 thiserror나 anyhow 등이 있다. theseerror는 주로 라이브러리 개발자가 구체적인 에러 타입을 정의하기에 편리하고, anyhow는 애플리케이션 내에서 간단하고 유연하게 에러를 처리·전파하기에 편리하도록 고안되었다.

Result를 반환하는 함수는 호출자에게 오류를 복구하거나 적절히 대처할 기회를 제공한다. Rust는 이 과정을 언어 차원에서 강제함으로써 에러 처리 로직을 누락하는 것을 방지한다. Option과는 달리 “왜” 값이 없거나 실패했는지를 나타내는 추가적인 정보가 Err 타입에 함께 담길 수 있으므로, 에러 디버깅과 로깅에 훨씬 유리하다.

프로그램이 정상적으로 동작하든 에러가 발생하든, Rust는 Result라는 명시적 메커니즘을 통해 각 상황을 정확하게 표현하도록 유도한다. Result의 패턴 매칭, ? 연산자, unwrap/expect, map/and_then 메서드, 그리고 사용자 정의 에러 타입을 통해 훨씬 견고하고 유지보수하기 쉬운 코드를 작성할 수 있다. 무엇보다 이러한 방식은 에러가 발생해도 프로그램이 곧장 패닉을 일으키기보다는, 상황에 맞추어 오류를 처리하고 필요하다면 안전하게 전파할 수 있도록 해준다. Rust가 추구하는 안전성과 명시적인 코드 작성 패턴의 핵심이 바로 이 Result와 에러 처리 방식에 담겨 있다고 해도 과언이 아니다.

Last updated