Rust std::error::Error 트레이트 이해하기

Rust std::error::Error 트레이트 이해하기

Rust의 오류 처리Result 타입이 익숙해지면 자연스럽게 다음 질문이 생깁니다. Result<T, E>E에는 어떤 타입(type)이든 들어갈 수 있는데, 표준 라이브러리의 std::error::Error 트레이트(trait)는 왜 따로 필요할까요?

직접 오류 타입을 만들다 보면 impl Error for MyError {}라는 비어 있는 구현도 자주 보입니다. 한편 여러 오류를 한꺼번에 받는 함수는 Box<dyn Error>를 반환하곤 하죠. 하지만 Error는 단순한 표식이 아닙니다. 오류 메시지와 원인 체인을 다루는 공통 인터페이스(interface)이며, 타입 소거(type erasure)와 다운캐스팅(downcasting)의 바탕이 됩니다.

이번 글에서는 외부 크레이트 없이 std::error::Error를 직접 구현해 보면서 이 트레이트가 Rust 오류 처리에서 맡는 역할을 하나씩 살펴보겠습니다.

Result의 E는 Error일 필요가 없습니다

먼저 Result<T, E>Error 트레이트를 분리해서 생각해야 합니다. Result의 정의에는 E: Error 같은 제약이 없습니다.

fn validate_age(age: u8) -> Result<(), &'static str> {
    if age < 18 {
        Err("must be at least 18 years old")
    } else {
        Ok(())
    }
}

이 코드는 정상적으로 컴파일되지만 &strError를 구현하지 않습니다. Result는 성공과 실패를 값으로 표현하고 전파하는 도구일 뿐, 실패 값이 어떤 공통 인터페이스를 가져야 하는지 강제하지 않기 때문입니다.

Error가 필요한 건 오류가 함수 바깥으로 나가기 시작할 때입니다. 서로 다른 오류를 같은 방식으로 출력하거나 원인을 따라가려면 공통 규약이 있어야 하니까요. Box<dyn Error>로 여러 오류를 한 타입처럼 다루는 일도 여기서 출발합니다.

작은 함수 안에서만 쓰는 오류라면 문자열이나 간단한 열거형으로도 충분합니다. 반면 라이브러리 경계를 넘거나 공통 오류 처리 코드에 전달할 타입이라면 Error를 구현하는 편이 좋습니다.

Error는 Debug와 Display를 요구합니다

Error 트레이트의 핵심 모양을 단순화하면 다음과 같습니다.

use std::fmt::{Debug, Display};

pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        None
    }
}

Error트레이트의 상속 관계를 이용해 DebugDisplay를 함께 요구합니다. Display는 사용자나 운영 로그에 보여줄 간결한 오류 메시지를 담당하고, Debug는 개발자가 내부 상태를 살펴볼 때 사용합니다. 표준 라이브러리 문서에서는 Display 메시지를 소문자로 시작하고 마침표 없이 끝내는 짧은 문장으로 작성하는 방식을 권장합니다.

가장 단순한 오류 타입은 다음처럼 만들 수 있습니다.

use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct InvalidPort {
    value: String,
}

impl fmt::Display for InvalidPort {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "invalid port number: {}", self.value)
    }
}

impl Error for InvalidPort {}

DebugDisplay를 이미 구현했고 하위 원인도 없으므로 Error 구현 블록은 비어 있습니다. 비어 있는 impl Error for InvalidPort {}만 추가해도 InvalidPortdyn Error로 다룰 수 있습니다. 구체 타입을 되찾는 다운캐스팅도 가능해지고요.

직접 작성해야 하는 코드가 많아 보이지만 구조는 단순합니다. 실무에서 반복되는 구현은 thiserror로 커스텀 오류 타입을 만드는 방법처럼 파생 매크로(derive macro)로 줄일 수 있습니다. thiserror가 새로운 오류 체계를 만드는 것은 아닙니다. 내부에서는 위와 같은 표준 Error, Display, From 구현을 대신 만들어 줍니다.

source로 오류의 원인을 연결합니다

상위 계층의 오류가 하위 계층에서 발생한 오류를 감싸는 경우가 있습니다. 가령 설정 파일을 읽다가 io::Error가 발생했지만 호출자에게는 어느 설정 파일에서 실패했는지도 알려주고 싶다고 해보죠.

use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

#[derive(Debug)]
struct ReadConfigError {
    path: PathBuf,
    source: io::Error,
}

impl fmt::Display for ReadConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "failed to read config at {}", self.path.display())
    }
}

impl Error for ReadConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source)
    }
}

fn read_config(path: &Path) -> Result<String, ReadConfigError> {
    fs::read_to_string(path).map_err(|source| ReadConfigError {
        path: path.to_owned(),
        source,
    })
}

ReadConfigErrorDisplay는 현재 계층의 문맥만 설명합니다. 실제 읽기 실패 원인인 io::Errorsource()가 반환하죠. 이렇게 연결된 오류들은 하나의 오류 체인(error chain)을 이룹니다.

여기서 반환 타입의 'static은 참조가 프로그램이 끝날 때까지 살아 있어야 한다는 뜻이 아닙니다. 반환된 참조의 수명(lifetime)은 여전히 &self에 묶여 있습니다. dyn Error + 'static은 참조 대상인 구체 오류 타입이 수명이 짧은 빌린 값을 품지 않아야 한다는 제약입니다. 그래야 나중에 구체 타입으로 안전하게 다운캐스팅할 수 있습니다.

표준 문서는 내부 오류를 source()로 제공한다면 바깥 오류의 Display에서 같은 메시지를 다시 출력하지 않기를 권장합니다. failed to read config: {source}처럼 원인까지 문자열에 넣고 오류 체인도 함께 출력하면 같은 내용이 두 번 보이기 때문입니다. 현재 계층의 문맥과 하위 원인을 분리해 두면 출력하는 쪽에서 원하는 형식을 선택할 수 있습니다.

오류 체인을 순회하는 방법

source()는 바로 아래 단계의 원인 하나만 반환합니다. 그 원인의 source()를 다시 호출하면 더 깊은 원인까지 차례로 내려갈 수 있습니다.

use std::error::Error;

fn print_error_chain(error: &(dyn Error + 'static)) {
    eprintln!("{error}");

    let mut source = error.source();
    while let Some(cause) = source {
        eprintln!("  caused by: {cause}");
        source = cause.source();
    }
}

이 함수는 가장 바깥 오류를 먼저 출력하고, 이후 원인 앞에 단계별로 들여쓰기를 넣어 보여줍니다. 오류 보고 라이브러리도 기본적으로 같은 방식으로 체인을 따라갑니다.

dyn Error::sources()라는 반복자 메서드도 문서에 보이지만 현재 안정 버전(stable)에서는 아직 사용할 수 없습니다. 안정 버전만 지원해야 한다면 위 예제처럼 source()while let을 조합하면 됩니다.

Error 구현과 오류 변환은 별개입니다

Error를 구현했다고 해서 ? 연산자가 모든 오류를 자동으로 바꿔주는 것은 아닙니다. Error는 출력과 원인 조회를 위한 규약이고, 타입 사이의 변환은 From이 담당합니다.

use std::error::Error;
use std::fmt;
use std::fs;
use std::io;

#[derive(Debug)]
struct AppError(io::Error);

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "failed to read application data")
    }
}

impl Error for AppError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.0)
    }
}

impl From<io::Error> for AppError {
    fn from(error: io::Error) -> Self {
        Self(error)
    }
}

fn load_data(path: &str) -> Result<String, AppError> {
    Ok(fs::read_to_string(path)?)
}

마지막 함수에서 ?가 작동하는 이유는 io::ErrorAppError가 모두 Error이기 때문이 아닙니다. From<io::Error> for AppError 구현이 변환 경로를 제공하기 때문입니다. From과 Into 트레이트의 관계를 떠올리면 이해하기 쉬운데요. 두 오류 사이에 필요한 문맥이 더 있다면 무조건 From을 구현하기보다 앞서 본 map_err로 직접 변환하는 편이 정확합니다.

dyn Error로 서로 다른 오류를 묶습니다

함수 하나에서 파일도 읽고 문자열도 파싱하면 서로 다른 오류 타입이 발생합니다. 반환 타입을 하나로 통일하고 싶을 때 Box<dyn Error>를 사용할 수 있습니다.

use std::error::Error;
use std::fs;

fn load_number(path: &str) -> Result<u32, Box<dyn Error>> {
    let text = fs::read_to_string(path)?;
    let number = text.trim().parse::<u32>()?;
    Ok(number)
}

첫 번째 ?io::Error를, 두 번째 ?ParseIntError를 전달합니다. 표준 라이브러리에는 E: Error인 값을 Box<dyn Error>로 바꾸는 From<E> 구현이 있습니다. 그래서 두 오류가 같은 반환 타입에 들어갈 수 있는 거예요.

dyn Error는 구체 타입을 컴파일 시점에 지우고 Error 인터페이스를 통해 동적으로 호출하는 트레이트 객체(trait object)입니다. 트레이트 객체는 크기가 정해져 있지 않으므로 Box, & 같은 포인터 뒤에 놓아야 합니다. 동작 원리가 궁금하다면 dyn 키워드와 동적 디스패치(dynamic dispatch)를 먼저 살펴보셔도 좋습니다.

이 방식은 호출자가 오류 종류보다 메시지와 원인 체인에 관심을 두는 애플리케이션 경계에서 편리합니다. 하지만 구체 타입이 가려지므로 라이브러리의 공개 API에서 남용하면 호출자가 match로 오류별 복구 전략을 세우기 어려워집니다.

Send와 Sync는 언제 붙일까요?

멀티스레드 런타임(runtime)이나 작업 큐로 오류를 넘겨야 한다면 다음 타입을 자주 보게 됩니다.

use std::error::Error;

type BoxError = Box<dyn Error + Send + Sync + 'static>;

Send 트레이트는 오류 값의 소유권을 다른 스레드로 옮길 수 있음을 뜻합니다. Sync 트레이트는 여러 스레드가 오류의 공유 참조를 안전하게 사용할 수 있음을 뜻하죠. 'static은 오류가 수명이 짧은 참조를 빌리지 않는다는 조건입니다.

모든 코드에 이 바운드(bound)가 필요한 것은 아닙니다. 단일 스레드 안에서 바로 처리할 오류라면 Box<dyn Error>로 충분합니다. 반대로 생성한 작업을 다른 스레드에서 실행하거나 오류를 스레드 사이에 공유하는 프레임워크라면 Send + Sync + 'static을 요구하는 편이 안전합니다.

Box<dyn Error>처럼 소유하는 트레이트 객체에서 객체 수명을 생략하면 일반적으로 'static이 기본값으로 적용됩니다. 타입 별칭(type alias)에 'static을 굳이 적는 이유는 이 조건을 눈에 보이게 남기려는 데 가깝습니다.

downcast로 구체 오류를 되찾습니다

dyn Error로 타입을 지웠더라도 런타임에 구체 타입을 확인할 수 있습니다. 빌린 오류는 downcast_ref()로 검사합니다.

use std::error::Error;
use std::io;

fn inspect_error(error: &(dyn Error + 'static)) {
    if let Some(io_error) = error.downcast_ref::<io::Error>() {
        eprintln!("I/O error kind: {:?}", io_error.kind());
    } else {
        eprintln!("other error: {error}");
    }
}

downcast_ref::<T>()는 실제 타입이 T일 때 Some(&T)를 반환하고, 다르면 None을 반환합니다. 가변 참조에는 downcast_mut()을 쓸 수 있고 Box<dyn Error>의 소유권을 가지고 있다면 downcast::<T>()를 호출해 Box<T>로 바꿀 수 있습니다. 타입 확인만 필요할 때는 is::<T>()가 더 간단합니다.

다만 다운캐스팅은 호출한 바로 그 오류 객체의 타입을 검사합니다. 찾는 타입이 원인 체인 안쪽에 있다면 먼저 source()로 체인을 순회하면서 각 원인에 downcast_ref()를 호출해야 합니다.

구체 오류와 Box dyn Error 중 무엇을 고를까요?

호출자가 오류 종류에 따라 다르게 복구해야 한다면 열거형이나 구조체로 구체 오류 타입을 공개하는 편이 좋습니다. 컴파일러가 모든 변형의 처리를 검사해 주고, 함수 시그니처만 읽어도 어떤 실패가 가능한지 알 수 있기 때문입니다. 라이브러리 코드에서 thiserror를 많이 사용하는 이유도 여기에 있습니다.

반대로 최상위 애플리케이션처럼 오류를 로그로 남기고 종료하는 곳에서는 Box<dyn Error>가 간결합니다. 프로토타입이나 서로 다른 라이브러리의 오류가 한곳으로 모이는 경계에서도 실용적이죠. 다만 맥락 추가와 보기 좋은 오류 보고까지 필요하다면 anyhow로 애플리케이션 오류를 처리하는 방법이 더 편리할 수 있습니다.

라이브러리 안쪽에서는 구체 오류 타입을 유지하고, 애플리케이션 경계에서 dyn Error나 anyhow로 합치는 조합이 흔합니다. Error 트레이트는 오류를 만들어 주지 않습니다. 대신 서로 다른 오류를 같은 방식으로 보고하고 탐색할 길을 열어줍니다.

마치며

std::error::ErrorResult에 들어갈 수 있는 타입을 제한하지 않습니다. 대신 DebugDisplay로 오류를 설명하고 source()로 하위 원인을 연결합니다. dyn Error로 서로 다른 오류를 함께 받았다가, 필요하면 다운캐스팅으로 구체 타입을 다시 확인할 수도 있습니다.

직접 구현 원리를 알아두면 thiserror나 anyhow가 어떤 보일러플레이트를 대신 처리해 주는지도 선명해집니다. 더 자세한 메서드와 안정화 상태는 Rust 표준 라이브러리의 Error 트레이트 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord