이미지 로딩 중...

Rust Result 타입으로 에러 처리 마스터하기 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 3 Views

Rust Result 타입으로 에러 처리 마스터하기

Rust의 Result<T, E> 타입을 활용한 복구 가능한 에러 처리 방법을 배웁니다. 실무에서 바로 활용할 수 있는 에러 처리 패턴과 함께 안전하고 명확한 에러 핸들링 기법을 익힐 수 있습니다.


목차

  1. Result 타입 기본 개념
  2. Result 반환하는 함수 작성
  3. match로 Result 처리하기
  4. unwrap과 expect 사용법
  5. ? 연산자로 에러 전파
  6. unwrap_or와 unwrap_or_else
  7. map과 and_then 체이닝
  8. 커스텀 에러 타입 정의

1. Result 타입 기본 개념

시작하며

여러분이 파일을 읽거나 네트워크 요청을 보낼 때, 항상 성공만 하면 좋겠지만 현실은 그렇지 않죠. 파일이 없을 수도 있고, 네트워크가 끊어질 수도 있습니다.

이런 상황에서 프로그램이 갑자기 죽어버리면 사용자 경험은 최악이 됩니다. 많은 언어에서는 예외(Exception)를 던지거나 특별한 값(-1, null 등)을 반환해서 에러를 표현합니다.

하지만 예외는 코드 흐름을 예측하기 어렵게 만들고, 특별한 값은 실수로 체크를 빼먹기 쉽습니다. Rust는 이 문제를 컴파일 타임에 해결합니다.

바로 Result<T, E> 타입을 사용해서 성공(Ok)과 실패(Err)를 명확히 구분하고, 에러 처리를 강제하는 것이죠.

개요

간단히 말해서, Result<T, E>는 두 가지 가능한 결과를 담는 열거형(enum)입니다. 성공했을 때는 Ok(T)에 값을 담고, 실패했을 때는 Err(E)에 에러 정보를 담습니다.

왜 이것이 필요할까요? 실무에서는 수많은 실패 가능성이 있습니다.

파일 입출력, 데이터베이스 연결, API 호출, 파싱 작업 등 어느 것 하나 항상 성공한다고 보장할 수 없습니다. Result를 사용하면 이런 불확실성을 타입 시스템으로 표현하고, 컴파일러가 에러 처리를 잊지 않도록 도와줍니다.

기존의 예외 방식에서는 함수 시그니처만 봐서는 에러가 발생할 수 있는지 알 수 없었습니다. 하지만 Result를 반환하는 함수는 "이 함수는 실패할 수 있다"고 타입으로 명시합니다.

호출하는 쪽에서는 반드시 이를 처리해야 합니다. Result의 핵심 특징은 세 가지입니다.

첫째, 타입 안전성 - 성공값과 에러값의 타입이 명확합니다. 둘째, 명시성 - 함수 시그니처에서 실패 가능성을 바로 알 수 있습니다.

셋째, 강제성 - 컴파일러가 에러 처리를 강제하여 실수를 방지합니다.

코드 예제

// Result는 두 가지 variant를 가진 enum입니다
enum Result<T, E> {
    Ok(T),   // 성공: T 타입의 값을 포함
    Err(E),  // 실패: E 타입의 에러를 포함
}

// 예시: 숫자를 2로 나누는 함수
fn divide(numerator: f64, denominator: f64) -> Result<f64, String> {
    if denominator == 0.0 {
        // 에러 케이스: 0으로 나눌 수 없음
        Err(String::from("0으로 나눌 수 없습니다"))
    } else {
        // 성공 케이스: 나눗셈 결과 반환
        Ok(numerator / denominator)
    }
}

설명

이것이 하는 일: Result는 연산이 성공했는지 실패했는지를 타입 레벨에서 표현하고, 컴파일러가 모든 경우를 처리하도록 강제하는 안전장치입니다. 첫 번째로, Result는 제네릭 타입 두 개를 받습니다.

T는 성공했을 때의 값 타입이고, E는 실패했을 때의 에러 타입입니다. divide 함수를 보면 성공 시 f64(나눗셈 결과)를 반환하고, 실패 시 String(에러 메시지)을 반환합니다.

이렇게 타입으로 명시하면 함수를 사용하는 개발자가 어떤 값과 에러를 기대해야 하는지 정확히 알 수 있습니다. 두 번째로, 함수 내부에서는 조건에 따라 Ok 또는 Err를 생성합니다.

denominator가 0이면 에러 상황이므로 Err로 감싼 에러 메시지를 반환합니다. 그렇지 않으면 계산 결과를 Ok로 감싸서 반환합니다.

이 패턴은 Rust에서 매우 일반적이며, 어떤 연산이든 실패 가능성이 있다면 Result로 표현합니다. 세 번째로, 이 함수를 호출하는 쪽에서는 Result를 받게 됩니다.

그냥 f64 값을 받는 게 아니라 Result<f64, String>을 받기 때문에, 값을 사용하기 전에 반드시 Ok인지 Err인지 확인해야 합니다. 만약 처리하지 않으면 컴파일러가 경고를 내보냅니다.

여러분이 이 패턴을 사용하면 런타임 에러가 크게 줄어듭니다. 에러 처리를 깜빡하는 실수가 컴파일 타임에 잡히기 때문입니다.

또한 코드를 읽는 사람도 함수 시그니처만 보고 "아, 이 함수는 실패할 수 있구나"라고 즉시 이해할 수 있습니다. 예외를 던지는 방식과 달리 에러 경로가 명확하여 디버깅도 훨씬 쉬워집니다.

실전 팁

💡 성공값 타입 T는 구체적인 타입을 사용하고, 에러 타입 E는 프로젝트 초기에는 String으로 시작해도 좋습니다. 나중에 커스텀 에러 타입으로 발전시킬 수 있습니다.

💡 Result를 반환하는 함수 이름에는 try_를 접두사로 붙이는 관례가 있습니다(예: try_parse, try_connect). 이렇게 하면 호출하는 쪽에서 에러 처리가 필요함을 즉시 알 수 있습니다.

💡 std::result::Result는 워낙 자주 쓰여서 prelude에 포함되어 있어 별도의 use 없이 바로 사용할 수 있습니다.

💡 Result<T, E>의 E는 반드시 에러를 나타내는 타입이어야 하지만, 실제로는 어떤 타입이든 사용 가능합니다. 하지만 관례적으로 Error trait을 구현한 타입을 사용합니다.


2. Result 반환하는 함수 작성

시작하며

여러분이 사용자 입력을 받아서 나이를 파싱하는 함수를 작성한다고 상상해보세요. 사용자가 "25"를 입력하면 문제없지만, "abc"를 입력하면 어떻게 될까요?

프로그램이 패닉에 빠지거나 잘못된 값을 반환하면 안 됩니다. 이런 상황에서 필요한 것이 "실패할 수 있는 함수"를 명시적으로 설계하는 능력입니다.

단순히 값을 반환하는 게 아니라, "성공했을 때의 값"과 "실패했을 때의 에러"를 모두 고려해야 합니다. Rust에서는 Result를 반환하는 함수를 작성함으로써 이를 우아하게 해결합니다.

함수의 타입 시그니처 자체가 "이 함수는 실패할 수 있다"는 계약이 되는 것이죠.

개요

간단히 말해서, Result를 반환하는 함수는 정상적인 반환값을 Ok로 감싸고, 에러 상황에서는 Err로 감싸서 반환합니다. 실무에서는 외부 입력을 다루거나, 리소스에 접근하거나, 복잡한 계산을 수행할 때 항상 실패 가능성을 고려해야 합니다.

예를 들어, 설정 파일을 파싱하는 함수, 데이터베이스 쿼리를 실행하는 함수, API 응답을 변환하는 함수 등이 모두 Result를 반환해야 합니다. 기존의 방식에서는 에러 코드를 반환하거나(-1, null 등) 전역 에러 플래그를 설정했다면, Rust에서는 Result로 성공과 실패를 명확히 분리합니다.

호출자는 Result를 받아서 반드시 두 경우를 모두 처리해야 하므로 에러 처리를 빼먹을 수 없습니다. Result를 반환하는 함수의 핵심 특징은 다음과 같습니다.

첫째, 명확한 계약 - 함수 시그니처가 실패 가능성을 명시합니다. 둘째, 조기 반환 - 에러가 발생하면 즉시 Err를 반환하여 코드 흐름이 명확합니다.

셋째, 조합 가능성 - Result를 반환하는 여러 함수를 쉽게 조합할 수 있습니다.

코드 예제

// 문자열을 양의 정수로 파싱하는 함수
fn parse_positive_number(s: &str) -> Result<u32, String> {
    // 빈 문자열 체크
    if s.is_empty() {
        return Err(String::from("입력이 비어있습니다"));
    }

    // 문자열을 숫자로 파싱 (실패할 수 있음)
    let num: u32 = match s.parse() {
        Ok(n) => n,
        Err(_) => return Err(String::from("유효한 숫자가 아닙니다")),
    };

    // 0은 양수가 아니므로 에러
    if num == 0 {
        return Err(String::from("0보다 큰 숫자를 입력하세요"));
    }

    // 모든 검증 통과: 성공
    Ok(num)
}

설명

이것이 하는 일: Result를 반환하는 함수는 여러 실패 지점을 체크하고, 각 지점에서 적절한 에러 메시지와 함께 Err를 반환하거나, 모든 검증을 통과하면 Ok로 값을 반환합니다. 첫 번째로, 함수는 입력 검증부터 시작합니다.

parse_positive_number는 빈 문자열을 받으면 파싱할 수 없으므로 즉시 에러를 반환합니다. 이런 조기 반환(early return) 패턴은 Rust에서 매우 일반적입니다.

에러 조건을 먼저 처리하고 빠르게 반환하면, 나머지 코드는 "정상 경로"만 다루면 되어 가독성이 높아집니다. 두 번째로, 다른 Result를 반환하는 함수를 호출할 때는 match로 처리합니다.

s.parse()는 Result<u32, ParseIntError>를 반환하는데, 성공하면 숫자를 얻고, 실패하면 우리 함수의 에러 타입으로 변환하여 반환합니다. 여기서 Err(_)의 언더스코어는 원래 에러 정보를 무시하고 우리만의 에러 메시지를 만든다는 의미입니다.

세 번째로, 비즈니스 로직 검증을 수행합니다. 파싱은 성공했지만 값이 0이면 "양의 정수"라는 요구사항에 맞지 않으므로 에러를 반환합니다.

이렇게 각 레이어에서 다른 종류의 검증을 수행하고, 모든 검증을 통과해야만 Ok(num)을 반환합니다. 여러분이 이 패턴을 사용하면 함수의 사전조건과 사후조건이 명확해집니다.

함수를 호출하는 쪽에서는 Result를 받아서 에러 케이스를 처리하거나 상위로 전파할 수 있습니다. 또한 여러 Result 반환 함수를 조합하여 복잡한 로직을 안전하게 구성할 수 있습니다.

에러 메시지도 구체적으로 작성하면 디버깅이 훨씬 쉬워집니다.

실전 팁

💡 에러 메시지는 사용자에게 보여줄 수도 있으므로 명확하고 친절하게 작성하세요. "에러 발생"보다는 "입력이 비어있습니다"가 훨씬 유용합니다.

💡 여러 에러 조건이 있을 때는 가장 빠르게 체크할 수 있는 것부터 순서대로 배치하면 성능이 향상됩니다.

💡 복잡한 함수에서는 Result를 반환하는 내부 헬퍼 함수를 만들어서 각 검증 단계를 분리하면 테스트와 유지보수가 쉬워집니다.

💡 에러 타입으로 String 대신 커스텀 enum을 사용하면 에러를 프로그래밍적으로 처리할 수 있어 더 강력합니다.


3. match로 Result 처리하기

시작하며

여러분이 Result를 반환하는 함수를 호출했다면, 이제 그 Result를 어떻게 처리할지 결정해야 합니다. 성공했을 때와 실패했을 때 각각 다른 동작을 해야 하는데, 어떻게 안전하게 처리할 수 있을까요?

많은 초보자들이 여기서 막힙니다. Result를 받았는데 값을 어떻게 꺼내야 하는지, 에러는 어떻게 처리해야 하는지 헷갈립니다.

그냥 무시하고 싶은 유혹도 있지만, Rust는 그것을 허용하지 않습니다. 가장 기본적이고 명시적인 방법이 바로 match 표현식입니다.

match를 사용하면 모든 경우를 빠짐없이 처리할 수 있고, 컴파일러가 이를 검증해줍니다.

개요

간단히 말해서, match는 Result의 Ok와 Err 케이스를 모두 처리하는 패턴 매칭 방식입니다. 각 케이스에 대해 원하는 동작을 명시적으로 작성합니다.

실무에서는 에러에 따라 다른 동작을 해야 하는 경우가 많습니다. 사용자에게 에러 메시지를 보여주거나, 로그를 남기거나, 기본값으로 폴백하거나, 재시도 로직을 실행하는 등 다양한 처리가 필요합니다.

match를 사용하면 이런 복잡한 로직을 체계적으로 구성할 수 있습니다. 기존의 if-else 체인이나 try-catch 블록과 달리, match는 모든 경우를 처리하지 않으면 컴파일 에러가 발생합니다.

이것이 Rust의 철학입니다: 에러는 예외적인 상황이 아니라 정상적인 제어 흐름의 일부입니다. match의 핵심 특징은 세 가지입니다.

첫째, 완전성(Exhaustiveness) - 모든 variant를 처리해야 합니다. 둘째, 값 추출 - 패턴으로 내부 값을 직접 바인딩할 수 있습니다.

셋째, 표현식 - match는 값을 반환하므로 변수 할당이나 return에 직접 사용할 수 있습니다.

코드 예제

use std::fs::File;

fn open_and_process_file(path: &str) {
    // File::open은 Result<File, std::io::Error>를 반환
    let file_result = File::open(path);

    // match로 Result 처리
    match file_result {
        // 성공 케이스: file 변수에 File이 바인딩됨
        Ok(file) => {
            println!("파일 열기 성공!");
            // 여기서 file을 사용하여 작업 수행
        },
        // 실패 케이스: error 변수에 에러 정보가 바인딩됨
        Err(error) => {
            println!("파일 열기 실패: {}", error);
            // 에러 처리 로직
        }
    }
}

설명

이것이 하는 일: match 표현식은 Result를 검사하여 Ok일 때와 Err일 때 각각 다른 코드 블록을 실행하고, 내부 값을 자동으로 추출하여 사용할 수 있게 합니다. 첫 번째로, match는 Result 값을 받아서 그 안에 Ok가 들어있는지 Err가 들어있는지 검사합니다.

File::open(path)는 파일 열기에 성공하면 Ok(File)을, 실패하면 Err(std::io::Error)를 반환합니다. match 문은 이 둘을 구분하여 적절한 arm(팔)을 실행합니다.

두 번째로, 패턴 매칭을 통해 내부 값을 추출합니다. Ok(file) 패턴은 Ok 안에 들어있는 File 객체를 file이라는 변수에 바인딩합니다.

마찬가지로 Err(error)는 에러 정보를 error 변수에 바인딩합니다. 이렇게 하면 별도의 unwrap이나 get 메서드 없이 직접 값을 사용할 수 있습니다.

세 번째로, 각 arm에서 원하는 로직을 실행합니다. Ok arm에서는 파일을 사용한 작업을 수행하고, Err arm에서는 에러 메시지를 출력하거나 다른 에러 처리를 합니다.

match는 표현식이므로 각 arm이 같은 타입의 값을 반환하면 그 값을 변수에 할당하거나 함수에서 반환할 수도 있습니다. 여러분이 match를 사용하면 에러 처리를 절대 빠뜨리지 않습니다.

컴파일러가 Ok와 Err를 모두 처리했는지 검증하기 때문입니다. 또한 코드를 읽는 사람도 "이 함수 호출은 실패할 수 있고, 실패 시 이렇게 처리한다"는 것을 명확히 알 수 있습니다.

match는 가장 명시적인 방법이므로 복잡한 에러 처리 로직이 필요할 때 특히 유용합니다.

실전 팁

💡 match의 각 arm은 같은 타입을 반환해야 하므로, Ok에서는 값을 반환하고 Err에서는 기본값이나 다른 처리를 하여 일관된 타입을 유지하세요.

💡 에러 정보를 상세히 활용하려면 Err(error)로 바인딩하고, 에러 정보가 필요 없으면 Err(_)로 무시할 수 있습니다.

💡 match는 표현식이므로 let result = match ... 형태로 사용하면 조건에 따라 다른 값을 할당할 수 있어 매우 강력합니다.

💡 if let 구문을 사용하면 하나의 케이스만 처리하고 나머지는 무시할 수 있습니다: if let Ok(file) = File::open(path) { ... }


4. unwrap과 expect 사용법

시작하며

여러분이 프로토타입을 빠르게 만들거나 예제 코드를 작성할 때, 매번 match로 에러를 처리하는 것이 번거로울 수 있습니다. 특히 "이 상황에서는 절대 에러가 발생하지 않을 것"이라고 확신할 때도 있죠.

하지만 Result를 그냥 무시할 수는 없습니다. Rust는 에러 처리를 강제하니까요.

이럴 때 필요한 것이 unwrap과 expect입니다. 이들은 "에러가 발생하면 프로그램을 패닉시킬 테니 값을 바로 꺼내겠다"는 의미입니다.

물론 이것은 양날의 검입니다. 편리하지만 잘못 사용하면 프로덕션 코드에서 예상치 못한 패닉이 발생할 수 있습니다.

언제, 어떻게 사용해야 하는지 정확히 알아야 합니다.

개요

간단히 말해서, unwrap은 Result가 Ok면 값을 반환하고 Err면 패닉을 일으키며, expect는 unwrap과 같지만 패닉 메시지를 직접 지정할 수 있습니다. 실무에서는 신중하게 사용해야 합니다.

초기 개발 단계나 테스트 코드, 또는 논리적으로 에러가 불가능한 경우에만 사용하는 것이 좋습니다. 예를 들어, 하드코딩된 올바른 문자열을 파싱하거나, 프로그램 초기화 중 필수 리소스를 로드할 때 사용합니다.

기존의 try-catch에서 catch 블록을 작성하지 않고 예외를 전파하는 것과 비슷하지만, Rust에서는 패닉이 복구 불가능한 에러를 의미한다는 점이 다릅니다. 패닉이 발생하면 스레드가 종료되거나(catch_unwind 없이는) 프로그램이 종료됩니다.

unwrap과 expect의 핵심 차이점은 디버깅 정보입니다. unwrap은 기본 메시지만 출력하지만, expect는 개발자가 작성한 맥락 정보를 포함하여 디버깅을 훨씬 쉽게 만들어줍니다.

프로덕션 코드에서 정말 사용해야 한다면 expect를 선호하세요.

코드 예제

use std::fs::File;

fn demo_unwrap_expect() {
    // unwrap: Ok면 값 반환, Err면 패닉
    let config_path = "config.json";
    // 주의: 파일이 없으면 패닉 발생!
    // let file = File::open(config_path).unwrap();

    // expect: unwrap과 같지만 커스텀 메시지 포함
    let file = File::open(config_path)
        .expect("config.json 파일을 열 수 없습니다. 파일이 존재하는지 확인하세요.");

    // 논리적으로 에러가 불가능한 경우의 unwrap 사용 예
    let valid_number = "42";
    let num: i32 = valid_number.parse()
        .unwrap(); // 하드코딩된 올바른 문자열이므로 안전

    println!("숫자: {}", num);
}

설명

이것이 하는 일: unwrap과 expect는 Result를 직접 값으로 변환하되, 에러가 있으면 프로그램을 중단시키는 방식으로 "에러는 발생하지 않을 것"이라는 개발자의 확신을 표현합니다. 첫 번째로, unwrap()은 Result가 Ok(value)일 때 value를 반환하고, Err(error)일 때는 panic!을 호출합니다.

예를 들어 File::open("config.json").unwrap()은 파일이 존재하면 File 객체를 얻지만, 파일이 없으면 "called Result::unwrap() on an Err value: ..."라는 메시지와 함께 프로그램이 종료됩니다. 두 번째로, expect("메시지")는 unwrap과 동일하게 작동하지만 패닉 시 개발자가 지정한 메시지를 출력합니다.

이것은 매우 중요한 차이입니다. unwrap이 발생한 곳이 여러 곳이면 어디서 패닉이 났는지 찾기 어렵지만, expect는 구체적인 메시지를 남기므로 즉시 문제를 파악할 수 있습니다.

세 번째로, 이들을 사용해야 하는 상황과 사용하지 말아야 하는 상황을 구분해야 합니다. 사용해도 되는 경우: (1) 예제나 프로토타입 코드, (2) 테스트 코드에서 실패 시 테스트를 실패시키고 싶을 때, (3) 논리적으로 에러가 불가능한 경우(하드코딩된 올바른 값).

사용하지 말아야 하는 경우: (1) 사용자 입력 처리, (2) 외부 리소스 접근, (3) 프로덕션 코드의 에러 처리. 여러분이 unwrap이나 expect를 사용할 때는 항상 "이것이 패닉을 일으켜도 괜찮은가?"를 자문해야 합니다.

만약 조금이라도 의심스럽다면 match나 ? 연산자를 사용하세요.

특히 라이브러리 코드에서는 unwrap을 절대 사용하지 말아야 합니다. 라이브러리 사용자가 원하지 않는 패닉을 경험하게 됩니다.

실전 팁

💡 코드 리뷰에서 unwrap을 발견하면 "이것이 패닉해도 괜찮은가?"를 반드시 물어보세요. 대부분의 경우 적절한 에러 처리로 대체해야 합니다.

💡 프로덕션 코드에서 unwrap을 사용해야 한다면, 그 위에 주석으로 "왜 이것이 안전한가"를 설명하세요. 미래의 당신이나 동료에게 큰 도움이 됩니다.

💡 expect의 메시지는 "무엇이 실패했는가"와 "어떻게 해결할 수 있는가"를 모두 포함하면 최고입니다.

💡 clippy 린터는 unwrap 사용을 경고해줍니다. #[allow(clippy::unwrap_used)]로 무시할 수 있지만, 정말 필요한 경우에만 사용하세요.


5. ? 연산자로 에러 전파

시작하며

여러분이 여러 단계의 작업을 수행하는 함수를 작성한다고 생각해보세요. 파일을 열고, 내용을 읽고, 파싱하고, 검증하는 등 각 단계마다 실패할 수 있습니다.

각 단계마다 match로 에러를 처리하면 코드가 엄청나게 길어지고 가독성이 떨어집니다. 이런 "에러 전파" 패턴은 매우 흔합니다.

중간 단계에서 에러가 발생하면 그것을 직접 처리하지 않고 호출자에게 넘기고 싶을 때가 많죠. 매번 match를 작성하는 것은 너무 번거롭습니다.

바로 이럴 때 사용하는 것이 ? 연산자입니다.

단 하나의 문자로 "Result가 Ok면 값을 꺼내고, Err면 즉시 함수에서 반환하라"는 로직을 표현할 수 있습니다.

개요

간단히 말해서, ? 연산자는 Result가 Ok면 그 안의 값을 반환하고, Err면 현재 함수에서 그 에러를 즉시 반환합니다.

실무에서는 여러 Result 반환 함수를 연쇄적으로 호출하는 경우가 매우 많습니다. 파일 작업, 네트워크 요청, 데이터베이스 쿼리 등 각 단계마다 실패 가능성이 있고, 중간 단계는 에러를 처리하지 않고 최종 호출자에게 전파하는 것이 일반적입니다.

기존의 try-catch에서 예외를 다시 던지는 것과 비슷하지만, Rust에서는 ? 하나로 훨씬 간결하게 표현됩니다.

또한 ? 연산자는 에러 타입을 자동으로 변환해주는 기능도 있어(From trait 구현 시) 매우 강력합니다.

? 연산자의 핵심 특징은 세 가지입니다.

첫째, 간결성 - 보일러플레이트 코드를 대폭 줄입니다. 둘째, 조기 반환 - 에러 발생 시 즉시 함수를 종료합니다.

셋째, 타입 변환 - From trait을 통해 에러 타입을 자동 변환합니다.

코드 예제

use std::fs::File;
use std::io::{self, Read};

// ? 연산자 없이 작성한 버전 (장황함)
fn read_username_from_file_verbose(path: &str) -> Result<String, io::Error> {
    let file_result = File::open(path);
    let mut file = match file_result {
        Ok(f) => f,
        Err(e) => return Err(e), // 에러 전파
    };

    let mut username = String::new();
    match file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e), // 에러 전파
    }
}

// ? 연산자로 간결하게 작성한 버전
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?; // Err면 즉시 반환
    let mut username = String::new();
    file.read_to_string(&mut username)?; // Err면 즉시 반환
    Ok(username) // 모든 작업 성공
}

설명

이것이 하는 일: ? 연산자는 Result를 평가하여 Ok일 때는 내부 값을 반환하고 Err일 때는 현재 함수를 조기 종료하며 에러를 상위로 전파합니다.

첫 번째로, ? 연산자는 표현식 뒤에 붙여서 사용합니다.

File::open(path)?는 내부적으로 "File::open을 호출하고, Result가 Ok(file)이면 file을 반환하고, Err(e)면 return Err(e)를 실행"하는 것과 같습니다. 이렇게 하면 match 블록 전체를 단 하나의 문자로 대체할 수 있습니다.

두 번째로, ? 연산자를 사용하는 함수는 반드시 Result(또는 Option)를 반환해야 합니다.

read_username_from_file의 반환 타입이 Result<String, io::Error>이므로 함수 내부에서 ?를 사용할 수 있습니다. 만약 main 함수에서 ?를 사용하고 싶다면 main의 반환 타입을 Result<(), Box<dyn Error>>로 변경해야 합니다.

세 번째로, ? 연산자는 에러 타입을 자동으로 변환합니다.

만약 함수의 에러 타입이 Box<dyn Error>인데 File::open이 io::Error를 반환하더라도, io::Error가 From trait을 구현하고 있다면 자동으로 변환되어 전파됩니다. 이것은 여러 다른 에러 타입을 다루는 복잡한 함수에서 매우 유용합니다.

여러분이 ? 연산자를 사용하면 코드가 훨씬 읽기 쉬워집니다.

verbose 버전과 비교하면 차이가 명확합니다. 에러 처리 로직이 숨겨져서 정상 경로의 흐름이 명확히 보입니다.

또한 여러 단계를 체이닝할 수 있어서 File::open(path)?.read_to_string(&mut s)?처럼 메서드 호출을 연결할 수도 있습니다.

실전 팁

💡 ? 연산자는 Result와 Option 모두에 사용할 수 있습니다. Option에 사용하면 None일 때 함수에서 None을 반환합니다.

💡 main 함수에서도 ?를 사용하고 싶다면 fn main() -> Result<(), Box<dyn Error>> { ... }로 선언하세요. 에러 발생 시 에러 메시지가 출력되고 프로그램이 종료됩니다.

💡 ? 연산자 후에 메서드 체이닝을 계속할 수 있습니다: File::open(path)?.metadata()? 같은 식으로 여러 Result 연산을 연결할 수 있습니다.

💡 디버깅 시 ? 연산자가 너무 많으면 어디서 에러가 발생했는지 찾기 어려울 수 있으므로, 중요한 지점에서는 명시적인 match나 map_err로 로깅을 추가하는 것이 좋습니다.


6. unwrap_or와 unwrap_or_else

시작하며

여러분이 설정 파일을 읽는데 파일이 없을 수도 있다고 가정해보세요. 이 경우 에러를 전파하거나 패닉을 일으키는 대신, 기본 설정값을 사용하고 싶을 수 있습니다.

즉, 에러를 "복구"하는 것이죠. 많은 실무 시나리오에서 에러가 발생해도 프로그램을 계속 실행할 수 있습니다.

캐시 로드 실패 시 빈 캐시로 시작하거나, API 호출 실패 시 기본값을 사용하는 등 우아한 성능 저하(graceful degradation)가 필요한 경우가 많습니다. Rust는 이런 상황을 위해 unwrap_or, unwrap_or_else, unwrap_or_default 같은 메서드를 제공합니다.

이들은 Result가 Ok면 값을 꺼내고, Err면 대체값을 제공하는 방식으로 에러를 복구합니다.

개요

간단히 말해서, unwrap_or는 Err일 때 지정한 기본값을 반환하고, unwrap_or_else는 Err일 때 클로저를 실행하여 기본값을 계산합니다. 실무에서는 설정 로드, 캐시 조회, 선택적 기능 활성화 등 많은 상황에서 에러를 복구하고 기본값으로 폴백해야 합니다.

예를 들어, 사용자 프로필 이미지를 로드하다 실패하면 기본 아바타 이미지를 사용하거나, 외부 API 응답이 없으면 로컬 데이터를 사용하는 식입니다. 기존의 try-catch에서 catch 블록 내에서 기본값을 반환하는 것과 비슷하지만, Rust에서는 이것이 메서드 체이닝으로 훨씬 자연스럽게 표현됩니다.

에러 처리와 값 추출이 하나의 표현식으로 통합됩니다. 이 메서드들의 핵심 차이점은 성능과 유연성입니다.

unwrap_or(default_value)는 항상 default_value를 평가하지만, unwrap_or_else(|| compute_default())는 Err일 때만 클로저를 실행합니다. 기본값 계산이 비용이 크다면 unwrap_or_else를 사용하세요.

코드 예제

use std::fs;

fn load_config_with_fallback(path: &str) -> String {
    // unwrap_or: 에러 시 기본값 사용 (항상 평가됨)
    let config = fs::read_to_string(path)
        .unwrap_or(String::from("default config"));

    config
}

fn load_config_lazy(path: &str) -> String {
    // unwrap_or_else: 에러 시 클로저 실행 (필요할 때만 평가)
    fs::read_to_string(path)
        .unwrap_or_else(|error| {
            // 에러 정보를 활용할 수 있음
            eprintln!("설정 파일 로드 실패: {}. 기본값 사용.", error);
            String::from("default config")
        })
}

fn get_user_age(input: &str) -> u32 {
    // unwrap_or_default: 타입의 기본값 사용 (u32는 0)
    input.parse::<u32>()
        .unwrap_or_default() // 파싱 실패 시 0 반환
}

설명

이것이 하는 일: 이 메서드들은 Result에서 값을 추출하되, Err일 때 패닉 대신 폴백 전략을 사용하여 안전하게 기본값을 제공합니다. 첫 번째로, unwrap_or(default)는 가장 간단한 형태입니다.

Result가 Ok(value)면 value를 반환하고, Err(_)면 default를 반환합니다. load_config_with_fallback에서 설정 파일을 읽다 실패하면 "default config"를 사용합니다.

주의할 점은 default 표현식이 Result와 관계없이 항상 평가된다는 것입니다. 즉, 비용이 큰 연산이라면 Ok일 때도 실행되어 낭비가 발생합니다.

두 번째로, unwrap_or_else(|| ...)는 지연 평가(lazy evaluation)를 제공합니다. 클로저는 Result가 Err일 때만 실행되므로 성능상 이점이 있습니다.

또한 클로저는 에러 값을 인자로 받을 수 있어(|error| ...) 에러 정보를 로깅하거나 에러 종류에 따라 다른 기본값을 제공할 수 있습니다. 세 번째로, unwrap_or_default()는 타입의 Default trait 구현을 사용합니다.

숫자 타입은 0, String은 빈 문자열, Vec은 빈 벡터 등이 기본값입니다. 이것은 특히 컬렉션 타입에서 유용합니다.

예를 들어 JSON 파싱이 실패하면 빈 벡터로 시작하고 싶을 때 사용할 수 있습니다. 여러분이 이 메서드들을 사용하면 프로그램의 견고성이 크게 향상됩니다.

선택적인 기능이나 외부 리소스에 의존하는 부분에서 에러가 발생해도 프로그램이 계속 동작할 수 있습니다. 다만 모든 에러를 조용히 무시하는 것은 위험하므로, 중요한 에러는 로깅하거나 모니터링해야 합니다.

실전 팁

💡 기본값 계산이 단순하면 unwrap_or, 복잡하거나 비용이 크면 unwrap_or_else를 사용하세요. 성능 차이가 클 수 있습니다.

💡 unwrap_or_else의 클로저에서 에러를 로깅하면 디버깅이 쉬워집니다. 기본값을 사용하더라도 에러가 발생했다는 사실을 기록하세요.

💡 Option 타입에도 같은 메서드들이 있습니다. None일 때 기본값을 제공하는 패턴은 Option과 Result 모두에서 유용합니다.

💡 연속된 Result 작업에서 중간 단계만 폴백하고 싶다면 메서드 체이닝을 활용하세요: step1()?.step2().unwrap_or(default)


7. map과 and_then 체이닝

시작하며

여러분이 Result를 반환하는 함수의 결과를 가공하고 싶다면 어떻게 해야 할까요? 예를 들어, 파일에서 읽은 숫자를 두 배로 만들거나, 파싱한 날짜를 포맷하고 싶을 때 말이죠.

match로 Ok 케이스를 처리할 수도 있지만, 더 우아한 방법이 있습니다. 함수형 프로그래밍에 익숙하다면 map이라는 개념을 알 것입니다.

컨테이너 안의 값을 변환하는 강력한 도구죠. Rust의 Result도 map, and_then 같은 메서드를 제공하여 에러 처리와 값 변환을 조합할 수 있습니다.

이 방식은 코드를 선언적으로 만들고 체이닝을 가능하게 합니다. "파일을 열고, 내용을 읽고, 파싱하고, 변환하라"는 일련의 작업을 메서드 체인 하나로 표현할 수 있습니다.

개요

간단히 말해서, map은 Result가 Ok일 때 그 값을 변환하고, and_then은 Result를 반환하는 함수를 체이닝할 때 사용합니다. 실무에서는 데이터 파이프라인을 구성할 때 이 패턴이 매우 유용합니다.

API 응답을 받아서 파싱하고, 검증하고, 변환하고, 데이터베이스에 저장하는 등 각 단계가 Result를 반환할 때, 이들을 깔끔하게 연결할 수 있습니다. 기존의 명령형 스타일에서는 각 단계마다 match나 if let을 사용해야 했다면, 함수형 스타일에서는 map과 and_then으로 체이닝하여 데이터 흐름을 한눈에 볼 수 있습니다.

에러 처리는 암묵적으로 전파되고, 개발자는 성공 경로에만 집중할 수 있습니다. map과 and_then의 핵심 차이는 변환 함수의 반환 타입입니다.

map의 클로저는 일반 값(T -> U)을 반환하고, and_then의 클로저는 Result(T -> Result<U, E>)를 반환합니다. 따라서 중첩된 Result를 피하려면 and_then을 사용해야 합니다.

코드 예제

use std::num::ParseIntError;

// map: Ok 값을 변환 (T -> U)
fn double_number(input: &str) -> Result<i32, ParseIntError> {
    input.parse::<i32>()
        .map(|n| n * 2) // Ok(n)을 Ok(n * 2)로 변환
}

// and_then: Result 반환 함수 체이닝 (T -> Result<U, E>)
fn parse_and_validate(input: &str) -> Result<i32, String> {
    input.parse::<i32>()
        .map_err(|e| format!("파싱 에러: {}", e)) // 에러 타입 변환
        .and_then(|n| {
            // Result를 반환하는 검증 로직
            if n > 0 {
                Ok(n)
            } else {
                Err(String::from("양수여야 합니다"))
            }
        })
}

// 복잡한 체이닝 예시
fn process_input(input: &str) -> Result<String, String> {
    input.parse::<i32>()
        .map_err(|e| format!("파싱 실패: {}", e))
        .and_then(|n| validate_range(n))
        .map(|n| n * 2)
        .map(|n| format!("결과: {}", n))
}

fn validate_range(n: i32) -> Result<i32, String> {
    if n >= 0 && n <= 100 {
        Ok(n)
    } else {
        Err(String::from("범위를 벗어났습니다 (0-100)"))
    }
}

설명

이것이 하는 일: map과 and_then은 Result를 변환하고 조합하는 도구로, 성공 경로의 로직만 작성하면 에러 전파는 자동으로 처리됩니다. 첫 번째로, map(|value| ...)은 Result<T, E>를 Result<U, E>로 변환합니다.

Result가 Ok(value)면 클로저를 실행하여 Ok(new_value)를 만들고, Err(e)면 그대로 Err(e)를 전파합니다. double_number 함수에서 parse가 성공하면 숫자를 두 배로 만들지만, 실패하면 ParseIntError가 그대로 반환됩니다.

map 안의 클로저는 에러를 신경 쓸 필요가 없습니다. 두 번째로, and_then(|value| ...)은 "flat map"이라고도 불리며, 중첩된 Result를 평탄화합니다.

클로저가 Result를 반환할 때 사용하는데, 그렇지 않으면 Result<Result<U, E>, E> 같은 이상한 타입이 됩니다. parse_and_validate에서 파싱 후 검증 로직을 and_then으로 연결하면, 두 단계 중 어느 하나라도 실패하면 Err가 반환됩니다.

세 번째로, map_err는 에러 타입을 변환합니다. 여러 에러 타입을 통합하거나, 에러 메시지를 더 구체적으로 만들 때 유용합니다.

ParseIntError를 String으로 변환하여 다른 String 에러들과 일관된 타입을 유지할 수 있습니다. 이렇게 하면 여러 다른 소스의 에러를 하나의 타입으로 통합할 수 있습니다.

여러분이 이런 함수형 패턴을 사용하면 코드가 파이프라인처럼 읽힙니다. process_input 함수를 보면 "파싱하고 -> 검증하고 -> 두 배로 만들고 -> 포맷한다"는 흐름이 명확합니다.

각 단계의 에러 처리를 명시적으로 작성하지 않아도 자동으로 전파됩니다. 이것은 복잡한 데이터 처리 로직을 간결하고 이해하기 쉽게 만듭니다.

실전 팁

💡 map은 변환 작업에, and_then은 추가적인 Result 반환 연산에 사용하세요. 예를 들어 파싱 후 검증은 and_then, 파싱 후 단순 계산은 map을 씁니다.

💡 map_err를 체인의 앞부분에 배치하면 이후 체이닝에서 일관된 에러 타입을 사용할 수 있어 편리합니다.

💡 너무 긴 체이닝은 가독성을 해칠 수 있으므로, 복잡한 로직은 별도 함수로 분리하고 and_then으로 연결하세요.

💡 Option도 같은 메서드들을 제공합니다. Result와 Option을 조합할 때는 ok_or로 Option을 Result로 변환하거나 transpose로 Result<Option>과 Option<Result>를 바꿀 수 있습니다.


8. 커스텀 에러 타입 정의

시작하며

여러분이 지금까지 String을 에러 타입으로 사용했다면, 프로젝트가 커질수록 한계를 느낄 것입니다. 에러 메시지만으로는 에러의 종류를 프로그래밍적으로 구분할 수 없고, 에러에 대한 추가 정보(에러 코드, 컨텍스트 등)를 담기도 어렵습니다.

실무 프로젝트에서는 다양한 종류의 에러가 발생합니다. 네트워크 에러, 파싱 에러, 검증 에러, 비즈니스 로직 에러 등이 각각 다른 방식으로 처리되어야 합니다.

사용자에게 보여줄 메시지도 다르고, 로깅 레벨도 다르고, 재시도 가능 여부도 다릅니다. Rust에서는 커스텀 enum을 정의하여 도메인 특화 에러 타입을 만들 수 있습니다.

이것은 타입 안전성을 제공하고, 에러를 체계적으로 관리하며, 에러 처리를 명확하게 만듭니다.

개요

간단히 말해서, 커스텀 에러 타입은 enum으로 정의하여 여러 종류의 에러를 표현하고, Display와 Error trait을 구현하여 표준 에러 처리와 통합합니다. 실무에서는 각 모듈이나 도메인마다 고유한 에러 타입을 정의하는 것이 일반적입니다.

예를 들어 웹 서버라면 HttpError, 데이터베이스 레이어는 DbError, 비즈니스 로직은 ValidationError 같은 식입니다. 각 에러 타입은 해당 레이어의 실패 모드를 명확히 표현합니다.

기존의 예외 클래스 상속 구조와 달리, Rust에서는 enum의 variant로 에러를 구분합니다. 이것은 더 명시적이고 패턴 매칭과 잘 어울립니다.

또한 thiserror나 anyhow 같은 크레이트를 사용하면 보일러플레이트를 크게 줄일 수 있습니다. 커스텀 에러 타입의 핵심 요소는 세 가지입니다.

첫째, enum으로 에러 종류를 구분합니다. 둘째, 각 variant는 추가 정보를 담을 수 있습니다.

셋째, Display와 Error trait 구현으로 표준 에러 생태계와 통합됩니다.

코드 예제

use std::fmt;

// 커스텀 에러 타입 정의
#[derive(Debug)]
enum DataError {
    NotFound(String),           // 데이터를 찾을 수 없음
    InvalidFormat { field: String, reason: String }, // 포맷 에러
    OutOfRange(i32, i32, i32),  // 범위 초과 (값, 최소, 최대)
}

// Display trait 구현 (에러 메시지)
impl fmt::Display for DataError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            DataError::NotFound(name) =>
                write!(f, "데이터를 찾을 수 없습니다: {}", name),
            DataError::InvalidFormat { field, reason } =>
                write!(f, "필드 '{}'의 포맷이 잘못되었습니다: {}", field, reason),
            DataError::OutOfRange(value, min, max) =>
                write!(f, "값 {}이(가) 범위 [{}, {}]를 벗어났습니다", value, min, max),
        }
    }
}

// Error trait 구현 (표준 에러 인터페이스)
impl std::error::Error for DataError {}

// 사용 예시
fn validate_age(age: i32) -> Result<i32, DataError> {
    if age < 0 || age > 150 {
        Err(DataError::OutOfRange(age, 0, 150))
    } else {
        Ok(age)
    }
}

설명

이것이 하는 일: 커스텀 에러 타입은 애플리케이션의 다양한 실패 모드를 명확히 구분하고, 각 에러에 필요한 컨텍스트 정보를 타입 안전하게 담으며, 표준 에러 처리 메커니즘과 통합됩니다. 첫 번째로, enum으로 에러 타입을 정의합니다.

DataError는 세 가지 variant를 가지는데, 각각 다른 종류의 에러를 나타냅니다. NotFound는 String을 포함하여 어떤 데이터가 없는지 기록하고, InvalidFormat은 구조체 형태로 필드 이름과 이유를 모두 담고, OutOfRange는 값과 범위 정보를 튜플로 담습니다.

이렇게 각 에러에 필요한 정보를 정확히 모델링할 수 있습니다. 두 번째로, Display trait을 구현하여 사람이 읽을 수 있는 에러 메시지를 제공합니다.

fmt 메서드에서 match로 각 variant를 처리하고, 포함된 정보를 사용하여 구체적인 메시지를 만듭니다. 이 메시지는 println!("{}", error)나 expect 등에서 표시됩니다.

사용자 친화적인 메시지를 작성하는 것이 중요합니다. 세 번째로, Error trait을 구현하여 표준 에러 생태계와 통합합니다.

기본 구현만으로도 충분한 경우가 많지만, source 메서드를 오버라이드하면 에러 체인을 만들 수 있습니다. 이렇게 하면 ?

연산자가 From trait을 통해 자동 변환을 수행하고, Box<dyn Error> 같은 트레이트 객체로 사용할 수 있습니다. 여러분이 커스텀 에러 타입을 사용하면 에러 처리가 훨씬 명확해집니다.

validate_age 함수를 호출하는 쪽에서는 match로 에러 종류를 구분하고, OutOfRange 에러에 대해서는 정확한 범위 정보를 얻어 사용자에게 알려줄 수 있습니다. 또한 에러 종류별로 다른 로깅 레벨을 적용하거나, 재시도 로직을 구현하거나, HTTP 상태 코드를 매핑하는 등 프로그래밍적 처리가 가능해집니다.

실전 팁

💡 thiserror 크레이트를 사용하면 Display와 Error trait 구현을 자동으로 생성할 수 있습니다. #[derive(Error)]와 #[error("...")] 속성만 추가하면 됩니다.

💡 에러 variant 이름은 구체적이고 의미 있게 지으세요. Error1, Error2보다는 NetworkTimeout, InvalidCredentials가 훨씬 좋습니다.

💡 다른 라이브러리의 에러를 감쌀 때는 variant에 source: SomeError를 포함하고 Error::source 메서드를 구현하여 에러 체인을 유지하세요.

💡 anyhow 크레이트는 애플리케이션 레벨에서 간편한 에러 처리를 제공하고, thiserror는 라이브러리에서 정확한 에러 타입을 정의할 때 사용합니다. 용도에 맞게 선택하세요.


#Rust#Result#ErrorHandling#Option#UnwrapOrElse#프로그래밍언어

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.