Rust 에러 처리 | Result, Option, ? 연산자
이 글의 핵심
Rust 에러 처리에 대한 실전 가이드입니다. Result, Option, ? 연산자 등을 예제와 함께 상세히 설명합니다.
들어가며
Rust에는 try/catch 스타일의 예외가 없습니다. 실패 가능한 연산은 Result로, 값이 없을 수 있음은 Option으로 타입에 적어 두고 처리합니다. 복구 가능한 오류를 값으로 돌려주는 방식이라, 제어 흐름이 추적하기 쉽습니다.
1. 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 메서드
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
});
// expect: unwrap + 커스텀 메시지
let value4 = divide(10, 2).expect("나눗셈 실패");
}
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),
}
}
2. Option
기본 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("값이 없습니다")
});
}
3. ? 연산자
기본 사용
? 연산자는 에러를 자동으로 전파하는 간결한 문법입니다:
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 블록 대신 한 줄로 처리
- 가독성: 에러 처리 로직이 명확
- 체이닝: 여러 ? 연산자를 연속으로 사용 가능
- 타입 안전: 컴파일 타임에 에러 타입 검증
여러 ? 연산자
여러 단계의 에러 처리를 간결하게 체이닝할 수 있습니다:
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()), // 에러 타입 변환 후 반환
};
실전 예시: 파일 처리 파이프라인:
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)
}
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
}
}
4. panic!
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!
}
5. 커스텀 에러 타입
기본 커스텀 에러
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())
}
}
6. 실전 예제
예제: 파일 읽기 및 파싱
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),
}
}
예제: 사용자 입력 검증
#[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 usernames = vec!["ab", "valid_user123", "invalid-user", "a".repeat(25)];
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),
}
}
}
7. 에러 처리 패턴
패턴 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)
}
패턴 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))
}
정리
핵심 요약
- Result<T, E>: 성공(Ok) 또는 실패(Err)
- Option
: 값 있음(Some) 또는 없음(None) - ? 연산자: 에러 자동 전파
- panic!: 복구 불가능한 에러
- 커스텀 에러: enum + Display + Error 트레이트
다음 단계
- Rust 트레이트
- Rust 컬렉션
- Rust 동시성
관련 글
- C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
- Swift 에러 처리 | do-catch, throw, Result
- C++ 예외 처리 | try/catch/throw
- C++ expected |
- C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]