패닉할 것인가, 말 것인가 (To Panic or Not to Panic)

Beginner

This tutorial is from open-source community. Access the source code

소개

To Panic or Not to Panic에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.

이 랩에서는 panic!을 호출할지 또는 Result를 반환할지 여부는 오류 상황의 복구 가능성과 호출 코드에 사용 가능한 옵션에 따라 결정됩니다.

패닉할 것인가, 말 것인가 (To panic or Not to panic)

그렇다면 panic!을 호출해야 할 때와 Result를 반환해야 할 때는 어떻게 결정해야 할까요? 코드가 패닉하면 복구할 방법이 없습니다. 복구 가능한 방법이 있는지 여부에 관계없이 모든 오류 상황에 대해 panic!을 호출할 수 있지만, 그렇게 하면 호출 코드 대신 상황이 복구 불가능하다고 결정하는 것입니다. Result 값을 반환하도록 선택하면 호출 코드에 옵션을 제공하는 것입니다. 호출 코드는 해당 상황에 적합한 방식으로 복구를 시도하도록 선택하거나, 이 경우 Err 값이 복구 불가능하다고 결정할 수 있으므로 panic!을 호출하여 복구 가능한 오류를 복구 불가능한 오류로 바꿀 수 있습니다. 따라서 Result를 반환하는 것은 실패할 수 있는 함수를 정의할 때 좋은 기본 선택입니다.

예제, 프로토타입 코드 및 테스트와 같은 상황에서는 Result를 반환하는 대신 패닉하는 코드를 작성하는 것이 더 적절합니다. 그 이유를 살펴보고, 컴파일러가 실패가 불가능하다는 것을 알 수 없지만, 여러분과 같은 사람이 알 수 있는 상황에 대해 논의해 보겠습니다. 이 장에서는 라이브러리 코드에서 패닉할지 여부를 결정하는 방법에 대한 몇 가지 일반적인 지침으로 마무리됩니다.

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

어떤 개념을 설명하기 위해 예제를 작성할 때, 강력한 오류 처리 코드를 포함하는 것은 예제를 덜 명확하게 만들 수 있습니다. 예제에서는 패닉할 수 있는 unwrap과 같은 메서드 호출이 애플리케이션이 오류를 처리하려는 방식의 자리 표시자로 사용된다는 것을 이해합니다. 이는 나머지 코드의 작업에 따라 달라질 수 있습니다.

마찬가지로, unwrapexpect 메서드는 오류를 처리하는 방법을 결정할 준비가 되기 전에 프로토타입을 만들 때 매우 유용합니다. 이러한 메서드는 프로그램을 더 강력하게 만들 준비가 되었을 때 코드에 명확한 마커를 남깁니다.

테스트에서 메서드 호출이 실패하면, 해당 메서드가 테스트 중인 기능이 아니더라도 전체 테스트가 실패하기를 원할 것입니다. panic!이 테스트가 실패로 표시되는 방식이기 때문에 unwrap 또는 expect를 호출하는 것이 바로 해야 할 일입니다.

컴파일러보다 더 많은 정보를 가지고 있는 경우

ResultOk 값을 갖도록 보장하는 다른 로직이 있지만, 해당 로직을 컴파일러가 이해하지 못하는 경우에도 unwrap 또는 expect를 호출하는 것이 적절합니다. 여전히 처리해야 할 Result 값이 있습니다. 호출하는 작업은 특정 상황에서는 논리적으로 불가능하더라도 일반적으로 실패할 가능성이 여전히 있습니다. 코드를 수동으로 검사하여 Err 변형이 절대 없을 것이라고 확신할 수 있다면 unwrap을 호출하는 것이 완벽하게 허용되며, 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 값을 얻으며, 컴파일러는 이 문자열이 항상 유효한 IP 주소임을 알 만큼 똑똑하지 않기 때문에 Err 변형이 가능성이 있는 것처럼 Result를 처리하도록 강제합니다. IP 주소 문자열이 프로그램에 하드코딩된 것이 아니라 사용자로부터 제공되어 실패할 가능성이 있는 경우, Result를 더 강력한 방식으로 처리하는 것이 확실히 좋습니다. 이 IP 주소가 하드코딩되었다는 가정을 언급하면, 향후 다른 소스에서 IP 주소를 가져와야 하는 경우 expect를 더 나은 오류 처리 코드로 변경하도록 유도할 것입니다.

오류 처리에 대한 지침

코드가 잘못된 상태가 될 수 있는 경우 코드가 패닉하도록 하는 것이 좋습니다. 이 맥락에서 잘못된 상태는 유효하지 않은 값, 모순된 값 또는 누락된 값이 코드에 전달되는 경우와 같이 일부 가정, 보장, 계약 또는 불변성이 깨진 경우를 의미합니다. 다음 중 하나 이상이 포함됩니다.

  • 잘못된 상태는 사용자가 잘못된 형식으로 데이터를 입력하는 경우와 같이 가끔 발생할 가능성이 있는 것이 아니라 예상치 못한 것입니다.
  • 이 시점 이후의 코드는 모든 단계에서 문제를 확인하는 대신 이 잘못된 상태에 있지 않다는 것에 의존해야 합니다.
  • 사용하는 유형으로 이 정보를 인코딩할 좋은 방법이 없습니다. "유형으로 상태 및 동작 인코딩"에서 의미하는 바의 예시를 살펴보겠습니다.

누군가 코드를 호출하고 말이 안 되는 값을 전달하는 경우, 라이브러리 사용자가 해당 경우에 무엇을 할지 결정할 수 있도록 오류를 반환하는 것이 가장 좋습니다. 그러나 계속 진행하는 것이 안전하지 않거나 해로울 수 있는 경우, panic!을 호출하고 라이브러리를 사용하는 사람에게 코드의 버그를 알려 개발 중에 수정할 수 있도록 하는 것이 최선의 선택일 수 있습니다. 마찬가지로, 제어할 수 없는 외부 코드를 호출하고 수정할 방법이 없는 유효하지 않은 상태를 반환하는 경우 panic!이 적절한 경우가 많습니다.

그러나 실패가 예상되는 경우, panic! 호출을 하는 것보다 Result를 반환하는 것이 더 적절합니다. 예시로는 파서에 잘못된 데이터가 제공되거나 HTTP 요청이 속도 제한에 도달했음을 나타내는 상태를 반환하는 경우가 있습니다. 이러한 경우, Result를 반환하는 것은 실패가 호출 코드가 처리 방법을 결정해야 하는 예상 가능한 가능성임을 나타냅니다.

코드가 유효하지 않은 값을 사용하여 호출될 경우 사용자를 위험에 빠뜨릴 수 있는 작업을 수행하는 경우, 먼저 값이 유효한지 확인하고 유효하지 않은 경우 패닉해야 합니다. 이는 주로 안전을 위한 이유입니다. 유효하지 않은 데이터에 대한 작업을 시도하면 코드에 취약성이 노출될 수 있습니다. 이것이 표준 라이브러리가 범위를 벗어난 메모리 접근을 시도하는 경우 panic!을 호출하는 주된 이유입니다. 현재 데이터 구조에 속하지 않는 메모리에 접근하는 것은 일반적인 보안 문제입니다. 함수는 종종 계약을 갖습니다. 입력이 특정 요구 사항을 충족하는 경우에만 동작이 보장됩니다. 계약 위반은 항상 호출자 측의 버그를 나타내며, 호출 코드가 명시적으로 처리해야 하는 종류의 오류가 아니기 때문에 계약 위반 시 패닉하는 것이 타당합니다. 실제로 호출 코드가 복구할 합리적인 방법이 없습니다. 호출하는 프로그래머가 코드를 수정해야 합니다. 특히 위반 시 패닉이 발생하는 경우 함수의 계약은 함수의 API 문서에 설명되어야 합니다.

그러나 모든 함수에 많은 오류 검사를 하는 것은 장황하고 성가실 것입니다. 다행히 Rust 의 타입 시스템 (따라서 컴파일러에서 수행하는 타입 검사) 을 사용하여 많은 검사를 수행할 수 있습니다. 함수에 특정 유형이 매개변수로 있는 경우, 컴파일러가 이미 유효한 값을 가지고 있는지 확인했음을 알고 코드의 로직을 진행할 수 있습니다. 예를 들어, Option 대신 특정 유형이 있는 경우, 프로그램은 nothing이 아닌 something을 가질 것으로 예상합니다. 그러면 코드는 SomeNone 변형에 대한 두 가지 경우를 처리할 필요가 없습니다. 확실히 값을 갖는 한 가지 경우만 갖게 됩니다. 함수에 아무것도 전달하려고 시도하는 코드는 컴파일조차 되지 않으므로 함수는 런타임에 해당 경우를 확인할 필요가 없습니다. 또 다른 예는 u32와 같은 부호 없는 정수 유형을 사용하여 매개변수가 음수가 되지 않도록 하는 것입니다.

유효성 검사를 위한 사용자 정의 타입 생성

Rust 의 타입 시스템을 사용하여 유효한 값을 갖도록 하는 아이디어를 한 단계 더 나아가 유효성 검사를 위한 사용자 정의 타입을 생성하는 것을 살펴보겠습니다. 2 장에서 코드가 사용자에게 1 에서 100 사이의 숫자를 추측하도록 요청했던 추측 게임을 기억하십시오. 비밀 번호와 비교하기 전에 사용자의 추측이 해당 숫자 사이에 있는지 확인하지 않았습니다. 추측이 양수인지 확인했을 뿐입니다. 이 경우 결과는 그다지 심각하지 않았습니다. "너무 높음" 또는 "너무 낮음"의 출력은 여전히 정확했을 것입니다. 그러나 사용자가 유효한 추측을 하도록 안내하고 사용자가 예를 들어 문자를 입력하는 경우와 범위 밖의 숫자를 추측하는 경우 다른 동작을 갖도록 하는 것이 유용할 것입니다.

이를 수행하는 한 가지 방법은 잠재적으로 음수를 허용하기 위해 추측을 u32가 아닌 i32로 구문 분석한 다음 다음과 같이 숫자가 범위 내에 있는지 확인하는 것입니다.

파일 이름: src/main.rs

loop {
    --snip--

    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--
}

if 표현식은 값이 범위를 벗어나는지 확인하고, 사용자에게 문제에 대해 알리고, continue를 호출하여 루프의 다음 반복을 시작하고 다른 추측을 요청합니다. if 표현식 이후에는 guess가 1 에서 100 사이임을 알고 guess와 비밀 번호 간의 비교를 진행할 수 있습니다.

그러나 이것은 이상적인 해결책이 아닙니다. 프로그램이 1 에서 100 사이의 값으로만 작동하는 것이 절대적으로 중요하고 이 요구 사항이 있는 함수가 많은 경우, 모든 함수에 이와 같은 검사를 하는 것은 지루할 것입니다 (그리고 성능에 영향을 미칠 수 있습니다).

대신, 새 유형을 만들고 모든 곳에서 유효성 검사를 반복하는 대신 유형의 인스턴스를 생성하는 함수에 유효성 검사를 넣을 수 있습니다. 그렇게 하면 함수가 시그니처에서 새 유형을 사용하고 수신하는 값을 자신 있게 사용하는 것이 안전합니다. 목록 9-13 은 new 함수가 1 에서 100 사이의 값을 수신하는 경우에만 Guess의 인스턴스를 생성하는 Guess 유형을 정의하는 한 가지 방법을 보여줍니다.

파일 이름: src/lib.rs

1 pub struct Guess {
    value: i32,
}

impl Guess {
  2 pub fn new(value: i32) -> Guess {
      3 if value < 1 || value > 100 {
          4 panic!(
                "Guess value must be between 1 and 100, got {}.",
                value
            );
        }

      5 Guess { value }
    }

  6 pub fn value(&self) -> i32 {
        self.value
    }
}

목록 9-13: 1 에서 100 사이의 값으로만 계속 진행되는 Guess 유형

먼저 value라는 필드를 가진 Guess라는 구조체를 정의합니다. 이 필드는 i32를 저장합니다 [1]. 이것은 숫자가 저장될 위치입니다.

그런 다음 Guessnew라는 연관 함수를 구현하여 Guess 값의 인스턴스를 생성합니다 [2]. new 함수는 i32 유형의 value라는 매개변수 하나를 갖고 Guess를 반환하도록 정의됩니다. new 함수의 본문 내 코드는 value가 1 에서 100 사이인지 확인하기 위해 테스트합니다 [3]. value가 이 테스트를 통과하지 못하면 panic! 호출을 합니다 [4]. 그러면 이 범위를 벗어난 valueGuess를 생성하면 Guess::new가 의존하는 계약을 위반하므로 호출 코드를 작성하는 프로그래머에게 수정해야 할 버그가 있음을 알립니다. Guess::new가 패닉할 수 있는 조건은 공개 API 문서에서 논의되어야 합니다. 14 장에서 생성할 API 문서에서 panic!의 가능성을 나타내는 문서화 규칙을 다룰 것입니다. value가 테스트를 통과하면 value 필드가 value 매개변수로 설정된 새 Guess를 생성하고 Guess를 반환합니다 [5].

다음으로, self를 빌리고 다른 매개변수가 없으며 i32를 반환하는 value라는 메서드를 구현합니다 [6]. 이 종류의 메서드는 때때로 getter라고 불립니다. 그 목적은 필드에서 일부 데이터를 가져와 반환하는 것이기 때문입니다. 이 public 메서드는 Guess 구조체의 value 필드가 private 이기 때문에 필요합니다. value 필드가 private 인 것은 Guess 구조체를 사용하는 코드가 value를 직접 설정할 수 없도록 하기 위해 중요합니다. 모듈 외부의 코드는 Guess::new 함수를 사용하여 Guess의 인스턴스를 반드시 사용해야 하므로 Guess::new 함수의 조건에 의해 확인되지 않은 value를 갖는 Guess가 없도록 보장합니다.

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

요약

축하합니다! To Panic or Not to Panic 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.