Rust 라이브러리 인터페이스를 설계할 때 오류 타입을 어떻게 설계할지에 대한 실용적인 원칙과 예시를 설명합니다.
내가 가장 좋아하는 Rust language 기능 하나를 꼽아야 한다면, 체계적인 오류 처리 방식을 고를 것이다. 합 타입, 제네릭(Result<T, E> 같은), 그리고 표준 라이브러리의 전체적인 설계는 내 엣지 케이스 집착과 완벽하게 잘 맞는다. 완벽하게 거의 맞는다. 나는 polymorphic variants가 몹시 그립다. Rust의 오류 처리는 너무 훌륭해서 Haskell조차 음울하고 몹시 안전하지 않게 보인다. Haskell도 Rust의 오류 처리 접근법을 재현할 수는 있지만, 표준 라이브러리는 런타임 예외의 길을 택했고, 실무자들도 그 흐름을 따랐다. 이 글에서는 Rust에서 라이브러리 인터페이스를 설계할 때 내가 오류를 어떻게 다루는지 설명한다.
오류를 대하는 내 방식은 범용 라이브러리를 쓰는지, 백그라운드 데몬을 쓰는지, 명령줄 도구를 쓰는지에 따라 달라진다.
애플리케이션은 사람과 맞닿아 있다. 애플리케이션은 사람의 개입 없이 문제를 해결하거나, 자동 복구가 불가능하거나 바람직하지 않을 경우 사용자가 문제를 해결할 수 있도록 명확한 설명을 제공할 때 제 역할을 잘한다.
라이브러리 코드는 다른 코드와 맞닿아 있다. 라이브러리는 오류를 투명하게 복구하고, 복구할 수 없는 오류 경우를 프로그래머에게 완전한 목록으로 제공할 때 제 역할을 잘한다.
이 글은 내가 가장 익숙한 분야인 라이브러리 설계를 대상으로 한다. 하지만 핵심인 공감의 원칙은 기계-기계, 인간-기계, 인간-인간 인터페이스 설계에도 똑같이 적용된다.
나는 이것들을 가이드라인이라고 부르고 싶지 않았고, 규칙이라고 부르고 싶지도 않았다. 나는 이것들이 목표이기를 바랐다. 이것들은 여러분이 코드에서 추구해야 할 것들이며, 늘 쉽게 달성되지는 않는다. 그리고 언제나 해낼 수 있는 것도 아닐지 모른다. 하지만 그에 가까워질수록 여러분의 코드는 더 좋아질 것이다.
오류 타입 설계에서 생기는 대부분의 문제는 같은 뿌리에서 나온다. 즉, 호출자보다 코드 작성자에게 편하도록 오류 경우를 만드는 것이다. 이 글에서 설명하는 모든 전략은 다음 만트라의 응용이다.
직접 그 오류를 처리해야 한다고 상상해 보라. 그 오류 타입과 문서만으로 견고한 코드를 작성할 수 있겠는가? 최종 사용자가 이해할 수 있는 메시지로 그 오류를 옮길 수 있겠는가?
다른 언어에서 Rust로 왔다면 익숙한 오류 처리 기법을 적용하고 싶어지기 쉽다. Go를 많이 썼다면 단일 오류 타입이 자연스러워 보일 수도 있다.
⊕ anyhow 패키지를 사용해 Go와 비슷한 오류 처리 방식을 구현한 예.
pub fn frobnicate(n: u64) -> anyhow::Result<String> { /* … */ }
C++로 단련되었거나 grpc를 많이 다뤘다면, 엄청나게 거대한 전역 오류 타입이 좋은 생각처럼 보일 수도 있다.
⊕ 예외에 의존하지 않는 대규모 C 및 C++ 코드베이스에서 흔한 오류 처리 방식은 가능한 모든 오류 경우를 담은 거대한 enum을 정의하는 것이다.
pub enum ProjectWideError {
InvalidInput,
DatabaseConnectionError,
Unauthorized,
FileNotFound,
// …
}
pub fn frobnicate(n: u64) -> Result<String, ProjectWideError> { /* … */ }
이런 접근법도 충분히 잘 작동할 수는 있지만, 장기적으로는 라이브러리 설계에 만족스럽지 않다고 나는 느꼈다. 하지만 명령줄 도구와 데몬에서 오류 구조를 단순화하기 위해 anyhow 방식은 자주 사용한다. 이런 접근법은 오류를 처리 하기보다 오류를 전파 하기에 유리하기 때문이다. 대개는 어떤 연산이 오류를 일으켰는지에 대한 문맥도 거의 없다.
인터페이스의 명확성과 단순함이라는 측면에서는 algebraic data types (adt s)를 이길 것이 없다. frobnicate 함수 인터페이스를 adt의 힘으로 고쳐 보자.
⊕ frobnicate 함수 예제에 대한 관용적인 오류 타입.
pub enum FrobnicateError {
/// Frobnicate does not accept inputs above this number.
InputExceeds(u64),
/// Frobnicate cannot work on mondays. Court order.
CannotFrobnicateOnMondays,
}
pub fn frobnicate(n: u64) -> Result<String, FrobnicateError> { /* … */ }
이제 타입 시스템이 독자에게 정확히 무엇이 잘못될 수 있는지 알려 주므로, 오류를 처리 하기가 아주 쉬워진다.
실패할 수 있는 함수마다 새로운 enum을 정의한다면 프로젝트를 영영 끝내지 못할 것이라고 생각할 수도 있다. 내 경험상 타입 시스템으로 실패를 표현하는 일은 인터페이스의 온갖 특이사항을 문서화하는 것보다 일이 덜 든다. 구체적인 타입은 좋은 문서를 쓰기 쉽게 만든다. 그리고 코드를 테스트하기 시작하면 그 가치를 몇 배로 돌려준다.
구현하는 각 함수에 대해 서로 다른 오류 타입을 도입하는 일을 주저하지 말라. 나는 아직까지 서로 다른 오류 타입을 너무 많이 써서 과했다고 느껴지는 Rust 코드를 본 적이 없다.
⊕ 구체적인 오류 타입이 실제로 어떻게 도움이 되는지: 테스트 케이스 작성이 더 즐거워진다.
#[test]
fn test_unfrobnicatable() {
assert_eq!(FrobnicateError::InputExceeds(MAX_FROB_INPUT), frobnicate(u64::MAX));
}
#[test]
fn test_frobnicate_on_mondays() {
sleep_until(next_monday());
assert_eq!(FrobnicateError::CannotFrobnicateOnMondays, frobnicate(0));
}
panic!매크로는 프로그램에서 감지된 버그를 나타내는 오류를 구성하는 데 사용된다.
Rust에서 panics의 주된 목적은 프로그램의 버그를 나타내는 것이다. 입력이 최종 사용자에게서 올 가능성이 있다면, 문서에 panic을 아무리 꼼꼼히 적어 두었더라도 입력 검증에 panic을 쓰고 싶은 유혹을 참아라. 사람들은 문서를 좀처럼 읽지 않으며, 경고를 쉽게 놓친다. 타입 시스템으로 그들을 안내하라.
⊕ 올바른 입력을 문서에 의존해 지정하는 라이브러리 함수.
/// Frobnicates an integer.
///
/// # Panics
///
/// This function panics if
/// * the `n` argument is greater than [`MAX_FROB_INPUT`].
/// * you call it on Monday.
pub fn frobnicate(n: u64) -> String { /* … */ }
반대로, 당신의 코드에서 반드시 성립해야 하는 불변식을 검사하기 위해 panic과 assertion을 사용하는 것은 괜찮다.
⊕ 불변식과 사후 조건을 검사하기 위해 assertion을 사용하는 예.
pub fn remove_from_tree<K: Ord, V>(tree: &mut Tree<K, V>, key: &K) -> Option<V> {
let maybe_value = /* … */;
debug_assert!(tree.balanced());
debug_assert!(!tree.contains(key));
maybe_value
}
실패가 호출자 프로그램의 심각한 버그를 나타낸다면 잘못된 입력에 대해 panic을 일으켜도 된다. 좋은 예로는 배열 경계 밖 인덱스나, 법칙을 지키지 않는 트레이트 구현(예를 들어 Ord 타입이 전순서 요구사항을 위반하는 경우)이 있다.
좋은 함수는 잘못된 입력에 대해 panic하지 않는다. 위대한 함수는 입력을 검증할 필요조차 없다. 이메일을 보내는 다음 함수 인터페이스를 생각해 보자.
⊕ send_mail 함수는 이메일 주소를 검증하고 이메일을 보낸다.
pub enum SendMailError {
/// One of the addresses passed to send_mail is invalid.
MalformedAddress { address: String, reason: String },
/// Failed to connect to the mail server.
FailedToConnect { source: std::io::Error, reason: String },
/* … */
}
pub fn send_mail(to: &str, cc: &[&str], body: &str) -> SendMailError { /* … */ }
여기서 send_mail 함수는 적어도 두 가지 일을 한다는 점에 주목하자. 이메일 주소를 검증하는 일과 이메일을 보내는 일이다. 유효한 주소를 입력으로 기대하는 함수가 많아지면 이런 구조는 금세 성가셔진다. 한 가지 해결책은 코드에 타입을 더 뿌려 두는 것이다. 이 경우에는 유효한 이메일 주소만 담는 EmailAddress 타입을 도입할 수 있다.
⊕ send_mail의 입력이 구성 단계에서부터 유효하도록 새 타입을 도입하는 예.
/// Represents valid email addresses.
pub struct EmailAddress(String);
impl std::str::FromStr for EmailAddress {
type Err = MalformedEmailAddress;
fn from_str(s: &str) -> Result<Self, Self::Err> { /* … */ }
}
pub enum SendMailError {
// no more InvalidAddress!
FailedToConnect { source: std::io::Error, reason: String },
/* … */
}
pub fn send_mail(
to: &EmailAddress,
cc: &[&EmailAddress],
body: &str,
) -> SendMailError { /* … */ }
유효한 주소를 다루는 함수가 더 추가되더라도, 그런 함수들은 더 이상 검증 로직을 실행하거나 주소 검증 오류를 반환할 필요가 없다. 또한 호출자는 프로그램이 그 주소를 받는 지점에 더 가까운 곳, 더 이른 시점에서 주소 검증을 수행할 수 있다.
오류 타입에 대해 std::error::Error 트레이트를 구현하는 것은 예의를 차리는 일과 비슷하다. 진심이 아니더라도 하는 편이 좋다.
어떤 호출자는 여러분의 아름다운 설계보다 다른 것에 더 관심이 있을 수 있다. 예를 들어 여러분의 오류를 Box<Error>나 anyhow::Result에 집어넣고 그냥 넘어가고 싶을 수도 있다. 그들은 CPU가 4096개인 기계를 처리할 필요가 없는 작은 명령줄 도구를 만들고 있을지도 모른다. 오류 타입에 std::error::Error를 구현해 두면 그들의 삶이 더 쉬워진다.
std::error::Error 트레이트 구현이 너무 번거롭다고 느껴진다면 thiserror 패키지를 써 보라.
⊕ thiserror 패키지를 사용해 std::error::Error 트레이트 구현을 단순화하는 예.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum FrobnicateError {
#[error("cannot frobnicate numbers above {0}")]
InputExceeds(u64),
#[error("thy shall not frobnicate on mondays (court order)")]
CannotFrobnicateOnMondays,
}
내가 가장 흔히 보는 오류의 형태는 다음과 같다.
⊕ 호출 그래프가 복잡한 함수에서 흔한 오류 처리 방식: 결과 오류 타입이 모든 의존성의 오류 타입을 감싼다.
pub enum FetchTxError {
IoError(std::io::Error),
HttpError(http2::Error),
SerdeError(serde_cbor::Error),
OpensslError(openssl::ssl::Error),
}
pub fn fetch_signed_transaction(
id: Txid,
pk: &[u8],
) -> Result<Option<Tx>, FetchTxError> { /* … */ }
이 오류 타입은 호출자에게 여러분이 무엇을 해결하려는지 알려 주지 않고, 어떻게 해결하는지를 알려 준다. 구현 세부사항이 호출자 코드로 새어 나와 많은 고통을 일으킨다.
* 가능한 오류 경우를 알려면 여러분의 클라이언트는 새어 나온 의존성의 문서를 읽어야 한다. 예를 들어 [`openssl::ssl::Error`](https://docs.rs/openssl/0.10.42/openssl/ssl/struct.Error.html)를 보라. 어떤 `openssl` 라이브러리 함수가 이 오류를 반환했는지도 모른 채 좋은 복구 전략을 세울 수 있겠는가?
* 여러분의 클라이언트는 여러분의 오류를 처리하기 위해 `openssl`과 `serde_cbor`를 직접 의존성에 추가해야 한다. 여러분이 `openssl`에서 `libressl`로, 또는 `serde_cbor`에서 `ciborium`으로 바꾸기로 하면, 클라이언트도 코드를 수정해야 한다.
이제 그 코드를 호출하는 동료 프로그래머의 안녕을 중심에 두고 `FetchTxError` 타입을 다시 설계해 보자.
⊕ `fetch_signed_transaction` 함수 예제에 대한 관용적인 오류 타입. `FetchTxError` 타입 생성자는 특정 해결책이 아니라 문제 영역을 기준으로 실패 경우를 표현한다. 타입에 외부 의존성이 없다는 점에 주목하라.
pub enum FetchTxError { /// Could not connect to the server. ConnectionFailed { url: String, reason: String, cause: Optionstd::io::Error, // ① },
/// Cannot find transaction with the specified txid. TxNotFound(Txid), // ②
/// The object data is not valid CBOR. InvalidEncoding { // ③ data: Bytes, error_offset: Option<usize>, error_message: String, },
/// The public key is malformed. MalformedPublicKey { // ④ key_bytes: Vec<u8>, reason: String, },
/// The transaction signature does not match the public key. SignatureVerificationFailed { // ④ txid: Txid, pk: Pubkey, sig: Signature, }, }
pub fn fetch_signed_transaction( id: Txid, pk: &[u8], ) -> Result<Tx, FetchTxError> { /* … */ }
새 설계는 여러 가지 개선점을 제공한다.
1. `ConnectionFailed` 생성자는 하위 수준의 `std::io::Error` 오류를 감싼다. 여기서는 무엇이 잘못되었는지 이해할 충분한 문맥이 있으므로 감싸기가 잘 작동한다.
2. `Option` 타입을 명시적인 오류 생성자인 `TxNotFound`로 대체해 `None` 경우의 의미를 분명하게 했다.
3. `InvalidEncoding` 생성자는 우리가 사용하는 디코딩 라이브러리의 세부사항을 숨긴다. 이제 다른 사람의 코드를 깨뜨리지 않고 `serde_cbor`를 교체할 수 있다.
4. 일반적인 암호화 오류를 `MalformedPublicKey`와 `SignatureVerificationFailed`라는 두 가지 구체적인 경우로 바꿨다. 이제 동료 프로그래머는 합리적인 판단을 내릴 더 많은 문맥을 갖게 된다. `MalformedPublicKey`는 사용자가 잘못된 키를 제공했다는 뜻이고, `SignatureVerificationFailed`는 상대방이 데이터를 변조했을 수 있음을 의미하므로 다른 피어에 연결을 시도해야 한다.
내가 `fetch_signed_transaction`을 호출해야 한다면 나는 후자의 인터페이스를 택하겠다. 여러분이라면 어떤 인터페이스를 고르겠는가? 어느 쪽이 테스트하기 더 쉬울까?
### [오류를 감싸지 말고 포함하라](https://mmapped.blog/posts/12-rust-error-handling#embed-not-wrap)
바로 앞 절에서 오류 경우를 포함시키는 전술을 이미 보았다. 이 전술은 인터페이스 이해를 너무나 쉽게 만들어 주기 때문에 더 많은 주목을 받을 가치가 있다.
우리가 암호학적 서명을 검증하는 작은 라이브러리를 작업 중이라고 상상해 보자. [ECDSA](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm)와 [BLS](https://en.wikipedia.org/wiki/BLS_digital_signature) 서명을 지원하고 싶다. 우선 가장 저항이 적은 길부터 시작해 보자.
⊕ 서드파티 라이브러리의 오류를 감싸는 서명 검증 함수 인터페이스.
pub enum Algorithm { Ecdsa, Bls12381 };
pub enum VerifySigError { EcdsaError { source: ecdsa::Error, context: String }, BlsError { source: bls12_381_sign::Error, context: String }, }
pub fn verify_sig( algorithm: Algorithm, pk: Bytes, sig: Bytes, msg_hash: Hash, ) -> Result<(), VerifySigError> { /* … */ }
이 `verify_sig` 함수 설계에는 몇 가지 문제가 있다.
* 호출자가 `algorithm`으로 `Ecdsa`를 넘기면 오류는 `EcdsaError`만 나올 수 있다는 암묵적 가정이 있다. 의미상으로는 분명해야 하지만, 타입 시스템은 이 불변식을 강제하지 않는다.
* 오류 타입이 호출자에게 구현 세부사항을 노출한다.
* 지원 알고리즘 목록을 확장하면 호출자는 모든 호출 지점을 수정해야 할 수도 있다.
이 문제들은 중첩 한 겹을 없애고 `ecdsa::Error`와 `bls12_381_sign::Error`의 오류 경우를 `VerifySigError` 오류 타입 안에 포함시킴으로써 해결할 수 있다. 그 결과는 분명하고 자기 설명적인 오류 타입이 되며, 호출자에게 여러분이 그들을 배려하고 있다는 사실을 전달한다.
⊕ 서드파티 라이브러리에서 온 오류 경우를 포함하고 중복을 제거한 서명 검증 함수 인터페이스.
pub enum Algorithm { Ecdsa, Bls12381 };
pub enum VerifySigError { MalformedPublicKey { pk: Bytes, reason: String }, MalformedSignature { sig: Bytes, reason: String }, SignatureVerificationFailed { algorithm: Algorithm, pk: Bytes, sig: Bytes, reason: String }, // … }
pub fn verify_sig( algorithm: Algorithm, pk: Bytes, sig: Bytes, msg_hash: Hash, ) -> Result<(), VerifySigError> { /* … */ }
오류를 감싸는 것이 타당한 경우도 몇 가지 있다.
* 시도한 연산과 관련 경로 같은 충분한 문맥을 포함한다면 `std::io::Error`를 감싸는 것은 허용 가능하다. `std::io::Error`는 추가 의존성을 가져오지 않고 숙련된 Rust 프로그래머라면 누구에게나 익숙하므로 인지 부담을 거의 늘리지 않는다. 또한 `std::io::Error`는 까다로운 경우를 진단하는 데 도움이 될 수 있는 하위 수준의 [운영체제 오류 코드](https://doc.rust-lang.org/std/io/struct.Error.html#method.raw_os_error)를 담을 수도 있다.
* 더 상위 수준의 오류를 문자열로 바꾸고 그 문자열을 여러분의 오류에 붙이는 것도 흔히 괜찮다. 단, 그 오류 타입 생성자가 충분히 설명적이어야 한다. 다만 이런 문자열에 이메일 주소나 비밀 키 같은 민감한 정보가 포함되지 않는지 확인해야 한다.
오류를 문자열로 바꾸는 대신 `Box<dyn Error>`로 감싸 두는 편을 더 선호할 수도 있다. 그러면 호출자가 오류를 [downcast](https://doc.rust-lang.org/1.62.0/std/error/trait.Error.html#method.downcast)하고, 문자열 변환을 미루고, [`source`](https://doc.rust-lang.org/1.62.0/std/error/trait.Error.html#method.source) 메서드로 오류 스택을 순회할 수 있기 때문이다. 하지만 실제로는 오류를 박싱하는 방식이 내게 큰 도움이 되지 않았다.
* 호출자가 원래 오류에서 어떤 정보를 프로그램적으로 꺼내야 한다면, 관련된 부분을 포함시키거나 타입 생성자를 더 추가하라. downcasting은 단기 처방이다.
* 호출자가 오류를 downcast하려면 같은 전이 의존성의 같은 시맨틱 버전에 의존해야 한다. 버전이 어긋나면 클라이언트 코드는 조용히 깨질 수 있다(예를 들어 클라이언트 코드는 `0.3`, 여러분의 코드는 `0.4`).
* 오류 타입을 복제하거나 직렬화하는 것이 불가능해진다(내 오류는 종종 프로세스 경계를 넘는다).
## [자료](https://mmapped.blog/posts/12-rust-error-handling#resources)
오류 처리 접근법에 관한 연구는 많다. 하지만 이런 아이디어를 실제 프로그래밍 인터페이스에 실용적으로 적용하는 일은 좋은 취향과 인간적인 연민을 필요로 하는 예술이다. 다음 자료들은 오류에 대한 내 생각에 가장 깊은 흔적을 남겼다.
1. David Teller, Arnaud Spiwack, Till Varoquaux의 [Catch me if you can: Looking for type-safe, hierarchical, lightweight, polymorphic and efficient error management in OCaml](https://web.archive.org/web/20110818020758/http://www.univ-orleans.fr/lifo/Members/David.Teller/publications/ml2008.pdf). 이 글은 고수준 함수형 언어의 기능이 어떻게 오류를 다루는 강력한 새로운 방식을 낳는지 보여 준다.
2. Haskell Wiki의 [Error vs. Exception](https://wiki.haskell.org/Error_vs._Exception) 글에는 `panic`(그 글에서는 error라고 부른다)과 복구 가능한 오류(그 글에서는 exception이라고 부른다) 사이에 생각할 거리를 주는 몇 가지 흥미로운 평행점이 있다.
3. Alexis King의 [Parse, don’t validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/)는 타입 주도 설계와 오류 처리에 대한 아름다운 입문서다.
4. Matt Parsons의 [The Trouble with Typed Errors](https://www.parsonsmatt.org/2018/11/03/trouble_with_typed_errors.html). 나는 Haskell에 특화된 그의 아이디어를 Rust에서 그대로 재현하려 하지는 않겠지만, 오류를 타입으로 정확히 표현하려는 그의 열정에는 깊이 공감한다.
이 글은 [Reddit](https://www.reddit.com/r/rust/comments/yvdz6l/blog_post_designing_error_types_in_rust)에서 토론할 수 있다.
## 비슷한 글
* [When Rust hurts](https://mmapped.blog/posts/15-when-rust-hurts.html)
* [Rust at scale: packages, crates, and modules](https://mmapped.blog/posts/03-rust-packages-crates-modules.html)
* [Tutorial: stable-structures](https://mmapped.blog/posts/14-stable-structures.html)
* [Scaling Rust builds with Bazel](https://mmapped.blog/posts/17-scaling-rust-builds-with-bazel.html)
* * *