오류 타입을 String 하나로 뭉치면 처음에는 편합니다. 문자열에 하위 오류 메시지가 남을 수는 있지만 파일 읽기 실패와 잘못된 설정값을 구분할 구조, 구체 오류 타입, 오류 원인 체인은 사라집니다. 반대로 모든 실패에서 panic!을 호출하면 복구할 수 있는 입력 오류까지 프로세스 종료로 바뀝니다. 오류 처리는 문구 작성보다 경계를 정하는 일에 가깝습니다.
이 글의 예제는 설정 파일을 읽고 파싱합니다. 표준 라이브러리만으로 Display, std::error::Error, source()를 구현하고 ?가 오류를 전달하거나 변환하는 지점을 살핍니다. 같은 코드를 단위 테스트, 통합 테스트, 실패 케이스와 테스트 예제로 검증합니다. thiserror를 피해야 한다는 주장이 아닙니다. 매크로가 대신 생성하는 코드를 먼저 이해한 뒤 반복이 실제로 커질 때 도입하자는 순서입니다.
1. 복구 가능한 실패와 버그
오류는 크게 복구 가능한 오류와 복구 불가능한 오류로 나뉩니다. 설정 파일 없음, 잘못된 포트, 네트워크 일시 장애처럼 호출자가 재시도하거나 사용자에게 설명할 수 있는 실패에는 Result<T, E>가 맞습니다. 배열 인덱스가 내부 불변식을 깨뜨렸거나 실행 불가능해야 하는 분기에 도달했다면 panic!, assert!, unreachable!로 프로그래머 오류를 드러낼 수 있습니다.
구분 기준은 실패가 드문가가 아닙니다. 잘못된 사용자 입력은 흔하지 않아도 정상적인 실행 환경에서 생길 수 있습니다. 따라서 예제의 파일 및 파싱 오류는 모두 Result로 돌려줍니다. 테스트에서 expect를 쓰는 것은 실패 시 테스트를 즉시 중단하고 의도를 붙이려는 선택이며 라이브러리의 복구 가능한 경로를 expect로 바꾸자는 뜻이 아닙니다.
2. 기계와 사람을 위한 오류 타입
ConfigError는 실패 종류별 데이터를 보존합니다. Read에는 경로와 io::Error, InvalidNumber에는 줄 번호, 키, 원문 값, ParseIntError가 들어갑니다.
#[derive(Debug)]
pub enum ConfigError {
Read {
path: PathBuf,
source: io::Error,
},
InvalidLine {
line: usize,
text: String,
},
InvalidNumber {
line: usize,
key: &'static str,
value: String,
source: ParseIntError,
},
MissingKey(&'static str),
}
호출자는 variant 패턴 매칭으로 대응할 수 있고 Display는 로그나 CLI에 보일 상위 문맥을 만듭니다. Debug와 Display의 역할을 섞지 않는 편이 좋습니다. 전자는 개발자용 구조 표현이고 후자는 한 단계의 간결한 설명입니다.
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read { path, .. } => {
write!(formatter, "failed to read configuration {}", path.display())
}
Self::InvalidLine { line, text } => {
write!(formatter, "invalid configuration at line {line}: {text:?}")
}
Self::InvalidNumber {
line, key, value, ..
} => write!(formatter, "invalid {key} value {value:?} at line {line}"),
Self::MissingKey(key) => write!(formatter, "missing required key {key:?}"),
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Read { source, .. } => Some(source),
Self::InvalidNumber { source, .. } => Some(source),
Self::InvalidLine { .. } | Self::MissingKey(_) => None,
}
}
}
source()는 문자열을 이어 붙이는 기능이 아닙니다. 현재 오류를 일으킨 하위 오류를 구조적으로 노출합니다. 파일 경로는 ConfigError::Read가 담당하고 OS 오류 종류와 메시지는 io::Error에 남습니다. 숫자 파싱도 마찬가지입니다. CLI는 오류 원인 체인을 순회해 각 층을 출력할 수 있고 테스트는 io::ErrorKind::NotFound 같은 값을 검사할 수 있습니다.
source가 없는 InvalidLine과 MissingKey는 예제가 직접 발견한 오류입니다. 억지로 원인을 만들어 넣을 필요는 없습니다. 모든 variant가 같은 깊이의 체인을 가져야 한다는 규칙도 없습니다.
3. ?와 오류 문맥
?는 Result::Err를 만나면 현재 함수에서 일찍 반환합니다. 반환 오류 타입이 다르면 From을 통한 변환도 시도합니다. 그렇다면 문맥까지 자동으로 만들어 줄까요? 어떤 문맥을 보존할지는 개발자가 결정해야 합니다.
pub fn load_settings(path: impl AsRef<Path>) -> Result<Settings, ConfigError> {
let path = path.as_ref();
let text = fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
parse_settings(&text)
}
여기서 io::Error에 대해 포괄적인 From<io::Error> for ConfigError를 구현하지 않았습니다. 자동 변환만 쓰면 어떤 파일을 읽다 실패했는지 담기 어렵기 때문입니다. 파일 I/O 경계에서 map_err로 경로를 추가한 뒤 ?로 반환합니다. 문맥이 필요한 자리에서는 명시적 변환이 더 정확합니다.
반면 애플리케이션 최상위 경계에서는 모든 설정 오류를 개별 variant가 아니라 하나의 상위 오류 타입으로 감싸도 정보가 줄지 않습니다. AppError가 ConfigError를 그대로 오류 원인으로 보관하므로 From과 ?가 잘 맞습니다.
impl From<ConfigError> for AppError {
fn from(source: ConfigError) -> Self {
Self { source }
}
}
pub fn load_for_app(path: impl AsRef<Path>) -> Result<Settings, AppError> {
Ok(load_settings(path)?)
}
이 변환 경계는 손실이 없는가라는 질문으로 검토하면 됩니다. 경로, 줄 번호, 원본 값, 하위 오류가 살아 있다면 상위 타입으로 묶어도 진단에 필요한 정보가 남습니다. .to_string() 결과에 하위 오류 문구가 포함될 수는 있어도 variant의 정체성과 오류 원인 체인을 구조적으로 탐색할 수 없게 되므로 API 경계에서는 대개 이릅니다.
4. 오류 crate 도입 기준
직접 구현은 원리를 보여 주지만 variant가 늘면 Display, Error::source, From의 반복도 늘어납니다. thiserror 같은 crate는 derive 매크로와 attribute로 이 반복을 줄이고 필드를 source로 연결하거나 From 구현을 생성하는 데 유용합니다. 팀에서 오류 enum이 많고 패턴이 안정됐다면 유지보수 비용을 낮출 수 있습니다.
선택 기준은 직접 구현이 더 순수한가가 아닙니다. 공개 API의 오류 구조를 명확히 정한 뒤 반복량, 매크로 의존성, 컴파일 시간, 팀의 익숙함을 함께 봅니다. 애플리케이션의 최상위 보고에는 동적 오류 보고와 추가 문맥을 제공하는 crate가 편할 수도 있습니다. 라이브러리 경계에서는 호출자가 분기할 수 있는 구체 오류 타입이 더 중요할 수 있습니다. 어느 쪽이든 원래 원인을 문자열로 지우지 않는 원칙은 같습니다.
5. 단위 테스트와 통합 테스트
단위 테스트는 src/lib.rs 안의 #[cfg(test)] 모듈에 둡니다. 비공개 parse_settings에 접근할 수 있어 줄 번호 계산, 알 수 없는 키, 필수 키 누락처럼 작은 규칙을 빠르게 고정하기 좋습니다.
#[test]
fn reports_the_line_and_value_for_an_invalid_port() {
let error = parse_settings("host=localhost\nport=nope\nretries=2\n")
.expect_err("invalid port should fail");
assert_eq!(error.to_string(), "invalid port value \"nope\" at line 2");
assert!(error.source().is_some());
}
tests/ 아래의 통합 테스트는 외부 crate처럼 공개 API만 사용합니다. 파일 읽기와 파싱을 함께 거치는지, 공개 오류 타입이 필요한 데이터를 보존하는지 검사합니다. 구현 세부를 직접 호출하지 않으므로 내부 리팩터링에 덜 민감합니다.
#[test]
fn invalid_fixture_preserves_parse_error_as_its_source() {
let error = load_settings(fixture("invalid-port.conf"))
.expect_err("invalid fixture should return an error");
assert!(matches!(
error,
ConfigError::InvalidNumber {
line: 2,
key: "port",
ref value,
..
} if value == "not-a-number"
));
assert!(error.source().is_some());
}
두 층에서 똑같은 입력을 반복할 필요는 없습니다. 파서의 세부 분기는 단위 테스트로 촘촘히 확인하고 통합 테스트는 파일 경계와 공개 계약에 집중합니다.
6. 오래가는 실패 테스트와 예제
실패 테스트는 실패했다는 사실만 검사하면 약합니다. 예제는 잘못된 포트가 2번째 줄에서 발견됐는지, 입력 값이 보존됐는지, 하위 파싱 오류가 남았는지를 함께 확인합니다. 존재하지 않는 파일 테스트도 문자열 전체 대신 저장된 경로와 io::ErrorKind::NotFound를 검사합니다. OS별 문구 변화에 덜 흔들리면서 계약은 더 정확히 고정합니다.
테스트 예제는 fixtures/valid.conf, fixtures/invalid-port.conf, fixtures/missing-host.conf로 작게 나눴습니다. 통합 테스트는 현재 작업 디렉터리를 가정하지 않고 env!("CARGO_MANIFEST_DIR")에서 경로를 만듭니다. 그러면 프로젝트 루트나 crate 디렉터리 어디서 cargo test를 실행해도 같은 파일을 찾습니다.
#[should_panic]은 panic 자체가 계약인 코드에 씁니다. 잘못된 설정처럼 Result::Err가 계약인 경우에는 expect_err, variant 패턴 매칭, 오류 원인 검사 쪽이 낫습니다. 컴파일되면 안 되는 API 규칙을 가르칠 때는 별도의 compile-fail 테스트 예제나 doctest를 둘 수 있지만 이 예제는 컴파일 진단을 주장하지 않으므로 런타임 실패 테스트만 포함합니다.
7. 독립 예제 실행과 테스트
이 예제는 Rust 2024를 사용합니다. 외부 의존성은 없습니다.
cd examples/article-17-errors-testing
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
Rust 1.98.1과 Cargo 1.98.1에서 다섯 명령은 모두 종료 코드 0으로 끝나야 합니다. 테스트 모음에는 단위 테스트 4개와 통합 테스트 3개가 있습니다. 통합 테스트 부분은 다음과 같습니다.
running 3 tests
test loads_the_valid_fixture_through_the_public_api ... ok
test invalid_fixture_preserves_parse_error_as_its_source ... ok
test missing_file_keeps_the_path_and_io_error ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
실행 결과는 한 줄입니다.
api.example.com:443 (retries=3)
테스트 수를 늘리는 것보다 각 층이 무엇을 보장하는지 나누는 편이 중요합니다. 단위 테스트는 파싱 규칙을, 통합 테스트는 공개 API와 실제 파일 경계를 맡습니다. 실패 테스트는 오류 variant와 문맥, 오류 원인 체인을 고정합니다. 이 구조가 잡히면 직접 구현을 유지할지 crate로 반복을 줄일지도 근거를 두고 결정할 수 있습니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 17 프로젝트에서 확인할 수 있습니다.
출처
- The Rust Programming Language: Error Handling
- The Rust Programming Language: Recoverable Errors with Result
- The Rust Programming Language: To panic! or Not to panic!
- Rust 표준 라이브러리:
std::error::Error - Rust 표준 라이브러리:
Display - Rust 표준 라이브러리:
From - The Rust Programming Language: How to Write Tests
- The Rust Programming Language: Test Organization
- The Cargo Book: Tests
답글 남기기