panic!
을 사용할지 말지
panic!
을 호출해야 할 때와 Result
를 반환해야 할 때를 어떻게 결정할까? 코드가 패닉을 일으키면 복구할 방법이 없다. 어떤 에러 상황에서든 panic!
을 호출할 수 있지만, 이는 호출하는 코드를 대신해 해당 상황이 복구 불가능하다고 판단하는 것이다. Result
값을 반환하면 호출하는 코드에 선택권을 준다. 호출하는 코드는 상황에 맞게 복구를 시도하거나, Err
값이 복구 불가능하다고 판단해 panic!
을 호출해 복구 가능한 에러를 복구 불가능한 상태로 바꿀 수 있다. 따라서 실패할 가능성이 있는 함수를 정의할 때는 Result
를 반환하는 것이 기본적으로 좋은 선택이다.
예제 코드, 프로토타입 코드, 테스트 코드와 같은 상황에서는 Result
를 반환하는 대신 패닉을 일으키는 코드를 작성하는 것이 더 적절하다. 왜 그런지 알아보고, 컴파일러가 실패가 불가능하다는 것을 알 수 없지만 사람은 알 수 있는 상황에 대해 논의한다. 이 장의 마지막에는 라이브러리 코드에서 패닉을 일으킬지 말지 결정하는 일반적인 가이드라인을 제공한다.
예제, 프로토타입 코드, 그리고 테스트
어떤 개념을 설명하기 위해 예제를 작성할 때, 강력한 오류 처리 코드를 포함하면 예제의 명확성이 떨어질 수 있다. 예제에서는 unwrap
과 같은 패닉을 일으킬 수 있는 메서드 호출이 실제 애플리케이션에서 오류를 처리하는 방식에 대한 자리 표시자로 사용된다는 점을 이해해야 한다. 이 방식은 코드의 나머지 부분이 하는 일에 따라 달라질 수 있다.
마찬가지로, 프로토타이핑 단계에서 오류 처리 방식을 결정하기 전에는 unwrap
과 expect
메서드가 매우 유용하다. 이 메서드들은 코드에 명확한 표시를 남겨두어, 프로그램을 더 견고하게 만들 준비가 되었을 때 쉽게 찾아낼 수 있게 해준다.
테스트 중에 메서드 호출이 실패하면, 해당 메서드가 테스트 대상 기능이 아니더라도 전체 테스트가 실패하길 원할 것이다. panic!
은 테스트가 실패했음을 표시하는 방법이므로, unwrap
이나 expect
를 호출하는 것이 바로 그런 상황에서 필요한 일이다.
컴파일러보다 더 많은 정보를 알고 있는 경우
Result
가 Ok
값을 가질 것이라는 것을 보장하는 다른 로직이 있지만, 그 로직을 컴파일러가 이해하지 못하는 경우에도 unwrap
이나 expect
를 호출하는 것이 적절하다. 여전히 Result
값을 처리해야 하지만, 특정 상황에서는 논리적으로 실패할 가능성이 없음에도 불구하고 일반적으로는 실패할 가능성이 있다. 코드를 직접 검토하여 Err
변형이 절대 발생하지 않을 것임을 보장할 수 있다면, unwrap
을 호출하는 것이 완전히 허용된다. 더 나아가 expect
텍스트에 Err
변형이 발생하지 않을 이유를 문서화하는 것이 더 좋다. 다음은 예제이다:
fn main() { 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
값을 반환하며, 컴파일러는 이 문자열이 항상 유효한 IP 주소임을 알아차리지 못하기 때문에 Err
변형이 가능한 것처럼 Result
를 처리하도록 요구한다. 만약 IP 주소 문자열이 프로그램에 하드코딩된 것이 아니라 사용자로부터 입력된 것이라면 실패할 가능성이 있으므로, 더 견고한 방식으로 Result
를 처리해야 한다. 이 IP 주소가 하드코딩되었다는 가정을 명시하면, 나중에 다른 소스에서 IP 주소를 가져와야 할 때 expect
를 더 나은 오류 처리 코드로 변경하도록 상기시킬 수 있다.
에러 처리 가이드라인
코드가 잘못된 상태에 빠질 가능성이 있다면 패닉을 발생시키는 것이 좋다. 여기서 _잘못된 상태_란 어떤 가정, 보장, 계약, 또는 불변 조건이 깨진 상황을 말한다. 예를 들어 유효하지 않은 값, 모순된 값, 누락된 값이 코드에 전달되는 경우가 이에 해당한다. 또한 다음 조건 중 하나 이상이 충족될 때도 마찬가지다:
- 잘못된 상태가 예상치 못한 상황일 때. 예를 들어 사용자가 잘못된 형식의 데이터를 입력하는 경우처럼 가끔 발생할 수 있는 상황과는 다르다.
- 코드가 이후에 이 잘못된 상태에 있지 않을 것이라고 가정하고 동작할 때. 즉, 매 단계마다 문제를 확인하지 않아도 된다고 가정할 때.
- 사용 중인 타입으로 이 정보를 표현할 적절한 방법이 없을 때. 이에 대한 예시는 18장의 “타입으로 상태와 동작 표현하기”에서 다룬다.
누군가가 코드를 호출할 때 의미 없는 값을 전달한다면, 가능한 경우 에러를 반환하는 것이 가장 좋다. 이렇게 하면 라이브러리 사용자가 해당 상황에서 어떻게 처리할지 결정할 수 있다. 그러나 계속 진행하는 것이 보안상 위험하거나 해로울 수 있는 경우, panic!
을 호출해 라이브러리 사용자에게 코드의 버그를 알리고 개발 중에 수정할 수 있도록 하는 것이 최선의 선택일 수 있다. 마찬가지로, 외부 코드를 호출했는데 그 코드가 유효하지 않은 상태를 반환하고 이를 수정할 방법이 없는 경우에도 panic!
을 호출하는 것이 적절하다.
그러나 실패가 예상되는 상황에서는 panic!
을 호출하는 대신 Result
를 반환하는 것이 더 적절하다. 예를 들어 파서가 잘못된 형식의 데이터를 받거나, HTTP 요청이 속도 제한에 도달했다는 상태 코드를 반환하는 경우가 이에 해당한다. 이러한 경우 Result
를 반환하면 실패가 예상 가능한 상황이며, 호출하는 코드가 이를 어떻게 처리할지 결정해야 함을 나타낸다.
코드가 유효하지 않은 값을 사용해 호출될 경우 사용자에게 위험을 초래할 수 있는 작업을 수행한다면, 코드는 먼저 값이 유효한지 확인하고 유효하지 않으면 패닉을 발생시켜야 한다. 이는 주로 보안상의 이유에서다: 유효하지 않은 데이터를 처리하려고 시도하면 코드가 취약점에 노출될 수 있다. 표준 라이브러리가 메모리 접근이 범위를 벗어났을 때 panic!
을 호출하는 주된 이유도 이 때문이다. 현재 데이터 구조에 속하지 않은 메모리에 접근하려고 시도하는 것은 흔한 보안 문제다. 함수는 종종 _계약_을 가진다: 입력이 특정 요구 사항을 충족할 때만 그 동작이 보장된다. 계약이 위반되었을 때 패닉을 발생시키는 것은 합리적이다. 왜냐하면 계약 위반은 항상 호출자 측의 버그를 나타내며, 호출 코드가 명시적으로 처리해야 할 종류의 에러가 아니기 때문이다. 사실, 호출 코드가 이를 복구할 합리적인 방법은 없다. 호출한 _프로그래머_가 코드를 수정해야 한다. 함수의 계약, 특히 위반 시 패닉이 발생하는 경우는 함수의 API 문서에 설명되어야 한다.
그러나 모든 함수에 많은 에러 검사를 추가하는 것은 번거롭고 지루할 수 있다. 다행히 Rust의 타입 시스템(그리고 컴파일러가 수행하는 타입 검사)을 활용해 많은 검사를 대신할 수 있다. 함수가 특정 타입을 매개변수로 받는다면, 컴파일러가 이미 유효한 값임을 보장했기 때문에 코드의 로직을 안전하게 진행할 수 있다. 예를 들어 Option
이 아닌 특정 타입을 사용한다면, 프로그램은 _아무것도 없음_이 아니라 _무언가_를 기대한다. 따라서 코드는 Some
과 None
두 가지 경우를 처리할 필요 없이, 값이 확실히 있다는 한 가지 경우만 처리하면 된다. 아무것도 전달하지 않으려는 코드는 컴파일조차 되지 않으므로, 런타임에 해당 경우를 확인할 필요가 없다. 또 다른 예로 u32
와 같은 부호 없는 정수 타입을 사용하면 매개변수가 절대 음수가 아님을 보장할 수 있다.
유효성 검사를 위한 커스텀 타입 만들기
Rust의 타입 시스템을 활용해 유효한 값을 보장하는 아이디어를 한 단계 더 발전시켜 유효성 검사를 위한 커스텀 타입을 만들어 보자. 2장에서 다룬 숫자 맞추기 게임을 떠올려보자. 코드는 사용자에게 1부터 100 사이의 숫자를 맞춰보라고 요청했다. 그러나 비밀 숫자와 비교하기 전에 사용자의 추측이 해당 범위 내에 있는지 검증하지 않았고, 단순히 양수인지 여부만 확인했다. 이 경우 결과가 크게 심각하지는 않았다. “너무 높음” 또는 “너무 낮음“이라는 출력은 여전히 정확했다. 하지만 사용자가 범위를 벗어난 숫자를 추측했을 때와 문자를 입력했을 때의 동작을 다르게 처리하는 것은 유용한 개선 사항이 될 것이다.
이를 구현하는 한 가지 방법은 추측값을 u32
가 아닌 i32
로 파싱해 음수도 허용한 다음, 숫자가 범위 내에 있는지 확인하는 것이다. 그런 다음 다음과 같이 범위 검사를 추가할 수 있다:
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
loop {
// --snip--
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
if guess < 1 || guess > 100 {
println!("The secret number will be between 1 and 100.");
continue;
}
match guess.cmp(&secret_number) {
// --snip--
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
if
표현식은 값이 범위를 벗어났는지 확인하고, 문제를 사용자에게 알린 다음, continue
를 호출해 루프의 다음 반복을 시작하고 다시 추측값을 요청한다. if
표현식 이후에는 guess
가 1과 100 사이임을 알 수 있으므로 guess
와 비밀 숫자를 비교할 수 있다.
그러나 이 방법은 이상적인 해결책이 아니다. 프로그램이 반드시 1과 100 사이의 값만을 처리해야 하고, 이 요구 사항을 가진 함수가 많다면, 모든 함수에 이런 검사를 추가하는 것은 번거롭고 성능에도 영향을 미칠 수 있다.
대신, 전용 모듈에 새로운 타입을 만들고 유효성 검사를 타입 인스턴스를 생성하는 함수에 넣어서 검사를 반복하지 않도록 할 수 있다. 이렇게 하면 함수가 새로운 타입을 시그니처에서 사용하고 받은 값을 안전하게 사용할 수 있다. 리스팅 9-13은 Guess
타입을 정의하는 한 가지 방법을 보여준다. 이 타입은 new
함수가 1과 100 사이의 값을 받았을 때만 Guess
인스턴스를 생성한다.
#![allow(unused)] fn main() { pub struct Guess { value: i32, } 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 } } pub fn value(&self) -> i32 { self.value } } }
Guess
타입먼저 guessing_game
이라는 새로운 모듈을 만든다. 그런 다음 해당 모듈에 Guess
라는 구조체를 정의하고, i32
타입의 value
필드를 갖도록 한다. 이 필드에 숫자가 저장된다.
그 다음 Guess
에 new
라는 연관 함수를 구현해 Guess
인스턴스를 생성한다. new
함수는 i32
타입의 value
라는 하나의 매개변수를 받고 Guess
를 반환하도록 정의된다. new
함수의 본문은 value
가 1과 100 사이인지 테스트한다. 만약 value
가 이 테스트를 통과하지 못하면 panic!
을 호출해 호출 코드를 작성하는 프로그래머에게 버그가 있음을 알린다. 이 범위를 벗어난 value
로 Guess
를 생성하면 Guess::new
가 의존하는 계약을 위반하게 된다. Guess::new
가 패닉을 일으킬 수 있는 조건은 공개 API 문서에 논의되어야 한다. 14장에서 API 문서에 패닉 가능성을 표시하는 문서화 규칙을 다룰 것이다. value
가 테스트를 통과하면 value
필드를 value
매개변수로 설정한 새로운 Guess
를 생성하고 반환한다.
그 다음, self
를 빌리고 다른 매개변수가 없으며 i32
를 반환하는 value
라는 메서드를 구현한다. 이런 종류의 메서드는 필드에서 데이터를 가져와 반환하기 때문에 _getter_라고도 한다. 이 공개 메서드는 Guess
구조체의 value
필드가 비공개이기 때문에 필요하다. value
필드를 비공개로 유지하는 것은 Guess
구조체를 사용하는 코드가 value
를 직접 설정할 수 없도록 하기 위함이다. guessing_game
모듈 외부의 코드는 반드시 Guess::new
함수를 사용해 Guess
인스턴스를 생성해야 하므로, Guess::new
함수의 조건으로 검사되지 않은 value
를 가진 Guess
가 만들어질 수 없다.
1과 100 사이의 숫자를 매개변수로 받거나 반환하는 함수는 시그니처에서 i32
대신 Guess
를 받거나 반환한다고 선언할 수 있고, 본문에서 추가 검사를 할 필요가 없다.
요약
Rust의 에러 처리 기능은 더 견고한 코드를 작성하는 데 도움을 준다. panic!
매크로는 프로그램이 처리할 수 없는 상태에 있음을 알리고, 잘못되거나 유효하지 않은 값으로 진행하려는 대신 프로세스를 중단하도록 한다. Result
열거형은 Rust의 타입 시스템을 활용해 작업이 실패할 가능성이 있음을 나타내며, 코드가 이를 복구할 수 있게 한다. Result
를 사용하면 호출하는 코드가 성공 또는 실패를 처리해야 함을 알릴 수 있다. 적절한 상황에서 panic!
과 Result
를 사용하면 불가피한 문제에 직면했을 때 코드가 더 안정적으로 동작한다.
이제 표준 라이브러리가 Option
과 Result
열거형을 제네릭과 함께 활용하는 유용한 방법을 살펴봤으니, 제네릭이 어떻게 동작하는지와 이를 코드에서 어떻게 사용할 수 있는지 알아보자.