`panic!` 사용 기준
호출자가 복구할 수 있는 실패, 값의 불변식, 계약 위반을 구분해 Result·커스텀 타입·panic 중 적절한 API 전략을 선택합니다.
그러면 언제 panic!을 부르고 언제 Result를 돌려주어야 할까요? 코드가 패닉을 내면 복구할 방법이 없습니다. 복구할 수 있는 상황이든 아니든 어떤 오류에든 panic!을 부를 수는 있지만, 그러면 부른 코드를 대신해 그 상황은 복구할 수 없다고 정해 버리는 셈입니다. 반대로 Result를 돌려주면 부른 코드에 선택권이 남습니다. 부른 쪽은 상황에 맞게 다시 시도하거나, 다른 값을 쓰거나, 사용자에게 알리거나, 그 Err를 복구할 수 없다고 보고 panic!을 부를 수도 있습니다. 그래서 실패할 수 있는 함수를 정의할 때는 Result를 돌려주는 것이 좋은 기본값입니다.
이 글에서는 예제나 프로토타입, 테스트처럼 패닉을 쓰는 편이 나은 경우, 컴파일러는 모르지만 사람은 실패하지 않는다고 아는 경우, 오류 처리의 일반 기준, 그리고 타입으로 값의 유효성을 지키는 방법을 살펴봅니다. 아래 그림은 실패할 수 있는 함수를 만들 때 Result, 유효성을 지키는 사용자 정의 타입, panic! 가운데 무엇을 고를지 따라가는 흐름입니다.
CALLER CHOICE · TYPE INVARIANT · CONTRACT BUG
API 경계가 실패 책임과 값 보장을 정한다
실패가 발생한다는 사실만으로 패닉을 선택하지 않습니다. 잘못된 값을 타입으로 막을 수 있는지, 호출자가 합리적으로 복구할 수 있는지, 계속 실행하면 계약과 안전이 깨지는지를 순서대로 확인합니다.
전용 타입으로 불변식을 표현할 수 있는가?
예라면 fallible 생성자와 비공개 필드로 커스텀 타입을 만들고, 거절 이유는 Result로 반환합니다. 아니라면 다음 기준을 봅니다.
호출자가 합리적으로 복구할 수 있는가?
예라면 Result로 입력 수정·재시도·대체·보고의 선택권을 남깁니다. 아니라면 계약 위반인지 확인합니다.
계약 위반이며 계속 실행하기 위험한가?
예라면 panic!을 고려하고 공개 API의 패닉 조건을 문서화합니다. 정상적인 외부 입력 실패와 구분합니다.
명확한 패닉 근거가 없으면 오류를 반환한다
실패의 책임과 호출자의 선택권을 상위 경계에 남깁니다.
| 상황 | 기본 전략 | 보존할 계약 |
|---|---|---|
| 사용자 입력·파싱·파일·네트워크 | Result | 오류 종류와 호출자가 취할 수 있는 재시도·대체·보고 방법을 드러냅니다. |
| 애플리케이션 최상단 | 메시지와 종료 상태로 변환 | 하위 계층은 Result의 정보를 보존하고, 사용자에게 보여 줄 문구와 종료 여부는 실행 경계에서 정합니다. |
| 여러 함수가 공유하는 값 범위 | 커스텀 타입 | 외부 입력에는 fallible 생성자를 두고 필드와 변경 경로가 불변식을 유지하게 합니다. |
| 호출자 코드의 계약 위반 | panic! | 계속 실행이 유효하지 않은 근거와 패닉 조건을 공개 문서에 적습니다. |
| 예제·프로토타입·테스트 | unwrap 또는 expect | 실패 지점을 빠르게 드러내되 운영 경로의 정상 실패 처리로 남기지 않습니다. |
| 사람이 증명한 불변식 | expect | 오류를 번역하는 대신 왜 성공해야 하는지 기대 조건을 메시지로 남깁니다. |
사용자 입력·파싱·파일·네트워크
오류 종류와 호출자가 선택할 재시도·대체·보고 방법을 반환 타입에 드러냅니다.
애플리케이션 최상단
하위 Result의 정보를 보존하고 실행 경계에서 메시지와 종료 상태로 변환합니다.
여러 함수가 공유하는 값 범위
fallible 생성자와 비공개 필드·변경 경로가 같은 불변식을 유지하게 합니다.
호출자 코드의 계약 위반
계속 실행이 유효하지 않은 근거와 패닉 조건을 공개 문서에 적습니다.
예제·프로토타입·테스트
unwrap·expect로 실패 지점을 드러내되 운영 경로의 복구 계약으로 남기지 않습니다.
사람이 증명한 불변식
expect 메시지에 오류 번역 대신 왜 성공해야 하는지를 남깁니다.
패닉은 “항상 즉시 프로세스 종료”와 같은 말이 아닙니다. 현재 스레드에서 시작해 설정과 경계에 따라 unwind하거나 abort할 수 있지만, 일반 라이브러리 API에서 예상 가능한 실패를 전달하는 일상적인 복구 채널로 사용하지는 않습니다.
실패가 일어날 수 있다는 것만으로 패닉을 고르지는 않습니다. 먼저 잘못된 값을 타입으로 막을 수 있는지, 그다음 부른 쪽이 그 실패에서 알맞게 복구할 수 있는지, 마지막으로 그대로 계속 실행하면 코드가 전제한 조건이나 안전이 깨지는지를 차례로 따집니다. 패닉은 현재 스레드에서 시작해 설정에 따라 스택을 되감거나 프로세스를 끝내므로, 패닉 한 번이 언제나 곧바로 전체 프로세스를 끝낸다고 생각할 필요는 없지만, 정상적으로 돌아오는 길이 끝난다는 점은 같습니다.
예제, 프로토타입 코드, 테스트
어떤 개념을 보여 주려고 예제를 쓸 때 튼튼한 오류 처리 코드까지 넣으면 예제가 오히려 덜 분명해질 수 있습니다. 예제에서 패닉을 낼 수 있는 unwrap 같은 메서드를 부르는 것은, 실제 프로그램에서라면 상황에 맞게 오류를 처리하라는 자리 표시로 이해하면 됩니다. 마찬가지로 아직 오류를 어떻게 처리할지 정하지 않은 프로토타입에서도 unwrap과 expect가 편리합니다. 이 메서드들은 코드에 분명한 표시를 남기므로, 프로그램을 더 튼튼하게 만들 준비가 되었을 때 고칠 곳을 찾기 쉽습니다.
테스트에서는 메서드 호출이 실패하면, 그 메서드가 테스트하려는 기능이 아니더라도 테스트 전체가 실패하는 것이 맞습니다. 테스트가 실패했다는 표시가 바로 panic!이므로, 테스트에서는 unwrap이나 expect를 쓰는 것이 자연스럽습니다. 이때 expect에 그 준비 값이 왜 유효해야 하는지 적어 두면, 실패했을 때 무엇이 잘못되었는지 알기 쉽습니다. 다만 사용자 입력이나 파일, 네트워크처럼 실제 프로그램에서 정상적으로 실패할 수 있는 곳을 이 메서드들로 처리해 버리면, 부른 쪽의 복구할 기회가 사라진다는 점은 기억합니다.
컴파일러가 증명하지 못한 불변식
어떤 Result가 반드시 Ok라는 것을 사람은 알지만, 컴파일러는 그 논리를 알 수 없는 경우가 있습니다. 그런 경우에는 expect를 부르되, 왜 Err가 나올 수 없다고 보는지를 메시지에 적는 것이 알맞습니다.
use std::net::IpAddr;
let home: IpAddr = "127.0.0.1"
.parse()
.expect("Hardcoded IP address should be valid");이 코드는 직접 적은 문자열을 파싱해 IpAddr를 만듭니다. 127.0.0.1이 올바른 IP 주소라는 것은 우리가 볼 수 있으므로 여기서 expect를 써도 됩니다. 하지만 직접 적은 올바른 문자열이라고 해서 parse의 반환 타입이 바뀌지는 않아서, 여전히 Result이고 컴파일러는 Err일 가능성을 처리하라고 요구합니다. 컴파일러는 이 문자열이 언제나 올바른 IP 주소라는 것까지 알 만큼 똑똑하지 않기 때문입니다. 같은 문자열이 프로그램에 적힌 것이 아니라 사용자에게서 왔다면 실패할 가능성이 분명히 있으므로, 그때는 Result를 더 튼튼하게 처리해야 합니다. 이 주소가 코드에 적혀 있다는 전제를 메시지에 적어 두면, 나중에 주소를 다른 곳에서 받도록 바꿀 때 expect를 더 나은 오류 처리로 바꿔야 한다는 것을 떠올리게 됩니다.
에러 처리를 위한 가이드라인
예상 가능한 실패에는 Result
잘못된 사용자 입력, 파싱 실패, 없는 파일, 부족한 권한, 네트워크 지연, HTTP 요청 수 제한처럼 실패가 예상되는 경우에는 panic!보다 Result를 돌려주는 것이 알맞습니다. 이런 실패는 부른 쪽이 어떻게 처리할지 정해야 할 정상적인 일이기 때문입니다. 함수가 돌려줄 수 있는 오류를 반환 타입에 드러내면, 부른 쪽은 그 오류를 처리하거나 위로 넘기는 길을 고를 수 있습니다.
라이브러리는 그것을 쓰는 프로그램보다 자신이 어떤 상황에서 쓰일지 덜 알고 있으므로, 대개 Result로 선택권을 남기는 편이 낫습니다. 실행 파일의 가장 바깥 코드는 받은 오류를 사용자에게 보여 줄 메시지와 종료 코드로 바꿀 수 있습니다. 그런 결정을 아래쪽 라이브러리 함수의 패닉으로 미리 정해 버리기보다, 아래쪽은 오류를 넘기고 바깥쪽이 정하게 합니다.
불변식이 깨졌다면 패닉을 고려
코드가 나쁜 상태에 빠질 수 있다면 패닉을 내는 편이 좋습니다. 여기서 나쁜 상태란 어떤 가정이나 보장, 또는 늘 참이어야 하는 성질인 불변식(invariant)이 깨진 상태로, 올바르지 않은 값이나 서로 모순되는 값, 빠진 값이 코드에 넘어온 경우입니다. 다음 조건이 함께 맞는다면 패닉을 생각해 봅니다.
| 조건 | 내용 |
|---|---|
| 버그인가 | 가끔 일어나는 외부의 실패가 아니라, 부른 코드의 버그나 내부 불변식이 깨진 것 |
| 계속할 수 없는가 | 그 뒤의 코드가 그 조건을 전제로 하므로, 계속 실행하면 결과가 틀리거나 안전하지 않은 것 |
| 복구할 수 없는가 | 부른 쪽이 그 자리에서 다시 시도하거나 다른 값으로 바꿔 알맞게 복구할 수 없는 것 |
| 타입으로 막을 수 없는가 | 잘못된 상태를 타입으로 아예 만들 수 없게 하는 방법을 먼저 검토한 것 |
누군가 우리 코드를 부르면서 말이 안 되는 값을 넘겼다면, 라이브러리를 쓰는 사람이 개발하는 동안 버그를 고칠 수 있도록 오류를 돌려주는 것이 대개 가장 좋지만, 계속 실행하면 안전하지 않거나 해로울 수 있다면 패닉을 내 버그를 드러내는 편이 나을 수 있습니다. 표준 라이브러리가 범위를 벗어난 인덱스에 패닉을 내는 것도 그 때문입니다. 현재 데이터 구조에 속하지 않는 메모리에 접근하는 것은 흔한 보안 문제이므로, 함수에 그런 조건이 있다면 그 조건이 깨졌을 때 패닉이 난다는 것을 함수의 API 문서에 적어 둡니다. 반면 slice.get(index)는 값이 없는 경우를 Option으로 돌려주어 부른 쪽에 선택권을 남깁니다.
타입이 표현하는 보장만 믿기
함수마다 오류 검사를 많이 하는 것은 장황하고 귀찮습니다. 다행히 러스트의 타입 시스템과 컴파일러가 하는 타입 검사로 많은 검사를 대신할 수 있습니다. 함수의 매개변수가 특정 타입이라면, 컴파일러가 이미 그 타입의 올바른 값이라는 것을 보장하므로 그 사실을 믿고 함수의 로직을 쓸 수 있습니다.
예를 들어 Option이 아닌 타입을 받는 함수는 값이 언제나 있다고 기대하므로, Some과 None을 따로 처리할 필요가 없습니다. 값이 없는 것을 넘기려는 코드는 아예 컴파일되지 않으므로, 함수가 실행 중에 그 경우를 확인할 필요도 없습니다. 또 u32 같은 부호 없는 정수를 쓰면 매개변수가 음수가 아니라는 것도 보장됩니다. 다만 타입은 표현한 조건만 보장하므로, u32가 1부터 100 사이라는 것까지 보장하지는 않습니다. 그런 좁은 규칙은 따로 사용자 정의 타입으로 나타내야 합니다.
더 깊이: assert!와 debug_assert!, 그리고 표준 라이브러리의 범위 타입
불변식을 확인하는 패닉은 흔히 assert!로 씁니다. debug_assert!는 같은 검사를 하지만 최적화하지 않은 빌드에서만 실행되며, 최적화한 빌드에서는 -C debug-assertions를 주지 않는 한 실행되지 않습니다. 실행되지 않을 때도 그 안의 식은 언제나 타입 검사를 받습니다. 문서는 검사하지 않은 단언 때문에 잘못된 상태로 계속 실행되더라도 안전한 코드에서라면 안전성이 깨지지는 않는다고 설명하면서, assert!를 debug_assert!로 바꾸는 일은 충분히 측정한 뒤에, 그리고 안전한 코드에서만 하라고 권합니다. unsafe 코드가 기대는 조건이라면 릴리스 빌드에서도 남는 assert!로 검사해야 한다는 뜻입니다.
뒤에서 만들 Guess처럼 값의 범위를 타입에 담는 방법은 표준 라이브러리에도 있습니다. Rust 1.79부터 제네릭으로 쓸 수 있는 std::num::NonZero<T>는 0이 아닌 정수만 담는 타입이며, NonZero::new는 Option을 돌려주므로 0을 넘기면 None을 얻습니다. 이 타입은 모든 비트가 0인 패턴을 쓰지 않으므로, NonZero 문서가 보장하듯 Option<NonZero<u32>>는 NonZero<u32>와 크기와 정렬이 같습니다. 검사를 통과한 값만 만들 수 있게 하면 쓰는 쪽의 검사가 줄어든다는 생각을, 표준 라이브러리는 메모리 배치에서도 이득을 보도록 구현한 셈입니다.
유효성을 위한 커스텀 타입 생성하기
러스트의 타입 시스템으로 올바른 값을 보장하는 생각을 한 걸음 더 밀고 나가, 값을 검사하는 사용자 정의 타입을 만들어 봅니다. 추리 게임에서는 사용자에게 1부터 100 사이의 숫자를 맞혀 보라고 했지만, 비밀 숫자와 비교하기 전에 그 범위 안인지 확인한 적은 없습니다. 범위를 벗어난 숫자를 맞히려 한다면 사용자에게 알려 주는 것이 좋을 것입니다.
함수마다 같은 if 검사를 되풀이하는 대신, 검사를 통과한 값만 담는 Guess 타입을 만들 수 있습니다. 중요한 것은 새 구조체의 이름이 아니라 다음 네 가지입니다. 원래의 값이 들어오는 공개 생성 함수에서 범위를 검사하고, 안쪽 필드는 비공개로 두어 모듈 밖에서 검사를 건너뛰고 만들지 못하게 하며, 검사를 통과한 값만 Guess로 만들고 실패하면 그 까닭을 부른 쪽에 돌려주고, 값을 바꾸는 모든 길도 같은 불변식을 지키게 합니다. 아래 그림은 문자열 입력이 숫자로 바뀌고 범위를 검사받아 Guess가 되는 흐름입니다.
검증 경계가 원시 값을 유효한 타입으로 바꾼다
abc는 i32 파싱 Err로 생성자 미호출, 0은 파싱 성공 뒤 범위 거부, 42는 두 경계를 통과해 Guess 값 42가 되는 실제 검증이다. 별도 입력별 중단 지점과 private 필드 불변식을 명확히 보여 줄 수 있다.
RAW VALUE · VALIDATION BOUNDARY · VALID TYPE
검증 경계가 유효한 타입을 만든다
커스텀 타입의 핵심은 검사를 없애는 것이 아니라 한 생성 경계에 모으는 것입니다. 외부 입력처럼 거절이 정상 흐름이면 fallible 생성자가 Result를 반환하고, 성공한 값만 비공개 필드에 넣어 불변식을 유지합니다.
보조 설명 · 아래 원래 코드와 실패 조건도 함께 확인합니다.
-
원시 입력은 아직 범위를 증명하지 않는다
i32는 음수까지 표현하고u32는 음수를 제외하지만, 어느 쪽도 값이1..=100안에 있다는 사실까지 보장하지 않습니다. -
파싱된 정수의 범위를 생성 함수가 검사한다
Guess::try_new가 경계 안의 값만Ok로 만들고, 범위 밖 값은 이유가 있는Err로 돌려줍니다. -
비공개 필드가 우회 생성을 막는다
모듈 밖 코드는 구조체 리터럴로 내부 값을 바꿀 수 없고 공개 생성 경계를 통과해야 합니다. 내부 메서드도 불변식을 깨뜨리지 않아야 보장이 계속됩니다.
-
소비 함수는 타입이 보장한 조건을 믿는다
매개변수로
Guess를 받는 함수는 같은 범위 검사를 반복하지 않고 비교나 도메인 로직에 집중할 수 있습니다.
유효한 값만 Guess가 된다
생성에 성공한 뒤에는 값의 범위가 타입의 불변식입니다. getter는 읽기만 허용하고, 범위를 깨뜨리는 setter는 제공하지 않습니다.
외부 입력 실패는 호출자가 처리한다
사용자에게 범위를 다시 안내하거나 재입력을 받고, 로그를 남기거나 상위 오류로 변환할 수 있습니다. 정상적인 입력 거절을 패닉으로 바꾸지 않습니다.
반환 타입이 생성 실패를 API에 드러낸다
#[derive(Debug, PartialEq, Eq)]
pub struct GuessError {
value: i32,
}
pub struct Guess {
value: i32,
}
impl Guess {
pub fn try_new(
value: i32,
) -> Result<Self, GuessError> {
if (1..=100).contains(&value) {
Ok(Self { value })
} else {
Err(GuessError { value })
}
}
pub fn value(&self) -> i32 {
self.value
}
}
| 생성 경계 | 맞는 조건 | 실패 계약 |
|---|---|---|
| fallible 생성자 | 사용자 입력, 파일, 네트워크처럼 유효하지 않은 값이 예상될 수 있습니다. | Result로 거절 이유를 반환해 호출자가 재시도·표시·변환을 선택합니다. |
| 패닉하는 생성자 | 잘못된 인자가 오직 프로그래머의 API 계약 위반이고 합리적인 호출자 복구가 없습니다. | 패닉 조건을 공개 문서의 # Panics에 적고, 외부 입력은 먼저 검증합니다. |
| 비공개 필드 | 모든 생성과 변경 경로가 같은 불변식을 지켜야 합니다. | 모듈 밖 우회를 막되 내부 코드도 unchecked 변경으로 보장을 깨뜨리지 않습니다. |
예상 가능한 거절은 Result
사용자 입력·파일·네트워크 값은 거절 이유를 반환해 호출자가 재시도·표시·변환을 선택하게 합니다.
복구할 수 없는 API 계약 위반
패닉 조건을 공개 문서의 # Panics에 적고, 외부 입력은 이 경계 전에 검증합니다.
모든 변경 경로가 불변식을 지킨다
모듈 밖 우회를 막고 내부 코드도 unchecked 변경으로 타입의 보장을 깨뜨리지 않습니다.
Option이 아닌 타입을 받는다는 사실은 값이 그 타입의 표현 안에 있다는 뜻이지만, 도메인 범위까지 자동으로 보장하지는 않습니다. Guess처럼 더 좁은 의미를 가진 타입이 그 추가 조건을 표현합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 파싱 abc/0/42의 세 독립 입력을 확인한다. |
|
|
| 도메인 검사 성공한 정수에 1..=100을 요구한다. |
|
|
| 유효 타입 성공한 42를 Guess로 만들어 읽는다. |
|
|
검증 경계가 원시 값을 유효한 타입으로 바꾼다
abc는 i32 파싱 Err로 생성자 미호출, 0은 파싱 성공 뒤 범위 거부, 42는 두 경계를 통과해 Guess 값 42가 되는 실제 검증이다. 별도 입력별 중단 지점과 private 필드 불변식을 명확히 보여 줄 수 있다.
RAW VALUE · VALIDATION BOUNDARY · VALID TYPE
검증 경계가 유효한 타입을 만든다
커스텀 타입의 핵심은 검사를 없애는 것이 아니라 한 생성 경계에 모으는 것입니다. 외부 입력처럼 거절이 정상 흐름이면 fallible 생성자가 Result를 반환하고, 성공한 값만 비공개 필드에 넣어 불변식을 유지합니다.
보조 설명 · 아래 원래 코드와 실패 조건도 함께 확인합니다.
-
원시 입력은 아직 범위를 증명하지 않는다
i32는 음수까지 표현하고u32는 음수를 제외하지만, 어느 쪽도 값이1..=100안에 있다는 사실까지 보장하지 않습니다. -
파싱된 정수의 범위를 생성 함수가 검사한다
Guess::try_new가 경계 안의 값만Ok로 만들고, 범위 밖 값은 이유가 있는Err로 돌려줍니다. -
비공개 필드가 우회 생성을 막는다
모듈 밖 코드는 구조체 리터럴로 내부 값을 바꿀 수 없고 공개 생성 경계를 통과해야 합니다. 내부 메서드도 불변식을 깨뜨리지 않아야 보장이 계속됩니다.
-
소비 함수는 타입이 보장한 조건을 믿는다
매개변수로
Guess를 받는 함수는 같은 범위 검사를 반복하지 않고 비교나 도메인 로직에 집중할 수 있습니다.
유효한 값만 Guess가 된다
생성에 성공한 뒤에는 값의 범위가 타입의 불변식입니다. getter는 읽기만 허용하고, 범위를 깨뜨리는 setter는 제공하지 않습니다.
외부 입력 실패는 호출자가 처리한다
사용자에게 범위를 다시 안내하거나 재입력을 받고, 로그를 남기거나 상위 오류로 변환할 수 있습니다. 정상적인 입력 거절을 패닉으로 바꾸지 않습니다.
반환 타입이 생성 실패를 API에 드러낸다
#[derive(Debug, PartialEq, Eq)]
pub struct GuessError {
value: i32,
}
pub struct Guess {
value: i32,
}
impl Guess {
pub fn try_new(
value: i32,
) -> Result<Self, GuessError> {
if (1..=100).contains(&value) {
Ok(Self { value })
} else {
Err(GuessError { value })
}
}
pub fn value(&self) -> i32 {
self.value
}
}
| 생성 경계 | 맞는 조건 | 실패 계약 |
|---|---|---|
| fallible 생성자 | 사용자 입력, 파일, 네트워크처럼 유효하지 않은 값이 예상될 수 있습니다. | Result로 거절 이유를 반환해 호출자가 재시도·표시·변환을 선택합니다. |
| 패닉하는 생성자 | 잘못된 인자가 오직 프로그래머의 API 계약 위반이고 합리적인 호출자 복구가 없습니다. | 패닉 조건을 공개 문서의 # Panics에 적고, 외부 입력은 먼저 검증합니다. |
| 비공개 필드 | 모든 생성과 변경 경로가 같은 불변식을 지켜야 합니다. | 모듈 밖 우회를 막되 내부 코드도 unchecked 변경으로 보장을 깨뜨리지 않습니다. |
예상 가능한 거절은 Result
사용자 입력·파일·네트워크 값은 거절 이유를 반환해 호출자가 재시도·표시·변환을 선택하게 합니다.
복구할 수 없는 API 계약 위반
패닉 조건을 공개 문서의 # Panics에 적고, 외부 입력은 이 경계 전에 검증합니다.
모든 변경 경로가 불변식을 지킨다
모듈 밖 우회를 막고 내부 코드도 unchecked 변경으로 타입의 보장을 깨뜨리지 않습니다.
Option이 아닌 타입을 받는다는 사실은 값이 그 타입의 표현 안에 있다는 뜻이지만, 도메인 범위까지 자동으로 보장하지는 않습니다. Guess처럼 더 좁은 의미를 가진 타입이 그 추가 조건을 표현합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 파싱 abc/0/42의 세 독립 입력을 확인한다. |
|
|
| 도메인 검사 성공한 정수에 1..=100을 요구한다. |
|
|
| 유효 타입 성공한 42를 Guess로 만들어 읽는다. |
|
|
외부 입력은 올바르지 않을 수 있으므로, 실패할 수 있는 생성 함수의 이름을 try_new로 하고 Result를 돌려주게 합니다.
#[derive(Debug, PartialEq, Eq)]
pub struct GuessError {
value: i32,
}
pub struct Guess {
value: i32,
}
impl Guess {
pub fn try_new(value: i32) -> Result<Self, GuessError> {
if (1..=100).contains(&value) {
Ok(Self { value })
} else {
Err(GuessError { value })
}
}
pub fn value(&self) -> i32 {
self.value
}
}(1..=100).contains(&value)는 value가 1 이상 100 이하인지 확인합니다. Guess::try_new가 Ok를 돌려주었다면 비공개 필드 value는 그 범위 안에 있습니다. 모듈 밖의 코드는 구조체 문법으로 Guess를 직접 만들거나 value를 바꿀 수 없으므로, Guess를 받는 함수는 다시 검사하지 않고 그 값이 1부터 100 사이라고 믿을 수 있습니다. 값을 읽을 때는 필드 대신 게터 메서드 value를 씁니다. 필드를 비공개로 둔 덕분에, 다른 모듈의 코드는 try_new를 거치지 않고는 Guess를 만들 수 없습니다. 모듈과 비공개는 모듈 경로와 아이템 참조에서 보았습니다.
이 정의 뒤에 다음 main을 붙여 그림의 세 입력을 확인해 봅니다.
fn main() {
for input in ["abc", "0", "42"] {
let raw = match input.trim().parse::<i32>() {
Ok(raw) => raw,
Err(_) => {
println!("{input}: not an integer");
continue;
}
};
match Guess::try_new(raw) {
Ok(guess) => println!("{input}: accepted {}", guess.value()),
Err(error) => println!("{input}: out of range {error:?}"),
}
}
}abc: not an integer
0: out of range GuessError { value: 0 }
42: accepted 42abc는 i32로 바뀌지 않으므로 try_new까지 가지 않습니다. 0은 정수로는 바뀌지만 1부터 100 사이가 아니므로 try_new가 Err를 돌려주고, 42는 두 검사를 모두 통과해 Guess가 되며 value()로 42를 읽습니다. 문자열을 숫자로 바꾸지 못한 실패와 범위를 벗어난 실패는 뜻이 다르므로, 필요하다면 서로 다른 오류 타입과 메시지로 나눕니다. 이 예제는 타입과 main을 같은 모듈에 두었는데, 비공개 필드는 같은 모듈의 코드를 막지 않으므로 다른 모듈에서 바로 만들지 못한다는 것까지 확인하려면 Guess를 별도의 mod 안에 넣습니다.
패닉하는 new는 언제 가능한가
공식 Book은 범위 밖의 값이 들어오면 패닉을 내는 Guess::new를 보여 줍니다. 오직 프로그래머가 이미 확인한 값만 받도록 설계한 API라서, 범위 밖의 인수가 부른 쪽의 버그라고 할 수 있다면 이렇게 할 수도 있습니다.
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}이렇게 하려면 외부 입력은 new를 부르기 전에 따로 검사하고, 어떤 경우에 패닉이 나는지 공개 문서의 # Panics 절에 적어야 합니다. 부른 쪽이 정상적으로 복구해야 하는 상황을 프로그래머의 버그로 둔갑시키지 않아야 한다는 점도 중요합니다. try_new와 패닉하는 new 가운데 어느 한쪽이 언제나 옳은 것은 아니며, 입력이 어디서 오는지와 부른 쪽에 어떤 선택권을 남길지에 따라 정합니다.
정리
- 실패할 수 있는 함수는
Result를 돌려주는 것이 기본이며, 그러면 부른 쪽이 다시 시도하거나 다른 값을 쓰거나 패닉을 낼지 고를 수 있습니다. - 예제와 프로토타입, 테스트에서는
unwrap과expect가 알맞고, 테스트에서는 실패가 곧 패닉입니다. - 컴파일러는 모르지만 사람이 실패하지 않는다고 아는 경우에는
expect에 그 까닭을 적습니다. - 버그이고, 계속하면 위험하고, 그 자리에서 복구할 수 없고, 타입으로 막을 수 없을 때 패닉을 생각하며, 패닉 조건은 문서에 적습니다.
- 타입은 표현한 조건만 보장하므로, 더 좁은 규칙은 비공개 필드와 검사하는 생성 함수를 가진 사용자 정의 타입으로 나타냅니다.
연습 문제
1. 어디서 거절될까
Guess 예제의 입력을 "-1", " 42 ", "100", "3.5"로 바꾸면, 각 입력은 숫자로 바꾸는 단계와 범위를 검사하는 단계 가운데 어디서 거절되나요? 통과하는 값은 무엇을 출력하나요?
해설 보기
-1은 i32로는 바뀌지만 범위를 벗어나 -1: out of range GuessError { value: -1 }을 출력합니다. " 42 "는 trim이 공백을 지워 42가 되고, 100은 범위의 끝값이므로 둘 다 통과해 각각 accepted 42와 accepted 100을 출력합니다. 3.5는 i32로 바뀌지 않으므로 try_new까지 가지 않고 not an integer를 출력합니다.
2. 무엇을 고를까
다음 함수는 실패할 때 Result를 돌려주어야 할까요, 패닉을 내야 할까요? (가) 사용자가 입력한 날짜 문자열을 파싱하는 함수 (나) 길이가 같아야 한다고 문서에 적힌 두 슬라이스를 더하는데, 길이가 다른 슬라이스가 넘어온 함수 (다) 설정 파일에서 포트 번호를 읽는 함수
해설 보기
(가)와 (다)는 Result를 돌려줍니다. 사용자가 잘못 입력하거나 설정 파일이 없고 내용이 틀린 것은 정상적으로 일어날 수 있는 실패이며, 부른 쪽이 다시 묻거나 기본값을 쓰는 식으로 복구할 수 있습니다. (나)는 문서에 적힌 조건을 부른 코드가 어긴 버그이므로 패닉을 생각할 수 있으며, 그 조건을 문서의 # Panics에 적어 둡니다. 다만 부른 쪽이 길이가 다른 경우를 정상적으로 다뤄야 한다면 Result나 Option을 돌려주는 편이 낫습니다.
3. 검사를 건너뛰는 길
다른 모듈의 코드가 Guess { value: 500 }으로 바로 만들려 하면 어떻게 되나요? value 필드를 pub으로 바꾸면 무엇이 달라지나요?
해설 보기
value가 비공개이므로 다른 모듈에서는 구조체 문법으로 Guess를 만들 수 없어 컴파일되지 않고, try_new를 거쳐야만 만들 수 있습니다. value를 pub으로 바꾸면 다른 모듈에서도 Guess { value: 500 }처럼 검사 없이 만들거나 값을 바꿀 수 있게 되어, Guess라면 1부터 100 사이라는 보장이 사라집니다.
이것으로 8장을 마칩니다. 러스트는 실패할 수 있는 일을 Result로, 계속할 수 없는 버그를 panic!으로 나타내며, 다음 장의 제네릭 데이터 타입에서는 지금까지 Option<T>와 Result<T, E>에서 본 제네릭을 직접 써 봅니다.