Rust 에러 처리 | Result, Option, ? 연산자
이 글의 핵심
예외가 없는 Rust에서는 실패 가능성이 함수 시그니처에 Result나 Option으로 드러납니다. match로 일일이 처리하던 코드를 ? 연산자와 조기 반환으로 줄이는 과정, 복구할 수 없는 상황에만 panic!을 쓰는 기준, 여러 에러를 하나의 커스텀 타입으로 묶어 변환하는 방법을 순서대로 익힙니다.
시리즈 안내
들어가며
Rust에는 try/catch 스타일의 예외가 없습니다. 실패 가능한 연산은 Result로, 값이 없을 수 있음은 Option으로 타입에 적어 두고 처리합니다. 복구 가능한 오류를 값으로 돌려주는 방식이라, 제어 흐름이 추적하기 쉽습니다.
Rust와의 첫 만남
“빌려주기 검사기(Borrow Checker)와 싸우는 게 프로그래밍의 반”이라는 농담이 있을 정도로, Rust는 처음에 정말 어렵습니다. 저도 첫 프로젝트에서 컴파일러 에러와 씨름하며 “이게 정말 생산성이 높은 언어인가?” 의심했습니다. 하지만 몇 주간 고생 끝에 컴파일이 통과된 코드는 런타임 에러가 거의 없다는 걸 깨달았습니다. C++에서는 세그멘테이션 폴트가 프로덕션에서 터지는 악몽을 자주 겪었는데, Rust는 그런 걱정이 없습니다. 컴파일러가 미리 잡아주므로요. 특히 멀티스레드 코드를 작성할 때 이 차이가 극명합니다. C++에서는 데이터 레이스를 찾느라 디버거와 씨름했지만, Rust는 컴파일 단계에서 “이 코드는 스레드 안전하지 않아”라고 알려줍니다. 처음엔 답답했지만, 지금은 이 엄격함이 감사합니다.
Result<T, E>의 메서드와 체이닝
기본 Result
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("0으로 나눌 수 없음"))
} else {
Ok(a / b)
}
}
fn main() {
match divide(10, 2) {
Ok(result) => println!("결과: {}", result),
Err(e) => println!("에러: {}", e),
}
}
Result<T, E>는 Ok(T)와 Err(E) 두 가지 중 하나인 열거형일 뿐, 언어에 특별히 내장된 예외 장치가 아닙니다. 이 설계의 핵심 효과는 실패 가능성이 함수 시그니처에 드러난다는 것입니다. divide가 i32가 아니라 Result<i32, String>을 반환하므로 호출자는 결과를 쓰기 전에 반드시 두 경우를 다뤄야 하고, 그냥 무시하면 Result에 붙은 #[must_use] 때문에 unused Result that must be used 경고가 납니다. Java나 C++에서처럼 “이 함수가 예외를 던질 수 있는지”를 문서로 확인해야 하는 일이 없습니다.
에러 타입으로 String을 쓴 것은 예제를 단순하게 하기 위해서입니다. 문자열 에러는 사람이 읽기는 쉽지만, 호출자가 “0으로 나눈 경우만 다르게 처리”하려면 문자열을 비교해야 합니다. 라이브러리나 규모 있는 코드에서는 뒤의 커스텀 에러 타입 절처럼 에러 종류를 열거형으로 정의해 match로 구분할 수 있게 합니다.
Result 메서드
fn main() {
let result = divide(10, 2);
// unwrap: Ok면 값, Err면 panic
let value = result.unwrap(); // 5
// unwrap_or: Err면 기본값
let value2 = divide(10, 0).unwrap_or(0); // 0
// unwrap_or_else: Err면 클로저 실행
let value3 = divide(10, 0).unwrap_or_else(|e| {
println!("에러: {}", e);
0 // 클로저도 i32를 반환해야 함
});
// expect: unwrap + 커스텀 메시지
let value4 = divide(10, 2).expect("나눗셈 실패");
}
unwrap_or_else의 클로저는 Ok 쪽 값과 같은 타입을 반환해야 합니다. 클로저 본문을 println! 한 줄로 끝내면 반환 타입이 ()가 되어 expected i32, found () 에러가 나므로, 위처럼 마지막 줄에 기본값을 둡니다.
unwrap_or(0)과 unwrap_or_else(|_| 0)의 차이는 기본값을 언제 계산하는가입니다. unwrap_or의 인자는 결과가 Ok여도 항상 먼저 평가되므로, unwrap_or(load_default_config())처럼 비싼 함수를 넣으면 성공한 경우에도 매번 호출됩니다. 기본값을 만드는 데 비용이 들거나 부수 효과가 있다면 클로저를 받는 unwrap_or_else를 써야 실패했을 때만 실행됩니다. Option의 ok_or와 ok_or_else, map_or와 map_or_else도 같은 관계입니다. Clippy의 or_fun_call 린트가 이 실수를 잡아 줍니다.
unwrap()이 panic을 일으키면 called `Result::unwrap()` on an `Err` value: "0으로 나눌 수 없음"처럼 에러 값만 나오고 왜 이 값이 반드시 성공해야 했는지는 알 수 없습니다. expect("설정 파일은 빌드 시 포함되므로 항상 존재해야 함")처럼 “성공을 기대한 이유”를 적어 두면, 나중에 panic 메시지만 보고도 어떤 가정이 깨졌는지 알 수 있습니다. 메시지를 “실패함”이 아니라 기대를 서술하는 문장으로 쓰는 것이 Rust 표준 문서가 권하는 관례입니다.
Result 체이닝
fn parse_and_double(s: &str) -> Result<i32, String> {
s.parse::<i32>()
.map(|n| n * 2)
.map_err(|e| format!("파싱 실패: {}", e))
}
fn main() {
match parse_and_double("42") {
Ok(n) => println!("결과: {}", n), // 84
Err(e) => println!("{}", e),
}
}
map은 Ok 안의 값만 변환하고 Err는 그대로 통과시키며, map_err는 반대로 Err 안의 값만 변환합니다. 이 두 메서드를 이어 붙이면 match 없이 “성공하면 두 배, 실패하면 메시지를 붙인 문자열”이라는 로직을 표현할 수 있습니다. 변환 함수 자체가 또 실패할 수 있다면 map 대신 and_then을 써야 합니다. map으로 Result를 반환하는 함수를 연결하면 결과가 Result<Result<i32, E>, E>처럼 중첩됩니다.
"42".parse::<i32>()의 ::<i32>(터보피시)는 어떤 타입으로 파싱할지 알려 줍니다. 결과를 let n: i32 = s.parse()?처럼 타입이 정해진 변수에 담으면 생략할 수 있지만, 여기처럼 바로 .map()을 이어 쓸 때는 컴파일러가 타입을 추론할 근거가 없어 type annotations needed 에러가 나므로 명시해야 합니다. " 42"처럼 공백이 섞인 문자열은 파싱에 실패(invalid digit found in string)하므로, 입력을 파싱하기 전에 trim()하는 습관이 필요합니다.
Option와 Result 사이의 변환
기본 Option
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("홍길동"))
} else {
None
}
}
fn main() {
match find_user(1) {
Some(name) => println!("사용자: {}", name),
None => println!("사용자 없음"),
}
}
Option 메서드
fn main() {
let x: Option<i32> = Some(5);
// map: 값이 있으면 변환
let y = x.map(|v| v * 2); // Some(10)
// and_then: 값이 있으면 함수 실행
let z = x.and_then(|v| Some(v + 1)); // Some(6)
// filter: 조건 만족하면 Some, 아니면 None
let w = x.filter(|v| v % 2 == 0); // None (5는 홀수)
// unwrap_or: None이면 기본값
let value = x.unwrap_or(0); // 5
// is_some, is_none
if x.is_some() {
println!("값 있음");
}
}
Option과 Result 변환
fn main() {
let opt: Option<i32> = Some(5);
// Option → Result
let res: Result<i32, &str> = opt.ok_or("값 없음");
let res2: Result<i32, String> = opt.ok_or_else(|| {
String::from("값이 없습니다")
});
}
Option은 “값이 없는 것이 정상적인 결과 중 하나”일 때 씁니다. HashMap 조회, 벡터의 get(i), 문자열의 첫 문자처럼 없다는 사실 외에 설명할 정보가 없는 경우입니다. 반대로 호출자가 “왜 없는지”를 알아야 한다면 Result가 맞습니다. find_user가 None을 반환하면 사용자가 없어서인지 DB 연결이 끊겨서인지 구분할 수 없으므로, DB를 조회하는 실제 함수라면 Result<Option<User>, DbError>처럼 두 타입을 겹쳐 “조회는 성공했지만 사용자가 없음”과 “조회 자체가 실패함”을 나눠 표현합니다.
ok_or는 Option을 Result로 바꾸는 다리입니다. 여러 단계가 ?로 이어지는 함수 안에서 Option을 반환하는 조회가 끼어 있을 때, map.get(key).ok_or(AppError::NotFound)?처럼 에러를 붙여 같은 흐름으로 처리할 수 있습니다. 여기서도 에러 값을 만드는 비용이 있다면 ok_or_else를 씁니다.
? 연산자로 에러 전파하기
기본 사용
? 연산자는 에러를 자동으로 전파하는 간결한 문법입니다:
use std::fs;
use std::io;
// ? 연산자 사용 (간결)
fn read_file() -> Result<String, io::Error> {
// fs::read_to_string(): Result<String, io::Error> 반환
let content = fs::read_to_string("file.txt")?;
// ? 연산자의 동작:
// - Ok(content)면: content를 추출하여 변수에 할당
// - Err(e)면: 즉시 함수를 종료하고 Err(e)를 반환 (조기 반환)
Ok(content)
// 성공 시 content를 Ok로 감싸서 반환
}
// ? 없이 작성하면 (장황)
fn read_file_verbose() -> Result<String, io::Error> {
// match로 명시적으로 처리
match fs::read_to_string("file.txt") {
Ok(content) => {
// 성공 시: content를 Ok로 감싸서 반환
Ok(content)
},
Err(e) => {
// 실패 시: 에러를 Err로 감싸서 반환
Err(e)
},
}
// ? 연산자 한 줄이 이 match 전체를 대체
}
// 사용 예제
fn main() {
match read_file() {
Ok(content) => {
println!("파일 내용:");
println!("{}", content);
},
Err(e) => {
println!("파일 읽기 실패: {}", e);
},
}
}
? 연산자의 장점:
- 간결성: match 블록 대신 한 줄로 처리
- 가독성: 에러 처리 로직이 명확
- 체이닝: 여러 ? 연산자를 연속으로 사용 가능
- 타입 안전: 컴파일 타임에 에러 타입 검증
?는 현재 함수의 반환 타입이 Result나 Option일 때만 쓸 수 있습니다. fn main() 안에서 바로 fs::read_to_string("file.txt")?를 쓰면 the `?` operator can only be used in a function that returns `Result` or `Option`이라는 에러가 납니다. main에서도 ?를 쓰고 싶다면 fn main() -> Result<(), Box<dyn std::error::Error>>로 선언하고 마지막에 Ok(())를 반환하면 됩니다. 에러가 나면 Error: ...가 출력되고 종료 코드 1로 끝납니다. 같은 함수 안에서 Result의 ?와 Option의 ?를 섞어 쓸 수도 없습니다. Option을 반환하는 함수에서 Result에 ?를 쓰려면 .ok()?로, 그 반대는 .ok_or(...)?로 변환해야 합니다.
여러 ? 연산자
여러 단계의 에러 처리를 간결하게 체이닝할 수 있습니다:
use std::fs;
use std::io;
fn read_and_parse() -> Result<i32, Box<dyn std::error::Error>> {
// Box<dyn std::error::Error>: 모든 에러 타입을 담을 수 있는 박스
// dyn: 동적 디스패치 (런타임에 타입 결정)
// 1단계: 파일 읽기
let content = fs::read_to_string("number.txt")?;
// Result<String, io::Error> 반환
// ? : Err이면 즉시 반환, Ok면 String 추출
// 2단계: 공백 제거 및 파싱
let number: i32 = content.trim().parse()?;
// trim(): 앞뒤 공백 제거
// parse::<i32>(): 문자열 → i32 변환
// Result<i32, ParseIntError> 반환
// ? : Err이면 즉시 반환, Ok면 i32 추출
// 3단계: 결과 계산
Ok(number * 2)
// 모든 단계가 성공하면 최종 결과 반환
}
// 사용 예제
fn main() {
match read_and_parse() {
Ok(n) => println!("결과: {}", n),
Err(e) => println!("에러: {}", e),
}
}
// ? 없이 작성하면 (매우 장황)
fn read_and_parse_verbose() -> Result<i32, Box<dyn std::error::Error>> {
// 1단계: 파일 읽기
let content = match fs::read_to_string("number.txt") {
Ok(c) => c,
Err(e) => return Err(Box::new(e)),
};
// 2단계: 파싱
let number: i32 = match content.trim().parse() {
Ok(n) => n,
Err(e) => return Err(Box::new(e)),
};
// 3단계: 결과 계산
Ok(number * 2)
}
? 연산자의 동작 원리:
// 이 코드:
let x = some_function()?;
// 는 다음과 같이 확장됨:
let x = match some_function() {
Ok(value) => value,
Err(e) => return Err(e.into()), // 에러 타입 변환 후 반환
};
이 확장에서 가장 중요한 부분은 e.into()입니다(정확히는 From::from(e)가 호출됩니다). ?는 에러를 그대로 반환하는 것이 아니라 함수의 에러 타입으로 변환해서 반환합니다. read_and_parse에서 io::Error와 ParseIntError라는 서로 다른 에러를 하나의 Box<dyn Error>로 반환할 수 있는 것도, 표준 라이브러리에 “Error를 구현한 모든 타입은 Box<dyn Error>로 변환 가능”이라는 From 구현이 있기 때문입니다. 뒤의 파일 읽기·파싱 예제에서 AppError에 From을 직접 구현하는 것도 이 변환이 동작하게 하기 위해서입니다. 변환이 없는 조합, 예를 들어 에러 타입이 String인 함수에서 io::Error에 ?를 쓰면 the trait `From<std::io::Error>` is not implemented for `String` 에러가 납니다. 이때는 .map_err(|e| e.to_string())?처럼 직접 바꿔 줍니다.
실전 예시: 파일 처리 파이프라인:
use std::fs;
use std::io;
fn process_file(path: &str) -> Result<Vec<i32>, Box<dyn std::error::Error>> {
// 파일 읽기 → 줄 분리 → 파싱 → 필터링
let content = fs::read_to_string(path)?; // 1. 파일 읽기
let numbers: Result<Vec<i32>, _> = content
.lines() // 2. 줄 분리
.map(|line| line.trim().parse()) // 3. 각 줄 파싱
.collect(); // 4. 결과 수집
let numbers = numbers?; // 파싱 에러 전파
// 5. 양수만 필터링
let positive: Vec<i32> = numbers.into_iter()
.filter(|&n| n > 0)
.collect();
Ok(positive)
}
collect()로 Result<Vec<i32>, _>를 모으는 부분은 Rust 반복자의 유용한 기능입니다. 각 줄의 파싱 결과가 Result<i32, ParseIntError>인데, 이것을 Vec<Result<...>>가 아니라 Result<Vec<i32>, ...>로 모으라고 지정하면 첫 번째 에러를 만나는 순간 멈추고 그 에러를 반환하며, 모두 성공했을 때만 Ok(Vec)을 돌려줍니다. 수동으로 루프를 돌며 에러를 확인하는 코드가 한 줄로 줄어듭니다.
다만 “첫 에러에서 멈춤”이 항상 원하는 동작은 아닙니다. 설정 파일 검증처럼 모든 잘못된 줄을 한꺼번에 보고해야 한다면, partition(Result::is_ok)로 성공과 실패를 나눠 모으는 방식이 사용자에게 더 친절합니다. 또 이 함수는 빈 줄 하나만 있어도 cannot parse integer from empty string 에러로 전체가 실패하므로, 실제 파일을 다룬다면 .filter(|line| !line.trim().is_empty())로 빈 줄을 먼저 걸러 내는 편이 좋습니다. 에러 메시지에 몇 번째 줄인지가 없다는 점도 실무에서는 아쉬운 부분이라, enumerate()로 줄 번호를 붙여 map_err로 에러에 포함시키는 경우가 많습니다.
Option에서 ? 사용
fn get_first_char(s: &str) -> Option<char> {
s.chars().next()
}
fn get_first_uppercase(s: &str) -> Option<char> {
let first = get_first_char(s)?;
if first.is_uppercase() {
Some(first)
} else {
None
}
}
panic!, unwrap, expect는 언제 쓰나
panic! 사용
fn main() {
panic!("프로그램 중단!");
}
unwrap과 expect
fn main() {
let result: Result<i32, &str> = Err("에러 발생");
// unwrap: Err면 panic
// let value = result.unwrap(); // panic!
// expect: panic 메시지 커스텀
// let value = result.expect("값을 가져올 수 없음"); // panic!
}
panic!은 예외처럼 보이지만 용도가 다릅니다. 기본 설정에서 panic은 현재 스레드의 스택을 되감으며 값들을 정리하고 그 스레드를 끝내는데, 정상적인 에러 처리 경로로 쓰라고 만든 것이 아닙니다. panic은 “프로그램에 버그가 있다”는 신호입니다. 배열 범위를 넘는 인덱스, 불변 조건이 깨진 상태, 절대 일어나면 안 되는 분기처럼 계속 진행하면 오히려 더 큰 문제가 생기는 경우에 씁니다. 반대로 사용자가 잘못된 파일 이름을 입력하거나 네트워크가 끊기는 것은 버그가 아니라 예상 가능한 상황이므로 Result로 처리합니다.
Cargo.toml에 panic = "abort"를 설정하면 되감기 없이 즉시 종료되어 바이너리가 작아지는 대신, std::panic::catch_unwind로 panic을 잡을 수 없게 됩니다. 웹 서버 프레임워크들은 요청 하나의 panic이 서버 전체를 죽이지 않도록 되감기를 이용해 요청 단위로 panic을 격리하므로, 이런 환경에서 abort로 바꾸면 동작이 크게 달라집니다. 운영 환경에서 panic이 났을 때 원인을 찾으려면 RUST_BACKTRACE=1 환경 변수로 백트레이스를 켜 두는 것이 좋습니다.
enum으로 커스텀 에러 타입 정의하기
기본 커스텀 에러
use std::fmt;
#[derive(Debug)]
enum MathError {
DivisionByZero,
NegativeNumber,
}
impl fmt::Display for MathError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
MathError::DivisionByZero => write!(f, "0으로 나눌 수 없음"),
MathError::NegativeNumber => write!(f, "음수는 허용되지 않음"),
}
}
}
impl std::error::Error for MathError {}
fn divide(a: i32, b: i32) -> Result<i32, MathError> {
if b == 0 {
Err(MathError::DivisionByZero)
} else {
Ok(a / b)
}
}
fn sqrt(n: i32) -> Result<f64, MathError> {
if n < 0 {
Err(MathError::NegativeNumber)
} else {
Ok((n as f64).sqrt())
}
}
커스텀 에러 타입이 갖춰야 할 것은 세 가지입니다. Debug(#[derive(Debug)])는 unwrap 실패 메시지나 {:?} 출력에 쓰이고, Display는 사용자에게 보여 줄 메시지를 정하며, std::error::Error 구현은 이 타입을 Box<dyn Error>로 다루거나 다른 에러의 원인(source())으로 연결할 수 있게 합니다. Error 트레이트의 메서드는 모두 기본 구현이 있어서 빈 impl 블록으로 충분하지만, Debug와 Display가 먼저 구현되어 있어야 컴파일됩니다.
열거형으로 에러를 정의하는 가장 큰 장점은 호출자가 match로 종류별로 다르게 대응할 수 있고, 새 에러 변형을 추가하면 모든 match에서 빠진 경우를 컴파일러가 알려 준다는 것입니다. 이런 보일러플레이트가 번거롭다면 thiserror 크레이트의 #[derive(Error)]와 #[error("0으로 나눌 수 없음")] 속성으로 Display와 Error 구현을 자동 생성할 수 있습니다. 흔히 쓰이는 구분은 “라이브러리는 thiserror로 구체적인 에러 타입을, 애플리케이션은 anyhow로 에러를 모아 문맥(.context("설정 파일 읽기 실패"))을 붙여 위로 전달”입니다.
예제: 파일 읽기·파싱과 입력 검증
예제: 파일 읽기 및 파싱
use std::fs;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for AppError {
fn from(err: io::Error) -> Self {
AppError::Io(err)
}
}
impl From<ParseIntError> for AppError {
fn from(err: ParseIntError) -> Self {
AppError::Parse(err)
}
}
fn read_number_from_file(path: &str) -> Result<i32, AppError> {
let content = fs::read_to_string(path)?;
let number = content.trim().parse::<i32>()?;
Ok(number)
}
fn main() {
match read_number_from_file("number.txt") {
Ok(n) => println!("숫자: {}", n),
Err(AppError::Io(e)) => println!("파일 에러: {}", e),
Err(AppError::Parse(e)) => println!("파싱 에러: {}", e),
}
}
From 구현 두 개가 이 예제의 핵심입니다. read_number_from_file의 반환 타입은 Result<i32, AppError>인데, 본문에서 ?를 쓴 두 곳은 각각 io::Error와 ParseIntError를 만듭니다. 앞에서 본 것처럼 ?는 에러를 From::from으로 변환하므로, 이 두 From 구현이 있으면 별도의 map_err 없이 각 에러가 AppError의 알맞은 변형으로 감싸집니다. main의 match는 에러 종류별로 다른 메시지를 보여 주는데, Box<dyn Error>를 반환했다면 이렇게 종류를 구분하려면 downcast_ref를 써야 했을 것입니다. 에러를 값으로 다룰 때 타입이 구체적일수록 호출자가 할 수 있는 일이 많아진다는 것을 보여 주는 예입니다.
이 AppError에 Display와 Error 구현을 추가하면, 원래 에러를 source()로 돌려주어 “파일 에러 ← 권한 없음” 같은 원인 사슬을 로그에 남길 수 있습니다. 제가 Rust로 CLI 도구를 만들 때 가장 흔하게 겪은 문제는 반대로 에러를 너무 일찍 문자열로 바꿔 버리는 것이었습니다. map_err(|e| e.to_string())을 여기저기 쓰면 당장은 편하지만, 나중에 “파일이 없을 때만 기본값으로 대체”하고 싶어져도 원래 io::ErrorKind::NotFound를 확인할 방법이 사라집니다.
예제: 사용자 입력 검증
#[derive(Debug)]
enum ValidationError {
TooShort,
TooLong,
InvalidChar,
}
fn validate_username(name: &str) -> Result<(), ValidationError> {
if name.len() < 3 {
return Err(ValidationError::TooShort);
}
if name.len() > 20 {
return Err(ValidationError::TooLong);
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(ValidationError::InvalidChar);
}
Ok(())
}
fn main() {
let long_name = "a".repeat(25); // String이므로 &str 목록에 넣으려면 먼저 변수에 담음
let usernames = vec!["ab", "valid_user123", "invalid-user", long_name.as_str()];
for name in usernames {
match validate_username(&name) {
Ok(_) => println!("✓ '{}' 유효함", name),
Err(ValidationError::TooShort) => println!("✗ '{}' 너무 짧음", name),
Err(ValidationError::TooLong) => println!("✗ '{}' 너무 김", name),
Err(ValidationError::InvalidChar) => println!("✗ '{}' 잘못된 문자", name),
}
}
}
vec! 안에서 문자열 리터럴(&str)과 "a".repeat(25)(String)를 섞으면 벡터 요소의 타입이 맞지 않아 expected `&str`, found `String` 에러가 납니다. 위 코드처럼 String을 먼저 변수에 담고 as_str()로 빌려 넣거나, 반대로 모든 요소를 String으로 통일해야 합니다.
검증 로직에는 유니코드와 관련된 함정이 두 가지 있습니다. name.len()은 글자 수가 아니라 바이트 수라서 한글 이름 “홍길동”은 9바이트로 계산되고, 7글자짜리 한글 아이디는 21바이트라 “너무 김”으로 거부됩니다. 글자 수 제한이 목적이라면 name.chars().count()를 써야 합니다. 또 char::is_alphanumeric()은 ASCII만이 아니라 유니코드 문자·숫자 전체에서 참이므로 한글, 한자, 아랍 숫자 등도 통과합니다. 아이디를 영문과 숫자로 제한하려는 의도였다면 is_ascii_alphanumeric()이 맞습니다. 서로 다른 유니코드 문자가 비슷하게 보이는 혼동(예: 라틴 a와 키릴 а)을 이용한 사칭 아이디를 막으려면 이런 차이가 실제로 중요해집니다.
검증 결과를 Result<(), ValidationError>로 돌려주는 것도 눈여겨볼 만합니다. 성공 시 돌려줄 값이 없으므로 단위 타입 ()를 쓰고, 실패 이유만 열거형으로 표현했습니다. 하나의 검사가 실패하면 바로 반환하는 구조라 여러 문제가 있어도 첫 번째만 알려 주는데, 회원 가입 폼처럼 모든 문제를 한꺼번에 보여 줘야 한다면 Vec<ValidationError>를 모아 반환하는 방식으로 바꿉니다.
조기 반환과 에러 변환 패턴
패턴 1: 조기 반환
fn process_data(data: &str) -> Result<i32, String> {
if data.is_empty() {
return Err(String::from("데이터 비어있음"));
}
let number = match data.parse::<i32>() {
Ok(n) => n,
Err(_) => return Err(String::from("파싱 실패")),
};
if number < 0 {
return Err(String::from("음수 불가"));
}
Ok(number * 2)
}
조기 반환 패턴은 “검사를 통과하지 못하면 즉시 나가고, 끝까지 내려온 코드는 모든 조건을 만족한다”는 구조입니다. 성공 경로가 들여쓰기 없이 맨 왼쪽에 놓여 읽기 쉽습니다. 이 예제의 match + return Err(...)는 data.parse::<i32>().map_err(|_| String::from("파싱 실패"))?로 줄일 수 있는데, 원래 파싱 에러(ParseIntError)의 내용을 버리고 “파싱 실패”라는 문자열만 남긴다는 점은 같습니다. 무엇이 잘못되었는지 사용자에게 보여 줘야 한다면 map_err(|e| format!("파싱 실패: {e}"))처럼 원래 에러를 메시지에 포함하십시오.
패턴 2: 에러 변환
use std::fs;
use std::io;
fn read_and_process() -> Result<String, Box<dyn std::error::Error>> {
let content = fs::read_to_string("data.txt")?;
let number: i32 = content.trim().parse()?;
Ok(format!("처리된 값: {}", number * 2))
}
Box<dyn std::error::Error>는 “어떤 에러든 담을 수 있는 상자”라서 에러 타입을 정의하지 않고도 ?를 자유롭게 쓸 수 있습니다. 작은 프로그램이나 main, 테스트에서는 가장 실용적인 선택입니다. 대신 호출자는 에러의 구체적인 종류를 타입으로 알 수 없어, 종류별 처리가 필요하면 e.downcast_ref::<io::Error>()로 런타임에 확인해야 합니다. 멀티스레드 코드에서 에러를 다른 스레드로 넘기려면 Box<dyn Error + Send + Sync>로 선언해야 한다는 점도 자주 부딪히는 부분입니다. tokio::spawn의 결과로 에러를 돌려주다 dyn Error cannot be sent between threads safely 에러를 만났다면 이 경계가 빠진 것입니다.
에러 처리 요약
- Result<T, E>: 성공(Ok) 또는 실패(Err)
- Option
: 값 있음(Some) 또는 없음(None) - ? 연산자: 에러 자동 전파
- panic!: 복구 불가능한 에러
- 커스텀 에러: enum + Display + Error 트레이트
다음 단계
같이 보면 좋은 글
- C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
- Swift 에러 처리 | do-catch, throw, Result
- C++ 예외 처리 | try/catch/throw
- C++ expected
- C++ 네트워크 에러 처리
- Rust 구조체와 열거형 | Struct, Enum, Pattern Matching
- Rust 트레이트 | Trait, 제네릭, 트레이트 바운드
- Rust 소유권 | Ownership, Borrowing, Lifetime
자주 묻는 질문 (FAQ)
Q. unwrap과 expect는 코드에서 써도 되나요?
A. 둘 다 Err나 None이면 panic을 일으키므로, 실패가 정상적으로 일어날 수 있는 입력 처리나 파일 읽기에는 ?로 에러를 전파하는 편이 맞습니다. 테스트 코드나 프로그램 로직상 절대 실패할 수 없는 값처럼 panic이 버그를 뜻하는 경우에는 쓸 수 있으며, 그때도 unwrap보다 실패 이유를 남기는 expect가 디버깅에 유리합니다.