Rust의 Result 타입으로 에러 핸들링 개선하기

배경

사이드 프로젝트로 CLI 도구를 만들면서 Rust를 본격적으로 사용하게 됐다. Node.js에서는 try-catch나 Promise의 reject로 에러를 처리했는데, Rust는 접근 방식이 달랐다.

Result 타입의 기본

Rust는 에러를 Result<T, E> 타입으로 표현한다. 성공 시 Ok(T), 실패 시 Err(E)를 반환하는 구조다.

use std::fs::File;
use std::io::Read;

fn read_config(path: &str) -> Result<String, std::io::Error> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

? 연산자가 핵심이다. 에러 발생 시 즉시 반환하고, 성공 시 값을 unwrap한다. try-catch보다 깔끔하다.

패턴 매칭으로 분기 처리

호출부에서는 match를 사용해 에러를 처리했다.

match read_config("config.json") {
    Ok(content) => println!("Config loaded: {}", content),
    Err(e) => eprintln!("Failed to read config: {}", e),
}

커스텀 에러 타입

프로젝트가 커지면서 여러 에러 타입을 통합할 필요가 생겼다. thiserror 크레이트를 활용했다.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("Parse error: {0}")]
    Parse(String),
}

소감

컴파일 타임에 에러 처리를 강제하니 런타임 오류가 현저히 줄었다. 초반에는 번거로웠지만, 코드 신뢰도가 높아지는 게 체감됐다. Node.js 프로젝트에서도 이런 명시성이 그립다.