본문으로 건너뛰기
Previous
Next
Rust 에러 처리 | Result, Option, ? 연산자

Rust 에러 처리 | Result, Option, ? 연산자

Rust 에러 처리 | Result, Option, ? 연산자

이 글의 핵심

Rust 에러 처리: Result, Option, ? 연산자. Result·Option.

시리즈 안내

#04 | 📋 전체 목차 | 이전: #03 구조체 · 다음: #05 트레이트


들어가며

Rust에는 try/catch 스타일의 예외가 없습니다. 실패 가능한 연산은 Result로, 값이 없을 수 있음은 Option으로 타입에 적어 두고 처리합니다. 복구 가능한 오류를 값으로 돌려주는 방식이라, 제어 흐름이 추적하기 쉽습니다.

Rust와의 첫 만남

“빌려주기 검사기(Borrow Checker)와 싸우는 게 프로그래밍의 반”이라는 농담이 있을 정도로, Rust는 처음에 정말 어렵습니다. 저도 첫 프로젝트에서 컴파일러 에러와 씨름하며 “이게 정말 생산성이 높은 언어인가?” 의심했습니다. 하지만 몇 주간 고생 끝에 컴파일이 통과된 코드는 런타임 에러가 거의 없다는 걸 깨달았습니다. C++에서는 세그멘테이션 폴트가 프로덕션에서 터지는 악몽을 자주 겪었는데, Rust는 그런 걱정이 없습니다. 컴파일러가 미리 잡아주니까요. 특히 멀티스레드 코드를 작성할 때 이 차이가 극명합니다. C++에서는 데이터 레이스를 찾느라 디버거와 씨름했지만, Rust는 컴파일 단계에서 “이 코드는 스레드 안전하지 않아”라고 알려줍니다. 처음엔 답답했지만, 지금은 이 엄격함이 감사합니다.

1. Result<T, E>

기본 Result

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("0으로 나눌 수 없음"))
    } else {
        Ok(a / b)
    }
}
fn main() {
    match divide(10, 2) {
        Ok(result) => println!("결과: {}", result),
        Err(e) => println!("에러: {}", e),
    }
}

Result 메서드

fn main() {
    let result = divide(10, 2);
    
    // unwrap: Ok면 값, Err면 panic
    let value = result.unwrap();  // 5
    
    // unwrap_or: Err면 기본값
    let value2 = divide(10, 0).unwrap_or(0);  // 0
    
    // unwrap_or_else: Err면 클로저 실행
    let value3 = divide(10, 0).unwrap_or_else(|e| {
        println!("에러: {}", e);
    });
    
    // expect: unwrap + 커스텀 메시지
    let value4 = divide(10, 2).expect("나눗셈 실패");
}

Result 체이닝

fn parse_and_double(s: &str) -> Result<i32, String> {
    s.parse::<i32>()
        .map(|n| n * 2)
        .map_err(|e| format!("파싱 실패: {}", e))
}
fn main() {
    match parse_and_double("42") {
        Ok(n) => println!("결과: {}", n),  // 84
        Err(e) => println!("{}", e),
    }
}

2. Option

기본 Option

fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("홍길동"))
    } else {
        None
    }
}
fn main() {
    match find_user(1) {
        Some(name) => println!("사용자: {}", name),
        None => println!("사용자 없음"),
    }
}

Option 메서드

fn main() {
    let x: Option<i32> = Some(5);
    
    // map: 값이 있으면 변환
    let y = x.map(|v| v * 2);  // Some(10)
    
    // and_then: 값이 있으면 함수 실행
    let z = x.and_then(|v| Some(v + 1));  // Some(6)
    
    // filter: 조건 만족하면 Some, 아니면 None
    let w = x.filter(|v| v % 2 == 0);  // None (5는 홀수)
    
    // unwrap_or: None이면 기본값
    let value = x.unwrap_or(0);  // 5
    
    // is_some, is_none
    if x.is_some() {
        println!("값 있음");
    }
}

Option과 Result 변환

fn main() {
    let opt: Option<i32> = Some(5);
    
    // Option → Result
    let res: Result<i32, &str> = opt.ok_or("값 없음");
    
    let res2: Result<i32, String> = opt.ok_or_else(|| {
        String::from("값이 없습니다")
    });
}

3. ? 연산자

기본 사용

? 연산자는 에러를 자동으로 전파하는 간결한 문법입니다:

use std::fs;
use std::io;
// ? 연산자 사용 (간결)
fn read_file() -> Result<String, io::Error> {
    // fs::read_to_string(): Result<String, io::Error> 반환
    let content = fs::read_to_string("file.txt")?;
    // ? 연산자의 동작:
    // - Ok(content)면: content를 추출하여 변수에 할당
    // - Err(e)면: 즉시 함수를 종료하고 Err(e)를 반환 (조기 반환)
    
    Ok(content)
    // 성공 시 content를 Ok로 감싸서 반환
}
// ? 없이 작성하면 (장황)
fn read_file_verbose() -> Result<String, io::Error> {
    // match로 명시적으로 처리
    match fs::read_to_string("file.txt") {
        Ok(content) => {
            // 성공 시: content를 Ok로 감싸서 반환
            Ok(content)
        },
        Err(e) => {
            // 실패 시: 에러를 Err로 감싸서 반환
            Err(e)
        },
    }
    // ? 연산자 한 줄이 이 match 전체를 대체
}
// 사용 예제
fn main() {
    match read_file() {
        Ok(content) => {
            println!("파일 내용:");
            println!("{}", content);
        },
        Err(e) => {
            println!("파일 읽기 실패: {}", e);
        },
    }
}

? 연산자의 장점:

  1. 간결성: match 블록 대신 한 줄로 처리
  2. 가독성: 에러 처리 로직이 명확
  3. 체이닝: 여러 ? 연산자를 연속으로 사용 가능
  4. 타입 안전: 컴파일 타임에 에러 타입 검증

여러 ? 연산자

여러 단계의 에러 처리를 간결하게 체이닝할 수 있습니다:

use std::fs;
use std::io;
fn read_and_parse() -> Result<i32, Box<dyn std::error::Error>> {
    // Box<dyn std::error::Error>: 모든 에러 타입을 담을 수 있는 박스
    // dyn: 동적 디스패치 (런타임에 타입 결정)
    
    // 1단계: 파일 읽기
    let content = fs::read_to_string("number.txt")?;
    // Result<String, io::Error> 반환
    // ? : Err이면 즉시 반환, Ok면 String 추출
    
    // 2단계: 공백 제거 및 파싱
    let number: i32 = content.trim().parse()?;
    // trim(): 앞뒤 공백 제거
    // parse::<i32>(): 문자열 → i32 변환
    // Result<i32, ParseIntError> 반환
    // ? : Err이면 즉시 반환, Ok면 i32 추출
    
    // 3단계: 결과 계산
    Ok(number * 2)
    // 모든 단계가 성공하면 최종 결과 반환
}
// 사용 예제
fn main() {
    match read_and_parse() {
        Ok(n) => println!("결과: {}", n),
        Err(e) => println!("에러: {}", e),
    }
}
// ? 없이 작성하면 (매우 장황)
fn read_and_parse_verbose() -> Result<i32, Box<dyn std::error::Error>> {
    // 1단계: 파일 읽기
    let content = match fs::read_to_string("number.txt") {
        Ok(c) => c,
        Err(e) => return Err(Box::new(e)),
    };
    
    // 2단계: 파싱
    let number: i32 = match content.trim().parse() {
        Ok(n) => n,
        Err(e) => return Err(Box::new(e)),
    };
    
    // 3단계: 결과 계산
    Ok(number * 2)
}

? 연산자의 동작 원리:

// 이 코드:
let x = some_function()?;
// 는 다음과 같이 확장됨:
let x = match some_function() {
    Ok(value) => value,
    Err(e) => return Err(e.into()),  // 에러 타입 변환 후 반환
};

실전 예시: 파일 처리 파이프라인:

use std::fs;
use std::io;
fn process_file(path: &str) -> Result<Vec<i32>, Box<dyn std::error::Error>> {
    // 파일 읽기 → 줄 분리 → 파싱 → 필터링
    let content = fs::read_to_string(path)?;  // 1. 파일 읽기
    
    let numbers: Result<Vec<i32>, _> = content
        .lines()                              // 2. 줄 분리
        .map(|line| line.trim().parse())      // 3. 각 줄 파싱
        .collect();                           // 4. 결과 수집
    
    let numbers = numbers?;  // 파싱 에러 전파
    
    // 5. 양수만 필터링
    let positive: Vec<i32> = numbers.into_iter()
        .filter(|&n| n > 0)
        .collect();
    
    Ok(positive)
}

Option에서 ? 사용

fn get_first_char(s: &str) -> Option<char> {
    s.chars().next()
}
fn get_first_uppercase(s: &str) -> Option<char> {
    let first = get_first_char(s)?;
    if first.is_uppercase() {
        Some(first)
    } else {
        None
    }
}

4. panic!

panic! 사용

fn main() {
    panic!("프로그램 중단!");
}

unwrap과 expect

fn main() {
    let result: Result<i32, &str> = Err("에러 발생");
    
    // unwrap: Err면 panic
    // let value = result.unwrap();  // panic!
    
    // expect: panic 메시지 커스텀
    // let value = result.expect("값을 가져올 수 없음");  // panic!
}

5. 커스텀 에러 타입

기본 커스텀 에러

use std::fmt;
#[derive(Debug)]
enum MathError {
    DivisionByZero,
    NegativeNumber,
}
impl fmt::Display for MathError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            MathError::DivisionByZero => write!(f, "0으로 나눌 수 없음"),
            MathError::NegativeNumber => write!(f, "음수는 허용되지 않음"),
        }
    }
}
impl std::error::Error for MathError {}
fn divide(a: i32, b: i32) -> Result<i32, MathError> {
    if b == 0 {
        Err(MathError::DivisionByZero)
    } else {
        Ok(a / b)
    }
}
fn sqrt(n: i32) -> Result<f64, MathError> {
    if n < 0 {
        Err(MathError::NegativeNumber)
    } else {
        Ok((n as f64).sqrt())
    }
}

6. 실전 예제

예제: 파일 읽기 및 파싱

use std::fs;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}
impl From<io::Error> for AppError {
    fn from(err: io::Error) -> Self {
        AppError::Io(err)
    }
}
impl From<ParseIntError> for AppError {
    fn from(err: ParseIntError) -> Self {
        AppError::Parse(err)
    }
}
fn read_number_from_file(path: &str) -> Result<i32, AppError> {
    let content = fs::read_to_string(path)?;
    let number = content.trim().parse::<i32>()?;
    Ok(number)
}
fn main() {
    match read_number_from_file("number.txt") {
        Ok(n) => println!("숫자: {}", n),
        Err(AppError::Io(e)) => println!("파일 에러: {}", e),
        Err(AppError::Parse(e)) => println!("파싱 에러: {}", e),
    }
}

예제: 사용자 입력 검증

#[derive(Debug)]
enum ValidationError {
    TooShort,
    TooLong,
    InvalidChar,
}
fn validate_username(name: &str) -> Result<(), ValidationError> {
    if name.len() < 3 {
        return Err(ValidationError::TooShort);
    }
    if name.len() > 20 {
        return Err(ValidationError::TooLong);
    }
    if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
        return Err(ValidationError::InvalidChar);
    }
    Ok(())
}
fn main() {
    let usernames = vec!["ab", "valid_user123", "invalid-user", "a".repeat(25)];
    
    for name in usernames {
        match validate_username(&name) {
            Ok(_) => println!("✓ '{}' 유효함", name),
            Err(ValidationError::TooShort) => println!("✗ '{}' 너무 짧음", name),
            Err(ValidationError::TooLong) => println!("✗ '{}' 너무 김", name),
            Err(ValidationError::InvalidChar) => println!("✗ '{}' 잘못된 문자", name),
        }
    }
}

7. 에러 처리 패턴

패턴 1: 조기 반환

fn process_data(data: &str) -> Result<i32, String> {
    if data.is_empty() {
        return Err(String::from("데이터 비어있음"));
    }
    
    let number = match data.parse::<i32>() {
        Ok(n) => n,
        Err(_) => return Err(String::from("파싱 실패")),
    };
    
    if number < 0 {
        return Err(String::from("음수 불가"));
    }
    
    Ok(number * 2)
}

패턴 2: 에러 변환

use std::fs;
use std::io;
fn read_and_process() -> Result<String, Box<dyn std::error::Error>> {
    let content = fs::read_to_string("data.txt")?;
    let number: i32 = content.trim().parse()?;
    Ok(format!("처리된 값: {}", number * 2))
}

정리

핵심 요약

  1. Result<T, E>: 성공(Ok) 또는 실패(Err)
  2. Option: 값 있음(Some) 또는 없음(None)
  3. ? 연산자: 에러 자동 전파
  4. panic!: 복구 불가능한 에러
  5. 커스텀 에러: enum + Display + Error 트레이트

다음 단계


관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「Rust 에러 처리 | Result, Option, ? 연산자」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「Rust 에러 처리 | Result, Option, ? 연산자」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Rust 에러 처리: Result, Option, ? 연산자. Result·Option로 흐름을 잡고 원리·코드·실무 적용을 한글로 정리합니다. Rust·에러처리·Result 중심으로 설명합니다. Start now. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Rust, 에러처리, Result, Option 등으로 검색하시면 이 글이 도움이 됩니다.