panic!을 쓸지, 쓰지 않을지

panic!을 쓸지, 쓰지 않을지

그럼 언제 panic!을 호출하고 언제 Result를 반환해야 할지 어떻게 결정할까요? 코드가 패닉에 빠지면 복구할 방법이 없어요. 복구 가능한 상황인지 아닌지와 무관하게 어떤 에러 상황에서든 panic!을 호출할 수는 있지만, 그러면 상황을 복구 불가능하다고 결정하는 것을 호출하는 코드를 대신 떠맡게 돼요. 반면 Result 값을 반환하기로 선택하면, 호출하는 코드에게 선택지를 주는 거예요. 호출 코드는 자신의 상황에 맞는 방식으로 복구를 시도할 수도 있고, 이 경우의 Err 값은 복구 불가능하다고 판단해 panic!을 호출해 우리의 복구 가능한 에러를 복구 불가능한 에러로 바꿀 수도 있어요. 그래서 실패할 수 있는 함수를 정의할 때는 Result를 반환하는 것이 좋은 기본 선택이에요.

출처: The Rust Book

예시, 프로토타입 코드, 테스트

어떤 개념을 설명하기 위한 예시를 작성할 때, 견고한 에러 처리 코드까지 넣으면 예시가 오히려 덜 명확해질 수 있어요. 예시에서는 패닉을 일으킬 수 있는 unwrap 같은 메서드의 호출이, 여러분의 코드 나머지가 하는 일에 따라 달라질 수 있는 방식으로 애플리케이션이 에러를 처리하길 원하는 자리의 자리표시자(placeholder)로 이해돼요.

마찬가지로 unwrapexpect 메서드는 프로토타이핑을 하면서 아직 에러를 어떻게 처리할지 결정할 준비가 되지 않았을 때 아주 유용해요. 이 메서드들은 곧 프로그램을 더 견고하게 만들 준비가 되면 손볼 수 있도록 코드에 명확한 표시를 남겨 주죠.

테스트에서 메서드 호출이 실패하면, 그 메서드가 테스트 대상 기능이 아니더라도 테스트 전체가 실패하길 원할 거예요. panic!이 테스트를 실패로 표시하는 방법이므로, unwrap이나 expect를 호출하는 것이 정확히 맞는 행동이에요.

컴파일러보다 더 많은 정보를 가질 때

ResultOk 값을 가질 것임을 보장하는 다른 로직이 있는데, 그 로직을 컴파일러가 이해하지 못하는 상황에서도 expect를 호출하는 게 적절할 수 있어요. 여전히 처리해야 할 Result 값은 남아 있어요. 호출하는 연산은 일반적으로는 여전히 실패할 가능성이 있으니까요, 비록 우리의 특정 상황에서는 논리적으로 실패가 불가능하더라도 말이죠. 코드를 직접 검사해 절대 Err 변형이 생기지 않는다고 확신할 수 있다면, 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를 더 나은 에러 처리 코드로 바꿔야 한다는 것을 상기시켜 줘요.

에러 처리 지침

코드가 **나쁜 상태(bad state)**에 빠질 가능성이 있다면 panic 하도록 코드를 짜는 것이 좋아요. 여기서 나쁜 상태라는 것은 어떤 가정, 보장, 계약(contract), 불변식(invariant)이 깨진 상황이에요. 예를 들어 잘못된 값, 모순된 값, 빠진 값이 코드로 전달되는 경우가죠. 그리고 여기에 다음 중 하나 이상이 더해질 때를 말해요.

  • 나쁜 상태가, 사용자가 잘못된 형식으로 데이터를 입력하는 것처럼 가끔 일어날 만한 일이 아니라, 예상치 못한 일인 경우.
  • 이 지점 이후의 코드가 매 단계에서 문제를 확인하지 않고, 나쁜 상태가 아니라는 것에 의존해야 하는 경우.
  • 이 정보를 사용하는 타입으로 인코딩할 좋은 방법이 없는 경우. 무슨 뜻인지는 18장의 "상태와 행동을 타입으로 인코딩하기"에서 예시를 다룰게요.

누군가가 의미가 없는 값을 넘겨 우리 코드를 호출한다면, 가능하면 에러를 반환해서 라이브러리 사용자가 그 경우에 무엇을 할지 스스로 정하게 하는 게 좋아요. 하지만 계속 진행하는 것이 안전하지 않거나 해롭다면, panic!을 호출해 라이브러리 사용자에게 자신의 코드 버그를 알리는 편이 최선일 수 있어요. 그래야 개발 중에 고칠 수 있으니까요. 비슷하게, 우리가 통제할 수 없는 외부 코드가 고칠 방법이 없는 잘못된 상태를 반환할 때도 panic!이 흔히 적절해요.

하지만 실패가 예상되는 상황이라면 panic!을 호출하는 것보다 Result를 반환하는 것이 더 적절해요. 예를 들어 파서가 잘못된 형식의 데이터를 받는 경우나, HTTP 요청이 요청 속도 제한(rate limit)에 걸렸음을 나타내는 상태를 반환하는 경우가 그렇죠. 이런 경우 Result를 반환하는 것은 실패가 예상되는 가능성이며, 호출 코드가 어떻게 처리할지 결정해야 한다는 뜻이에요.

코드가 잘못된 값으로 호출되면 사용자를 위험에 빠뜨릴 수 있는 연산을 수행한다면, 코드가 먼저 값이 유효한지 검증하고 유효하지 않으면 패닉을 일으켜야 해요. 이건 대부분 안전 때문이에요. 잘못된 데이터로 연산을 시도하면 코드가 취약점에 노출될 수 있거든요. 이것이 표준 라이브러리가 경계를 벗어난 메모리 접근을 시도할 때 panic!을 호출하는 주요 이유예요. 현재 데이터 구조에 속하지 않는 메모리에 접근하는 건 흔한 보안 문제니까요. 함수에는 흔히 **계약(contract)**이 있어요. 입력이 특정 요구 사항을 충족할 때만 동작이 보장된다는 뜻이죠. 계약을 위반하면 패닉을 일으키는 건 합리적이에요. 계약 위반은 항상 호출자 쪽 버그를 나타내며, 호출 코드가 명시적으로 처리해야 할 종류의 에러가 아니기 때문이에요. 사실 호출 코드가 복구할 합리적인 방법이 없고, 호출하는 프로그래머가 코드를 고쳐야 해요. 함수의 계약, 특히 위반 시 패닉을 일으킬 경우에는 함수의 API 문서에 설명해야 해요.

하지만 모든 함수에 에러 검사를 잔뜩 넣는 건 장황하고 짜증나요. 다행히 Rust의 타입 시스템(그리고 컴파일러가 하는 타입 검사)을 활용해 많은 검사를 대신 할 수 있어요. 함수가 특정 타입을 매개변수로 받는다면, 컴파일러가 유효한 값을 보장했다는 걸 알면서 코드 로직을 진행할 수 있어요. 예를 들어 Option이 아닌 어떤 타입을 가지면, 프로그램은 *없음(nothing)*이 아니라 *있음(something)*을 기대하는 거예요. 그러면 코드가 SomeNone 변형 두 경우를 처리할 필요가 없고, 확실히 값이 있는 한 가지 경우만 처리하면 돼요. 함수에 아무것도(없음) 넘기려는 코드는 컴파일조차 되지 않으므로, 함수가 런타임에 그 경우를 확인할 필요가 없어요. 또 다른 예는 u32 같은 부호 없는 정수 타입을 쓰는 것인데, 이는 매개변수가 절대 음수가 되지 않도록 보장해 줘요.

검증을 위한 커스텀 타입

Rust의 타입 시스템을 사용해 유효한 값을 보장한다는 아이디어를 한 단계 더 나아가, 검증을 위한 커스텀 타입을 만드는 것을 살펴볼게요. 2장의 추측 게임(guessing game)에서, 코드가 사용자에게 1에서 100 사이의 숫자를 맞히라고 요청했던 걸 기억하나요? 그때 우리는 사용자의 추측이 그 숫자들 사이에 있는지 실제로 검증하지는 않았어요. 비밀 숫자와 비교하기 전에 추측이 양수인지만 검증했죠. 이 경우 결과가 그리 심각하지 않았어요. "Too high"나 "Too low" 출력이 여전히 정확했으니까요. 하지만 사용자를 유효한 추측으로 안내하고, 사용자가 범위를 벗어난 숫자를 추측할 때와 예를 들어 문자를 입력할 때 서로 다른 동작을 하게 하는 건 유용한 개선이 될 거예요.

이를 위한 한 가지 방법은 추측을 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은 new 함수가 1과 100 사이의 값을 받았을 때만 Guess의 인스턴스를 만들도록 하는 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
    }
}
}

src/guessing_game.rs의 코드는 src/lib.rsmod guessing_game; 모듈 선언을 추가하는 데 의존하지만, 그 부분은 여기서 보여주지 않았어요. 이 새 모듈의 파일 안에서 우리는 value라는 필드를 가진 Guess라는 구조체를 정의하는데, 그 필드는 i32를 담아요. 숫자가 바로 여기에 저장되는 거죠.

그다음 Guessnew라는 연관 함수(associated function)를 구현해 Guess 값의 인스턴스를 만들어요. new 함수는 i32 타입의 value라는 매개변수 하나를 받고 Guess를 반환하도록 정의돼요. new 함수 본문의 코드는 value가 1과 100 사이인지 테스트해요. value가 이 테스트를 통과하지 못하면 panic!을 호출하는데, 1과 100 사이가 아닌 valueGuess를 만드는 것은 Guess::new가 의존하는 계약을 위반하는 일이기 때문에, 호출 코드를 작성하는 프로그래머에게 고쳐야 할 버그가 있음을 알려 주죠. Guess::new가 패닉을 일으킬 수 있는 조건은 공개 API 문서에 논의되어야 하는데, 14장에서 panic! 가능성을 나타내는 문서 작성 관례를 다룰 거예요. value가 테스트를 통과하면 value 필드를 value 매개변수로 설정한 새 Guess를 만들어 반환해요.

다음으로 self를 빌리고 다른 매개변수가 없으며 i32를 반환하는 value라는 메서드를 구현해요. 이런 메서드를 가끔 **게터(getter)**라고 부르는데, 목적이 필드에서 데이터를 가져와 반환하는 것이기 때문이에요. Guess 구조체의 value 필드가 비공개이므로 이 공개 메서드가 필요해요. value 필드가 비공개인 게 중요한 이유는, Guess 구조체를 사용하는 코드가 value를 직접 설정하지 못하게 하기 위해서예요. guessing_game 모듈 밖의 코드는 Guess의 인스턴스를 만들기 위해 반드시 Guess::new 함수를 사용해야 하므로, Guess::new 함수의 조건으로 검증되지 않은 value를 가진 Guess가 생길 방법이 없음을 보장하는 거죠.

이제 1과 100 사이의 숫자만 매개변수로 받거나 반환하는 함수는 시그니처에서 i32 대신 Guess를 받거나 반환한다고 선언할 수 있고, 본문에서 추가 검사를 할 필요가 없어요.

정리

Rust의 에러 처리 기능은 더 견고한 코드를 작성하도록 설계됐어요. panic! 매크로는 프로그램이 처리할 수 없는 상태에 있음을 알리고, 잘못되거나 부정확한 값으로 진행을 시도하는 대신 프로세스를 멈추도록 해 주죠. Result 이늄은 Rust의 타입 시스템을 사용해 연산이 코드가 복구할 수 있는 방식으로 실패할 수 있음을 나타내요. Result를 사용해 우리 코드를 호출하는 코드에 잠재적인 성공이나 실패를 처리해야 한다고 알릴 수도 있어요. 적절한 상황에서 panic!Result를 사용하면, 피할 수 없는 문제들 앞에서도 우리 코드가 더 신뢰할 수 있게 돼요.

이제 표준 라이브러리가 OptionResult 이늄과 함께 제네릭을 유용하게 사용하는 모습을 봤으니, 제네릭이 어떻게 동작하는지 그리고 우리 코드에서 어떻게 사용할 수 있는지 이야기할 거예요.

더 알아보기 (Learn more)