이미지 로딩 중...

Rust 입문 가이드 16 match로 Result 처리하기 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 3 Views

Rust 입문 가이드 16 match로 Result 처리하기

Rust의 Result 타입과 match 표현식을 활용한 에러 처리 방법을 배웁니다. 실무에서 안전하고 명확한 에러 핸들링을 구현하는 방법을 단계별로 알아봅니다.


목차

  1. Result 타입의 기본 이해 - 성공과 실패를 명확하게 표현하기
  2. match로 Result 처리하기 - 명확한 에러 핸들링
  3. unwrap과 expect - 빠른 프로토타이핑과 명확한 에러 메시지
  4. 에러 전파하기 - ? 연산자의 마법
  5. 커스텀 에러 타입 정의하기 - 도메인 특화 에러 처리
  6. Result 체이닝하기 - 함수형 에러 처리
  7. 여러 Result 결합하기 - 복수의 에러 가능한 작업 처리
  8. Result와 Option의 상호 변환 - 에러와 없음의 경계

1. Result 타입의 기본 이해 - 성공과 실패를 명확하게 표현하기

시작하며

여러분이 파일을 읽거나 네트워크 요청을 보낼 때, 항상 성공한다고 보장할 수 있을까요? 실제 개발 현장에서는 파일이 존재하지 않거나, 네트워크 연결이 끊기거나, 권한이 없는 등 다양한 실패 상황이 발생합니다.

많은 언어에서는 예외(Exception)를 던지거나 null을 반환하여 이러한 상황을 처리합니다. 하지만 예외는 코드의 흐름을 예측하기 어렵게 만들고, null 체크를 잊어버리면 런타임 에러가 발생합니다.

Rust는 이런 문제를 Result 타입으로 해결합니다. Result는 성공(Ok)과 실패(Err)를 명확하게 구분하여 컴파일 시점에 에러 처리를 강제합니다.

이를 통해 더 안전하고 예측 가능한 코드를 작성할 수 있습니다.

개요

간단히 말해서, Result는 작업이 성공했을 때의 값(Ok)과 실패했을 때의 에러(Err)를 모두 담을 수 있는 열거형(enum)입니다. 실무에서 외부 리소스와 상호작용할 때마다 Result를 사용합니다.

파일 시스템 접근, 데이터베이스 쿼리, API 호출, 문자열 파싱 등 실패할 가능성이 있는 모든 작업에서 Result를 반환합니다. 예를 들어, 사용자가 입력한 문자열을 숫자로 변환하는 경우, 잘못된 형식이면 Err를 반환하여 안전하게 처리할 수 있습니다.

전통적인 예외 처리 방식에서는 try-catch 블록으로 감싸거나 에러를 무시할 수 있었습니다. 하지만 Rust의 Result는 반드시 처리하도록 강제하여 에러를 놓치는 실수를 방지합니다.

Result의 핵심 특징은 세 가지입니다. 첫째, 타입 안정성으로 성공과 실패 케이스를 명확히 구분합니다.

둘째, 컴파일러가 에러 처리 누락을 경고합니다. 셋째, 패턴 매칭으로 가독성 높은 에러 처리 코드를 작성할 수 있습니다.

코드 예제

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

fn read_username_from_file(path: &str) -> Result<String, std::io::Error> {
    // 파일 열기 시도 - 실패하면 에러 반환
    let mut file = File::open(path)?;

    // 파일 내용을 읽을 문자열 버퍼
    let mut contents = String::new();

    // 파일 읽기 시도 - 실패하면 에러 반환
    file.read_to_string(&mut contents)?;

    // 성공 시 Ok로 감싸서 반환
    Ok(contents)
}

설명

Result는 Rust에서 실패 가능한 작업의 결과를 표현하는 핵심 타입입니다. Result<T, E>는 제네릭 타입으로, T는 성공 시 반환될 값의 타입이고 E는 실패 시 반환될 에러의 타입입니다.

위 코드에서 read_username_from_file 함수는 Result<String, std::io::Error>를 반환합니다. 이는 성공하면 String을, 실패하면 io::Error를 반환한다는 의미입니다.

함수 시그니처만 봐도 이 함수가 실패할 수 있다는 것을 명확히 알 수 있습니다. File::open(path)?는 Result를 반환하는데, ?

연산자는 Ok면 값을 추출하고, Err면 즉시 함수에서 에러를 반환합니다. 이는 조기 반환(early return) 패턴으로, 에러가 발생하면 더 이상 진행하지 않고 호출자에게 에러를 전파합니다.

file.read_to_string(&mut contents)?도 마찬가지로 읽기에 성공하면 계속 진행하고, 실패하면 즉시 에러를 반환합니다. 모든 작업이 성공적으로 완료되면 Ok(contents)로 감싸서 최종 결과를 반환합니다.

여러분이 이 패턴을 사용하면 각 단계에서 발생할 수 있는 에러를 자연스럽게 처리할 수 있습니다. 코드는 "성공 경로"를 따라 작성되고, 에러는 ?

연산자로 자동으로 전파됩니다. 또한 함수를 호출하는 쪽에서도 Result를 받기 때문에 반드시 에러를 처리해야 하며, 타입 시스템이 이를 보장합니다.

실전 팁

💡 Result를 반환하는 함수는 함수명에 try_를 붙이거나 문서에 실패 조건을 명시하여 호출자가 에러 처리를 준비할 수 있도록 합니다

💡 ? 연산자는 Result를 반환하는 함수 내에서만 사용할 수 있으므로, main 함수도 Result를 반환하도록 변경하면 편리합니다 (fn main() -> Result<(), Box<dyn Error>>)

💡 여러 에러 타입을 다뤄야 할 때는 Box<dyn Error>나 anyhow 크레이트를 사용하여 에러 타입을 통합하면 코드가 간결해집니다

💡 Result는 is_ok(), is_err(), unwrap_or_default() 같은 편리한 메서드를 제공하므로, 상황에 맞게 활용하세요

💡 프로덕션 코드에서는 unwrap()보다 expect()를 사용하여 패닉 발생 시 명확한 메시지를 제공하거나, match로 적절히 처리하는 것이 좋습니다


2. match로 Result 처리하기 - 명확한 에러 핸들링

시작하며

여러분이 파일을 읽는 프로그램을 작성했는데, 파일이 없을 때와 읽기 권한이 없을 때를 다르게 처리하고 싶다면 어떻게 해야 할까요? 단순히 에러를 출력하는 것이 아니라, 상황에 따라 다른 대응 전략을 취해야 하는 경우가 많습니다.

? 연산자는 편리하지만 모든 에러를 동일하게 전파합니다.

하지만 실제 개발에서는 특정 에러는 복구하고, 다른 에러는 사용자에게 알리고, 또 다른 에러는 로깅만 하는 등 세밀한 제어가 필요합니다. 바로 이럴 때 match 표현식이 빛을 발합니다.

match는 Result의 Ok와 Err를 패턴 매칭하여 각 경우에 대해 정확히 원하는 처리를 할 수 있게 해줍니다.

개요

간단히 말해서, match는 Result의 Ok와 Err 케이스를 명시적으로 분리하여 각각 다른 로직을 실행할 수 있는 강력한 제어 구조입니다. 실무에서 에러의 종류에 따라 다른 동작을 해야 할 때 match를 사용합니다.

네트워크 에러면 재시도하고, 인증 에러면 로그인 페이지로 이동하고, 데이터 파싱 에러면 기본값을 사용하는 등 복잡한 에러 처리 로직을 구현할 수 있습니다. 예를 들어, 설정 파일을 읽을 때 파일이 없으면 기본 설정을 생성하고, 다른 에러면 프로그램을 종료하는 식으로 처리할 수 있습니다.

if-else 체인이나 여러 개의 예외 핸들러를 사용하는 방식과 달리, match는 모든 케이스를 한눈에 볼 수 있고 컴파일러가 누락된 케이스를 검증합니다. 기존에는 예외 타입별로 catch 블록을 여러 개 작성했다면, Rust에서는 match의 단일 구조로 모든 케이스를 처리합니다.

match의 핵심 특징은 완전성(exhaustiveness) 검사입니다. 컴파일러가 모든 가능한 케이스를 처리했는지 확인하여 놓친 에러 처리가 없도록 보장합니다.

또한 표현식이므로 값을 반환할 수 있어 에러 처리 결과를 변수에 바로 할당할 수 있습니다.

코드 예제

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

fn open_file_with_fallback(path: &str) -> File {
    // 파일 열기 시도하고 Result를 match로 처리
    match File::open(path) {
        // 성공 케이스: 파일을 그대로 반환
        Ok(file) => file,
        // 실패 케이스: 에러 종류별로 처리
        Err(error) => match error.kind() {
            // 파일이 없으면 새로 생성
            ErrorKind::NotFound => match File::create(path) {
                Ok(new_file) => new_file,
                Err(e) => panic!("파일 생성 실패: {:?}", e),
            },
            // 다른 에러는 패닉
            other_error => panic!("파일 열기 실패: {:?}", other_error),
        },
    }
}

설명

match 표현식은 Result의 두 가지 변형(Ok, Err)을 패턴 매칭으로 구분하여 처리합니다. 이는 Rust의 가장 강력한 제어 구조 중 하나로, 타입 안정성과 완전성을 보장합니다.

위 코드의 첫 번째 match는 File::open의 결과를 처리합니다. Ok(file)이면 file을 바로 반환하고, Err(error)면 에러 객체를 받아 더 세밀하게 처리합니다.

이렇게 중첩된 match를 사용하면 여러 단계의 의사결정을 명확하게 표현할 수 있습니다. error.kind()는 io::Error의 구체적인 종류를 반환합니다.

두 번째 match에서 ErrorKind::NotFound(파일이 없음)인 경우 File::create로 새 파일을 생성하려고 시도합니다. 이것도 실패할 수 있으므로 세 번째 match로 처리합니다.

이는 "파일이 없으면 만들고, 만들기도 실패하면 포기한다"는 정책을 코드로 명확히 표현한 것입니다. other_error 패턴은 와일드카드로, NotFound가 아닌 모든 에러를 처리합니다.

권한 거부, 디스크 공간 부족 등 다른 모든 에러는 여기서 패닉을 발생시킵니다. 컴파일러는 모든 ErrorKind 변형을 처리했는지 확인하므로, 새로운 에러 타입이 추가되어도 안전합니다.

여러분이 이 패턴을 사용하면 복잡한 에러 처리 로직을 계층적으로 구성할 수 있습니다. 각 단계에서 어떤 결정을 내리는지가 코드에 명확히 드러나며, 예외적인 상황에서도 프로그램의 동작을 예측할 수 있습니다.

또한 match는 표현식이므로 이 함수처럼 최종 값을 반환하는 데에도 자연스럽게 사용됩니다.

실전 팁

💡 중첩된 match가 깊어지면 가독성이 떨어지므로, if let이나 ? 연산자와 조합하여 깊이를 줄이는 것이 좋습니다

💡 에러 처리 로직이 복잡하면 별도 함수로 분리하여 각 match 분기를 간결하게 유지하세요

💡 ErrorKind 외에도 에러 메시지나 원인(source)을 검사하여 더 세밀한 분기를 만들 수 있습니다

💡 프로덕션 환경에서는 panic! 대신 적절한 에러를 로깅하고 복구 가능한 기본값을 반환하는 것이 더 안전합니다

💡 match의 각 분기에서 반환하는 타입이 일치해야 하므로, 타입 불일치 에러가 나면 분기별 반환 타입을 확인하세요


3. unwrap과 expect - 빠른 프로토타이핑과 명확한 에러 메시지

시작하며

여러분이 간단한 스크립트를 작성하거나 프로토타입을 만들 때, 매번 match나 ? 연산자로 에러를 처리하는 것이 번거롭게 느껴진 적 있나요?

특히 테스트 코드나 예제 코드에서는 에러가 발생하면 그냥 프로그램을 멈추는 것이 더 간단할 때가 많습니다. 반면 프로덕션 코드에서는 "이 작업은 반드시 성공해야 한다"는 전제가 있는 경우도 있습니다.

예를 들어, 프로그램 초기화 단계에서 필수 설정 파일이 없으면 계속 실행할 이유가 없습니다. 이런 상황에서 unwrap과 expect를 사용하면 빠르게 Result를 처리할 수 있습니다.

하지만 잘못 사용하면 예상치 못한 패닉을 일으킬 수 있으므로 신중하게 사용해야 합니다.

개요

간단히 말해서, unwrap은 Ok면 값을 꺼내고 Err면 패닉을 일으키는 메서드이고, expect는 패닉 시 커스텀 메시지를 제공하는 unwrap의 변형입니다. 실무에서 unwrap은 주로 테스트 코드, 예제, 또는 에러가 절대 발생하지 않는다고 확신하는 경우에만 사용합니다.

예를 들어, 하드코딩된 문자열을 파싱하거나, 이미 존재를 확인한 파일을 여는 경우입니다. expect는 프로그램의 전제 조건(precondition)이 깨졌을 때 명확한 메시지와 함께 실패하고 싶을 때 사용합니다.

try-catch로 예외를 무시하는 것과 달리, unwrap/expect는 명시적으로 "여기서 에러가 나면 프로그램이 멈춥니다"라고 선언하는 것입니다. 이는 숨겨진 버그보다 명확한 실패가 낫다는 Rust의 철학을 반영합니다.

unwrap/expect의 핵심은 명확성입니다. 코드를 읽는 사람이 "이 지점에서 에러가 나면 복구하지 않겠다"는 의도를 바로 알 수 있습니다.

또한 패닉은 스택 트레이스를 제공하여 디버깅을 돕습니다.

코드 예제

use std::env;
use std::fs;

fn main() {
    // 환경 변수 읽기 - 없으면 명확한 메시지와 함께 패닉
    let config_path = env::var("CONFIG_PATH")
        .expect("CONFIG_PATH 환경 변수가 설정되지 않았습니다");

    // 설정 파일 읽기 - 없으면 프로그램 실행 불가
    let config = fs::read_to_string(&config_path)
        .expect("설정 파일을 읽을 수 없습니다");

    // 하드코딩된 JSON 파싱 - 항상 성공해야 함
    let default_config = r#"{"timeout": 30}"#;
    let parsed: serde_json::Value = serde_json::from_str(default_config)
        .unwrap(); // 개발자 실수가 아니면 절대 실패하지 않음

    println!("설정 로드 완료: {}", config);
}

설명

unwrap과 expect는 Result에서 값을 강제로 추출하는 메서드로, Err일 경우 프로그램을 패닉 상태로 만듭니다. 이는 복구할 수 없거나 복구하지 않기로 결정한 상황에서 사용합니다.

env::var("CONFIG_PATH")는 환경 변수가 없으면 Err를 반환합니다. expect를 사용하면 "CONFIG_PATH 환경 변수가 설정되지 않았습니다"라는 명확한 메시지와 함께 패닉이 발생합니다.

이는 프로그램 운영자에게 무엇이 잘못되었는지 정확히 알려줍니다. unwrap을 사용했다면 일반적인 에러 메시지만 출력되어 원인 파악이 어려웠을 것입니다.

fs::read_to_string도 마찬가지로 expect를 사용합니다. 설정 파일 없이는 프로그램이 어차피 제대로 동작할 수 없으므로, 초기화 단계에서 바로 실패하는 것이 더 안전합니다.

이는 "빨리 실패하기(fail fast)" 원칙을 따르는 것으로, 문제를 조기에 발견하여 더 큰 피해를 방지합니다. serde_json::from_str(default_config).unwrap()은 흥미로운 케이스입니다.

default_config는 소스 코드에 하드코딩되어 있고, 개발자가 직접 작성한 JSON 문자열입니다. 이것이 파싱에 실패한다면 개발자의 실수이므로, 패닉으로 즉시 알아차리는 것이 적절합니다.

프로덕션에서 이런 패닉이 발생한다면 테스트가 부족했다는 신호입니다. 여러분이 unwrap/expect를 사용할 때는 "이 패닉이 발생하면 누가 어떻게 대응할 것인가"를 생각해야 합니다.

복구 불가능한 프로그램 설정 오류라면 expect로 명확히 알리고, 논리적으로 불가능한 에러라면 unwrap으로 개발자 실수를 드러내는 것이 적절합니다.

실전 팁

💡 expect 메시지는 "무엇이 잘못되었고 어떻게 고칠 수 있는지"를 설명해야 합니다. "설정 파일 읽기 실패" 대신 "CONFIG_PATH가 유효한 파일 경로인지 확인하세요"처럼 작성하세요

💡 프로덕션 코드에서 unwrap을 사용했다면, 주석으로 "왜 여기서 에러가 불가능한지" 또는 "왜 패닉이 적절한지"를 설명하세요

💡 사용자 입력이나 외부 데이터에는 절대 unwrap/expect를 사용하지 마세요. 대신 match나 ? 연산자로 우아하게 처리하세요

💡 테스트 코드에서는 자유롭게 unwrap을 사용하되, 프로덕션 코드로 갈수록 적절한 에러 처리로 교체하세요

💡 unwrap_or, unwrap_or_else, unwrap_or_default 같은 변형을 사용하면 패닉 없이 기본값을 제공할 수 있어 더 안전합니다


4. 에러 전파하기 - ? 연산자의 마법

시작하며

여러분이 여러 단계의 작업을 수행하는 함수를 작성할 때, 각 단계마다 match로 에러를 처리하면 코드가 금방 복잡해집니다. 파일을 열고, 읽고, 파싱하고, 검증하는 과정에서 각각 에러가 발생할 수 있는데, 이를 모두 match로 처리하면 중첩이 깊어져 가독성이 떨어집니다.

실무에서는 대부분의 에러를 현재 함수에서 직접 처리하지 않고, 호출한 쪽으로 전달하여 더 상위 레벨에서 처리하는 것이 일반적입니다. 이렇게 하면 각 함수는 자신의 핵심 로직에만 집중하고, 에러 처리 정책은 상위에서 결정할 수 있습니다.

Rust의 ? 연산자는 바로 이런 에러 전파를 간결하게 만들어주는 문법적 설탕입니다.

match로 작성하면 10줄이 넘는 코드를 단 한 글자로 표현할 수 있습니다.

개요

간단히 말해서, ? 연산자는 Result가 Ok면 값을 추출하여 계속 진행하고, Err면 즉시 함수를 반환하여 에러를 호출자에게 전파하는 단축 문법입니다.

실무에서 비즈니스 로직과 에러 처리를 분리할 때 ? 연산자를 적극 활용합니다.

데이터 처리 파이프라인을 구성할 때, 각 단계(읽기, 변환, 검증, 저장)에서 발생하는 에러를 중간 함수에서 일일이 처리하지 않고 최상위 함수나 main에서 통합 처리합니다. 예를 들어, 사용자 등록 API에서 데이터베이스 에러, 이메일 전송 에러, 검증 에러를 모두 API 핸들러 레벨에서 HTTP 응답으로 변환하는 식입니다.

Java나 Go의 명시적 에러 반환과 비교하면, ? 연산자는 훨씬 간결합니다.

Go에서 "if err != nil { return err }"를 매번 작성하는 반면, Rust는 단순히 ?만 붙이면 됩니다. 하지만 Go와 달리 Rust는 타입 시스템으로 에러 처리 누락을 컴파일 타임에 잡아냅니다.

? 연산자의 핵심은 에러 타입 변환(From trait)과 조기 반환입니다.

서로 다른 에러 타입도 자동으로 변환되며, 성공 경로를 직선적으로 작성하면서도 에러 안전성을 보장합니다.

코드 예제

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

fn read_and_process_file(path: &str) -> Result<u32, io::Error> {
    // 파일 열기 - 실패하면 즉시 에러 반환
    let mut file = File::open(path)?;

    // 내용 읽기 - 실패하면 즉시 에러 반환
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    // 줄 수 세기 - 공백 제거 후 파싱
    let line_count = contents.lines()
        .filter(|line| !line.trim().is_empty())
        .count() as u32;

    // 모든 작업이 성공하면 결과 반환
    Ok(line_count)
}

설명

? 연산자는 Rust에서 에러를 처리하는 가장 관용적인 방법입니다.

이는 match를 사용한 명시적 처리를 간결하게 만들어주는 문법 설탕으로, 컴파일러가 자동으로 확장합니다. File::open(path)?

부분을 match로 작성하면 이렇게 됩니다: "match File::open(path) { Ok(file) => file, Err(e) => return Err(e.into()) }". ?

연산자는 이 복잡한 로직을 단 한 글자로 표현합니다. Ok면 file 값을 추출하여 변수에 할당하고, Err면 즉시 함수에서 에러를 반환합니다.

file.read_to_string(&mut contents)?도 동일한 방식으로 동작합니다. 읽기가 성공하면 다음 줄로 진행하고, 실패하면 함수가 즉시 종료되며 io::Error가 호출자에게 전달됩니다.

이렇게 하면 에러 처리 코드가 보이지 않아 "행복한 경로(happy path)"에 집중할 수 있습니다. contents.lines() 이하는 에러를 발생시키지 않는 안전한 작업입니다.

빈 줄을 제외한 줄 수를 세고, 최종 결과를 Ok로 감싸서 반환합니다. 함수 시그니처가 Result를 반환하므로, 성공 케이스도 Ok로 명시적으로 래핑해야 합니다.

? 연산자의 강력함은 에러 타입 변환에도 있습니다.

From trait이 구현되어 있으면 서로 다른 에러 타입을 자동으로 변환합니다. 예를 들어, io::Error를 Box<dyn Error>로 자동 변환하여 여러 에러 타입을 하나의 Result로 통합할 수 있습니다.

여러분이 ? 연산자를 사용하면 비즈니스 로직이 명확히 드러나고, 에러 처리 보일러플레이트가 사라집니다.

각 함수는 자신의 책임만 수행하고, 에러는 자연스럽게 위로 전파되어 적절한 레벨에서 처리됩니다. 이는 깨끗하고 유지보수하기 쉬운 코드의 핵심입니다.

실전 팁

💡 ? 연산자는 Result나 Option을 반환하는 함수 내에서만 사용할 수 있으므로, main 함수도 Result를 반환하도록 수정하면 편리합니다

💡 여러 에러 타입을 다룰 때는 각 에러에 From trait을 구현하거나, thiserror 크레이트로 커스텀 에러 타입을 정의하세요

💡 ? 연산자 뒤에 .context() 같은 메서드를 체이닝하여 에러에 추가 정보를 붙일 수 있습니다 (anyhow 크레이트 사용 시)

💡 디버깅 시 ? 연산자가 많으면 어디서 에러가 났는지 추적이 어려울 수 있으므로, 중요한 지점에는 로깅을 추가하세요

💡 Option에도 ? 연산자를 사용할 수 있어, None을 조기 반환하는 패턴에 유용합니다


5. 커스텀 에러 타입 정의하기 - 도메인 특화 에러 처리

시작하며

여러분이 복잡한 애플리케이션을 개발할 때, 표준 라이브러리의 io::Error나 ParseIntError만으로는 부족하다고 느낀 적 있나요? 예를 들어, 사용자 인증 시스템에서 "비밀번호가 틀림", "계정이 잠김", "토큰 만료" 같은 도메인 특화 에러를 표현하고 싶을 때가 있습니다.

일반적인 에러 타입으로는 이런 세밀한 구분이 어렵고, 에러 메시지 문자열에 의존하면 타입 안정성이 깨집니다. 또한 에러마다 다른 복구 전략을 적용하거나, 사용자에게 다른 메시지를 보여주려면 에러를 구조적으로 표현해야 합니다.

Rust에서는 enum으로 커스텀 에러 타입을 정의하여 도메인의 실패 상황을 명확히 모델링할 수 있습니다. 이렇게 하면 에러 처리가 비즈니스 로직의 일부가 되어 더욱 안전하고 표현력 있는 코드를 작성할 수 있습니다.

개요

간단히 말해서, 커스텀 에러 타입은 여러분의 애플리케이션 도메인에 특화된 실패 상황을 표현하는 enum으로, 각 에러 케이스를 타입 레벨에서 구분합니다. 실무에서 복잡한 비즈니스 규칙이 있을 때 커스텀 에러를 정의합니다.

결제 시스템에서 "잔액 부족", "카드 만료", "일일 한도 초과" 같은 에러를 구분하거나, API 서버에서 "인증 실패", "권한 없음", "리소스 없음" 같은 HTTP 상태 코드와 매핑되는 에러를 만듭니다. 예를 들어, 파일 업로드 시스템에서 "파일 크기 초과", "지원하지 않는 형식", "바이러스 탐지" 등을 각각 다른 사용자 메시지와 로깅 레벨로 처리할 수 있습니다.

문자열 기반 에러나 숫자 코드와 달리, enum 기반 커스텀 에러는 컴파일러가 모든 케이스를 처리했는지 검증합니다. Java의 checked exception처럼 명시적이지만, 예외 계층 구조 없이 더 단순합니다.

커스텀 에러의 핵심은 타입 안정성과 표현력입니다. 각 에러 변형은 관련 데이터를 담을 수 있고, Display와 Error trait을 구현하여 표준 에러 처리 생태계와 통합됩니다.

코드 예제

use std::fmt;

// 사용자 인증 관련 커스텀 에러 타입
#[derive(Debug)]
enum AuthError {
    InvalidCredentials,
    AccountLocked { until: String },
    TokenExpired,
    DatabaseError(std::io::Error),
}

// 사용자 친화적인 에러 메시지 제공
impl fmt::Display for AuthError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AuthError::InvalidCredentials =>
                write!(f, "이메일 또는 비밀번호가 올바르지 않습니다"),
            AuthError::AccountLocked { until } =>
                write!(f, "계정이 {}까지 잠겼습니다", until),
            AuthError::TokenExpired =>
                write!(f, "세션이 만료되었습니다. 다시 로그인하세요"),
            AuthError::DatabaseError(e) =>
                write!(f, "서버 오류: {}", e),
        }
    }
}

// 인증 함수 예시
fn authenticate(email: &str, password: &str) -> Result<String, AuthError> {
    if email.is_empty() || password.is_empty() {
        return Err(AuthError::InvalidCredentials);
    }
    // 실제로는 데이터베이스 조회 등의 로직
    Ok("user_token_123".to_string())
}

설명

커스텀 에러 타입은 여러분의 애플리케이션이 실패하는 방식을 타입 시스템으로 명확히 표현합니다. 이는 단순히 에러를 보고하는 것을 넘어, 비즈니스 로직의 일부로 에러를 모델링하는 것입니다.

AuthError enum은 사용자 인증에서 발생할 수 있는 네 가지 실패 상황을 정의합니다. InvalidCredentials는 데이터가 없는 단순 변형이고, AccountLocked은 잠금 해제 시간을 담는 구조체 형태입니다.

TokenExpired는 세션 만료를 나타내고, DatabaseError는 하위 io::Error를 래핑합니다. 이렇게 각 에러 케이스가 필요한 만큼의 정보를 담을 수 있습니다.

fmt::Display trait 구현은 각 에러를 사용자 친화적인 메시지로 변환합니다. InvalidCredentials는 보안상 구체적인 정보를 주지 않고, AccountLocked은 언제까지 기다려야 하는지 알려주며, DatabaseError는 내부 에러를 숨기고 일반적인 메시지를 보여줍니다.

이런 세밀한 제어가 커스텀 에러의 장점입니다. authenticate 함수는 Result<String, AuthError>를 반환합니다.

성공하면 토큰 문자열을, 실패하면 구체적인 AuthError 변형을 반환합니다. 호출하는 쪽에서는 match로 각 에러 케이스에 대해 다른 UI를 보여주거나 다른 복구 전략을 적용할 수 있습니다.

실무에서는 thiserror 크레이트를 사용하여 더 간결하게 커스텀 에러를 정의합니다. #[derive(Error)]로 Display와 Error trait을 자동 구현하고, #[error("...")] 속성으로 메시지를 지정합니다.

또한 From trait을 구현하여 다른 에러 타입을 자동으로 변환할 수 있습니다. 여러분이 커스텀 에러를 정의하면 에러 처리가 타입 시스템의 도움을 받습니다.

새로운 에러 케이스를 추가하면 기존 match 표현식에서 컴파일 에러가 나서 놓친 처리를 즉시 발견할 수 있습니다. 또한 에러가 코드베이스 전체에서 일관되게 표현되어 이해하고 유지보수하기 쉬워집니다.

실전 팁

💡 thiserror 크레이트를 사용하면 #[derive(Error)]와 #[error("...")] 속성으로 보일러플레이트를 크게 줄일 수 있습니다

💡 에러 변형에는 최대한 구체적인 정보를 담되, 보안상 민감한 정보(비밀번호 등)는 절대 포함하지 마세요

💡 From trait을 구현하여 표준 라이브러리 에러나 다른 크레이트의 에러를 자동으로 변환하면 ? 연산자와 잘 어울립니다

💡 에러 타입은 작게 시작하여 필요에 따라 변형을 추가하세요. 처음부터 모든 케이스를 예상하기는 어렵습니다

💡 로깅과 사용자 메시지를 분리하여, Display는 사용자용, Debug는 개발자용으로 구분하면 보안과 디버깅 모두에 유리합니다


6. Result 체이닝하기 - 함수형 에러 처리

시작하며

여러분이 데이터 파이프라인을 구성할 때, 여러 단계의 변환을 거치는 경우가 많습니다. 문자열을 파싱하고, 검증하고, 변환하고, 저장하는 각 단계마다 에러가 발생할 수 있는데, 이를 명령형으로 처리하면 중간 변수와 에러 체크가 난무합니다.

함수형 프로그래밍에 익숙하다면 map, filter, flatMap 같은 연산으로 데이터를 변환하는 것이 더 자연스럽게 느껴질 것입니다. Result도 이런 함수형 메서드를 제공하여 에러 가능한 작업을 우아하게 체이닝할 수 있습니다.

Result의 map, and_then, or_else 같은 메서드를 사용하면 에러 처리를 유지하면서도 선언적이고 간결한 코드를 작성할 수 있습니다. 이는 특히 데이터 변환이 많은 경우 가독성을 크게 향상시킵니다.

개요

간단히 말해서, Result 체이닝은 map, and_then 같은 메서드로 성공 케이스를 변환하고, 에러는 자동으로 전파하는 함수형 프로그래밍 패턴입니다. 실무에서 설정 파일 파싱, API 응답 처리, 데이터 검증 파이프라인 등 여러 단계의 변환이 필요한 곳에 체이닝을 사용합니다.

JSON을 파싱하고, 특정 필드를 추출하고, 타입을 변환하고, 비즈니스 규칙을 검증하는 과정을 하나의 체인으로 표현할 수 있습니다. 예를 들어, 사용자 입력을 받아 데이터베이스 모델로 변환하는 과정에서 각 단계의 실패를 자연스럽게 처리합니다.

명령형 if-else 체인과 달리, 함수형 체이닝은 데이터 흐름을 선형적으로 표현하여 "무엇을" 하는지 명확히 보여줍니다. JavaScript의 Promise 체인이나 Java의 Optional/Stream과 유사하지만, Rust는 타입 시스템으로 에러 처리를 강제합니다.

Result 체이닝의 핵심은 성공 경로만 작성하면서도 에러 안정성을 유지하는 것입니다. 중간에 에러가 발생하면 자동으로 체인이 중단되고 최종 Result는 Err가 됩니다.

코드 예제

fn parse_and_validate_age(input: &str) -> Result<u32, String> {
    input
        .trim()  // 공백 제거
        .parse::<u32>()  // 문자열을 숫자로 파싱
        .map_err(|_| "유효한 숫자를 입력하세요".to_string())  // 에러 변환
        .and_then(|age| {  // 파싱 성공 시 검증
            if age == 0 {
                Err("나이는 0보다 커야 합니다".to_string())
            } else if age > 150 {
                Err("나이가 너무 큽니다".to_string())
            } else {
                Ok(age)  // 검증 통과
            }
        })
        .map(|age| {  // 최종 변환
            println!("유효한 나이: {}", age);
            age
        })
}

설명

Result 체이닝은 함수형 프로그래밍의 모나드 패턴을 Rust에 적용한 것으로, 에러 처리를 암시적으로 유지하면서 데이터 변환을 명시적으로 표현합니다. input.trim().parse::<u32>()는 Result<u32, ParseIntError>를 반환합니다.

map_err는 에러 타입을 변환하는 메서드로, ParseIntError를 사용자 친화적인 String 메시지로 바꿉니다. 이는 에러 타입을 통일하여 함수의 반환 타입과 맞추는 역할을 합니다.

and_then은 flatMap과 같은 역할로, 클로저가 Result를 반환할 때 사용합니다. 파싱에 성공하면 age 값을 받아 비즈니스 규칙(0보다 크고 150 이하)을 검증합니다.

검증 실패 시 Err를 반환하면 체인이 중단되고, 성공하면 Ok(age)로 계속 진행됩니다. and_then의 중요한 점은 Result<Result<T, E>, E>가 아닌 Result<T, E>를 반환하여 중첩을 제거한다는 것입니다.

마지막 map은 성공 케이스만 변환합니다. 여기서는 부수 효과(side effect)로 로그를 출력하고, age를 그대로 반환합니다.

만약 이전 단계에서 에러가 발생했다면 이 map은 실행되지 않고 에러가 그대로 전파됩니다. 전체 체인은 "문자열을 파싱하고, 에러 메시지를 개선하고, 비즈니스 규칙을 검증하고, 결과를 로깅한다"는 흐름을 선형적으로 표현합니다.

각 단계는 독립적이면서도 에러 처리는 자동으로 연결됩니다. 여러분이 Result 체이닝을 사용하면 복잡한 변환 로직을 읽기 쉬운 파이프라인으로 만들 수 있습니다.

특히 여러 데이터 소스를 결합하거나, 순차적인 검증을 수행하거나, 변환 단계가 많은 경우에 코드가 훨씬 깔끔해집니다. 또한 각 단계를 별도 함수로 추출하기 쉬워 테스트와 재사용도 용이합니다.

실전 팁

💡 map은 Ok 값을 변환할 때, and_then은 Result를 반환하는 함수를 체이닝할 때 사용합니다. 구분이 헷갈리면 반환 타입을 확인하세요

💡 map_err로 에러 타입을 변환하여 함수 전체에서 일관된 에러 타입을 유지하면 ? 연산자와 함께 사용하기 좋습니다

💡 체인이 길어지면 가독성이 떨어질 수 있으므로, 의미 있는 단위로 변수에 할당하거나 별도 함수로 분리하세요

💡 or_else는 에러를 복구하거나 대체 값을 제공할 때 유용합니다. 예: result.or_else(|_| fallback())

💡 ok(), err() 메서드로 Result를 Option으로 변환하여 에러를 무시하고 싶을 때 명시적으로 표현할 수 있습니다


7. 여러 Result 결합하기 - 복수의 에러 가능한 작업 처리

시작하며

여러분이 여러 개의 파일을 읽거나, 여러 API를 동시에 호출하거나, 폼의 여러 필드를 검증해야 할 때, 각각이 독립적으로 실패할 수 있습니다. 한 작업이 실패하면 전체를 중단해야 할 때도 있고, 모든 작업의 결과를 모아서 한꺼번에 처리해야 할 때도 있습니다.

단순히 ? 연산자로 처리하면 첫 번째 에러에서 멈추지만, 때로는 모든 에러를 수집하여 사용자에게 보여주고 싶을 수 있습니다.

예를 들어, 회원가입 폼에서 이메일, 비밀번호, 전화번호 검증이 모두 실패했다면, 각각의 에러 메시지를 모두 표시해야 합니다. Rust는 Result의 컬렉션을 다루는 다양한 패턴을 제공합니다.

collect(), partition(), iter().find() 등을 활용하여 여러 Result를 효과적으로 결합할 수 있습니다.

개요

간단히 말해서, 여러 Result를 결합한다는 것은 독립적인 에러 가능한 작업들을 하나의 Result로 통합하거나, 성공과 실패를 분리하는 것을 의미합니다. 실무에서 병렬로 실행되는 작업의 결과를 통합하거나, 폼 검증처럼 모든 필드를 검사하여 에러를 수집하거나, 일괄 처리에서 성공과 실패를 분리할 때 이 패턴을 사용합니다.

예를 들어, 여러 마이크로서비스를 호출하여 대시보드를 구성할 때, 하나의 서비스 실패로 전체가 실패하지 않고 부분 성공을 허용하거나, 또는 모든 서비스가 성공해야만 진행하도록 할 수 있습니다. 다른 언어의 Promise.all (모두 성공) 또는 Promise.allSettled (모든 결과 수집)와 유사하지만, Rust는 타입 시스템으로 에러 처리를 강제하고 더 세밀한 제어를 제공합니다.

여러 Result 결합의 핵심은 실패 전략(fail-fast vs. collect-all)을 명확히 하는 것입니다.

collect()는 첫 에러에서 중단하고, partition()은 성공과 실패를 모두 수집합니다.

코드 예제

fn validate_user_input(
    email: &str,
    password: &str,
    age: &str,
) -> Result<(String, String, u32), Vec<String>> {
    // 각 필드를 검증하고 에러를 수집
    let mut errors = Vec::new();

    let validated_email = validate_email(email)
        .map_err(|e| errors.push(e));
    let validated_password = validate_password(password)
        .map_err(|e| errors.push(e));
    let validated_age = validate_age(age)
        .map_err(|e| errors.push(e));

    // 에러가 있으면 모든 에러 반환
    if !errors.is_empty() {
        return Err(errors);
    }

    // 모두 성공하면 값 추출 (unwrap은 안전함)
    Ok((
        validated_email.unwrap(),
        validated_password.unwrap(),
        validated_age.unwrap(),
    ))
}

// 또는 collect()로 간결하게
fn process_files(paths: &[&str]) -> Result<Vec<String>, std::io::Error> {
    // 각 파일을 읽고, 하나라도 실패하면 즉시 에러 반환
    paths.iter()
        .map(|path| std::fs::read_to_string(path))
        .collect()  // Vec<Result<String, E>>를 Result<Vec<String>, E>로 변환
}

설명

여러 Result를 다루는 방식은 비즈니스 요구사항에 따라 달라집니다. 때로는 빠른 실패(fail-fast)가 적절하고, 때로는 모든 정보를 수집해야 합니다.

validate_user_input 함수는 이메일, 비밀번호, 나이를 각각 검증하며, 모든 에러를 수집하는 패턴을 보여줍니다. 각 검증 함수가 Err를 반환하면 map_err로 에러 벡터에 추가합니다.

세 검증이 모두 끝난 후 errors가 비어있지 않으면 Err(errors)로 모든 에러를 반환하고, 비어있으면 Ok로 검증된 값들을 반환합니다. 이는 사용자에게 "이메일 형식이 잘못되었고, 비밀번호가 너무 짧고, 나이가 유효하지 않습니다"처럼 모든 문제를 한 번에 알려줄 수 있습니다.

process_files 함수는 반대로 fail-fast 전략을 사용합니다. paths.iter().map()은 각 파일을 읽는 Result들의 이터레이터를 생성합니다.

collect::<Result<Vec<_>, _>>()는 Vec<Result<T, E>>를 Result<Vec<T>, E>로 변환하는데, 첫 번째 Err를 만나면 즉시 전체가 Err가 됩니다. 모든 파일 읽기가 성공해야만 Ok(Vec<String>)을 얻습니다.

이는 "모든 파일이 필수적이므로 하나라도 없으면 작업을 계속할 수 없다"는 정책을 표현합니다. partition()을 사용하면 더 세밀한 제어가 가능합니다.

results.into_iter().partition(Result::is_ok)는 성공과 실패를 각각의 벡터로 분리합니다. 이후 성공한 작업만 처리하고, 실패한 작업은 로깅하거나 재시도 큐에 넣는 등 유연한 처리가 가능합니다.

여러분이 여러 Result를 다룰 때는 먼저 비즈니스 요구사항을 명확히 하세요. 하나의 실패가 전체 작업을 무의미하게 만드는가?

아니면 부분 성공이 가치가 있는가? 사용자에게 모든 에러를 보여줘야 하는가?

이런 질문에 답하면 적절한 패턴이 자연스럽게 결정됩니다.

실전 팁

💡 Iterator의 collect()는 Result를 "뒤집을" 수 있어 매우 강력합니다. Vec<Result<T, E>>를 Result<Vec<T>, E>로 자동 변환합니다

💡 모든 에러를 수집하려면 map_err로 에러를 Vec에 추가하거나, filter_map으로 Err만 추출하세요

💡 대규모 일괄 처리에서는 성공과 실패를 분리하여 성공한 것만 커밋하고, 실패는 별도로 처리하는 것이 효율적입니다

💡 try_fold()나 try_for_each() 같은 메서드를 사용하면 이터레이션 중 첫 에러에서 조기 종료할 수 있습니다

💡 병렬 처리 시 rayon 크레이트의 par_iter()와 collect()를 결합하면 여러 Result를 동시에 처리할 수 있습니다


8. Result와 Option의 상호 변환 - 에러와 없음의 경계

시작하며

여러분이 해시맵에서 값을 찾거나, 리스트에서 조건에 맞는 항목을 검색할 때, Option<T>를 반환받습니다. None은 "값이 없다"는 의미이지 에러는 아닙니다.

하지만 때로는 None을 에러로 취급하여 ? 연산자로 전파하고 싶을 때가 있습니다.

반대로 Result<T, E>를 받았는데 에러의 구체적인 내용은 중요하지 않고, 단순히 "성공했는지 여부"만 알고 싶을 때도 있습니다. 이럴 때 Result를 Option으로 변환하여 간단히 처리할 수 있습니다.

Rust는 Result와 Option 사이의 변환을 위한 다양한 메서드를 제공합니다. ok(), ok_or(), transpose() 등을 사용하여 두 타입을 자유롭게 오갈 수 있습니다.

개요

간단히 말해서, Result와 Option은 서로 변환 가능한 타입으로, ok()는 Result를 Option으로, ok_or()는 Option을 Result로 변환합니다. 실무에서 데이터 조회와 에러 처리를 결합할 때 이 변환을 자주 사용합니다.

데이터베이스 쿼리가 Option을 반환하는데 None을 "리소스 없음" 에러로 처리하고 싶거나, 설정 파일에서 선택적 항목을 읽을 때 None은 기본값을 사용하지만 파일 읽기 실패는 에러로 처리하는 경우입니다. 예를 들어, 캐시에서 데이터를 찾고(Option), 없으면 데이터베이스에서 읽고(Result), 둘 다 실패하면 에러를 반환하는 패턴에서 유용합니다.

nullable 값과 예외를 구분하지 않는 언어와 달리, Rust는 "값이 없음"과 "작업 실패"를 타입 레벨에서 구분합니다. 하지만 실용적으로 이 둘을 변환하는 방법도 제공합니다.

Result-Option 변환의 핵심은 컨텍스트에 따라 None을 에러로 해석하거나, 에러를 무시하고 성공 여부만 추출하는 유연성입니다.

코드 예제

use std::collections::HashMap;

fn get_user_age(users: &HashMap<String, u32>, name: &str) -> Result<u32, String> {
    // Option을 Result로 변환: None을 에러로 취급
    users.get(name)
        .copied()  // &u32를 u32로 복사
        .ok_or_else(|| format!("사용자 '{}'를 찾을 수 없습니다", name))
}

fn try_parse_config(input: &str) -> Option<u32> {
    // Result를 Option으로 변환: 에러를 무시하고 성공 여부만 관심
    input.parse::<u32>().ok()
}

// Result<Option<T>>를 Option<Result<T>>로 변환
fn load_optional_feature() -> Result<Option<String>, std::io::Error> {
    // 파일이 없어도 되지만, 있는데 읽기 실패하면 에러
    match std::fs::read_to_string("optional_config.txt") {
        Ok(content) => Ok(Some(content)),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e),
    }
}

설명

Result와 Option은 Rust의 양대 에러 처리 타입이지만, 의미가 다릅니다. Option은 "값이 있거나 없음"을 표현하고, Result는 "성공 또는 실패"를 표현합니다.

하지만 실무에서는 이 둘의 경계가 모호할 때가 많습니다. get_user_age 함수는 HashMap에서 값을 조회하는데, get()이 Option<&u32>를 반환합니다.

copied()로 참조를 값으로 변환하고, ok_or_else로 None을 Err로 변환합니다. ok_or_else는 클로저를 받아 에러 메시지를 동적으로 생성할 수 있어, 사용자 이름을 포함한 구체적인 에러를 만들 수 있습니다.

이는 "데이터가 없다"는 것을 "에러 상황"으로 해석하는 것입니다. try_parse_config는 반대 방향입니다.

parse()가 Result를 반환하는데, ok() 메서드로 Ok는 Some으로, Err는 None으로 변환합니다. 여기서는 파싱이 실패한 이유는 중요하지 않고, 단순히 "성공했는지 여부"만 알면 됩니다.

이는 선택적 설정 값을 읽을 때 유용합니다. load_optional_feature는 더 복잡한 케이스입니다.

파일이 없는 것(None)과 읽기 실패(Err)를 구분해야 합니다. match로 NotFound 에러는 Ok(None)으로 처리하고, 다른 에러는 Err로 전파합니다.

이는 "선택적 기능이므로 없어도 되지만, 파일이 있는데 손상되었다면 에러"라는 정책을 표현합니다. 여러분이 Result와 Option을 변환할 때는 "None/Err가 의미하는 바"를 명확히 하세요.

None이 정상적인 상태인가, 아니면 예외적인 상황인가? 에러의 구체적인 내용이 중요한가, 아니면 성공 여부만 중요한가?

이런 질문에 답하면 적절한 변환 메서드를 선택할 수 있습니다.

실전 팁

💡 ok_or는 고정된 에러 값을, ok_or_else는 클로저로 생성한 에러를 사용합니다. 에러 생성 비용이 크면 ok_or_else를 사용하세요

💡 transpose()는 Result<Option<T>, E>를 Option<Result<T, E>>로 변환하여 중첩된 타입을 다루기 쉽게 만듭니다

💡 Option의 ? 연산자를 사용하려면 함수가 Option을 반환해야 하고, Result의 ?를 사용하려면 Result를 반환해야 합니다. 혼용 시 적절히 변환하세요

💡 err()는 Result의 Err 값만 Option으로 추출합니다. 에러 로깅 시 유용합니다

💡 None을 여러 종류의 에러로 변환해야 한다면 ok_or_else를 사용하거나, 커스텀 에러 타입의 NotFound 변형을 정의하세요


#Rust#Result#match#ErrorHandling#PatternMatching#프로그래밍언어

댓글 (0)

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