Rust Result 메서드 정리: ?, map_err, and_then, 콤비네이터

Rust의 에러 처리에서 봤듯이, Rust는 실패 가능성을 Result<T, E> 타입으로 반환 타입에 박아 넣는 언어입니다. 다만 매번 match로 풀어내기엔 코드가 너무 장황해지죠. 그래서 표준 라이브러리는 Result를 다루는 다양한 메서드를 갖춰두고 있습니다.
참고로 Result<T, E> 자체는 E가 Error 트레이트를 구현하도록 요구하지 않습니다.
에러 타입의 공통 인터페이스가 필요한 이유는 Rust std::error::Error 트레이트에서 따로 다룹니다.
이번 글에서는 실무에서 자주 만나는 Result 메서드를 용도별로 정리해보겠습니다. 조기 반환에 쓰는 ? 연산자부터, 에러를 변환하는 map_err, 값을 갈아 끼우는 map/and_then, 그리고 회복용 unwrap_or 패밀리까지 한 번에 살펴봅시다.
Result는 그냥 enum
먼저 Result 타입 자체를 짚고 넘어가겠습니다. 표준 라이브러리에 정의된 Result는 단순한 열거형(enum)입니다.
enum Result<T, E> {
Ok(T),
Err(E),
}
성공이면 Ok(T), 실패면 Err(E). 이게 전부인데요. 핵심은 “성공”과 “실패”를 같은 타입의 두 가지 변형(variant)으로 표현한다는 점입니다. 그래서 Result 위에서 동작하는 모든 메서드는 결국 “Ok면 이렇게, Err면 저렇게”라는 분기를 다른 모양으로 포장한 것에 가깝습니다.
값을 만들 때는 그냥 Ok(...)나 Err(...)로 감싸면 됩니다.
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("0으로 나눌 수 없습니다"))
} else {
Ok(a / b)
}
}
? 연산자: 조기 반환의 지름길
매번 match로 풀어내면 코드가 계단식으로 늘어납니다.
fn load_user() -> Result<User, AppError> {
let text = match std::fs::read_to_string("user.json") {
Ok(t) => t,
Err(e) => return Err(AppError::from(e)),
};
let user = match serde_json::from_str::<User>(&text) {
Ok(u) => u,
Err(e) => return Err(AppError::from(e)),
};
Ok(user)
}
이 패턴을 한 글자로 줄여주는 게 ? 연산자입니다.
fn load_user() -> Result<User, AppError> {
let text = std::fs::read_to_string("user.json")?;
let user = serde_json::from_str::<User>(&text)?;
Ok(user)
}
?는 뒤에 오는 값이 Ok면 내용물을 꺼내 계속 진행하고, Err면 함수에서 즉시 빠져나오며 에러를 반환합니다. 자바스크립트의 await와 비슷하게 생겼지만 하는 일은 “실패 시 조기 반환”이죠. Option에도 똑같이 쓸 수 있어서 None이면 함수 전체가 None으로 끝납니다.
다만 ?를 쓰려면 한 가지 조건이 있는데요. 반환하는 에러 타입이 함수의 반환 타입과 맞아야 한다는 것입니다. 정확히는 From<원본에러> for 함수에러가 구현되어 있어야 자동 변환이 일어납니다. 안 맞을 땐 map_err로 직접 갈아 끼워줘야 합니다.
map_err로 에러 갈아 끼우기
파일 I/O는 std::io::Error를 반환하고, JSON 파싱은 serde_json::Error를 반환합니다. 이 둘을 하나의 함수에서 ?로 이어 쓰려면 서로 다른 에러 타입을 우리 앱의 에러 타입으로 바꿔줘야 합니다.
map_err는 Result<T, E>의 Err 쪽만 다른 타입으로 변환해주는 메서드입니다.
fn load_user() -> Result<User, AppError> {
let text = std::fs::read_to_string("user.json")
.map_err(|e| AppError::Io(e))?;
let user = serde_json::from_str::<User>(&text)
.map_err(|e| AppError::Parse(e))?;
Ok(user)
}
뒤에서 볼 map이 Ok 쪽 값을 변환한다면, map_err는 Err 쪽 값만 변환합니다. Ok였다면 그대로 통과시키고, Err일 때만 클로저가 호출되죠.
“그냥 From 트레이트를 구현해두면 ?가 알아서 변환해주지 않나?”라고 생각하셨다면 정확합니다. thiserror의 #[from] 어트리뷰트도 결국 그 From 구현을 자동으로 만들어줍니다. 그럼 map_err는 언제 쓰느냐 하면, 같은 에러 타입을 상황에 따라 다르게 분류하고 싶을 때입니다.
fn fetch_user(id: u64) -> Result<User, AppError> {
let response = http_get(&format!("/users/{}", id))
.map_err(|e| match e.status() {
Some(404) => AppError::NotFound(id),
_ => AppError::Network(e),
})?;
Ok(response.json()?)
}
같은 reqwest::Error라도 404는 NotFound, 나머지는 Network로 나눠 담고 있죠. 이런 문맥 의존적인 변환은 From으로는 표현할 수 없고, map_err가 딱 맞습니다.
inspect_err로 에러 들여다보기
map_err가 에러를 다른 타입으로 갈아 끼우는 도구라면, inspect_err는 에러를 그대로 둔 채 살짝 들여다보기만 하는 도구입니다. 실패한 이유를 로그로 남기되 에러 자체는 위로 올려보내고 싶을 때 자주 쓰입니다.
fn load_user() -> Result<User, AppError> {
let text = std::fs::read_to_string("user.json")
.inspect_err(|e| eprintln!("user.json 읽기 실패: {e}"))?;
let user = serde_json::from_str::<User>(&text)
.inspect_err(|e| eprintln!("JSON 파싱 실패: {e}"))?;
Ok(user)
}
inspect_err는 Err(e)일 때만 클로저에 &e를 넘겨 호출하고, 그다음 원래의 Result를 그대로 돌려줍니다. 값을 소비하지 않기 때문에 뒤에 ?를 그대로 이어 붙일 수 있죠.
Result::inspect_err는 Rust 1.76부터 표준 라이브러리에 들어왔고, 성공 쪽을 엿보고 싶다면 inspect를 쓰면 됩니다. tracing 같은 로깅 라이브러리와 함께 쓰면 ?로 깔끔하게 에러를 전파하면서도 어디서 어떤 이유로 실패했는지 흔적을 남길 수 있어 디버깅이 한결 수월해집니다.
map과 and_then으로 성공값 다루기
map_err가 Err 쪽 값을 변환했다면, map은 Ok 쪽 값을 변환합니다.
let length: Result<usize, std::io::Error> = std::fs::read_to_string("user.json")
.map(|text| text.len());
파일 읽기가 성공하면 문자열 대신 길이만 갖는 Result로 바뀌고, 실패하면 에러는 그대로 통과합니다. Ok만 골라서 변환하기 때문에 분기 없이도 안전하죠.
그런데 변환 함수가 또 Result를 반환하면 어떨까요? map을 그대로 쓰면 Result<Result<T, E>, E>처럼 중첩이 생겨버립니다. 이때 쓰는 게 and_then입니다.
let user: Result<User, AppError> = std::fs::read_to_string("user.json")
.map_err(AppError::from)
.and_then(|text| serde_json::from_str::<User>(&text).map_err(AppError::from));
and_then은 Ok(v)일 때 클로저를 호출해서 그 결과를 그대로(중첩 없이) 돌려주고, Err면 변환 없이 통과시킵니다. 다른 언어에서 flatMap이나 모나드의 bind라고 부르는 그 개념과 같습니다.
물론 위 같은 흐름은 ? 연산자로 쓰면 훨씬 직관적입니다.
fn load_user() -> Result<User, AppError> {
let text = std::fs::read_to_string("user.json")?;
let user = serde_json::from_str::<User>(&text)?;
Ok(user)
}
and_then이 빛나는 건 메서드 체인 한복판에서 흐름을 끊고 싶지 않을 때, 또는 Iterator 같은 다른 도구와 엮어 쓸 때입니다.
or와 or_else로 대체 결과 만들기
반대 방향으로, Err일 때 다른 Result로 갈아치우고 싶다면 or나 or_else를 씁니다.
let config: Result<String, _> = std::fs::read_to_string("config.local.toml")
.or_else(|_| std::fs::read_to_string("config.toml"));
먼저 로컬 설정 파일을 시도하고, 실패하면 기본 설정 파일을 시도하는 패턴이죠. or는 미리 만들어둔 Result를 그대로 사용하고, or_else는 클로저가 호출될 때 비로소 대체 Result를 만들어냅니다. 호출 비용이 크다면 or_else가 더 적절합니다.
map과 map_err가 “성공/실패값을 변환”이라면, and_then과 or_else는 “Result 자체를 다른 Result로 교체”라고 정리할 수 있습니다.
| 메서드 | 동작 대상 | 클로저가 받는 값 | 클로저가 반환하는 값 |
|---|---|---|---|
map | Ok(v) | v: T | U → Ok(U) |
map_err | Err(e) | e: E | F → Err(F) |
and_then | Ok(v) | v: T | Result<U, E> |
or_else | Err(e) | e: E | Result<T, F> |
Option과 Result 넘나들기
코드를 쓰다 보면 Option과 Result를 엮어야 하는 순간이 자주 옵니다. 예를 들어 해시맵에서 설정값을 꺼내는 동작은 Option을 반환하지만, 함수 전체는 Result를 반환해야 한다면요.
이때는 ok_or나 ok_or_else를 씁니다.
fn get_port(config: &HashMap<String, String>) -> Result<u16, AppError> {
let raw = config
.get("port")
.ok_or(AppError::MissingField("port"))?;
let port = raw.parse().map_err(|_| AppError::InvalidPort)?;
Ok(port)
}
ok_or는 Some(v)를 Ok(v)로, None을 Err(기본값)으로 바꿔줍니다. 에러 값을 만드는 비용이 크다면 지연 평가되는 ok_or_else(|| ...)를 쓰는 편이 좋습니다.
반대로 Result를 Option으로 바꾸고 싶을 땐 .ok()를 쓰면 됩니다. “이 연산이 실패하면 그냥 None으로 넘어가자” 같은 상황에서 유용합니다.
unwrap_or로 기본값 회복하기
모든 에러를 상위로 전파할 필요는 없습니다. 회복 가능한 실패라면 기본값으로 대체하는 게 더 깔끔할 때가 많죠.
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8080);
가장 단순한 건 unwrap_or(default)로, 에러면 주어진 기본값을 그대로 돌려줍니다. 기본값을 만드는 비용이 크거나 에러 값을 참고해 결정하고 싶다면 unwrap_or_else(|e| ...)가 더 적절하고, 타입에 Default 구현이 있다면 unwrap_or_default()로 한층 간결하게 쓸 수도 있죠.
unwrap과 expect는 마지막 수단
반면 unwrap()과 expect()는 에러를 만나면 그대로 panic!으로 프로그램을 종료시킵니다.
let config = std::fs::read_to_string("config.toml").unwrap();
let port: u16 = std::env::var("PORT")
.expect("PORT 환경 변수가 설정되어야 합니다")
.parse()
.expect("PORT는 숫자여야 합니다");
예제 코드나 테스트, 또는 실패하면 정말로 프로그램을 멈춰야 하는 부트스트랩 단계에서는 편리합니다. 하지만 일반 서비스 코드에 남겨두면 사용자 입력 하나로 서버가 죽을 수 있으니 조심해야 합니다.
expect는 unwrap과 동작은 같지만 panic 메시지를 직접 지정할 수 있어서, 디버깅 단서가 필요한 자리에서는 unwrap 대신 expect를 쓰는 게 좋습니다.
unwrap/expect로 panic을 내야 하는 상황과 Result로 에러를 전파해야 하는 상황의 구분 기준이 궁금하시다면 Rust의 에러 처리를 함께 보시면 좋습니다.
마치며
Result 메서드는 처음 보면 종류가 많아 보이지만, 결국 “Ok와 Err 중 어느 쪽을 어떻게 다룰 것인가”라는 단순한 질문의 변주들입니다. map과 map_err로 한쪽만 변환하고, and_then과 or_else로 Result 자체를 갈아치우고, ?로 빠르게 빠져나가고, unwrap_or로 회복하는 패턴 몇 가지만 익혀두어도 실무 코드의 대부분을 커버할 수 있습니다.
앱 전용 에러 타입을 설계하는 단계로 넘어가셨다면 thiserror로 커스텀 에러 정의하기를, Rust 전반이 궁금하시다면 Rust 관련 글을 함께 살펴보시면 좋습니다.
더 자세한 내용은 Rust 표준 라이브러리의 std::result 문서를 참고하세요.
This work is licensed under CC BY 4.0