DaleSchool

Result와 에러 전파: ?의 마법

중급20분

학습 목표

  • Result<T, E>의 Ok/Err를 처리할 수 있다
  • ? 연산자로 에러를 전파할 수 있다
  • 사용자 정의 에러 타입을 만들 수 있다
  • unwrap() 대신 ?를 사용하도록 코드를 리팩토링할 수 있다

동작하는 코드

모듈 16에서 Resultunwrap()을 배웠어요. 이번에는 unwrap() 없이 에러를 우아하게 처리하는 방법을 배울 거예요.

파일을 읽는 프로그램을 만들어볼게요.

use std::fs;

fn main() {
    // unwrap() 버전 — 파일이 없으면 panic!
    // let content = fs::read_to_string("hello.txt").unwrap();

    // match 버전 — 안전하지만 장황해요
    match fs::read_to_string("hello.txt") {
        Ok(content) => println!("파일 내용: {content}"),
        Err(e) => println!("파일을 읽을 수 없어요: {e}"),
    }
}

파일이 없어도 프로그램이 죽지 않아요! 하지만 match가 매번 들어가면 코드가 복잡해지겠죠?

직접 해보기

  1. hello.txt 파일을 만들고 내용을 넣은 후 실행해보세요.
  2. 파일 이름을 존재하지 않는 이름으로 바꿔서 실행해보세요.
  3. unwrap() 버전의 주석을 풀고 파일 없이 실행해보세요 — 어떤 에러 메시지가 나오나요?

? 연산자 — match를 한 글자로

?Result를 반환하는 함수 안에서 사용할 수 있어요. Ok면 값을 꺼내고, Err면 즉시 에러를 반환해요.

먼저 ? 없이 match로 쓰면 이렇게 돼요.

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

fn read_greeting() -> Result<String, io::Error> {
    let content = match fs::read_to_string("hello.txt") {
        Ok(text) => text,
        Err(e) => return Err(e),  // 에러면 즉시 반환
    };
    Ok(format!("인사말: {content}"))
}

?를 쓰면 이걸 한 글자로 줄일 수 있어요!

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

fn read_greeting() -> Result<String, io::Error> {
    let content = fs::read_to_string("hello.txt")?;
    Ok(format!("인사말: {content}"))
}

fn main() {
    match read_greeting() {
        Ok(msg) => println!("{msg}"),
        Err(e) => println!("에러: {e}"),
    }
}

fs::read_to_string("hello.txt")? — 이 ?가 하는 일은 정확히 위의 match와 같아요. "성공하면 값을 꺼내고, 실패하면 에러를 호출자에게 전파해라."

? 체이닝

?는 여러 번 연속으로 쓸 수 있어요. 에러가 발생하면 그 시점에서 즉시 함수를 빠져나가요.

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

fn read_number_from_file() -> Result<i32, Box<dyn std::error::Error>> {
    let content = fs::read_to_string("number.txt")?;  // 파일 읽기 실패?
    let number = content.trim().parse::<i32>()?;       // 숫자 변환 실패?
    Ok(number)
}

fn main() {
    match read_number_from_file() {
        Ok(n) => println!("숫자: {n}"),
        Err(e) => println!("에러: {e}"),
    }
}

match로 썼다면 중첩된 match 지옥이 됐을 거예요. ? 덕분에 "해피 패스"만 깔끔하게 볼 수 있어요.

main()에서 Result 반환하기

?Result를 반환하는 함수 안에서만 쓸 수 있어요. main()에서도 Result를 반환하면 ?를 쓸 수 있어요!

use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let content = fs::read_to_string("hello.txt")?;
    println!("{content}");
    Ok(())
}

Box<dyn std::error::Error>는 "어떤 에러든 담을 수 있는 상자"예요. 지금은 이렇게 외워두세요.

리팩토링 전/후

모듈 07의 숫자 맞추기 게임에서 unwrap()을 썼던 걸 기억하나요? ?로 어떻게 바뀌는지 보세요.

Before — unwrap() 투성이

use std::io;

fn main() {
    let mut input = String::new();
    io::stdin().read_line(&mut input).unwrap();
    let number: i32 = input.trim().parse().unwrap();
    println!("입력한 숫자: {number}");
}

unwrap()이 두 곳이에요. 입력 실패든 변환 실패든 프로그램이 그냥 죽어요.

After — ? 연산자

use std::io;

fn read_number() -> Result<i32, Box<dyn std::error::Error>> {
    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let number = input.trim().parse::<i32>()?;
    Ok(number)
}

fn main() {
    match read_number() {
        Ok(n) => println!("입력한 숫자: {n}"),
        Err(e) => println!("잘못된 입력: {e}"),
    }
}

unwrap()이 사라지고, 에러가 나도 프로그램이 죽지 않아요!

사용자 정의 에러

실제 프로젝트에서는 여러 종류의 에러가 섞여요. enum으로 에러 타입을 만들고 From 트레이트를 구현하면 ? 연산자와 함께 깔끔하게 쓸 수 있어요.

use std::fs;
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "파일 에러: {e}"),
            AppError::Parse(e) => write!(f, "변환 에러: {e}"),
        }
    }
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn read_config_port() -> Result<u16, AppError> {
    let content = fs::read_to_string("config.txt")?;  // io::Error → AppError
    let port = content.trim().parse::<u16>()?;         // ParseIntError → AppError
    Ok(port)
}

fn main() {
    match read_config_port() {
        Ok(port) => println!("포트: {port}"),
        Err(e) => println!("설정 에러: {e}"),
    }
}

From 트레이트를 구현해두면 ? 연산자가 자동으로 에러 타입을 변환해줘요. io::ErrorParseIntErrorAppError로 바뀌어서 하나의 Result로 반환돼요.

더 알아보기: map_err()

에러 타입을 변환할 때 From을 구현하지 않고 map_err()를 쓸 수도 있어요.

use std::fs;

fn read_port() -> Result<u16, String> {
    let content = fs::read_to_string("config.txt")
        .map_err(|e| format!("파일 읽기 실패: {e}"))?;
    let port = content.trim().parse::<u16>()
        .map_err(|e| format!("포트 변환 실패: {e}"))?;
    Ok(port)
}

간단한 경우에는 map_err()가 편하고, 여러 함수에서 같은 에러 타입을 쓸 때는 From이 나아요.

연습 1 (쉬움). 아래 함수에서 unwrap()?로 바꿔보세요.

use std::fs;

fn read_file() -> Result<String, std::io::Error> {
    let content = fs::read_to_string("data.txt").unwrap(); // ? 로 바꾸세요
    Ok(content)
}

fn main() {
    match read_file() {
        Ok(text) => println!("{text}"),
        Err(e) => println!("에러: {e}"),
    }
}

연습 2 (보통). 파일에서 두 숫자를 읽어 더하는 함수를 작성하세요. 파일이 없거나 숫자 변환에 실패하면 에러를 반환해야 해요.

use std::fs;

fn sum_from_file(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    // 여기를 완성하세요
    // 1. 파일을 읽으세요 (?)
    // 2. 줄 단위로 나누고 각 줄을 i32로 변환하세요 (?)
    // 3. 합계를 반환하세요
    // 힌트: lines()와 parse::<i32>()를 사용하세요
}

fn main() {
    match sum_from_file("numbers.txt") {
        Ok(total) => println!("합계: {total}"),
        Err(e) => println!("에러: {e}"),
    }
}

연습 3 (도전). 아래 코드에 AppError 열거형과 From 구현을 추가해서 ?로 에러를 처리하세요.

use std::fs;
use std::io;
use std::num::ParseIntError;

// 여기에 AppError enum을 정의하세요
// From<io::Error>와 From<ParseIntError>를 구현하세요

fn load_age(path: &str) -> Result<u8, AppError> {
    let content = fs::read_to_string(path)?;
    let age = content.trim().parse::<u8>()?;
    Ok(age)
}

fn main() {
    match load_age("age.txt") {
        Ok(age) => println!("나이: {age}"),
        Err(e) => println!("에러: {:?}", e),
    }
}

Q1. ? 연산자의 동작은?

  • A) 항상 panic을 일으킨다
  • B) Err를 무시하고 기본값을 반환한다
  • C) Ok이면 값을 꺼내고, Err이면 에러를 호출자에게 전파한다
  • D) Option만 처리할 수 있다

Q2. 다음 코드가 컴파일되지 않는 이유는?

fn main() {
    let content = std::fs::read_to_string("hello.txt")?;
    println!("{content}");
}
  • A) read_to_string은 존재하지 않는 함수다
  • B) main()Result를 반환하지 않아서 ?를 쓸 수 없다
  • C) contentmut이 아니다
  • D) std::fs를 import하지 않았다

Q3. From 트레이트를 에러 타입에 구현하면 어떤 장점이 있나요?

  • A) 프로그램이 더 빠르게 실행된다
  • B) ? 연산자가 에러 타입을 자동으로 변환해준다
  • C) 에러가 발생하지 않게 된다
  • D) unwrap()을 안전하게 만들어준다