Skip to main content

Command Palette

Search for a command to run...

Rust #9: 9장 오류처리

Published
7 min read

개요

Rust는 다른 프로그래밍 언어와 다르게 복구 가능한 오류복구 불가능한 오류를 구분해서 처리합니다. 이시간을 통해 이 두가지 유형에 대해 알아보는 시간을 가져 봅시다.

복구할 수 없는 오류 panic! 매크로

복구할 수 없는 오류란 프로그램의 오동작을 최소화 하기 위해 즉시 프로그램을 종료해야 할 때 사용됩니다. 이때 panic! 매크로를 사용하게 됩니다.

패닉 시 Rust의 기본 동작

Rust는 패닉이 발생하면 스택을 백업하고 만나는 함수마다 데이터를 정리하는 행위를 합니다. 이 작업은 상당한 작업이 되기 때문에 컴파일한 실행파일을 작게 만드려면 Cargo.toml에 다음의 설정을 할 수 있습니다.

[profile.release]
panic = 'abort'

panic! 매크로를 간단히 살펴보겠습니다.

fn main() {
    panic!("crash and burn");
}

위의 코드를 컴파일 하여 실행하면 익숙한 런타임 오류 화면을 볼 수 있습니다.

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

여기서 panic! 매크로 인자의 메시지가 출력됨을 볼 수 있습니다.

panic! 역추적 사용

다음의 코드를 통해,

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

배열의 배열 인덱싱이 잘못되었을 때 출력되는 런타임 오류 메시지가 panic! 매크로로 발생한 메시지와 유사함을 알 수 있습니다.

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

이때 추석 기능을 사용하려면 환경변수 RUST_BACKTRACE를 0이 아닌 다른 값으로 설정 하면,

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/std/src/panicking.rs:483
   1: core::panicking::panic_fmt
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:85
   2: core::panicking::panic_bounds_check
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:62
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:255
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:15
   5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/alloc/src/vec.rs:1982
   6: panic::main
             at ./src/main.rs:4
   7: core::ops::function::FnOnce::call_once
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

추적 정보를 확인할 수 있습니다.

복구 가능한 오류 Result

가령, 파일을 열려고 했을 때 파일이 없어 실패한 경우 프로그램을 종료하는 대신 새로운 파일을 생성하는 것이 로직에 부합할 수 있습니다. 이 때 사용하는 것이 Result 열거형인데요, Option 열거형과 함께 Rust 표준 라이브러리에서 많이 사용하는 기능입니다.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Result 열거형은 다음처럼 사용할 수 있습니다.

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
};

앞전 시간에 배웠던 match 문을 통해 정상 동작일 때 file 핸들을 반환하고, 오류가 발생했을 때 패닉으로 처리하는 코드입니다.

다른 오류에 대한 일치

위의 코드는 오류가 발생했을 때 패닉으로 프로그램으로 종료하게 되는데요, 우리가 원하는 것은 오류의 다양한 원인에 대한 처리라고 한다면 다음의 코드처럼 작성할 수 있습니다.

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the fie: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error)
            }
        },
    };
}

위의 코드는 파일을 열려고 할 때 파일이 없으면 파일을 생성하고, 파일을 생성해서 파일을 핸들을 반환하거나, 파일을 생성할 수 없으면 패닉을 발생하고, 그 이외의 오류 역시 패닉을 발생하는 코드입니다.

이 코드는 match 키워드를 이용한 정상 코드이지만, 몇 개의 처리로 인해 조금 복잡해 보입니다. 다음의 코드처럼 개선할 수 있습니다.

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {:?}", error);
            })
        } else {
            panic!("Problem opening the file: {:?}", error);
        }
    });
}

위의 코드는 unwrap_or_else() 메소드를 이용해 정상 반환의 경의 핸들을 반환하고 오류일 경우 클로저로 처리를 넘기는 것으로 코드를 좀 더 단순화 하였습니다. 13장에서 클로저에 대해 배울 예정입니다.

Panic on Error 바로가기: unwrap과 expect

match를 사용하면 오류 처리에 대해 잘 처리할 수 있지만 장황하고 의도를 잘 전달하지 못할 수 도 있습니다. 다음의 코드를 보시죠.

use std::fs::File;

fn main() {
        let f = File::open("hello.txt").unwrap();
}

unwrap() 메소드로 값만 취하고 에러는 무시하고 있습니다. 만약 오류가 발생한다면 오류가 발생했을 때 unwrap() 메소드에서 대신 panic!을 호출해 줍니다.

hread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4

unwrap()과 유사하지만 사용자가 메시지를 출력할 수 있는 expect() 메소드가 있습니다.

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}
thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

오류 전파

만약 자신이 처리할 수 없는 오류라면 그것을 호출한 코드로 돌려줄 수 있습니다.

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

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

위의 코드는 match를 이용해 발생 오류를 그대로 반환하는 값인 Result<T, E>의 E로 반환합니다. 여기서 살펴볼 필요가 있는 코드는 Err(e) => return Err(e),인데요, 오류가 발생했을 경우 메소드 끝까지 도달하지 않고 여기서 오류를 반환하도록 합니다.

오류 전파의 지름길: ?연산자

?연산자를 사용하면 오류를 호출한 메소드로 오류를 전달합니다. ?연산자를 사용하여 아래처럼 위의 코드를 좀 더 간단하게 작성할 수 있습니다.

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

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

?연산자는 당연히 이어서 쓸 수 도 있습니다.

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

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();

    File::open("hello.txt")?.read_to_string(&mut s)?;

    Ok(s)
}

최종적으로 이것을 다음처럼 간단하게 할 수 있습니다.

use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}

오류 전파를 위한 바로가기: ? 연산자

다음의 코드는 보시죠.

use std::fs::File;

fn main() {
    let f = File::open("hello.txt")?;
}

? 연산자에 의해서 결정될 수 있는 유형은 Result<T, E> 또는 Option<T> 또는 여기서 다루지 않은 std::ops::Try 일 수 있습니다. 컴파일러는 이것을 결정할 수 없기 때문에 컴파일 오류가 발생합니다.

다음의 코드처럼 반환형을 캐스팅 할 수 있습니다.

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("hello.txt")?;

    Ok(())
}

여기서 사용하는 Box<dyn Error> 유형은 17장에서 다룰 예정입니다.

panic! 하거나 하지 않거나

그렇다면 언제 panic!으로 프로그램을 종료해야 하고 하지 않아야 하는 걸까요? 프로그램의 로직에 따라 panic! 대상이거나 아닐 수 있습니다. 메소드를 설계할 때 이것을 알 수 없는 경우가 대부분이므로 반환형으로 Result<T, E>를 사용하는게 좋은 기본 선택이 됩니다.

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

프로토타입으로 전개하는 코드는 unwrap()또는 expect()메소드를 이용해 오류 처리를 특별히 하지 않고 코드를 전개하는게 효율적입니다. 또한 테스트 코드는 메서드 호출이 실패했을 때 테스트 중인 기능이 아니더라도 테스트가 실패하는게 맞습니다. 이때에도 unwra[() 또는 expect() 메소드는 유용합니다.

다음의 코드처럼 하드코딩된 코드를 테스트할 때,

    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1".parse().unwrap();

"127.0.0.1"에 대한 파싱으로 오류가 발생하지 않을것이란 기대를 할 수 있습니다. 이럴 때 unwarp()를 사용할 수 있습니다.

오류 처리 지침

다음의 오류 지침을 따라 봅시다.

  • 나쁜 상태는 가끔 발생할 것으로 예상되는 것이 아닙니다.
  • 이 시점 이후의 코드는 이 나쁜 상태에 있지 않아야 합니다.
  • 사용하는 유형으로 이 정보를 인코딩하는 좋은 방법은 없습니다.

이럴때 panic!을 사용할 수 있습니다.

검증을 위한 사용자 정의 유형 생성

다음의 코드를 예로 보시죠.

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

이것은 입력된 guess 값의 범위 검사를 로직에서 수행하기 때문에 이상적이지 않습니다. 이것을 다음처럼 사용자 유형에서 처리할 수 있습니다.

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

정리

Rust의 오류 처리 기능은 보다 강력한 코드를 작성할 수 있도록 설계되었습니다. Rust는 panic! 매크로 및 Result<T, E>를 이용해서 즉각적으로 중지해야 하는 오류 유형과 복구할 수 있는 오류 유형을 처리하도록 합니다.

96 views

More from this blog

개발, 테스트, 운영에서의 도커 활용

핵심 원칙: "한 번 빌드하고, 어디서든 실행한다 (Build once, run anywhere)" 도커의 가장 큰 장점은 환경 일관성입니다. 동일한 도커 이미지를 사용하여 개발, 테스트, 운영 환경을 구성함으로써 "제 PC에서는 됐는데..." 하는 문제를 최소화할 수 있습니다. 1. 개발 단계 (Development) 목표: 빠른 코드 변경 반영, 쉬운 디버깅, 실제 운영 환경과 유사한 환경 구성. Docker 사용 방안: Dockerf...

May 9, 20256 min read15

[EF Core] 데이터 삭제 시 소프트 삭제 적용

DB에서 데이터를 삭제하면 일반적으로 복구할 수 없습니다. 또한 관계에 따라 영구 삭제 자체가 어려울 수도 있습니다. 그래서 데이터를 영구 삭제하는 대신 IsDeleted 속성을 true로 주고 IsDeleted 속성을 필터링해서 조회하는 방법을 사용하기도 합니다. 이를 소프트 삭제라고 합니다. 그런데 EF에서 알아서 데이터 삭제 시 소프트 삭제를 하고 쿼리시 IsDeleted 속성을 체크해서 삭제한 데이터를 제외한 데이터만 쿼리하게 하는 ...

Mar 18, 20243 min read19

[EF Core] ValueConverter를 이용해서 엔터티 속성의 도메인 관리

EF Core를 사용하면서 문자열 길이 등의 특성을 일일이 지정하는 것은 번거롭습니다. ... [MaxLength(32)] public string? 제목 { get; set; } 엔터티가 한 개일 때는 상관이 없으나 제목 유형이 여러 엔터티에 사용될 경우 유형을 지정하기 번거롭습니다. 속성 유형을 도메인으로 관리하면 참 편할텐데요, ValueConverter를 이용할 수 있습니다. 그런데 이것을 인터페이스 정적 추상를 사용해서 다음처럼 ...

Mar 16, 20242 min read8

디모이 블로그

154 posts

.NET 관련 기술을 선호하고 새로운 언어를 배우는데 관심이 있습니다.