오류 전파와 ? 연산자

에러 처리는 함수 내부에서 발생한 오류 상황을 상위 호출 스택으로 전달하는 과정을 포함한다. 이러한 과정을 Rust에서는 오류 전파(error propagation)라고 부른다. 오류 전파는 함수 내부에서 발생할 수 있는 에러를 자신의 책임이 아닌 상위 레벨에서 처리하도록 미루는 메커니즘을 말한다. Rust가 오류를 안전하게 전파할 수 있도록 제공하는 핵심 기능 중 하나가 바로 ? 연산자이다.

? 연산자는 현재 함수의 반환 타입이 Result 혹은 Option과 호환되는 경우에 사용 가능하다. Result를 반환하는 함수에서 ? 연산자를 사용하면, 내부에서 일어난 실패(Err)가 즉시 상위로 전파되어 함수를 종료시키며, 성공(Ok) 값인 경우에는 정상적으로 값을 추출하여 이후 로직에서 사용할 수 있도록 해준다. Option에서 ?를 사용하면 None이 나타난 순간 함수에서 즉시 None을 반환한다.

이 연산자는 매번 match 문을 작성하지 않고도 오류 처리를 간결하게 작성할 수 있게 해주는 문법적 편의 장치다. 즉, match 문으로 일일이 확인했던 과정을 자동화한 것과 비슷한 역할을 한다. 내부적으로는 아래와 유사한 동작을 수행한다.

match some_result {
    Ok(value) => value,
    Err(e) => return Err(e),
}

함수 내부에서 여러 단계의 함수 호출이 중첩되는 경우에도 ? 연산자를 지속적으로 사용할 수 있다. 예를 들어, 파일에서 데이터를 읽은 다음 숫자로 파싱하고, 그 숫자를 이용해 또 다른 연산을 수행하는 일련의 과정에서, 각각의 단계에서 언제든 Err가 발생하면 해당 오류가 즉시 상위로 전파된다. 따라서 ? 연산자를 적절히 활용하면 읽기 쉬운 흐름을 유지하면서, 에러 발생 시점에 빠르게 제어 흐름을 중단하고 반환값으로 오류를 전달할 수 있다.

함수 원형에서 Result<T, E>를 반환하도록 선언했다면, 내부에서 ? 연산자를 통해 발생한 오류는 즉시 return Err(...) 형태로 전파된다. 이 방식은 프로그램이 오류를 다룰 수 있는 안정적인 구조를 마련해 준다. 만약 호출부에서 다시 ? 연산자를 사용한다면, 또 한 번 상위로 오류가 전파되므로 궁극적으로는 최종 에러 처리 로직(예: main 함수에서 에러를 로깅하거나 사용자에게 메시지를 출력하는 등)까지 전달될 수 있다.

? 연산자를 사용할 때 주의할 점은, 현재 함수의 시그니처가 반드시 해당 에러 타입을 반환할 수 있도록 준비되어 있어야 한다는 것이다. 예를 들어, std::io 모듈의 오류를 반환할 가능성이 있는 작업을 ?로 처리하려면, 함수가 Result<T, std::io::Error> 또는 이와 호환되는 에러 타입을 반환해야 한다. 그렇지 않으면 컴파일 에러가 발생한다. 즉, 함수가 반환할 수 있는 에러 타입이 ? 연산자를 통해 발생할 수 있는 에러와 호환되어야 한다.

main 함수에서도 ? 연산자를 사용할 수 있지만, Rust 2018 에디션부터는 main 함수가 Result<(), E> 형태로 정의되어야 한다. 이렇게 하면 main 함수 내부의 다양한 작업에서 발생할 수 있는 에러를 ? 연산자를 통해 깔끔하게 전파하고, 최종적으로는 main 함수가 Result 타입을 반환함으로써 프로그램 전체에 적절한 에러 처리를 적용할 수 있다.

아래는 파일을 읽고 그 내용을 숫자로 파싱한 다음, 어떤 계산을 수행하는 예시 코드이다. 한 번의 잘못된 작업이라도 발생하면 Err 값을 반환하며 즉시 상위로 전파된다.

use std::fs::File;
use std::io::{self, Read};

fn read_and_calculate(path: &str) -> Result<i32, io::Error> {
    let mut file = File::open(path)?;  // 실패 시 즉시 함수가 Err(...)로 종료됨
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // 여기서도 실패 시 즉시 반환
    let number: i32 = contents.trim().parse().map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "파싱 실패"))?;
    Ok(number + 42)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result = read_and_calculate("data.txt")?;
    println!("결과: {}", result);
    Ok(())
}

위 예시는 다음과 같은 사실을 보여 준다. File::open 함수에서 에러가 발생하면, ? 연산자에 의해 그 즉시 Err(...)를 반환해 함수가 종료된다. 파일을 정상적으로 열었다면 read_to_string 함수에서 에러 여부를 확인할 차례가 된다. 여기서 에러가 발생하면 다시 ? 연산자에 의해 Err(...) 반환 후 즉시 함수 종료가 이루어진다. 마지막으로 parse 동작 과정에서 에러가 발생하면 map_err를 통해 io::Error로 변환한 뒤 다시 ? 연산자에 의해 전파한다. 모든 과정에서 에러가 없다면 최종 계산 값을 Ok(...)로 감싸 반환한다.

에러 전파와 ? 연산자 조합은 상위 레벨로 오류를 전달하기에 매우 편리하며, 명시적인 match 문보다 코드가 간결해진다. 하지만 모든 상황에서 ? 연산자를 사용하는 것이 항상 최선인 것은 아니다. 특정 지점에서 에러를 처리하고 복구하거나, 다른 형태로 변환해 전달하는 로직이 필요하다면, match 문이나 기타 에러 변환 도구를 활용해야 한다. 즉, ? 연산자는 “이 위치에서 발생한 에러는 내가 책임지지 않고 곧장 상위로 넘기겠다”라는 의도를 표현하는 것이다.

오류 전파는 시스템 전반의 에러 처리 모델에 직결되는 문제이기 때문에, 발생한 에러를 어디까지 전파해야 하며 어떤 시점에 처리해야 하는지에 대한 설계가 중요하다. Rust는 ? 연산자를 통해 가능한 한 간단하면서도 안전한 방식으로 오류 전파를 지원한다. 코드가 더 복잡해지거나 커스텀 에러 타입을 이용하고 싶다면, ? 연산자와 함께 anyhow, thiserror 같은 에러 핸들링 라이브러리를 조합해 사용함으로써 에러 추적이나 사용자 정의 메시지 처리를 더욱 유연하게 구성할 수 있다.

정리하면, ? 연산자는 Rust의 에러 전파를 단순화하는 강력한 도구이며, 함수 내부에서 Result나 Option을 반환하는 상황이 빈번할 때 유용하다. 함수가 다양한 오류를 처리하기 어렵거나, 상위에서 처리하도록 미루는 것이 자연스럽다고 판단되는 경우라면 적극 활용하자. 이렇게 하면 코드가 명료해지고, 에러 처리의 흐름을 쉽게 추적할 수 있으며, 안전성과 가독성을 모두 잡을 수 있다.

Last updated