Rust 표준 라이브러리의 std::io::Error 구현을 해부하며, 오류 타입 설계에서 캡슐화와 검사 가능성의 균형, OS 오류 코드 노출, 그리고 커스텀 오류 래핑까지 다양한 요구를 어떻게 만족시키는지 살펴본다.
URL: https://matklad.github.io/2020/10/15/study-of-std-io-error.html
2020년 10월 15일
이 글에서는 Rust 표준 라이브러리의 std::io::Error 타입 구현을 해부해 본다. 문제의 코드는 여기 있다: library/std/src/io/error.rs.
이 글은 다음 중 하나로 읽을 수 있다.
이 글을 읽으려면 Rust의 오류 처리에 대한 기본적인 친숙함이 필요하다.
Result<T, E>와 함께 사용할 Error 타입을 설계할 때 가장 먼저 던져야 할 질문은 “이 오류는 어떻게 사용될 것인가?”이다. 보통 다음 중 하나가 해당된다.
오류가 프로그램적으로 처리된다. 소비자(호출자)가 오류를 검사하므로, 내부 구조가 합리적인 수준으로 노출되어야 한다.
오류가 전파되어 사용자에게 표시된다. 소비자는 fmt::Display 이상으로 오류를 검사하지 않으므로, 내부 구조를 캡슐화해도 된다.
구현 세부사항을 노출하는 것과 캡슐화하는 것 사이에는 긴장이 있다는 점에 주목하자. 첫 번째 경우를 구현할 때 흔한 안티패턴은 ‘싱크대(enum에 다 때려 넣기)’ 방식의 enum을 정의하는 것이다:
pub enum Error {
Tokio(tokio::io::Error),
ConnectionDiscovery {
path: PathBuf,
reason: String,
stderr: String,
},
Deserialize {
source: serde_json::Error,
data: String,
},
...,
Generic(String),
}
이 접근에는 여러 문제가 있다.
첫째, 하위 라이브러리의 오류를 그대로 노출하면 그것들이 곧 공개 API의 일부가 된다. 의존성에 메이저 semver 변경이 생기면, 여러분도 메이저 버전을 올려야 할 수 있다.
둘째, 구현 세부사항이 고정되어 버린다. 예컨대 ConnectionDiscovery의 크기가 너무 크다는 것을 알아차렸을 때, 이 variant를 박싱(boxing)하는 것은 호환성을 깨는 변경(breaking change)이 된다.
셋째, 대개 더 큰 설계 문제를 암시한다. 싱크대 에러는 서로 다른 실패 모드를 하나의 타입에 우겨 넣는다. 하지만 실패 모드가 크게 다르다면, 그것들을 합리적으로 “처리(handle)”하기가 어렵다! 이는 상황이 오히려 두 번째 경우에 더 가깝다는 신호다.
enum 접근이 아무리 나쁘더라도, 첫 번째 경우(검사 가능성 극대화)에서는 최대한의 inspectability를 달성하긴 한다.
전파 중심인 두 번째 오류 관리 방식은 보통 박싱된 트레잇 오브젝트를 사용해 처리한다. 예를 들어
Box<dyn
std::error::Error>
같은 타입은 어떤 구체 오류로부터도 만들 수 있고, Display로 출력할 수 있으며, 동적 다운캐스팅을 통해 내부 오류를 선택적으로 드러낼 수도 있다. anyhow 크레이트가 이 스타일의 훌륭한 예다.
그런데 std::io::Error는 흥미롭다. 위 둘 다를 만족해야 할 뿐 아니라 그 이상을 원한다.
std이므로, 캡슐화와 미래 호환성(future-proofing)이 최우선이다.EWOULDBLOCK).io::Error는 또한 보캐뷸러리(vocabulary) 타입이므로, OS 오류가 아닌 “애매한” 오류도 표현할 수 있어야 한다. 예컨대 Rust의 Path에는 내부적으로 0 바이트가 들어갈 수 있는데, 그런 경로를 open하면 syscall을 하기 전에 io::Error를 반환해야 한다.std::io::Error는 대략 이렇게 생겼다:
pub struct Error {
repr: Repr,
}
enum Repr {
Os(i32),
Simple(ErrorKind),
Custom(Box<Custom>),
}
struct Custom {
kind: ErrorKind,
error: Box<dyn error::Error + Send + Sync>,
}
가장 먼저 눈에 띄는 점은 내부적으로 enum이라는 것인데, 이는 잘 숨겨진 구현 세부사항이다. 다양한 오류 조건을 검사하고 처리할 수 있도록, 별도의 공개 필드 없는 kind enum이 제공된다:
#[derive(Clone, Copy)]
#[non_exhaustive]
pub enum ErrorKind {
NotFound,
PermissionDenied,
Interrupted,
...
Other,
}
impl Error {
pub fn kind(&self) -> ErrorKind {
match &self.repr {
Repr::Os(code) => sys::decode_error_kind(*code),
Repr::Custom(c) => c.kind,
Repr::Simple(kind) => *kind,
}
}
}
ErrorKind와 Repr 모두 enum이지만, ErrorKind를 공개하는 것은 훨씬 덜 무섭다. #[non_exhaustive]이면서 Copy이고 필드가 없는 enum의 설계 공간은 사실상 한 점(point)이다. 그럴듯한 대안도 없고, 호환성 위험도 적다.
일부 io::Error는 단순한 OS 오류 코드(raw OS error code)다:
impl Error {
pub fn from_raw_os_error(code: i32) -> Error {
Error { repr: Repr::Os(code) }
}
pub fn raw_os_error(&self) -> Option<i32> {
match self.repr {
Repr::Os(i) => Some(i),
Repr::Custom(..) => None,
Repr::Simple(..) => None,
}
}
}
플랫폼별 sys::decode_error_kind 함수가 오류 코드를 ErrorKind enum으로 매핑한다. 이 조합 덕분에 코드는 .kind()를 검사함으로써 크로스 플랫폼으로 오류 “범주”를 처리할 수 있다. 하지만 OS 의존적으로 아주 특정한 오류 코드에 대해 처리해야 할 필요가 생기면 그것도 가능하다. 이 API는 중요한 저수준 세부사항을 추상화로 ‘지워버리지’ 않으면서, 편리한 추상화를 조심스럽게 제공한다.
std::io::Error는 ErrorKind로부터도 만들 수 있다:
impl From<ErrorKind> for Error {
fn from(kind: ErrorKind) -> Error {
Error { repr: Repr::Simple(kind) }
}
}
이는 오류 코드 스타일의 오류 처리를 크로스 플랫폼으로 접근할 수 있게 해 준다. 가능한 가장 빠른 오류가 필요할 때 유용하다.
마지막으로, 표현(repr)의 세 번째 형태인 완전 커스텀 variant가 있다:
impl Error {
pub fn new<E>(kind: ErrorKind, error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self::_new(kind, error.into())
}
fn _new(
kind: ErrorKind,
error: Box<dyn error::Error + Send + Sync>,
) -> Error {
Error {
repr: Repr::Custom(Box::new(Custom { kind, error })),
}
}
pub fn get_ref(
&self,
) -> Option<&(dyn error::Error + Send + Sync + 'static)> {
match &self.repr {
Repr::Os(..) => None,
Repr::Simple(..) => None,
Repr::Custom(c) => Some(&*c.error),
}
}
pub fn into_inner(
self,
) -> Option<Box<dyn error::Error + Send + Sync>> {
match self.repr {
Repr::Os(..) => None,
Repr::Simple(..) => None,
Repr::Custom(c) => Some(c.error),
}
}
}
주목할 점:
제네릭 new 함수가 단형(monomorphic) _new 함수로 위임한다. 이는 단형화(monomorphization) 동안 중복 생성되는 코드가 줄어들어 컴파일 시간을 개선한다. 런타임도 약간 좋아질 수 있다고 생각한다. _new는 inline으로 표시되지 않아서 호출 지점(call-site)에 함수 호출이 생성될 텐데, 이는 오히려 좋다. 오류 생성은 콜드 패스(cold-path)이며, 명령어 캐시를 아끼는 것이 반갑기 때문이다.
Custom variant는 박싱되어 있다. 전체 size_of를 더 작게 유지하기 위해서다. 스택 위(on-the-stack) 오류 크기는 중요하다. 오류가 전혀 없을 때에도 그 비용을 지불하기 때문이다!
두 타입 모두 'static 오류를 가리킨다:
type A = &(dyn error::Error + Send + Sync + 'static);
type B = Box<dyn error::Error + Send + Sync>
dyn Trait + '_에서 '_는 (트레잇 오브젝트가 참조 뒤에 있지 않다면) 'static으로 생략된다. 반대로 트레잇 오브젝트가 참조 뒤에 있으면 다음처럼 생략된다:
&'a dyn Trait +
'a
get_ref, get_mut, into_inner가 내부 오류에 대한 완전한 접근을 제공한다. os_error 케이스와 비슷하게, 추상화는 세부사항을 흐리지만, 내부 데이터를 있는 그대로 꺼낼 수 있는 후크(hook)도 제공한다.마찬가지로 Display 구현은 내부 표현에 대한 가장 중요한 세부사항을 드러낸다.
impl fmt::Display for Error {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.repr {
Repr::Os(code) => {
let detail = sys::os::error_string(*code);
write!(fmt, "{} (os error {})", detail, code)
}
Repr::Simple(kind) => write!(fmt, "{}", kind.as_str()),
Repr::Custom(c) => c.error.fmt(fmt),
}
}
}
정리하면, std::io::Error는:
ErrorKind 패턴을 통해 범주 기반 오류 처리를 위한 편리한 방법을 제공한다.마지막 항목은 io::Error를 즉석(ad-hoc) 오류로도 사용할 수 있다는 뜻이다. &str과 String은 Box<dyn std::error::Error>로 변환 가능하기 때문이다:
io::Error::new(io::ErrorKind::Other, "something went wrong")
또한 간단한 anyhow 대체제로도 쓸 수 있다. 일부 라이브러리는 이런 식으로 오류 처리를 단순화할 수 있다고 생각한다:
io::Error::new(io::ErrorKind::InvalidData, my_specific_error)
예를 들어 serde_json은 다음 메서드를 제공한다:
fn from_reader<R, T>(rdr: R) -> Result<T, serde_json::Error>
where
R: Read,
T: DeserializeOwned,
Read는 io::Error로 실패할 수 있으므로, serde_json::Error는 내부적으로 io::Error를 표현할 수 있어야 한다. 나는 이것이 방향이 거꾸로(backwards)라고 생각한다(하지만 전체 맥락은 모르며, 틀렸다는 것이 증명된다면 기쁠 것이다!). 대신 시그니처는 이렇게 되었어야 한다고 본다:
fn from_reader<R, T>(rdr: R) -> Result<T, io::Error>
where
R: Read,
T: DeserializeOwned,
그러면 serde_json::Error에는 Io variant가 필요 없고, serde_json::Error는 InvalidData kind를 가진 io::Error 안에 저장(stash)하면 된다.
std::io::Error는 큰 타협 없이도 매우 다양한 유스케이스를 만족시키는 진정으로 놀라운 타입이라고 생각한다. 하지만 더 나아질 수 있을까?
std::io::Error의 가장 큰 문제는 파일 시스템 작업이 실패했을 때, 어떤 경로(path)에서 실패했는지 알 수 없다는 점이다! 이는 이해할 만하다. Rust는 시스템 언어이므로 OS가 제공하는 것 위에 많은 살을 덧붙이면 안 된다. OS는 정수 반환 코드만 돌려주는데, 여기에 힙 할당된 PathBuf를 결합하는 것은 용납하기 어려운 오버헤드일 수 있다!
여기에 대한 명백히 좋은 해결책은 모르겠다. 한 가지 옵션은 (std를 인식하는 cargo를 얻게 되면) 컴파일 타임 또는(RUST_BACKTRACE 같은 방식으로) 런타임 스위치를 추가해, 경로 관련 IO 오류에서 항상 힙 할당을 하게 만드는 것이다. 비슷한 모양의 문제로 io::Error가 백트레이스를 담지 않는다는 점도 있다.
또 다른 문제는 std::io::Error가 가능한 만큼 효율적이지는 않다는 점이다:
assert_eq!(size_of::<io::Error>(), 2 * size_of::<usize>());
enum Repr {
Os(i32),
Simple(ErrorKind),
// 첫 번째 Box :|
Custom(Box<Custom>),
}
struct Custom {
kind: ErrorKind,
// 두 번째 Box :(
error: Box<dyn error::Error + Send + Sync>,
}
이제는 이걸 고칠 수 있다고 생각한다!
첫째, failure나 anyhow처럼 얇은(thin) 트레잇 오브젝트를 사용하면 이중 간접 참조를 없앨 수 있다. 이제 GlobalAlloc이 있으니 구현도 비교적 straightforward하다.
둘째, 포인터는 정렬(alignment)되어 있다는 사실을 이용해, 최하위 비트(least significant bit)를 세트한 usize에 Os와 Simple variant를 모두 숨겨 넣을 수 있다. 심지어 두 번째 최하위 비트도 창의적으로 사용할 수 있다고 본다. 첫 번째 비트는 niche로 남겨 두는 식으로 말이다. 그렇게 하면 io::Result<i32> 같은 것도 포인터 크기(pointer-sized)로 만들 수 있다!
이로써 글을 마친다. 다음에 라이브러리를 위한 오류 타입을 설계하게 되면, 잠시 시간을 내어 std::io::Error의 소스를 들여다보라. 훔칠 만한 것을 발견할지도 모른다!
토론: /r/rust.