러스트 언어 입문 시리즈 - 7편: 에러 처리(Error Handling)와 Result 타입, Option 타입 비교하기

이전 글에서는 러스트의 트레이트(Trait)와 제네릭(Generic) 개념을 통해 추상화와 다형성을 어떻게 안전하고 명확하게 달성하는지 살펴보았습니다. 이번에는 러스트에서 에러를 처리하는 방식을 알아봅시다. C++에서 예외(Exceptions)를 던지고 try/catch로 받는 패턴에 익숙하다면, 러스트가 보여주는 접근 방식은 다소 낯설게 느껴질 수 있습니다.

 

러스트는 기본적으로 예외(throw)와 catch 블록이 없습니다. 대신 함수의 반환값을 통해 에러 상황을 명시적으로 처리하는 Result<T, E> 타입, 값이 존재하지 않을 수도 있음을 표현하는 Option<T> 타입, 그리고 프로그램이 더 이상 진행할 수 없는 치명적 상황에서 사용하는 panic! 매크로로 구성된 에러 처리 철학을 가지고 있습니다.

런타임 예외를 던지지 않는 언어

C++에서는 문제가 발생했을 때 throw로 예외를 발생시키고, 호출 스택을 거슬러 올라가며 catch 블록을 만나면 처리하는 방식으로 에러를 다룹니다. 이 방식은 강력하지만 제어 흐름을 숨기고, 예외 안전성(Exception Safety) 보장을 위해 주의를 기울여야 합니다. 또한 성능 및 바이너리 크기 문제나, 커다란 코드베이스에서 예외 처리 정책을 통일하기가 어려운 문제가 있습니다.

러스트는 이 문제를 해결하기 위해 예외를 통한 제어 흐름을 권장하지 않고, 명시적인 오류 처리를 지향합니다. 러스트에서는 함수가 정상 결과와 에러를 구분해서 반환하는 Result<T, E> 타입을 통해 개발자가 의식적으로 오류를 처리하도록 유도합니다.

Result<T, E> 타입

Result<T, E>는 정상적인 결과(Ok(T)) 또는 **에러(Err(E))**를 표현하는 열거형 enum 타입입니다. C++에서 std::optional과 std::expected(C++23 표준으로 추가 예정)을 합쳐놓은 느낌이라 할 수 있습니다.

fn read_file_content(path: &str) -> Result<String, std::io::Error> {
    std::fs::read_to_string(path)
}

fn main() {
    match read_file_content("hello.txt") {
        Ok(content) => println!("파일 내용: {}", content),
        Err(e) => println!("파일을 읽는 중 에러 발생: {}", e),
    }
}

위 코드에서 read_file_content 함수는 Result<String, std::io::Error>를 반환합니다. 파일을 성공적으로 읽으면 Ok(파일내용), 실패하면 Err(에러정보)를 반환합니다. 호출 측에서는 match 구문이나 ? 연산자를 사용하여 에러를 명시적으로 처리합니다.

C++에서 std::fstream을 사용할 때 스트림 상태를 체크하거나 예외를 던질 수 있는데, 러스트는 항상 Result를 반환함으로써 성공/실패를 명확히 표현합니다.

? 연산자로 간결한 에러 전파

매번 match 구문을 써서 에러를 핸들링하는 것은 번거로울 수 있습니다. 러스트는 ? 연산자를 통해 에러 전파를 간결하게 표현할 수 있게 합니다.

fn read_two_files(file1: &str, file2: &str) -> Result<String, std::io::Error> {
    let content1 = std::fs::read_to_string(file1)?;  
    let content2 = std::fs::read_to_string(file2)?;  
    Ok(content1 + &content2)
}

? 연산자는 Result가 Ok면 언래핑(unwrapping)하여 값만 반환하고, Err면 현재 함수에서 바로 Err를 반환하게 합니다. C++에서 예외를 던지는 대신 함수 반환값을 통해 "문제가 발생하면 즉시 반환"하는 패턴을 간결하게 만든 것이라 볼 수 있습니다.

Option<T> 타입으로 값의 존재 여부 표현

C++에서 nullptr 반환으로 "값이 없음"을 표현하거나, 특정 인자 조합에서 의미가 없는 값을 반환하는 패턴을 사용한 적이 있을 것입니다. 러스트는 이런 상황을 명시적으로 표현하기 위해 Option<T> 타입을 제공합니다.

fn get_first_char(text: &str) -> Option<char> {
    text.chars().next()
}

fn main() {
    let s = "Rust";
    match get_first_char(s) {
        Some(c) => println!("첫 글자: {}", c),
        None => println!("빈 문자열입니다."),
    }
}

C++17 이후 std::optional<T>와 유사한 개념입니다. 다만 러스트에서는 Option 타입이 표준 라이브러리에서 광범위하게 사용되며, unwrap(), map(), and_then() 등의 메서드로 함수형 스타일의 처리가 가능합니다. 이로써 런타임 널 포인터 참조 문제를 컴파일 시점에 방지할 수 있습니다.

panic! 매크로: 더 이상 진행할 수 없는 상황

C++에서 assert를 통해 디버그 빌드 시 조건 검증을 하거나, 심각한 에러 발생 시 throw 혹은 std::terminate() 등을 사용할 수 있습니다. 러스트에서는 panic! 매크로를 이용해 더 이상 진행이 불가능한 치명적 상황에서 프로그램을 중지할 수 있습니다.

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("0으로 나눌 수 없습니다!");
    }
    a / b
}

panic!은 스택 언와인딩(stack unwinding)을 수행하거나, 설정에 따라 즉시 abort하여 프로그램을 종료합니다. 주로 프로그램 내부 로직의 불변조건(invariant)이 깨졌을 때, 즉 "여기에 도달했다면 로직에 심각한 버그"라는 상황에서 사용됩니다.

러스트는 panic! 사용을 최소화하고, 가능한 한 Result나 Option을 통해 에러를 명시적으로 처리하도록 장려합니다. 이는 C++에서 예외 사용을 줄이고, 반환값을 통해 에러 처리를 더욱 명확하게 관리하려는 최신 트렌드와도 맥이 닿아 있습니다.

에러 처리와 main 함수

C++에서 main 함수 내에서 예외를 잡지 않으면 std::terminate()가 호출되듯, 러스트에서도 main 함수 내 에러 처리를 명시적으로 해야 합니다. 다만 main 함수 반환 타입을 Result로 설정하면 에러 발생 시 자연스럽게 에러를 반환할 수 있습니다.

fn main() -> Result<(), std::io::Error> {
    let content = std::fs::read_to_string("config.txt")?;
    println!("설정 파일: {}", content);
    Ok(())
}

이런 식으로 러스트의 메인 함수는 명시적인 에러 처리 코드로 더 안전하게 작성될 수 있습니다.

C++와의 비교 정리

  • 예외 대신 Result: 러스트는 예외를 던지지 않고 Result<T, E> 타입을 통한 명시적 오류 처리를 권장. C++ 예외와 달리 제어 흐름이 명확히 드러나며, ? 연산자 같은 문법 지원으로 코드 가독성 개선.
  • Option vs optional: 러스트 Option<T>는 C++17의 std::optional<T>와 유사. 값의 유무를 컴파일 타임에 명확히 인식.
  • panic! vs assert/terminate: panic!은 C++ assert나 terminate와 비슷한 역할. 로직 불변성 위반 시 프로그램 중단.
  • 함수형 스타일 처리: map, and_then 등 메서드를 통해 함수형 스타일로 Option과 Result를 다룰 수 있어, C++에서 std::optional을 다루는 것보다 편리한 경우가 많음.

앞으로의 학습 방향

이번 글에서는 러스트의 에러 처리 철학과 도구들을 소개했습니다. 다음 글에서는 안전하고 병렬적인 프로그래밍을 지원하는 러스트의 Concurrency(동시성), Sync/Send 트레이트, 스레드(Threads), 채널(Channels) 등의 주제를 다루며 C++의 스레드 라이브러리와 비교해보겠습니다.

유용한 링크와 리소스

반응형