Rust에서 에러를 표현하고 처리하는 핵심 메커니즘 중 하나가 Result<T, E>이다. Result<T, E>는 오류 발생 가능성을 명시적으로 드러내어 안전하고 예측 가능한 방식으로 실패를 다룰 수 있게 해준다. Result 타입은 크게 Ok(T)와 Err(E)라는 두 가지 변 variant 으로 이루어지는데, Ok(T)는 성공 케이스를 나타내고 Err(E)는 실패 케이스를 나타낸다. E는 보통 오류 정보를 담는 타입으로, 표준 라이브러리에서 제공하는 에러 타입(std::io::Error 등)을 활용하거나 직접 정의한 커스텀 에러 타입을 사용할 수 있다. Rust의 타입 시스템은 Result 타입을 활용해 컴파일 타임에 오류 처리를 강제함으로써, 런타임 오류로 인한 예기치 않은 프로그램 중단을 크게 줄여 준다.
Result<T, E>가 반환될 수 있는 함수를 작성할 때는 보통 다음과 같은 형태로 시그니처를 정의한다.
함수 내에서는 정상 동작이 끝나면 Ok(반환_값)을, 오류 상황이 확인되면 Err(에러_정보)를 반환한다. 이때 에러_타입으로는 표준 라이브러리에서 제공하는 에러 타입을 바로 사용해도 되고, 상황에 따라 커스텀 타입을 만들어 사용할 수도 있다. 커스텀 에러 타입을 정의하면 에러 로그나 핸들링 과정을 보다 세밀하게 제어할 수 있기 때문에, 복잡한 프로젝트에서는 커스텀 에러 구현이 선호되는 편이다.
Result<T, E>를 사용해 반환된 값을 처리하는 표준적인 방법은 match 구문이다. match 구문을 통해 Ok(T) 상황과 Err(E) 상황을 모두 명시적으로 다룰 수 있다. 예를 들어 파일 읽기를 시도하는 read_file 함수를 Result<String, std::io::Error> 형태로 작성했다고 해보자.
fn read_file(path: &str) -> Result<String, std::io::Error> {
let content = std::fs::read_to_string(path)?;
Ok(content)
}
fn main() {
let file_path = "config.txt";
let result = read_file(file_path);
match result {
Ok(text) => {
println!("파일 내용: {}", text);
}
Err(e) => {
eprintln!("파일을 읽는 중 오류가 발생하였다: {:?}", e);
}
}
}
이 코드는 match를 통해 파일 읽기 결과를 Ok와 Err로 분기 처리한다. Ok(text) 분기에서는 읽어 들인 파일 내용을 안전하게 사용할 수 있고, Err(e) 분기에서는 오류 정보를 활용해 적절한 조치를 취할 수 있다.
실제로는 match 구문 대신 질문표 연산자(?)를 사용하면 에러 전파가 더욱 간결해진다. 함수 안에서 Result 타입을 반환할 때 오류가 발생하면 즉시 Err(E)를 반환하고, 그렇지 않으면 Ok 값만 반환하는 패턴을 간단하게 표현할 수 있다. 위의 read_file 구현체에서도 std::fs::read_to_string(path)? 로 작성한 부분이 질문표 연산자를 사용한 예이다. 만약 std::fs::read_to_string(path)가 Err를 반환한다면 그 즉시 read_file 함수도 Err를 반환하고 함수를 종료한다. 반대로 Ok 상태라면 해당 값(content)을 그대로 이어받아 계속 진행한다.
질문표 연산자는 다음과 같은 조건에서 사용할 수 있다.
another_function에서 에러가 발생하면 현재 함수도 즉시 Err를 반환한다. 질문표 연산자는 Result 이외에도 Option 타입이나 다른 트레이트 기반의 타입에도 확장해 사용할 수 있지만, 기본적인 개념은 Result<T, E>의 에러 전파와 동일하다.
Result<T, E>에는 match 구문이나 질문표 연산자 외에도 다양한 메서드 체인이 제공된다. 예를 들어 unwrap()이나 expect()는 Err(E)를 만나면 즉시 패닉(panic)을 발생시키고 프로그램을 중단한다. 개발 과정에서 빠르게 프로토타입을 만들거나 테스트할 때는 unwrap, expect 같은 메서드를 가끔 사용하기도 하지만, 실제 운영 환경에서는 명시적인 오류 처리가 없는 unwrap이나 expect가 예기치 못한 프로그램 중단을 일으킬 수 있으므로 주의해야 한다. unwrap_or, unwrap_or_else, ok_or, or_else 같은 메서드는 Err 상황에서 대체 값을 주거나 다른 함수를 호출하도록 지원해 준다.
Result<T, E>의 E로 표현할 수 있는 에러 타입은 다양하다. 러스트 표준 라이브러리에서는 std::io::Error, std::num::ParseIntError 등 여러 가지 에러 타입을 제공한다. 라이브러리가 제공하는 에러 타입을 그대로 사용할 수도 있고, 필요에 따라 별도의 에러 열거형(enum)을 정의해서 커스텀 에러 처리를 구현할 수도 있다. 커스텀 에러 타입을 만들면 상황별로 다양한 오류 정보를 세밀하게 담을 수 있고, 에러를 처리하는 과정에서도 구체적인 분기를 손쉽게 다룰 수 있다.
에러 처리를 한 곳에서 모아서 관리해야 하는 복잡한 프로젝트라면 다양한 에러 타입을 하나로 묶는 공용 에러 타입을 사용하기도 한다. 이를 위해서는 에러 트레이트(std::error::Error)를 구현하거나, thiserror, anyhow 같은 크레이트를 활용해 에러 핸들링을 단순화하는 방법이 있다. 이 과정에서 Result<T, E> 형태를 유지하면서도 여러 종류의 에러를 하나의 타입 안에 우아하게 통합할 수 있다.
Result<T, E>를 통해 에러를 처리하는 방식은 매우 유연하면서도 안정적이다. Rust 컴파일러는 Result 타입을 무시하거나 제대로 처리하지 않으면 컴파일 경고나 오류를 발생시키므로, 프로그래머가 반드시 오류 처리 로직을 고려하도록 만든다. 이 점이 Rust의 안정성과 신뢰성을 뒷받침하는 핵심적인 장점이다. Rust 코드에서 오류 처리는 “어떤 상황에서 프로그램이 실패할 수 있는지”를 정확히 파악하고, “이 실패를 어떻게 회복하거나 알릴 것인지”를 체계적으로 표현하는 데 초점을 둔다.
Result<T, E> 타입을 사용하는 핵심은 성공과 실패를 명확히 분리하고, 실패에 대한 핸들링 정책을 코드로서 명시하는 것이다. Rust의 표준 라이브러리와 커뮤니티에서 널리 사용되는 다양한 기법(질문표 연산자, match 구문, 메서드 체인, 커스텀 에러 타입)이 모두 이를 돕는다. 이 메커니즘을 잘 이해하고 활용하면, 복잡한 시스템에서도 치명적인 오류를 예측 가능하고 통제 가능한 형태로 다룰 수 있다. 결국 이는 코드의 안전성과 유지보수성을 높이는 중요한 수단이 된다.