The Missing Piece in Rust Error Handling
Rust 오류 처리에 빠진 조각
Rust의 오류 집합을 타입 선언 없이 조합하고, 처리한 오류를 반환 타입에서 제거하는 라이브러리 eros를 소개합니다. 함수 시그니처에 실제로 남은 오류만 표현하면서 운영 맥락도 함께 전달하는 방식을 설명합니다.
- 주제
AI 요약
Rust는 명시적인 제어 흐름과 값으로서의 오류, ?를 이용한 간결한 전파를 지원합니다. 불편한 지점은 Result의 오류 타입을 정하는 일입니다. 구체적인 오류 타입을 쓰면 열거형과 변환 코드를 마련해야 하고, anyhow처럼 불투명한 타입을 쓰면 함수가 어떤 오류를 반환하는지 시그니처에서 알기 어렵습니다. 글은 정밀함과 편의성을 함께 얻으려면 오류 타입도 함수처럼 쉽게 조합할 수 있어야 한다고 주장합니다.
오류 타입을 조합하기
파일에서 서버 포트를 읽는 함수는 파일 읽기에서 io::Error, 숫자 변환에서 ParseIntError를 만납니다. 일반적인 방식은 두 오류를 담은 PortError 열거형을 만들고 thiserror의 From 지원을 쓰는 것입니다. 하지만 다른 작업을 추가할 때마다 오류 열거형을 중첩하거나, 변환을 늘리거나, 프로그램 전체에 큰 오류 타입을 둘지 결정해야 합니다. 큰 타입을 쓰면 함수가 실제로 반환하지 않는 오류까지 시그니처에 표시될 수 있습니다. anyhow 방식은 전파와 문맥 추가가 편리하지만, 컴파일러가 처리하지 않은 구체적인 오류를 추적하지 못합니다.
저자는 라이브러리에는 타입 오류를, 애플리케이션에는 불투명한 오류를 쓰라는 구분 대신 호출자가 오류 종류에 따라 다른 처리를 해야 하는지를 기준으로 삼습니다. eros에서는 eros::Result<u16, (io::Error, ParseIntError)>처럼 튜플로 오류 집합을 적습니다. 튜플은 두 오류를 함께 저장하는 구조가 아니라, 발생 가능한 타입 중 하나를 담는 열린 합 타입(open sum type)입니다. 따라서 조합마다 새 열거형을 선언하지 않아도 됩니다.
일반 Result의 오류에는 .union()을 붙여 주변 시그니처가 정한 오류 집합에 넣습니다. 시그니처에서 io::Error를 빼면 파일 읽기 오류를 전파하는 코드가 컴파일되지 않습니다. 여러 함수의 결과를 합칠 때는 .widen()을 사용합니다. 호스트 주소를 읽고 포트를 읽은 뒤 소켓을 바인딩하는 함수라면 (io::Error, AddrParseError, ParseIntError)를 선언하고 각 하위 함수의 오류 집합을 넓혀 합칠 수 있습니다. 가능한 오류가 선언된 집합에 없으면 컴파일 오류가 발생하며, 두 작업에서 공통으로 생기는 io::Error는 한 번만 적습니다.
오류를 처리하면 반환 타입도 달라집니다
recover는 특정 오류를 처리한 뒤 해당 타입을 오류 집합에서 제거합니다. 포트 파일을 읽지 못하면 기본값 8080을 쓰되 숫자 변환 실패는 호출자에게 남긴다면, 반환 오류 집합에는 ParseIntError만 표시됩니다. 읽기 오류는 해당 함수가 이미 복구했으므로 호출자는 이를 고려할 필요가 없습니다. 여러 오류를 모두 기본값으로 처리하면 집합은 빈 튜플 ()이 됩니다. 이때 .into_value()는 남은 오류가 없을 때만 값을 꺼내도록 컴파일 단계에서 확인합니다.
반대로 호출자가 오류 종류를 구분하지 않고 위로 전달하기만 한다면 eros::Result<u16>처럼 오류 집합을 생략할 수 있습니다. 이때 기본값은 AnyError입니다. 구체적인 오류 타입을 쓰는 하위 함수에서 이 포괄적인 타입으로 ?를 이용해 전파할 수도 있습니다. 따라서 복구 판단이 필요한 경계에서는 타입을 유지하고, 전달만 하는 경계에서는 타입을 지울 수 있습니다. 저자는 이를 애플리케이션 전체에 한 가지 오류 처리 방식을 강제하지 않는 선택지로 설명합니다.
오류 타입과 운영 맥락
구체적인 타입만으로는 어떤 파일을 읽다가 실패했는지 알 수 없습니다. PermissionDenied 같은 타입은 복구 정책을 정하는 데 유용하지만, 실패를 파악하려면 경로와 작업 정보도 필요합니다. #[context("Load server port from {}", path)] 같은 매크로로 함수의 작업과 입력값을 오류에 붙이고, 호출 지점에서도 Configure server, Start application 같은 문맥을 추가할 수 있습니다. 예시 출력은 원래 오류 메시지를 유지한 뒤 문맥을 안쪽부터 바깥쪽 순서로 나열합니다. 타입은 어떤 복구를 할지 알려주고, 문맥은 복구하지 못한 실패가 어디서 일어났는지 설명합니다.
글은 앞서 error_set으로 정밀한 오류 집합을 탐구한 경험을 언급합니다. eros에서도 Result, ?, 오류 값이라는 Rust의 기존 구조를 유지하면서 오류 집합을 조합하고 줄이는 데 초점을 둡니다. 덧붙인 Zig 예시에서는 ||로 오류 집합을 합치고 try로 전파합니다. Rust의 eros는 이 조합성을 유지하면서 ZeroPort처럼 입력값을 담은 오류 페이로드도 표현할 수 있다고 설명합니다.
Lobsters 반응
- @abrambleninja — 정말 멋집니다. 공유해 주셔서 감사합니다. 저도 오류 타입에서 똑같은 불편을 겪었는데, 이 방식은 아주 좋아 보입니다. 실제로 해당 호출에서 발생할 수 있는 작은 오류 집합으로 오류를 정의할 수 있다는 점이 좋습니다. 관련 없는 오류까지 모듈 수준 열거형에 넣지 않아도 되니 실제 발생 가능한 경우를 더 정확히 표현할 수 있습니다. 꼭 써보고 싶습니다. 오류 처리에서 또 불편했던 점은 여러 오류를 반환하는 코드 작성입니다. 백엔드 CRUD 애플리케이션에서 생성 요청의 각 필드를 검증한다고 해보겠습니다. 제약을 위반한 필드를 하나만 반환하는 대신 위반한 필드를 모두 반환하고 싶지만,
Result에서는?가 즉시 반환하므로 어렵습니다. 오류 벡터를 쓰는 크레이트를 이용한 해결책도 봤지만 그다지 우아하게 느껴지지는 않았습니다. 이 크레이트의 구성 요소 중 해결책을 찾는 데 도움이 될 만한 것이 있는지 궁금합니다. - @wrs — 정말 좋아 보입니다. 단점은 없나요? 기존 코드를 호출할 때 오류를 이 타입으로 바꾸는
anyhow어댑터가 있을 수 있는지도 궁금합니다.- @madsmtm — 처음에는
.widen()이나.narrow()를 쓸 때 타입 순서를 맞춰야 한다는 점이 단점이라고 생각했습니다. 하지만 순서와 상관없이 작동하는 것 같습니다.eros라이브러리는 오류를 실제로 순서 없는 집합처럼 다루기 위해 여러 기술을 쓰는 것 같습니다. 같은 타입을 두 번 지정하거나 오류가 아닌 타입을 넣는 등 잘못된 조합을 쓰면 컴파일 오류가 단점일 수 있을까요? 시험해 보지는 않았습니다. 어쨌든 이 모든 방식이 어떻게 작동하는지 설명하는 글을 읽고 싶습니다. - @yshui — 제가 이해한 바로는 컴파일 시점 타입 태그를 붙인
anyhow와 본질적으로 비슷합니다. 다만 특성(trait) 풀이가 꽤 까다로워 보여 컴파일 시간이 얼마나 걸릴지 걱정됩니다. - @wrs — 제 비전문가 눈에는 앞서 말한 순서 없는 집합 기법의 부분집합 탐색이 지수 시간이 아니라 이차 시간으로 보입니다. 오류 타입이 보통 개수라면 문제가 아닐 수도 있겠습니다. 튜플 제네릭을 직접 작성해야 해서 오류 타입을 임의로 26개까지만 쓸 수 있다는 점도 보입니다.
- @madsmtm — 처음에는
원문: mcmah309.github.io / 번역·요약: Trawling