panic! 을 써야 할 때와 쓰지 말아야 할 때
그렇다면 언제 panic! 을 호출해야 하고, 언제 Result 를 반환해야 할까요? 코드가
패닉하면 복구할 방법이 없습니다. 복구 가능성이 있든 없든 모든 에러 상황에 대해
panic! 을 호출할 수는 있지만, 그렇게 하면 “이 상황은 복구 불가능하다”는 결정을
호출하는 코드 대신 여러분이 내려 버리는 것입니다. 반대로 Result 값을 반환하면
호출하는 코드에 선택지를 남겨 둡니다. 호출하는 코드는 자기 상황에 맞는 방식으로
복구를 시도할 수도 있고, 혹은 이 경우 Err 값이 사실상 복구 불가능하다고 판단해
panic! 을 호출하고, 여러분의 “복구 가능한 에러”를 자기 쪽에서는 “복구 불가능한
에러”로 바꿀 수도 있습니다. 그래서 실패할 수 있는 함수를 정의할 때는 Result 를
반환하는 것이 좋은 기본 선택입니다.
예제 코드, 프로토타입 코드, 테스트 같은 상황에서는 Result 대신 패닉하는 코드를
작성하는 편이 더 적절할 수 있습니다. 왜 그런지 먼저 살펴보고, 그다음에는 컴파일러는
실패 가능성을 알 수 없지만 인간인 여러분은 “실패할 수 없다”고 판단할 수 있는 상황을
논의하겠습니다. 마지막에는 라이브러리 코드에서 패닉할지 말지를 결정할 때 도움이 되는
일반적인 가이드라인으로 장을 마무리합니다.
예제, 프로토타입 코드, 테스트
어떤 개념을 설명하기 위한 예제를 작성할 때, 그 안에 견고한 에러 처리 코드까지 다
넣어 버리면 오히려 예제가 덜 명확해질 수 있습니다. 예제에서 unwrap 같은 패닉
가능 메서드를 호출하는 것은, “여기에는 실제 애플리케이션이 상황에 맞게 에러를
처리하는 방식이 들어갈 자리표시자” 정도로 이해하면 됩니다.
마찬가지로, 에러를 어떻게 처리할지 아직 결정하지 않은 프로토타입 단계에서는
unwrap 과 expect 가 매우 편리합니다. 나중에 프로그램을 더 견고하게 만들 준비가
되었을 때 “여기를 다시 봐야 한다”는 표시를 코드에 명확하게 남겨 두기 때문입니다.
테스트에서 어떤 메서드 호출이 실패한다면, 그 메서드 자체가 테스트 대상이 아니더라도
테스트 전체가 실패해야 합니다. 러스트에서 테스트 실패는 panic! 으로 표시되므로,
unwrap 이나 expect 를 호출했을 때 그대로 패닉하는 동작은 오히려 우리가 원하는
정확한 행동입니다.
여러분이 컴파일러보다 더 많은 정보를 알고 있을 때
또 다른 적절한 경우는, 어떤 로직 덕분에 Result 가 반드시 Ok 값을 가질 것이라는
사실을 여러분은 알고 있지만 컴파일러는 알지 못하는 경우입니다. 일반적인 의미에서
실패할 가능성이 여전히 있는 연산을 호출하고 있으므로, 코드 안에는 여전히 Result
값이 생깁니다. 그러나 코드 전체를 사람이 직접 살펴보았을 때 특정 상황에서는 Err
variant가 절대로 나올 수 없다고 확신할 수 있다면, expect 를 호출하고 왜 그런
확신을 갖는지를 expect 의 메시지 안에 적어 두는 것은 충분히 괜찮습니다.
다음은 예시입니다.
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 를 더 적절한 에러 처리 코드로 바꿔야 한다는 사실도 쉽게
드러납니다.
에러 처리 가이드라인
코드가 나쁜 상태(bad state)에 빠질 가능성이 있다면, 그럴 때는 패닉하게 만드는 것이 바람직합니다. 여기서 나쁜 상태 란 어떤 가정, 보장, 계약, 혹은 불변 조건이 깨진 상태를 말합니다. 예를 들어 잘못된 값, 서로 모순되는 값, 필요한 값의 부재 같은 것이 여기에 해당하고, 보통 다음 중 하나 이상이 함께 성립합니다.
- 그 나쁜 상태는 사용자가 가끔 잘못된 형식으로 데이터를 입력하는 것처럼 “가끔 일어날 수 있는 일” 이 아니라, “예상 밖의 일” 이다.
- 그 이후의 코드가 매 단계마다 문제를 검사하는 대신, “지금 나쁜 상태가 아니다”는 사실에 의존해야 한다.
- 사용 중인 타입으로 이 정보를 깔끔하게 인코딩할 좋은 방법이 없다. 18장의 “상태와 동작을 타입으로 인코딩하기” 절에서 이 의미가 무엇인지 예제를 통해 살펴봅니다.
누군가가 여러분의 코드를 호출할 때 말이 안 되는 값을 넘겼다면, 가능하다면 에러를
반환해 라이브러리 사용자가 자기 상황에 맞는 결정을 내리게 하는 것이 좋습니다.
그러나 계속 진행하는 것이 위험하거나 해로울 수 있는 경우라면, panic! 을 호출해
라이브러리를 사용하는 사람에게 “여기에는 버그가 있다”는 사실을 알려 주고 개발 중에
고치게 하는 편이 더 좋을 수도 있습니다. 마찬가지로, 여러분이 제어할 수 없는 외부
코드를 호출했는데 그것이 고칠 방법도 없는 잘못된 상태를 반환한다면, panic! 이
적절한 선택인 경우가 많습니다.
반대로 실패가 기대되는 상황이라면, panic! 보다는 Result 를 반환하는 편이
적절합니다. 예를 들어 파서에 잘못된 형식의 데이터가 들어오거나, HTTP 요청이
속도 제한에 걸렸음을 나타내는 상태 코드를 반환하는 경우가 그렇습니다. 이런 경우
Result 를 반환하는 것은 “실패가 예상 가능한 가능성” 이며, 호출하는 코드가 이를
어떻게 처리할지 결정해야 함을 의미합니다.
코드가 잘못된 값으로 호출되었을 때 사용자를 위험하게 만들 수 있는 연산을 수행한다면,
코드 안에서 먼저 값이 유효한지 검사하고, 유효하지 않다면 패닉해야 합니다. 이것은
주로 안전 때문입니다. 잘못된 데이터로 연산하려고 하면 코드가 취약점에 노출될 수
있습니다. 이것이 표준 라이브러리가 배열 범위를 벗어난 메모리 접근 시 panic! 을
호출하는 주된 이유입니다. 현재 자료구조에 속하지 않는 메모리에 접근하려는 시도는
아주 흔한 보안 문제이기 때문입니다. 함수는 종종 계약(contract) 을 가집니다.
입력이 특정 요구사항을 만족할 때만 그 동작이 보장됩니다. 계약이 깨졌을 때 패닉하는
것은 자연스러운 일입니다. 계약 위반은 항상 호출자 쪽의 버그 를 의미하기 때문이며,
호출하는 코드가 일일이 처리해야 할 종류의 에러가 아니기 때문입니다. 사실 호출하는
코드가 “복구”할 합리적인 방법도 없습니다. 고쳐야 하는 것은 호출하는 프로그래머 의
코드입니다. 함수의 계약은, 특히 계약 위반이 패닉을 일으킨다면 API 문서에 설명되어
있어야 합니다.
그렇다고 모든 함수에 이런 검사를 전부 손으로 넣는다면 장황하고 귀찮아집니다. 다행히
러스트의 타입 시스템(그리고 컴파일러의 타입 검사)을 이용해 많은 검사를 대신 시킬 수
있습니다. 함수가 어떤 타입을 매개변수로 받는다면, 컴파일러가 이미 유효한 값만 들어올
수 있도록 보장했다는 사실을 믿고 이후 로직을 진행할 수 있습니다. 예를 들어 Option
대신 단일 타입을 받는다면, 이 프로그램은 없음 이 아니라 무언가 가 반드시 있다는
것을 기대하고 있습니다. 그러면 코드는 Some 과 None 두 경우를 모두 다룰 필요
없이, 값이 확실히 있다는 경우 하나만 처리하면 됩니다. 함수에 “없음” 을 넘기려는 코드는
아예 컴파일조차 되지 않으므로, 함수는 런타임에서 그 경우를 검사할 필요가 없습니다.
또 다른 예는 u32 같은 부호 없는 정수 타입을 사용하는 것입니다. 그러면 매개변수가
절대 음수가 아님이 보장됩니다.
검증을 위한 사용자 정의 타입
러스트 타입 시스템을 사용해 “유효한 값만 가진다”는 사실을 보장하는 아이디어를 한 걸음 더 밀어붙여, 검증용 사용자 정의 타입을 만드는 방법을 살펴봅시다. 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 사이임을 알고 있으므로, 비밀 숫자와의
비교를 안심하고 진행할 수 있습니다.
하지만 이 방법도 이상적이지는 않습니다. 프로그램이 정말로 1부터 100 사이의 값에서만 동작해야 하고, 이런 요구를 가진 함수가 여러 개 있다면, 모든 함수 안에 이런 검사를 반복해서 적는 것은 지루하고(심지어 성능에도 영향을 줄 수 있습니다).
대신 별도 모듈 안에 새 타입을 만들고, 인스턴스를 생성하는 함수 안에 검증을 넣을 수
있습니다. 그러면 검증을 여러 곳에 반복하지 않아도 되고, 함수들은 그 새 타입을
시그니처에 사용함으로써 “전달받은 값은 이미 유효하다”는 사실을 믿고 코드를 쓸 수
있습니다. 목록 9-13은 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 타입참고로 이 코드는 src/guessing_game.rs 안에 있지만, 여기에는 보여 주지 않은
mod guessing_game; 모듈 선언이 src/lib.rs 안에 추가되어 있어야 합니다. 새
모듈 파일 안에서 우리는 value 라는 i32 필드를 가진 Guess 구조체를 정의합니다.
이 필드에 실제 숫자가 저장됩니다.
그리고 나서 Guess 위에 new 라는 연관 함수를 구현합니다. new 함수는 value
라는 이름의 i32 매개변수 하나를 받고 Guess 를 반환합니다. new 본문 안의 코드는
value 가 1과 100 사이인지 검사합니다. 만약 이 검사를 통과하지 못하면
panic! 을 호출합니다. 이것은 이 함수를 호출하는 코드를 작성한 프로그래머에게
“고쳐야 할 버그가 있다”는 사실을 알려 주기 위함입니다. 범위 밖 값으로 Guess 를
만드는 것은 Guess::new 가 기대하는 계약을 어기는 것이기 때문입니다. Guess::new
가 패닉할 수 있는 조건은 공개 API 문서에도 설명되어 있어야 합니다. 14장에서는 API
문서 안에 panic! 가능성을 어떻게 표시하는지도 다룹니다. 반대로 value 가 검사를
통과하면, value 필드가 그 값으로 설정된 새 Guess 를 만들어 반환합니다.
그 다음에는 self 를 빌리고 다른 매개변수는 받지 않으며 i32 를 반환하는 value
라는 메서드를 구현합니다. 이런 종류의 메서드는 보통 필드 데이터를 꺼내 반환하는
역할을 하므로 getter 라고 부릅니다. 이 public 메서드가 필요한 이유는, Guess
구조체의 value 필드가 private 이기 때문입니다. value 필드를 private 로 두는 것은
중요합니다. Guess 를 사용하는 코드가 직접 value 를 설정하지 못하게 하기 위해서
입니다. 모듈 바깥 코드가 Guess 인스턴스를 만들려면 반드시 Guess::new 를 사용해야
하고, 그러면 Guess 안의 값은 항상 Guess::new 의 검사를 통과한 값이라는 사실이
보장됩니다.
1부터 100 사이 숫자만 받아야 하는 함수라면, 이제 시그니처에서 i32 대신 Guess
를 받거나 반환한다고 선언할 수 있습니다. 그러면 함수 본문 안에서 추가 검사 코드를
또 작성할 필요가 없습니다.
정리
러스트의 에러 처리 기능은 여러분이 더 견고한 코드를 작성하도록 설계되어 있습니다.
panic! 매크로는 프로그램이 더 이상 감당할 수 없는 상태에 있다는 신호를 보내고,
잘못된 값으로 계속 진행하는 대신 프로세스를 멈추게 합니다. Result enum은 타입
시스템을 사용해, 어떤 연산이 실패할 수 있지만 여러분의 코드가 그 실패로부터 복구할
수 있다는 사실을 표현합니다. Result 는 여러분의 코드를 호출하는 코드에게도,
성공과 실패 가능성을 모두 처리해야 한다는 사실을 알릴 수 있습니다. 적절한 상황에서
panic! 과 Result 를 사용하는 것은, 피할 수 없는 문제들 앞에서도 코드를 더
신뢰할 수 있게 만들어 줍니다.
이제 여러분은 표준 라이브러리가 Option 과 Result enum에서 제네릭을 어떻게
유용하게 사용하는지도 보았습니다. 다음으로는 제네릭이 어떻게 동작하는지, 그리고
여러분 자신의 코드에서 제네릭을 어떻게 사용할 수 있는지를 이야기하겠습니다.