RUST · 심층 가이드
Rust 에러 처리 완전 정리
Result 기반 처리, 사용자 정의 오류 타입, 입력 검증, 안전한 파싱과 나눗셈, fallback까지 Rust 오류 처리 15가지를 한 편에 모았습니다.
Rust에는 예외가 없습니다. 실패할 수 있는 함수는 Result를 돌려주고, 호출한 쪽이 그것을 반드시 마주 봐야 합니다. try/catch로 감싸 두면 어디선가 알아서 잡히던 흐름이 사라지는 대신, 어떤 함수가 실패할 수 있는지가 시그니처에 그대로 드러납니다. 오류 처리를 나중으로 미룰 수 없게 만드는 설계라고 보면 됩니다.
Result 반환과 안전한 숫자 파싱을 먼저 보면 ? 연산자가 왜 필요한지 자연스럽게 이해됩니다. 이어서 사용자 정의 오류와 도메인 오류 표현을 읽으면 서로 다른 오류 타입을 하나로 모으는 방법이 보이고, 다단계 검증과 fallback 값 사용에서 실패를 흘려보낼지 대체할지 판단하는 기준이 잡힙니다. finally 정리 항목은 Rust에 finally가 없는 대신 Drop이 그 역할을 한다는 점을 보여 줍니다.
덜 알려진 함정은 정수 오버플로입니다. 디버그 빌드에서는 오버플로가 패닉으로 잡히지만 릴리스 빌드에서는 기본 설정상 조용히 감싸 돌아갑니다. 개발 중에는 멀쩡하다가 배포본에서만 값이 이상해지는 전형적인 경로입니다. 경계가 의심스러운 산술에는 checked_add처럼 결과를 Option으로 돌려주는 메서드나 saturating_ 계열을 쓰는 편이 안전합니다.
01try catch 기본
require_positive는 값이 0 이하면 Err, 아니면 Ok를 돌려주고 main은 1을 넣어 성공 쪽을 찍습니다. Rust에는 예외가 없어 실패 가능성이 반환 타입에 드러나고, 호출부는 그 값을 반드시 다뤄야 합니다. 오류를 String으로 두면 만들기는 쉽지만 종류별로 분기할 수 없어 나중에 열거형으로 옮기게 됩니다.
Rust code
// try catch 기본
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
println!("{:?}", require_positive(1));
}실패 가능성이 반환 타입에 드러나 있어 호출부가 Result를 다루지 않고 지나칠 수 없고, 어디서 실패할 수 있는지가 시그니처만 봐도 보입니다.
오류를 String으로 두면 종류별로 분기할 수 없어, 나중에 재시도 대상과 영구 실패를 가르려면 문자열 내용을 비교하는 취약한 코드가 됩니다.
02사용자 정의 오류
직접 정의한 오류 대신 표준 라이브러리의 ParseIntError가 등장합니다. 문자 x를 숫자로 바꾸는 데 실패하고, err()가 Result를 Option으로 접어 오류 쪽만 남깁니다. err()는 값을 소비하므로 이후 같은 Result를 다시 검사할 수 없고, 남겨 두려면 as_ref()를 먼저 붙여야 합니다.
Rust code
// 사용자 정의 오류
fn main() {
let parsed = "x".parse::<i32>();
println!("{:?}", parsed.err());
}err()는 Result를 Option<E>로 접어 오류만 남기므로, 성공 값은 필요 없고 실패 여부만 보고 싶을 때 짧게 쓸 수 있습니다.
err()는 Result를 소비하므로 그 뒤에 같은 값을 다시 검사할 수 없고, 남겨 두려면 as_ref()를 먼저 붙여야 합니다.
03입력 검증
0 이하를 걸러 내는 검사 함수에 3을 넘겨 통과 경로를 보여 줍니다. 검증 결과를 Result로 돌려 놓으면 잘못된 값이 함수 밖으로 새어 나가지 못합니다. 한 걸음 더 나가면 검증된 값만 담는 전용 타입을 만들어, 생성자를 통과한 다음부터는 같은 확인을 반복하지 않게 하는 방법도 있습니다.
Rust code
// 입력 검증
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
println!("{:?}", require_positive(3));
}검증을 통과한 값만 담는 전용 타입을 만들고 생성자에서만 검사하면, 그 값을 받는 함수들은 같은 확인을 반복하지 않아도 됩니다.
검증 함수가 돌려준 Result를 let _ =로 버리면 잘못된 값이 그대로 다음 단계로 흘러가고, 문제는 한참 뒤에야 드러납니다.
04안전한 숫자 파싱
문자 x를 i32로 바꾸려다 실패한 오류를 꺼내 출력합니다. 파싱 실패가 패닉이 아니라 값으로 돌아오기 때문에 사용자 입력을 그대로 받아도 프로그램이 죽지 않습니다. 타입은 parse::<i32>()처럼 지정하거나 받는 변수에 적어야 하며, 둘 다 없으면 무엇으로 바꿀지 몰라 컴파일이 실패합니다.
Rust code
// 안전한 숫자 파싱
fn main() {
let parsed = "x".parse::<i32>();
println!("{:?}", parsed.err());
}parse는 대상 타입을 터보피시나 변수 표기로 알려 주어야 하고, 앞뒤 공백이 있으면 실패하므로 trim()을 먼저 거치는 편이 좋습니다.
사용자 입력에 parse().unwrap()을 쓰면 숫자가 아닌 글자 하나에 프로세스 전체가 패닉으로 멈추므로, 실패를 값으로 받아 처리해야 합니다.
05안전한 나눗셈
나눗셈은 없지만 0을 미리 막는다는 점에서 발상은 같습니다. 0 이하를 Err로 돌려보내고 5는 통과시킵니다. 정수 나눗셈에서 0으로 나누면 실행 중 패닉이 나므로 이런 가드를 두거나 checked_div로 Option을 받아야 합니다. 부동소수점은 패닉 대신 inf나 NaN이 되어 문제가 한참 뒤에 드러납니다.
Rust code
// 안전한 나눗셈
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
println!("{:?}", require_positive(5));
}정수 나눗셈은 checked_div가 0으로 나누는 경우를 None으로 돌려주므로, 가드를 직접 쓰지 않고도 실패를 값으로 받을 수 있습니다.
부동소수점 나눗셈은 0으로 나눠도 패닉하지 않고 inf나 NaN이 되어, 잘못된 값이 계산을 타고 흘러가다 한참 뒤에야 드러납니다.
06finally 정리
정리 코드 없이 파싱 오류만 출력합니다. Rust에는 finally가 없고, 값이 스코프를 벗어날 때 Drop이 자동으로 불려 파일이나 잠금이 풀립니다. 이 정리는 패닉으로 스택이 풀릴 때도 실행되며, 시점을 앞당기고 싶으면 drop(value)를 직접 부르면 됩니다.
Rust code
// finally 정리
fn main() {
let parsed = "x".parse::<i32>();
println!("{:?}", parsed.err());
}정리 코드는 Drop 구현에 두면 값이 스코프를 벗어날 때 자동으로 불리고, 패닉으로 스택이 풀릴 때도 같은 순서로 실행됩니다.
Drop 안에서는 오류를 돌려줄 방법이 없어 파일 닫기 실패가 조용히 묻히므로, 결과를 확인해야 한다면 flush나 sync_all을 직접 불러야 합니다.
07오류 다시 던지기
검사 함수가 Result를 돌려주고 호출부는 그 값을 그대로 출력합니다. 상위로 넘길 때는 ?가 Err를 즉시 반환해 주는데, 반환 타입의 오류가 다르면 From 구현을 거쳐 변환됩니다. 변환 경로가 없으면 컴파일 오류가 나므로 여러 오류를 한 함수에서 전파하려면 공통 오류 타입이나 Box<dyn Error>가 필요합니다.
Rust code
// 오류 다시 던지기
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
println!("{:?}", require_positive(7));
}?는 Err를 즉시 반환하면서 From 구현을 거쳐 함수의 오류 타입으로 바꿔 주므로, 변환만 정의해 두면 전파 코드가 한 글자로 줄어듭니다.
오류 타입이 다른데 From 구현이 없으면 ?에서 컴파일 오류가 나므로, 여러 출처를 한 함수에서 전파하려면 공통 오류 타입이 필요합니다.
08도메인 오류 표현
도메인 타입 대신 표준 파싱 오류를 그대로 보여 줍니다. 업무 규칙에 맞는 오류는 보통 enum으로 경우를 나누고 Display와 Error를 구현해 만듭니다. 열거형으로 두면 호출부가 match로 상황별 대응을 나눌 수 있고, 새 변형을 추가했을 때 처리를 빠뜨린 곳을 컴파일러가 짚어 줍니다.
Rust code
// 도메인 오류 표현
fn main() {
let parsed = "x".parse::<i32>();
println!("{:?}", parsed.err());
}오류를 enum으로 나누고 Display와 Error를 구현하면, 호출부가 match로 상황별 대응을 갈라 쓸 수 있습니다.
오류 열거형에 #[non_exhaustive]를 붙이면 외부 크레이트는 항상 _ 갈래를 두어야 하므로, 공개 API에 쓸 때는 그 부담을 감안해야 합니다.
09fallback 값 사용
9는 조건을 통과해 성공 값이 나오지만, 실패했을 때 무엇으로 대신할지는 호출부의 몫으로 남습니다. unwrap_or(0)이나 unwrap_or_else로 기본값을 주면 흐름이 끊기지 않습니다. 다만 대체값을 쓰는 순간 오류 내용이 사라지므로, 원인을 남겨야 한다면 클로저 안에서 기록하는 형태가 낫습니다.
Rust code
// fallback 값 사용
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
println!("{:?}", require_positive(9));
}unwrap_or_else는 실패했을 때만 클로저를 실행하므로, 기본값을 만드는 비용이 성공 경로에서는 전혀 발생하지 않습니다.
대체값으로 넘어가는 순간 원래 오류 내용이 사라지므로, 원인을 남겨야 한다면 클로저 안에서 먼저 기록한 뒤 기본값을 돌려주어야 합니다.
10assertion
단언문 없이 파싱 실패를 값으로 받아 출력합니다. assert!는 조건이 깨지면 패닉으로 프로세스를 멈추므로 사용자 입력이 아니라 코드가 스스로 지켜야 할 불변식에 씁니다. debug_assert!는 릴리스 빌드에서 통째로 빠져 검사 비용이 사라지는 대신, 그 검사에 기대던 안전성도 함께 사라집니다.
Rust code
// assertion
fn main() {
let parsed = "x".parse::<i32>();
println!("{:?}", parsed.err());
}assert!는 코드가 스스로 지켜야 할 불변식에 쓰고, 바깥에서 들어온 값의 검사는 패닉이 아니라 Result로 돌려주는 것이 맞습니다.
debug_assert!는 릴리스 빌드에서 통째로 빠지므로, 그 검사에 안전성을 기대고 있었다면 배포본에서만 조건이 깨진 채 지나갑니다.
11Result 반환
함수 시그니처의 Result<i32, String>이 성공 값과 실패 이유를 한 타입에 담습니다. 호출부는 {:?}로 통째로 찍었지만 실제 코드에서는 match나 ?로 두 경우를 갈라야 합니다. Result에는 미사용 경고가 붙어 있어 반환값을 버리면 경고가 뜨고, 정말 무시하려면 let _ =로 뜻을 밝혀야 합니다.
Rust code
// Result 반환
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
println!("{:?}", require_positive(11));
}Result에는 #[must_use]가 붙어 있어 반환값을 그냥 버리면 경고가 뜨고, 정말 무시하려면 let _ =로 의도를 밝혀야 합니다.
{:?}로 통째로 찍고 넘어가면 성공과 실패가 같은 경로를 타게 되므로, 실제 코드에서는 match나 ?로 두 경우를 갈라야 합니다.
12다단계 검증
단계는 하나뿐이고, 문자열을 숫자로 바꾸는 데 실패한 결과만 남습니다. 검사를 여러 개 이으려면 and_then으로 Result를 체인하거나 ?를 줄줄이 쓰면 됩니다. 이 방식은 첫 실패에서 멈추므로, 폼 입력처럼 모든 문제를 한 번에 보여 줘야 하면 오류를 벡터에 모으는 쪽으로 바꿔야 합니다.
Rust code
// 다단계 검증
fn main() {
let parsed = "x".parse::<i32>();
println!("{:?}", parsed.err());
}and_then으로 Result를 이어 붙이면 앞 단계가 성공했을 때만 다음 검사가 실행되어, 중첩된 if 없이 단계를 늘릴 수 있습니다.
?를 줄줄이 쓰면 첫 실패에서 멈추므로, 폼 입력처럼 모든 문제를 한 번에 보여 줘야 하는 화면에서는 오류를 벡터에 모으는 방식이 필요합니다.
13로깅 래퍼
검사 함수와 그 결과 출력만 있어 로깅이 들어갈 지점을 오히려 또렷하게 드러냅니다. 값을 건드리지 않고 들여다볼 때는 inspect_err처럼 참조만 받는 조합자가 어울립니다. 오류를 기록한 뒤 Ok로 바꿔 삼켜 버리면 호출부가 실패를 알 수 없게 되므로, 기록과 전파는 분리해 두는 편이 낫습니다.
Rust code
// 로깅 래퍼
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
println!("{:?}", require_positive(13));
}inspect_err는 오류를 참조로만 들여다보고 값을 그대로 흘려보내므로, 기록을 끼워 넣어도 원래 흐름이 달라지지 않습니다.
오류를 기록한 뒤 Ok로 바꿔 삼켜 버리면 호출부는 실패를 알 수 없게 되어, 로그에는 남아 있는데 처리는 성공한 것처럼 진행됩니다.
14recover 흐름
복구 없이 오류를 꺼내 보는 데서 끝납니다. 회복은 보통 unwrap_or_default()로 기본값을 쓰거나 or_else로 다른 경로를 시도하는 식입니다. 패닉까지 되돌리려는 catch_unwind는 언와인드가 꺼진 빌드에서는 동작하지 않으므로 일반적인 오류 처리 수단으로 삼기 어렵습니다.
Rust code
// recover 흐름
fn main() {
let parsed = "x".parse::<i32>();
println!("{:?}", parsed.err());
}or_else는 실패했을 때만 다른 경로를 시도하고 결과가 다시 Result라, 대체 시도까지 실패한 경우를 그대로 이어서 다룰 수 있습니다.
catch_unwind로 패닉을 되돌리려는 시도는 언와인드가 꺼진 빌드에서 아예 동작하지 않으므로, 일반적인 오류 처리 수단으로 삼을 수 없습니다.
15입력 가드
함수 첫머리에서 조건을 확인하고 어긋나면 곧장 Err로 빠져나가는 조기 반환 형태입니다. 중첩된 if가 줄어 본문에는 정상 경로만 남습니다. 오류 문자열을 to_string()으로 만들면 호출할 때마다 힙 할당이 생기므로, 자주 불리는 경로라면 정적 &'static str이나 오류 열거형이 더 가볍습니다.
Rust code
// 입력 가드
fn require_positive(value: i32) -> Result<i32, String> { if value <= 0 { Err("positive only".to_string()) } else { Ok(value) } }
fn main() {
println!("{:?}", require_positive(15));
}조기 반환으로 잘못된 입력을 앞에서 걸러 내면 본문에 정상 경로만 남아, 중첩된 if가 사라지고 읽는 순서가 실행 순서와 같아집니다.
가드마다 to_string()으로 메시지를 만들면 자주 불리는 경로에서 힙 할당이 반복되므로, 정적 문자열이나 오류 열거형이 더 가볍습니다.
정리하며
- 예외 대신 Result를 쓰므로 실패 가능성이 시그니처에 드러납니다
- ? 연산자는 오류 타입 변환이 정의돼 있어야 동작합니다
- finally 대신 Drop이 정리 책임을 지므로 자원 해제는 타입에 맡깁니다
- 정수 오버플로는 릴리스 빌드에서 조용히 감싸므로 checked 계열을 씁니다
더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.