Rust 에러 처리 | Result, Option, ? 연산자

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);
        },
    }
}

? 연산자의 장점:

  1. 간결성: match 블록 대신 한 줄로 처리
  2. 가독성: 에러 처리 로직이 명확
  3. 체이닝: 여러 ? 연산자를 연속으로 사용 가능
  4. 타입 안전: 컴파일 타임에 에러 타입 검증

여러 ? 연산자

여러 단계의 에러 처리를 간결하게 체이닝할 수 있습니다:

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))
}

정리

핵심 요약

  1. Result<T, E>: 성공(Ok) 또는 실패(Err)
  2. Option: 값 있음(Some) 또는 없음(None)
  3. ? 연산자: 에러 자동 전파
  4. panic!: 복구 불가능한 에러
  5. 커스텀 에러: enum + Display + Error 트레이트

다음 단계

  • Rust 트레이트
  • Rust 컬렉션
  • Rust 동시성

관련 글

  • C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
  • Swift 에러 처리 | do-catch, throw, Result
  • C++ 예외 처리 | try/catch/throw
  • C++ expected |
  • C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]