Rust의 에러 처리: 예외 대신 값으로 다루는 법
자바스크립트로 파일을 읽거나 파이썬으로 API를 호출해본 경험이 있으시다면, 아마 try/catch나 try/except로 예외를 잡는 방식이 익숙하실 겁니다. 그런데 Rust 코드를 처음 보면 이상한 점이 눈에 들어옵니다. 분명히 파일을 여는데 try도 없고, 함수 시그니처에 throws도 없고, 대신 Result<T, E>라는 낯선 타입이 반환됩니다. 🤔
이번 글에서는 다른 언어와 비교했을 때 Rust 에러 처리가 가지는 가장 특이한 지점들과, 일상적인 실패와 버그를 Rust가 어떻게 갈라놓는지를 정리해보겠습니다.
예외가 아니라 값
대부분의 언어는 에러를 “예외(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은 enum
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로 풀어내면 코드가 장황해져서 실무에서는 ?, map_err, unwrap_or 같은 메서드를 더 많이 씁니다. Result를 다루는 구체적인 메서드들은 Result 메서드 정리 글에서 따로 다룹니다.
panic!은 언제 쓰나요?
여기서 자연스럽게 궁금해지는 질문이 있습니다. “그럼 예외 같은 건 아예 없는 건가요?”
완전히 없는 건 아닙니다. Rust에도 panic!이라는 탈출구가 있고, 배열 범위를 벗어나거나 0으로 나누면 런타임에 패닉이 발생합니다. 다만 철학이 다릅니다. panic!은 “이 프로그램의 불변식이 깨졌다”는 신호이지, 일상적인 에러 흐름이 아닙니다.
그래서 실무에서는 보통 이렇게 구분합니다. 네트워크 오류나 파일이 없는 경우, 잘못된 입력값처럼 복구 가능한 실패는 Result로 돌려주고, 절대 일어나서는 안 되는 상태 — 즉 프로그램 버그 — 는 panic!으로 빠르게 실패시키며 원인을 드러냅니다.
자바나 파이썬에서 “예외”라는 한 바구니에 담던 것을, Rust는 “예측 가능한 실패”와 “버그”로 갈라놓은 셈입니다.
unwrap()과 expect()는 Result를 panic으로 바꿔주는 도구라서, 본질적으로 “여기서 실패하면 버그다”라고 선언하는 셈입니다. 예제 코드나 부트스트랩 단계에서는 편리하지만, 사용자 입력 같은 외부 데이터에 적용하면 사용자 한 명이 서버를 죽일 수 있으니 조심해야 합니다.
에러 종류에 따라 다르게 대응하기
같은 함수가 반환하는 에러라도, 안에 들어 있는 변형(variant)에 따라 회복 가능성이 다릅니다. 모두 똑같이 위로 전파하는 대신, 각 변형에 맞춰 처리 전략을 달리하는 게 실무에서 자주 등장하는 패턴인데요.
예를 들어 비동기 채널에 이벤트를 보내는 상황을 보겠습니다. try_send는 TrySendError를 돌려주는데, 이 enum에는 두 가지 변형이 있습니다.
match watch_sender.try_send(()) {
Ok(_) => {}
Err(TrySendError::Full(_)) => {
tracing::trace!("이벤트 합쳐짐: 소비자가 못 따라옴");
}
Err(err @ TrySendError::Closed(_)) => {
tracing::error!("채널이 닫혔습니다: {err}");
}
}
Full은 “버퍼가 가득 찼다”는 뜻으로, 보통 일시적이고 회복 가능한 상태입니다. 파일 변경 알림처럼 같은 이벤트가 연달아 오면 하나만 봐도 충분한 시나리오에서는 그냥 합쳐서 흘려보내고 trace! 정도로만 남기죠.
Closed는 수신자가 drop되어 채널이 영구적으로 닫혔다는 뜻이라 회복이 불가능합니다. 보통 프로그램 설계상 일어나면 안 되는 상태라서 error!로 가장 높은 레벨 로그를 남기는 거고요.
여기서 마지막 팔에 쓰인 err @ TrySendError::Closed(_) 문법이 @ 바인딩입니다. 변형에 매칭되는 동시에 그 값 전체를 err 변수로 묶어주는데, 덕분에 로그 메시지에 {err}로 채널 에러를 그대로 끼워 넣을 수 있습니다. 위쪽 팔처럼 _로 버려도 되고, 아래쪽 팔처럼 살려서 더 자세한 정보를 남겨도 되는 거죠.
이런 식으로 에러 변형별 처리 전략을 명시적으로 갈라두면, 어떤 실패가 정상적인 흐름이고 어떤 실패가 비상 신호인지 코드만 봐도 드러납니다. From이나 ?로 자동 전파하는 대신 한 번 멈춰서 의도를 표현해야 하는 자리가 바로 이런 곳입니다.
어떤 에러 타입을 만들까
규모가 작은 스크립트나 애플리케이션이라면 Box<dyn Error>나 anyhow의 통합 에러 타입으로도 충분합니다. 하지만 라이브러리를 만들거나 호출자가 에러 종류를 구별해야 할 때는, 도메인별로 enum을 정의해서 명시적으로 표현하는 편이 좋습니다.
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(serde_json::Error),
NotFound(u64),
}
매번 이런 boilerplate를 손으로 쓰는 건 번거롭기 때문에, thiserror 같은 매크로 라이브러리를 활용하면 훨씬 깔끔해집니다. Display, From, Error 트레이트 구현을 어트리뷰트 한 줄로 자동 생성해주죠.
마치며
Rust의 에러 처리를 처음 보면 보일러플레이트가 늘어난 것처럼 느껴지실 수 있습니다. 하지만 잠깐 써보면 함수 시그니처만 봐도 어떤 실패가 가능한지 한눈에 들어오고, 컴파일러가 에러 처리를 강제해주기 때문에 빠뜨릴 일이 없다는 안정감이 생깁니다.
Result를 다루는 구체적인 메서드들은 Result 메서드 정리에서, 값의 부재를 다루는 Option은 Rust의 Option에서, 앱 전용 에러 타입 설계는 thiserror로 커스텀 에러 정의하기에서 이어서 읽어보시면 좋습니다.
더 자세한 내용은 Rust 공식 도서의 에러 처리 챕터를 참고하세요.
This work is licensed under
CC BY 4.0