Rust의 에러 처리: Result, ? 연산자, map_err
자바스크립트로 파일을 읽거나 파이썬으로 API를 호출해본 경험이 있으시다면, 아마 try/catch나 try/except로 예외를 잡는 방식이 익숙하실 겁니다.
그런데 Rust 코드를 처음 보면 이상한 점이 눈에 들어옵니다.
분명히 파일을 여는데 try도 없고, 함수 시그니처에 throws도 없고, 대신 Result<T, E>라는 낯선 타입이 반환됩니다. 🤔
이번 글에서는 다른 언어와 비교했을 때 Rust 에러 처리의 가장 특이한 지점들과, 실무에서 자주 쓰는 ? 연산자, map_err 같은 도구를 하나씩 풀어보겠습니다.
예외가 아니라 값
대부분의 언어는 에러를 “예외(exception)“라는 별도의 흐름으로 다룹니다.
함수가 정상적으로 끝나는 길과 예외가 던져지는 길이 따로 있고, 어딘가에서 그 예외를 catch해주지 않으면 프로그램이 뚝 끊어지죠.
function readConfig(path) {
const text = fs.readFileSync(path, "utf8"); // 실패하면 throw
return JSON.parse(text); // 실패하면 throw
}
함수의 반환 타입은 string | object처럼 보이지만, 실제로는 언제든 예외로 빠질 수 있습니다.
타입 시그니처만 봐서는 이 함수가 어떤 실패를 할 수 있는지 알 길이 없고, 호출자가 try/catch를 빠뜨려도 컴파일러는 아무 경고도 해주지 않습니다.
Rust는 이런 숨겨진 제어 흐름을 싫어합니다. 대신 실패 가능성을 반환 타입에 직접 박아 넣습니다.
fn read_config(path: &str) -> Result<Config, ConfigError> {
// ...
}
여기서 Result<Config, ConfigError>는 “성공하면 Config, 실패하면 ConfigError”라는 뜻입니다.
호출하는 쪽에서는 성공과 실패를 모두 처리하지 않으면 컴파일이 안 됩니다.
에러를 잊고 지나칠 자유가 없는 대신, 어떤 에러가 발생할 수 있는지를 타입만 보고 알 수 있다는 이야기입니다.
Result와 Option
Rust 표준 라이브러리의 에러 처리는 두 개의 열거형(enum)에서 출발합니다.
enum Result<T, E> {
Ok(T),
Err(E),
}
enum Option<T> {
Some(T),
None,
}
Result는 “실패할 수도 있는 연산”에, Option은 “값이 없을 수도 있는 상황”에 씁니다.
자바스크립트의 null이나 자바의 NullPointerException 같은 함정을 피하기 위해, Rust는 아예 “값이 없음”도 타입 시스템으로 표현합니다.
값을 꺼내고 싶으면 match로 두 경우를 모두 처리해주면 됩니다.
match read_config("app.toml") {
Ok(config) => println!("로드 성공: {:?}", config),
Err(e) => eprintln!("설정을 읽지 못했습니다: {}", e),
}
match는 두 경우를 빠뜨리면 컴파일이 실패하기 때문에, 에러 케이스를 까먹고 넘어가는 일이 원천적으로 차단됩니다.
패턴 매칭이 궁금하시다면 Rust의 match 사용법을 함께 보시면 좋습니다.
물음표 연산자로 조기 반환
매번 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으로 끝나버립니다.
다만 ?를 쓰려면 한 가지 조건이 있습니다.
반환하는 에러 타입이 함수의 반환 타입과 맞아야 한다는 것입니다.
여기서 자연스럽게 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가 딱 맞습니다.
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으로 넘어가자” 같은 상황에서 유용합니다.
기본값으로 복구하기
모든 에러를 상위로 전파할 필요는 없습니다. 회복 가능한 실패라면 기본값으로 대체하는 게 더 깔끔할 때가 많죠.
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()는 에러나 None을 만나면 그대로 panic!으로 프로그램을 종료시킵니다.
예제 코드나 테스트에서는 편하지만, 실제 서비스 코드에 남겨두면 사용자 입력 하나로 서버가 죽을 수 있으니 조심해야 합니다.
panic!은 언제 쓰나요?
여기서 자연스럽게 궁금해지는 질문이 있습니다. “그럼 예외 같은 건 아예 없는 건가요?”
완전히 없는 건 아닙니다.
Rust에도 panic!이라는 탈출구가 있고, 배열 범위를 벗어나거나 0으로 나누면 런타임에 패닉이 발생합니다.
다만 철학이 다릅니다.
panic!은 “이 프로그램의 불변식이 깨졌다”는 신호이지, 일상적인 에러 흐름이 아닙니다.
그래서 실무에서는 보통 이렇게 구분합니다.
네트워크 오류나 파일이 없는 경우, 잘못된 입력값처럼 복구 가능한 실패는 Result로 돌려주고, 절대 일어나서는 안 되는 상태 — 즉 프로그램 버그 — 는 panic!으로 빠르게 실패시키며 원인을 드러냅니다.
자바나 파이썬에서 “예외”라는 한 바구니에 담던 것을, Rust는 “예측 가능한 실패”와 “버그”로 갈라놓은 셈입니다.
마치며
Rust의 에러 처리를 처음 보면 보일러플레이트가 늘어난 것처럼 느껴지실 수 있습니다.
하지만 잠깐 써보면 함수 시그니처만 봐도 어떤 실패가 가능한지 한눈에 들어오고, ? 연산자 덕분에 코드도 의외로 평평하게 유지됩니다.
map_err로 에러를 재분류하고, ok_or로 Option과 Result를 연결하고, unwrap_or로 회복하는 패턴만 익혀두어도 실무 코드의 상당 부분을 커버할 수 있습니다.
앱 전용 에러 타입을 설계하는 단계로 넘어가셨다면 thiserror로 커스텀 에러 정의하기를, Rust 전반에 대한 글이 궁금하시다면 Rust 관련 글을 함께 살펴보시면 좋습니다.
더 자세한 내용은 Rust 표준 라이브러리의 std::result 문서를 참고하세요.
This work is licensed under
CC BY 4.0