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을 정의하는 것이다:
rustpub 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를 달성하긴 한다.
전파 중심인 두 번째 오류 관리 방식은 보통 박싱된 트레잇 오브젝트를 사용해 처리한다. 예를 들어
rustBox<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는 대략 이렇게 생겼다:
rustpub 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이 제공된다:
rust#[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)다:
rustimpl 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로부터도 만들 수 있다:
rustimpl From<ErrorKind> for Error { fn from(kind: ErrorKind) -> Error { Error { repr: Repr::Simple(kind) } } }
이는 오류 코드 스타일의 오류 처리를 크로스 플랫폼으로 접근할 수 있게 해 준다. 가능한 가장 빠른 오류가 필요할 때 유용하다.
마지막으로, 표현(repr)의 세 번째 형태인 완전 커스텀 variant가 있다:
rustimpl 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 오류를 가리킨다:
rusttype A = &(dyn error::Error + Send + Sync + 'static); type B = Box<dyn error::Error + Send + Sync>
dyn Trait + '_에서 '_는 (트레잇 오브젝트가 참조 뒤에 있지 않다면) 'static으로 생략된다. 반대로 트레잇 오브젝트가 참조 뒤에 있으면 다음처럼 생략된다:
rust&'a dyn Trait + 'a
get_ref, get_mut, into_inner가 내부 오류에 대한 완전한 접근을 제공한다. os_error 케이스와 비슷하게, 추상화는 세부사항을 흐리지만, 내부 데이터를 있는 그대로 꺼낼 수 있는 후크(hook)도 제공한다.마찬가지로 Display 구현은 내부 표현에 대한 가장 중요한 세부사항을 드러낸다.
rustimpl 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은 다음 메서드를 제공한다:
rustfn 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)라고 생각한다(하지만 전체 맥락은 모르며, 틀렸다는 것이 증명된다면 기쁠 것이다!). 대신 시그니처는 이렇게 되었어야 한다고 본다:
rustfn 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>());
rustenum 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.