Rust Result 메서드 정리: ?, map_err, and_then, 콤비네이터
Rust의 에러 처리에서 봤듯이, Rust는 실패 가능성을 Result<T, E> 타입으로 반환 타입에 박아 넣는 언어입니다. 다만 매번 match로 풀어내기엔 코드가 너무 장황해지죠. 그래서 표준 라이브러리는 Result를 다루는 다양한 메서드를 갖춰두고 있습니다.
이번 글에서는 실무에서 자주 만나는 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