본문으로 건너뛰기

Chap.9 error-handling

· 약 48분
brown

러스트의 신뢰성에 대한 약속은 에러 처리에도 확장되어 있습니다.

에러는 소프트웨어에서 피할 수 없는 현실이며, 따라서 러스트는 무언가 잘못되었을 경우에 대한 처리를 위한 몇 가지 기능을 갖추고 있습니다.

러스트는 에러를 두 가지 범주로 묶습니다:

  • 복구 가능한(recoverable) 에러
    • 복구 가능한 에러는 사용자에게 문제를 보고하고 연산을 재시도하는 것이 보통 합리적인 경우인데, 이를테면 파일을 찾지 못하는 에러가 그렇습니다.
  • 복구 불가능한(unrecoverable) 에러
    • 복구 불가능한 에러는 언제나 버그의 증상이 나타나는데, 예를 들면 배열의 끝을 넘어선 위치의 값에 접근하려고 시도하는 경우가 그렇습니다.

대부분의 언어들은 이 두 종류의 에러를 분간하지 않으며 예외 처리(exception)와 같은 메카니즘을 이용하여 같은 방식으로 둘 다 처리합니다.

러스트는 예외 처리 기능이 없습니다.

  • 복구 가능한 에러를 위한 Result<T, E> 값과
  • 복구 불가능한 에러가 발생했을 때 실행을 멈추는 panic! 매크로를 가지고 있습니다.

이번 장에서는 panic!을 호출하는 것을 먼저 다룬 뒤, Result<T, E> 값을 반환하는 것에 대해 이야기 하겠습니다.

추가로, 에러로부터 복구을 시도할지 아니면 실행을 멈출지를 결정할 때 고려할 것에 대해 탐구해 보겠습니다.

panic!과 함께하는 복구 불가능한 에러

  • 러스트는 panic! 매크로를 가지고 있습니다.
  • 이 매크로가 실행되면, 여러분의 프로그램은 실패 메세지를 출력하고, 스택을 되감고 청소하고, 그 후 종료됩니다.
  • 이런 일이 발생하는 가장 흔한 상황은 어떤 종류의 버그가 발견되었고 프로그래머가 이 에러를 어떻게 처리할지가 명확하지 않을 때 입니다.

panic!에 응하여 스택을 되감거나 그만두기

기본적으로, panic!이 발생하면, 프로그램은 되감기(unwinding) 를 시작하는데, 이는 러스트가 패닉을 마주친 각 함수로부터 스택을 거꾸로 훑어가면서 데이터를 제거한다는 뜻이지만, 이 훑어가기 및 제거는 일이 많습니다.

다른 대안으로는 즉시 그만두기(abort) 가 있는데, 이는 데이터 제거 없이 프로그램을 끝내는 것입니다. 프로그램이 사용하고 있던 메모리는 운영체제에 의해 청소될 필요가 있을 것입니다. 여러분의 프로젝트 내에서 결과 바이너리가 가능한 작아지기를 원한다면, 여러분의 Cargo.toml 내에서 적합한 [profile] 섹션에 panic = 'abort'를 추가함으로써 되감기를 그만두기로 바꿀 수 있습니다.

예를 들면, 여러분이 릴리즈 모드 내에서는 패닉 상에서 그만두기를 쓰고 싶다면, 다음을 추가하세요: [profile.release] panic = 'abort'

단순한 프로그램 내에서 panic! 호출을 시도해 봅시다:

  • panic!("crash and burn");
  • panic!의 호출이 마지막 세 줄의 에러 메세지를 야기합니다.
  • 위 예제의 경우, 가리키고 있는 줄은 우리 코드 부분이고, 해당 줄로 가면 panic! 매크로 호출을 보게 됩니다.
  • 그 외의 경우들에서는, panic! 호출이 우리가 호출한 코드 내에 있을 수도 있습니다.
  • 에러 메세지에 의해 보고되는 파일 이름과 라인 번호는 panic! 매크로가 호출된 다른 누군가의 코드일 것이며, 궁극적으로 panic!을 이끌어낸 것이 우리 코드 라인이 아닐 것입니다.
  • 문제를 일으킨 코드 부분을 발견하기 위해서 panic! 호출이 발생된 함수에 대한 백트레이스(backtrace)를 사용할 수 있습니다.
  • 백트레이스가 무엇인가에 대해서는 뒤에 더 자세히 다를 것입니다.

panic! 백트레이스 사용하기

다른 예를 통해서, 우리 코드가 직접 매크로를 호출하는 대신 우리 코드의 버그 때문에 panic! 호출이 라이브러리로부터 발생될 때는 어떻게 되는지 살펴봅시다.

이러한 상황에서 C와 같은 다른 언어들은 여러분이 원하는 것이 아닐지라도, 여러분이 요청한 것을 정확히 주려고 시도할 것입니다: 여러분은 벡터 내에 해당 요소와 상응하는 위치의 메모리에 들어 있는 무언가를 얻을 것입니다. 설령 그 메모리 영역이 벡터 소유가 아닐지라도 말이죠.

이러한 것을 버퍼 오버리드(buffer overread) 라고 부르며, 만일 어떤 공격자가 읽도록 허용되어선 안 되지만 배열 뒤에 저장된 데이터를 읽어낼 방법으로서 인덱스를 다룰 수 있게 된다면, 이는 보안 취약점을 발생시킬 수 있습니다.

여러분의 프로그램을 이러한 종류의 취약점으로부터 보호하기 위해서, 여러분이 존재하지 않는 인덱스 상의 요소를 읽으려 시도한다면, 러스트는 실행을 멈추고 계속하기를 거부할 것입니다. 한번 시도해 봅시다:

  • 위 에러는 우리가 작성하지 않은 파일인 libcollections/vec.rs를 가리키고 있습니다.
  • 이는 표준 라이브러리 내에 있는 Vec<T>의 구현 부분입니다.
  • 우리가 벡터 v에 []를 사용할 때 실행되는 코드는 libcollections/vec.rs 안에 있으며, 그곳이 바로 panic!이 실제 발생한 곳입니다.
  • 그 다음 노트는 RUST_BACKTRACE 환경 변수를 설정하여 에러의 원인이 된 것이 무엇인지 정확하게 백트레이스할 수 있다고 말해주고 있습니다.
  • 백트레이스 (backtrace) 란 어떤 지점에 도달하기까지 호출해온 모든 함수의 리스트를 말합니다.
  • 러스트의 백트레이스는 다른 언어들에서와 마찬가지로 동작합니다: 백트레이스를 읽는 요령은 위에서부터 시작하여 여러분이 작성한 파일이 보일 때까지 읽는 것입니다. 그곳이 바로 문제를 일으킨 지점입니다.
  • 여러분의 파일을 언급한 줄보다 위에 있는 줄들은 여러분의 코드가 호출한 코드입니다; 밑의 코드는 여러분의 코드를 호출한 코드입니다.
  • 이 줄들은 핵심(core) 러스트 코드, 표준 라이브러리, 혹은 여러분이 이용하고 있는 크레이트를 포함하고 있을지도 모릅니다. 백트레이스를 얻어내는 시도를 해봅시다: Listing 9-2는 여러분이 보게 될 것과 유사한 출력을 보여줍니다:

환경 변수 RUST_BACKTRACE가 설정되었을 때 panic!의 호출에 의해 발생되는 백트레이스 출력

  • 이러한 정보들과 함께 백트레이스를 얻기 위해서는 디버그 심볼이 활성화되어 있어야 합니다.
  • 디버그 심볼은 여기서와 마찬가지로 여러분이 cargo build나 cargo run을 --release 플래그 없이 실행했을 때 기본적으로 활성화됩니다.

여러분의 코드가 추후 패닉에 빠졌을 때, 여러분의 특정한 경우에 대하여 어떤 코드가 패닉을 일으키는 값을 만드는지와 코드는 대신 어떻게 되어야 할지를 알아낼 필요가 있을 것입니다.

우리는 panic!으로 다시 돌아올 것이며 언제 panic!을 써야 하는지, 혹은 쓰지 말아야 하는지에 대해 이 장의 뒷부분에서 알아보겠습니다. 다음으로 Result를 이용하여 에러로부터 어떻게 복구하는지를 보겠습니다.

Result와 함께하는 복구 가능한 에러

대부분의 에러는 프로그램을 전부 멈추도록 요구될 정도로 심각하지는 않습니다. 종종 어떤 함수가 실패할 때는, 우리가 쉽게 해석하고 대응할 수 있는 이유에 대한 것입니다.

예를 들어, 만일 우리가 어떤 파일을 여는데 해당 파일이 존재하지 않아서 연산에 실패했다면, 프로세스를 멈추는 대신 파일을 새로 만드는 것을 원할지도 모릅니다

enum Result<T, E> {
Ok(T),
Err(E),
}
  • T와 E는 제네릭 타입 파라미터입니다;
  • 지금으로서 여러분이 알아둘 필요가 있는 것은, T는 성공한 경우에 Ok variant 내에 반환될 값의 타입을 나타내고 E는 실패한 경우에 Err variant 내에 반환될 에러의 타입을 나타내는 것이라는 점입니다.
  • Result가 이러한 제네릭 타입 파라미터를 갖기 때문에, 우리가 반환하고자 하는 성공적인 값과 에러 값이 다를 수 있는 다양한 상황 내에서 표준 라이브러리에 정의된 Result 타입과 함수들을 사용할 수 있습니다.

실패할 수도 있기 때문에 Result 값을 반환하는 함수를 호출해 봅시다

use std::fs::File;
fn main() {
let f = File::open("hello.txt");
}
  • File::open이 Result를 반환하는지 어떻게 알까요?

  • 표준 라이브러리 API 문서를 찾아보거나, 컴파일러에게 물어볼 수 있습니다!

  • File::open 함수의 반환 타입이 Result<T, E>

  • 여기서 제네릭 파라미터 T는 성공값의 타입인 std::fs::File로 채워져 있는데,

  • 이것은 파일 핸들입니다.

  • 에러에 사용되는 E의 타입은 std::io::Error입니다.

  • 이 반환 타입은 File::open을 호출하는 것이 성공하여 우리가 읽거나 쓸 수 있는 파일 핸들을 반환해 줄 수도 있다는 뜻입니다.

    • 함수 호출은 또한 실패할 수도 있습니다:
    • 예를 들면 파일이 존재하지 않거나 파일에 접근할 권한이 없을지도 모릅니다.
  • File::open 함수는 우리에게 성공했는지 혹은 실패했는지를 알려주면서 동시에 파일 핸들이나 에러 정보 둘 중 하나를 우리에게 제공할 방법을 가질 필요가 있습니다.

  • 바로 이러한 정보가 Result 열거형이 전달하는 것과 정확히 일치합니다.

  • File::open이 성공한 경우, 변수 f가 가지게 될 값은 파일 핸들을 담고 있는 Ok 인스턴스가 될 것입니다.

  • 실패한 경우, f의 값은 발생한 에러의 종류에 대한 더 많은 정보를 가지고 있는 Err의 인스턴스가 될 것입니다.

use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => {
panic!("There was a problem opening the file: {:?}", error)
},
};
}

Listing 9-4: match 표현식을 사용하여 발생 가능한 Result variant들을 처리하기

  • Option 열거형과 같이 Result 열거형과 variant들은 프렐루드(prelude)로부터 가져와진다는 점을 기억하세요. ??
  • 따라서 match의 각 경우에 대해서 Ok와 Err 앞에 Result::를 특정하지 않아도 됩니다.

여기서 우리는 러스트에게 결과가 Ok일 때에는 Ok variant로부터 내부의 file 값을 반환하고, 이 파일 핸들 값을 변수 f에 대입한다고 말해주고 있습니다.

match 이후에는 읽거나 쓰기 위해 이 파일 핸들을 사용할 수 있습니다.

서로 다른 에러에 대해 매칭하기

  • Listing 9-3의 코드는 File::open이 실패한 이유가 무엇이든 간에 panic!을 일으킬 것입니다.
  • 대신 우리가 원하는 것은 실패 이유에 따라 다른 행동을 취하는 것입니다:
  • 파일이 없어서 File::open이 실패한 것이라면, 새로운 파일을 만들어서 핸들을 반환하고 싶습니다.
  • 만일 그 밖의 이유로 File::open이 실패한 거라면, 예를 들어 파일을 열 권한이 없어서라면, Listing 9-4에서 했던 것과 마찬가지로 panic!을 일으키고 싶습니다.
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(ref error) if error.kind() == ErrorKind::NotFound => {
match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => {
panic!(
"Tried to create file but there was a problem: {:?}",
e
)
},
}
},
Err(error) => {
panic!(
"There was a problem opening the file: {:?}",
error
)
},
};
}
  • Err variant 내에 있는 File::open이 반환하는 값의 타입은 io::Error인데, 이는 표준 라이브러리에서 제공하는 구조체입니다.
  • 이 구조체는 kind 메소드를 제공하는데 이를 호출하여 io::ErrorKind값을 얻을 수 있습니다.
  • io::ErrorKind는 io 연산으로부터 발생할 수 있는 여러 종류의 에러를 표현하는 variant를 가진, 표준 라이브러리에서 제공하는 열거형입니다.
  • 우리가 사용하고자 하는 variant는 ErrorKind::NotFound인데, 이는 열고자 하는 파일이 아직 존재하지 않음을 나타냅니다.
  • 조건문 if error.kind() == ErrorKind::NotFound는 매치 가드(match guard) 라고 부릅니다:
  • 패턴에는 ref가 필요하며 그럼으로써 error가 가드 조건문으로 소유권 이동이 되지 않고 그저 참조만 됩니다.
    • 패턴 내에서 참조자를 얻기 위해 &대신 ref이 사용되는 이유는 18장에서 자세히 다룰 것입니다.
    • 짧게 설명하면, &는 참조자를 매치하고 그 값을 제공하지만, ref는 값을 매치하여 그 참조자를 제공합니다.
  • 매치 가드 내에서 확인하고자 하는 조건문은 error.kind()에 의해 반환된 값이 ErrorKind 열거형의 NotFound variant인가 하는 것입니다.
  • 바깥쪽 match의 마지막 갈래는 똑같이 남아서, 파일을 못 찾는 에러 외에 다른 어떤 에러에 대해서도 패닉을 일으킵니다.

에러가 났을 때 패닉을 위한 숏컷: unwrap과 expect

  • match의 사용은 충분히 잘 동작하지만, 살짝 장황하기도 하고 의도를 항상 잘 전달하는 것도 아닙니다.
  • Result<T, E> 타입은 다양한 작업을 하기 위해 정의된 수많은 헬퍼 메소드를 가지고 있습니다.
  • 그 중 하나인 unwrap 이라 부르는 메소드는 Listing 9-4에서 작성한 match 구문과 비슷한 구현을 한 숏컷 메소드입니다.
  • 만일 Result 값이 Ok variant라면, unwrap은 Ok 내의 값을 반환할 것입니다.
  • 만일 Result가 Err variant라면, unwrap은 우리를 위해 panic! 매크로를 호출할 것입니다. 아래에 unwrap이 작동하는 예가 있습니다:
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap();
}

또 다른 메소드인 expect는 unwrap과 유사한데, 우리가 panic! 에러 메세지를 선택할 수 있게 해줍니다. unwrap대신 expect를 이용하고 좋은 에러 메세지를 제공하는 것은 여러분의 의도를 전달해주고 패닉의 근원을 추적하는 걸 쉽게 해 줄 수 있습니다. expect의 문법은 아래와 같이 생겼습니다:

use std::fs::File;

fn main() {
let f = File::open("hello.txt").expect("Failed to open hello.txt");
}
  • expect는 unwrap과 같은 식으로 사용됩니다:
    • 파일 핸들을 리턴하거나 panic! 매크로를 호출하는 것이죠.
  • expect가 panic! 호출에 사용하는 에러 메세지는 unwrap이 사용하는 기본 panic! 메세지보다는 expect에 넘기는 파라미터로 설정될 것입니다.
  • 만일 우리가 여러 군데에 unwrap을 사용하면, 정확히 어떤 unwrap이 패닉을 일으켰는지 찾기에 좀 더 많은 시간이 걸릴 수 있는데, 그 이유는 패닉을 호출하는 모든 unwrap이 동일한 메세지를 출력하기 때문입니다.

에러 전파하기

실패할지도 모르는 무언가를 호출하는 구현을 가진 함수를 작성할 때, 이 함수 내에서 에러를 처리하는 대신, 에러를 호출하는 코드 쪽으로 반환하여 그쪽에서 어떻게 할지 결정하도록 할 수 있습니다.

이는 에러 전파하기로 알려져 있으며, 에러가 어떻게 처리해야 좋을지 좌우해야 할 상황에서, 여러분의 코드 내용 내에서 이용 가능한 것들보다 더 많은 정보와 로직을 가지고 있을 수도 있는 호출하는 코드 쪽에 더 많은 제어권을 줍니다.

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");

let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};

let mut s = String::new();

match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}

Listing 9-6: match를 이용하여 호출 코드 쪽으로 에러를 반환하는 함수

함수의 반환 타입부터 먼저 살펴봅시다:

  • Result<String, io::Error>. 이는 함수가 Result<T, E> 타입의 값을 반환하는데 제네릭 파라미터 T는 구체적 타입(concrete type)인 String로 채워져 있고, 제네릭 타입 E는 구체적 타입인 io::Error로 채워져 있습니다.
  • 만일 이 함수가 어떤 문제 없이 성공하면, 함수를 호출한 코드는 String을 담은 값을 받을 것입니다
    • 이 함수가 파일로부터 읽어들인 사용자 이름이겠지요
  • . 만일 어떤 문제가 발생한다면, 이 함수를 호출한 코드는 문제가 무엇이었는지에 대한 더 많은 정보를 담고 있는 io::Error의 인스턴스를 담은 Err 값을 받을 것입니다.
    • 이 함수의 반환 타입으로서 io::Error를 선택했는데,
    • 그 이유는 우리가 이 함수 내부에서 호출하고 있는 실패 가능한 연산 두 가지가 모두 이 타입의 에러 값을 반환하기 때문입니다:
    • File::open 함수와 read_to_string 메소드 말이죠.
  1. 함수의 본체는 File::open 함수를 호출하면서 시작합니다.
  2. 그다음에는 Listing 9-4에서 본 match와 유사한 식으로 match을 이용해서 Result 값을 처리하는데, Err 경우에 panic!을 호출하는 대신 이 함수를 일찍 끝내고 File::open으로부터의 에러 값을 마치 이 함수의 에러 값인 것처럼 호출하는 쪽의 코드에게 전달합니다.
  3. 만일 File::open이 성공하면, 파일 핸들을 f에 저장하고 계속합니다.
  4. 그 뒤 변수 s에 새로운 String을 생성하고 파일의 콘텐츠를 읽어 s에 넣기 위해 f에 있는 파일 핸들의 read_to_string 메소드를 호출합니다.
  5. File::open가 성공하더라도 read_to_string 메소드가 실패할 수 있기 때문에 이 함수 또한 Result를 반환합니다.
  6. 따라서 이 Result를 처리하기 위해서 또 다른 match가 필요합니다:
  7. 만일 read_to_string이 성공하면, 우리의 함수가 성공한 것이고, 이제 s 안에 있는 파일로부터 읽어들인 사용자 이름을 Ok에 싸서 반환합니다.
  8. 만일 read_to_string이 실패하면, File::open의 반환값을 처리했던 match에서 에러값을 반환하는 것과 같은 방식으로 에러 값을 반환합니다.
  9. 하지만 여기서는 명시적으로 return이라 말할 필요는 없는데, 그 이유는 이 함수의 마지막 표현식이기 때문입니다.
  10. 그러면 이 코드를 호출하는 코드는 사용자 이름을 담은 Ok 값 혹은 io::Error를 담은 Err 값을 얻는 처리를 하게 될 것입니다.
  • 호출하는 코드가 이 값을 가지고 어떤 일을 할 것인지 우리는 알지 못합니다.
  • 만일 그쪽에서 Err 값을 얻었다면, 예를 들면 panic!을 호출하여 프로그램을 종료시키는 선택을 할 수도 있고, 기본 사용자 이름을 사용할 수도 있으며, 혹은 파일이 아닌 다른 어딘가에서 사용자 이름을 찾을 수도 있습니다.

러스트에서 에러를 전파하는 패턴은 너무 흔하여 러스트에서는 이를 더 쉽게 해주는 물음표 연산자 ?를 제공합니다.

에러를 전파하기 위한 숏컷: ?

Listing 9-7은 Listing 9-6과 같은 기능을 가진 read_username_from_file의 구현을 보여주는데, 다만 이 구현은 물음표 연산자를 이용하고 있습니다:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
  • 새로운 String을 만들어 s에 넣는 부분을 함수의 시작 부분으로 옮겼습니다;
  • 이 부분은 달라진 것이 없습니다. f 변수를 만드는 대신, File::open("hello.txt")?의 결과 바로 뒤에 read_to_string의 호출을 연결시켰습니다.
  • read_to_string 호출의 끝에는 여전히 ?가 남아있고, File::open과 read_to_string이 모두 에러를 반환하지 않고 성공할 때 s 안의 사용자 이름을 담은 Ok를 여전히 반환합니다.
  • 함수의 기능 또한 Lsting 9-6과 Listing 9-7의 것과 동일

?는 Result를 반환하는 함수에서만 사용될 수 있습니다

  • ?는 Result 타입을 반환하는 함수에서만 사용이 가능한데, 이것이 Listing 9-6에 정의된 match 표현식과 동일한 방식으로 동작하도록 정의되어 있기 때문입니다.
  • Result 반환 타입을 요구하는 match 부분은 return Err(e)이며, 따라서 함수의 반환 타입은 반드시 이 return과 호환 가능한 Result가 되어야 합니다.
  • 여러분은 ?를 사용하여 호출하는 코드에게 잠재적으로 에러를 전파하는 대신 match나 Result에서 제공하는 메소드들 중 하나를 사용하여 이를 처리할 필요가 있을 것입니다.

panic!이냐, panic!이 아니냐, 그것이 문제로다

  • 코드가 패닉을 일으킬 때는 복구할 방법이 없습니다
  • 그렇다면 여러분은 여러분의 코드를 호출하는 코드를 대신하여 현 상황은 복구 불가능한 것이라고 결정을 내리는 겁니다.
  • 여러분이 Result 값을 반환하는 선택을 한다면, 호출하는 코드에게 결단을 내려주기보다는 옵션을 제공하는 것입니다.
  • 그들은 그들의 상황에 적합한 방식으로 복구를 시도할 수도 있고, 혹은 현재 상황의 Err은 복구 불가능하다고 사실상 결론을 내려서 panic!을 호출하여 여러분이 만든 복구 가능한 에러를 복구 불가능한 것으로 바꿔놓을 수도 있습니다.
  • 그러므로, 여러분이 실패할지도 모르는 함수를 정의할 때는 Result을 반환하는 것이 기본적으로 좋은 선택입니다.
  • 몇 가지 상황에서는 Result를 반환하는 대신 패닉을 일으키는 코드를 작성하는 것이 더 적합하지만, 덜 일반적입니다.

예제, 프로토타입 코드, 그리고 테스트는 전부 패닉을 일으켜도 완전 괜찮은 곳입니다

여러분이 어떤 개념을 그려내기 위한 예제를 작성 중이라면, 강건한 에러 처리 코드를 예제 안에 넣는 것은 또한 예제를 덜 깨끗하게 만들 수 있습니다.

컴파일러보다 여러분이 더 많은 정보를 가지고 있을 때

Result가 Ok 값을 가지고 있을 거라 확신할 다른 논리를 가지고 있지만, 그 논리가 컴파일러에 의해 이해할 수 있는 것이 아닐 때라면, unwrap을 호출하는 것이 또한 적절할 수 있습니다.

use std::net::IpAddr;

let home = "127.0.0.1".parse::<IpAddr>().unwrap();
  • 여기서는 하드코딩된 스트링을 파싱하여 IpAddr 인스턴스를 만드는 중입니다.
  • 우리는 127.0.0.1이 유효한 IP 주소임을 볼 수 있으므로, 여기서 unwrap을 사용하는 것은 허용됩니다.
  • 그러나, 하드코딩된 유효한 스트링을 갖고 있다는 것이 parse 메소드의 반환 타입을 변경해주지는 않습니다:
  • 우리는 여전히 Result 값을 갖게 되고, 컴파일러는 마치 Err variant가 나올 가능성이 여전히 있는 것처럼 우리가 Result를 처리하도록 할 것인데,
    • 그 이유는 이 스트링이 항상 유효한 IP 주소라는 것을 알 수 있을 만큼 컴파일러가 똑똑하지는 않기 때문입니다.
    • 만일 IP 주소 스트링이 프로그램 내에 하드코딩된 것이 아니라 사용자로부터 입력되었다면,
    • 그래서 실패할 가능성이 생겼다면, 우리는 대신 더 강건한 방식으로 Result를 처리할 필요가 분명히 있습니다.

에러 처리를 위한 가이드라인

여러분의 코드가 결국 나쁜 상태에 처하게 될 가능성이 있을 때는 여러분의 코드에 panic!을 넣는 것이 바람직합니다.

이 글에서 말하는 나쁜 상태란 어떤 가정, 보장, 계약, 혹은 불변성이 깨질 때를 뜻하는 것으로, 이를테면 유효하지 않은 값이나 모순되는 값, 혹은 찾을 수 없는 값이 여러분의 코드를 통과할 경우를 말합니다

만일 어떤 사람이 여러분의 코드를 호출하고 타당하지 않은 값을 집어넣었다면, panic!을 써서 여러분의 라이브러리를 사용하고 있는 사람에게 그들의 코드 내의 버그를 알려서 개발하는 동안 이를 고칠 수 있게끔 하는 것이 최선책일 수도 있습니다.

비슷한 식으로, 만일 여러분의 제어권을 벗어난 외부 코드를 호출하고 있고, 이것이 여러분이 고칠 방법이 없는 유효하지 않은 상태를 반환한다면, panic!이 종종 적합합니다.

나쁜 상태에 도달했지만, 여러분이 얼마나 코드를 잘 작성했든 간에 일어날 것으로 예상될 때라면 panic!을 호출하는 것보다 Result를 반환하는 것이 여전히 더 적합합니다.

이에 대한 예는 기형적인 데이터가 주어지는 파서나, 속도 제한에 달했음을 나타내는 상태를 반환하는 HTTP 요청 등을 포함합니다.

이러한 경우, 여러분은 이러한 나쁜 상태를 위로 전파하기 위해 호출자가 그 문제를 어떻게 처리할지를 결정할 수 있도록 하기 위해서 Result를 반환하는 방식으로 실패가 예상 가능한 것임을 알려줘야 합니다. panic!에 빠지는 것은 이러한 경우를 처리하는 최선의 방식이 아닐 것입니다.

  • 여러분의 코드가 어떤 값에 대해 연산을 수행할 때, 여러분의 코드는 해당 값이 유효한지를 먼저 검사하고, 만일 그렇지 않다면 panic!을 호출해야 합니다.
  • 이는 주로 안전상의 이유를 위한 것입니다:
  • 유효하지 않은 데이터 상에서 어떤 연산을 시도하는 것은 여러분의 코드를 취약점에 노출시킬 수 있습니다.
  • 이는 여러분이 범위를 벗어난 메모리 접근을 시도했을 경우 표준 라이브러리가 panic!을 호출하는 주된 이유입니다:
    • 현재의 데이터 구조가 소유하지 않은 메모리를 접근 시도하는 것은 흔한 보안 문제입니다.
    • 함수는 종종 계약을 갖고 있습니다: 입력이 특정 요구사항을 만족시킬 경우에만 함수의 행동이 보장됩니다.
    • 이 계약을 위반했을 때 패닉에 빠지는 것은 사리에 맞는데, 그 이유는 계약 위반이 언제나 호출자 쪽의 버그임을 나타내고, 이는 호출하는 코드가 명시적으로 처리하도록 하는 종류의 버그가 아니기 때문입니다.
    • 사실, 호출하는 쪽의 코드가 복구시킬 합리적인 방법은 없습니다:
    • 호출하는 프로그래머는 그 코드를 고칠 필요가 있습니다. 함수에 대한 계약은, 특히 계약 위반이 패닉의 원인이 될 때는, 그 함수에 대한 API 문서에 설명되어야 합니다.

하지만 여러분의 모든 함수 내에서 수많은 에러 검사를 한다는 것은 장황하고 짜증 날 것입니다. 다행스럽게도, 러스트의 타입 시스템이 (그리고 컴파일러가 하는 타입 검사 기능이) 여러분을 위해 수많은 검사를 해줄 수 있습니다. 여러분의 함수가 특정한 타입을 파라미터로 갖고 있다면, 여러분이 유효한 값을 갖는다는 것을 컴파일러가 이미 보장했음을 아는 상태로 여러분의 코드 로직을 진행할 수 있습니다.

  • 예를 들면, 만약 여러분이 Option이 아닌 어떤 타입을 갖고 있다면,
  • 여러분의 프로그램은 아무것도 아닌 것이 아닌 무언가를 갖고 있음을 예측합니다.
  • 그러면 여러분의 코드는 Some과 None variant에 대한 두 경우를 처리하지 않아도 됩니다:
  • 이는 분명히 값을 가지고 있는 하나의 경우만 있을 것입니다.
  • 여러분의 함수에 아무것도 넘기지 않는 시도를 하는 코드는 컴파일조차 되지 않을 것이고, 따라서 여러분의 함수는 그러한 경우에 대해서 런타임에 검사하지 않아도 됩니다. 또 다른 예로는 u32와 같은 부호 없는 정수를 이용하는 것이 있는데, 이는 파라미터가 절대 음수가 아님을 보장합니다.

유효성을 위한 커스텀 타입 생성하기

러스트의 타입 시스템을 이용하여 유효한 값을 보장하는 아이디어에서 한 발 더 나가서, 유효성을 위한 커스텀 타입을 생성하는 것을 살펴봅시다.

if 표현식은 우리의 값이 범위 밖에 있는지 혹은 그렇지 않은지 검사하고, 사용자에게 문제점을 말해주고, continue를 호출하여 루프의 다음 반복을 시작하고 다른 추측값을 요청해줍니다. if 표현식 이후에는, guess가 1과 100 사이의 값이라는 것을 아는 상태에서 guess와 비밀 숫자의 비교를 진행할 수 있습니다.

  • 하지만, 이는 이상적인 해결책이 아닙니다:
  • 만일 프로그램이 오직 1과 100 사이의 값에서만 동작하는 것이 전적으로 중요하고, 많은 함수가 이러한 요구사항을 가지고 있다면, 모든 함수 내에서 이렇게 검사를 하는 것은 지루할 것입니다.
  • 그리고 잠재적으로 성능에 영향을 줄 것입니다.

대신, 우리는 새로운 타입을 만들어서, 유효성 확인을 모든 곳에서 반복하는 것보다는 차라리 그 타입의 인스턴스를 생성하는 함수 내에 유효성 확인을 넣을 수 있습니다.

이 방식에서, 함수가 그 시그니처 내에서 새로운 타입을 이용하고 받은 값을 자신 있게 사용하는 것은 안전합니다. Listing 9-9는 new 함수가 1과 100 사이의 값을 받았을 때에만 인스턴스를 생성하는 Guess 타입을 정의하는 한 가지 방법을 보여줍니다:

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

Guess {
value
}
}

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

Listing 9-9: 1과 100 사이의 값일 때만 계속되는 Guess 타입

  1. 먼저 u32를 갖는 value라는 이름의 항목을 가진 Guess라는 이름의 구조체를 선언하였습니다.
  2. 그런 뒤 Guess 값의 인스턴스를 생성하는 new라는 이름의 연관 함수를 구현하였습니다.
    1. new 함수는 u32 타입의 값인 value를 파라미터를 갖고 Guess를 반환하도록 정의 되었습니다.
    2. new 함수의 본체에 있는 코드는 value가 1부터 100 사이의 값인지 확인하는 테스트를 합니다.
    3. 만일 value가 이 테스트에 통과하지 못하면 panic!을 호출하며, 이는 이 코드를 호출하는 프로그래머에게 고쳐야 할 버그가 있음을 알려주는데, 범위 밖의 value를 가지고 Guess를 생성하는 것은 Guess::new가 필요로 하는 계약을 위반하기 때문입니다.
    4. Guess::new가 패닉을 일으킬 수도 있는 조건은 공개된 API 문서 내에 다뤄져야 합니다;
    5. 여러분이 만드는 API 문서 내에서 panic!의 가능성을 가리키는 것에 대한 문서 관례는 14장에서 다룰 것입니다.
    6. 만일 value가 테스트를 통과한다면, value 항목을 value 파라미터로 설정한 새로운 Guess를 생성하여 이 Guess를 반환합니다.
  3. 다음으로, self를 빌리고, 파라미터를 갖지 않으며, u32를 반환하는 value라는 이름의 메소드를 구현했습니다.
    1. 이러한 종류 메소드를 종종 게터(getter) 라고 부르는데, 그 이유는 이런 함수의 목적이 객체의 항목으로부터 어떤 데이터를 가져와서 이를 반환하는 것이기 때문입니다.
    2. 이 공개 메소드는 Guess 구조체의 value 항목이 비공개이기 때문에 필요합니다.
    3. value 항목이 비공개라서 Guess 구조체를 이용하는 코드가 value를 직접 설정하지 못하도록 하는 것은 중요합니다:
    4. 모듈 밖의 코드는 반드시 Guess::new 함수를 이용하여 새로운 Guess의 인스턴스를 만들어야 하는데,
    5. 이는 Guess가 Guess::new 함수의 조건들을 확인한 적이 없는 value를 갖는 방법이 없음을 보장합니다.

그러면 파라미터를 가지고 있거나 오직 1에서 100 사이의 숫자를 반환하는 함수는 u32 보다는 Guess를 얻거나 반환하는 시그니처로 선언되고 더 이상의 확인이 필요치 않을 것입니다.

정리

  • 러스트의 에러 처리 기능은 여러분이 더 강건한 코드를 작성하는 데 도움을 주도록 설계되었습니다.
  • panic! 매크로는 여러분의 프로그램이 처리 불가능한 상태에 놓여 있음에 대한 신호를 주고 여러분이 유효하지 않거나 잘못된 값으로 계속 진행하는 시도를 하는 대신 실행을 멈추게끔 해줍니다.
  • Result 열거형은 러스트의 타입 시스템을 이용하여 여러분의 코드가 복구할 수 있는 방법으로 연산이 실패할 수도 있음을 알려줍니다.
  • 또한 Result를 이용하면 여러분의 코드를 호출하는 코드에게 잠재적인 성공이나 실패를 처리해야 할 필요가 있음을 알려줄 수 있습니다.
  • panic!과 Result를 적합한 상황에서 사용하는 것은 여러분의 코드가 불가피한 문제에 직면했을 때도 더 신뢰할 수 있도록 해줄 것입니다.

이제 표준 라이브러리가 Option과 Result 열거형을 가지고 제네릭을 사용하는 유용한 방식들을 보았으니, 제네릭이 어떤 식으로 동작하고 여러분의 코드에 어떻게 이용할 수 있는지에 대해 다음 장에서 이야기해 보겠습니다.