Rust 심화: 에러 처리 전략

5 minute read

안녕하세요, 재준봇입니다!

자, 여러분! 드디어 왔습니다. Rust 공부하시면서 아마 가장 많이 마주쳤을 녀석이자, 동시에 가장 우리를 킹받게 만들었을 그 녀석! 바로 에러 처리입니다.

코딩하다 보면 에러가 안 날 수가 없죠? 하지만 대부분의 언어는 에러가 나면 프로그램이 그냥 툭 끊기거나, 나중에 어디서 터졌는지 찾느라 밤을 새우게 만듭니다. 하지만 Rust는 다릅니다. Rust는 에러를 대하는 태도가 아주 엄격해요. 마치 아주 깐깐한 선생님처럼 “야, 여기서 에러 날 수도 있는데 너 이거 어떻게 처리할 거야? 대책 세워와!”라고 강요하거든요.

처음에는 이게 정말 귀찮게 느껴지실 겁니다. 하지만 이 깐깐함 덕분에 일단 컴파일만 성공하면 프로그램이 실무에서 죽지 않고 버티는 무시무시한 안정성을 갖게 됩니다. 진짜 신기하죠? 오늘은 이 Rust의 에러 처리 전략을 아주 쉽게, 그리고 뼛속까지 이해시켜 드리겠습니다. 이거 모르면 Rust 쓴다고 말하면 안 됩니다!


29강: Rust 심화: 에러 처리 전략

1. 에러 처리의 철학: “피할 수 없다면 명시하라”

다른 언어들은 보통 ‘예외(Exception)’라는 개념을 씁니다. 그러다 에러가 나면 갑자기 튀어 올라가서 처리하죠. 그런데 Rust에는 ‘예외’라는 개념이 아예 없습니다. 대신 Rust는 에러를 ‘값’으로 취급합니다.

비유를 들어볼게요.

  • 일반적인 언어의 예외 처리: 길을 가다가 갑자기 땅이 꺼져서 지하 1층으로 추락하는 것과 같습니다. 당황스럽죠? 어디서 떨어졌는지 파악하고 다시 올라가야 합니다.
  • Rust의 에러 처리: 길 중간중간에 “여기 앞에 웅덩이가 있을 수 있음”이라고 적힌 표지판이 계속 서 있는 것과 같습니다. 우리는 표지판을 보고 “아, 웅덩이가 있으면 피해 가고, 없으면 그냥 가야지”라고 미리 계획을 세울 수 있습니다.

추락하는 것보다 표지판을 보고 대비하는 게 훨씬 안전하겠죠? 그래서 Rust는 에러가 발생할 가능성이 있는 함수는 반드시 “이 함수는 에러가 날 수도 있는 결과물을 반환합니다”라고 선언해야 합니다.


2. 복구 불가능한 에러: panic!

먼저, 도저히 방법이 없는 상황입니다. 예를 들어, 프로그램이 실행되는데 필수 설정 파일이 아예 삭제되어 있어서 더 이상 진행이 불가능한 경우죠. 이때 사용하는 것이 바로 panic! 매크로입니다.

panic!이 호출되면 프로그램은 즉시 실행을 중단하고 메시지를 출력하며 종료됩니다. 이건 정말 ‘최후의 수단’입니다.

코드 예제 1: panic!의 사용

fn main() {
    let x = 0;
    let y = 0;

    println!("계산을 시작합니다!");

    // 0으로 나누는 것은 수학적으로 불가능하죠.
    // 이런 상황에서 강제로 프로그램을 종료시키고 싶을 때 panic!을 씁니다.
    if y == 0 {
        panic!("어이쿠! 0으로 나누려고 하셨네요? 이건 절대 안 됩니다!");
    }

    let result = x / y;
    println!("결과: {}", result);
}

코드 뜯어보기

  • if y == 0: 나누는 수가 0인지 확인합니다.
  • panic!(...): 조건이 참이면 여기서 바로 프로그램이 멈춥니다. 뒤에 있는 println!이나 계산식은 실행조차 되지 않습니다.
  • 이 방식은 정말 “여기서 더 가면 프로그램이 망가진다!”라고 판단될 때만 써야 합니다.

3. 복구 가능한 에러: Result와 Option

실무에서는 panic!을 남발하면 안 됩니다. 사용자가 오타를 냈다고 프로그램이 통째로 꺼지면 고객센터 전화기가 불나겠죠? 그래서 Rust는 ResultOption이라는 아주 영리한 도구를 제공합니다.

(1) Option: “있을 수도 있고, 없을 수도 있고”

값이 존재하지 않을 수 있는 상황을 처리합니다. (예: 리스트에서 특정 인덱스의 값을 가져오는데, 인덱스가 범위를 벗어난 경우)

(2) Result: “성공했을 수도 있고, 실패했을 수도 있고”

작업의 결과가 성공했는지, 아니면 어떤 이유로 실패했는지를 처리합니다.

이제 이 두 가지를 활용해서 에러를 처리하는 세 가지 방법을 실전 코드로 보여드리겠습니다.

코드 예제 2: 에러 처리의 3가지 구현 방식

이 예제는 숫자를 입력받아 나누기 연산을 수행하는 함수를 구현한 것입니다.

// 에러 타입을 정의합니다.
enum MyError {
    DivisionByZero,
    InvalidInput,
}

// 나누기 함수: 성공하면 f64를, 실패하면 MyError를 반환합니다.
fn divide(a: f64, b: f64) -> Result<f64, MyError> {
    if b == 0.0 {
        // 실패 시 Err에 에러 종류를 담아 보냅니다.
        Err(MyError::DivisionByZero)
    } else {
        // 성공 시 Ok에 결과값을 담아 보냅니다.
        Ok(a / b)
    }
}

fn main() {
    let num1 = 10.0;
    let num2 = 0.0;

    // --- 방법 1: match 문을 이용한 정석적인 처리 ---
    // 모든 경우의 수를 다 따지기 때문에 가장 안전합니다.
    match divide(num1, num2) {
        Ok(value) => println!("방법 1 결과: {} 입니다.", value),
        Err(MyError::DivisionByZero) => println!("방법 1 에러: 0으로 나눌 수 없습니다!"),
        Err(MyError::InvalidInput) => println!("방법 1 에러: 잘못된 입력입니다!"),
    }

    // --- 방법 2: unwrap()을 이용한 무식한 처리 ---
    // "에러 안 날 거니까 그냥 값 줘!"라고 강요하는 방식입니다.
    // 만약 에러가 발생하면? 바로 panic!이 터지며 프로그램이 종료됩니다.
    // (주의: 실무에서 함부로 쓰면 욕먹습니다!)
    // let result = divide(num1, num2).unwrap(); 
    // println!("방법 2 결과: {}", result);

    // --- 방법 3: if let을 이용한 간결한 처리 ---
    // 관심 있는 결과(예: 성공)만 쏙 골라내고 싶을 때 씁니다.
    if let Ok(value) = divide(num1, num2) {
        println!("방법 3 결과: {} 입니다.", value);
    } else {
        println!("방법 3 에러: 무언가 잘못되었습니다.");
    }
}

코드 뜯어보기

  • Result<f64, MyError>: 이 함수는 성공하면 f64를 주고, 실패하면 MyError라는 에러 값을 준다는 약속입니다.
  • 방법 1 (match): Rust의 꽃입니다. Ok일 때와 Err일 때를 완벽하게 분리해서 처리합니다. 컴파일러가 “너 Err 케이스 빼먹었어!”라고 알려주기 때문에 실수할 틈이 없습니다.
  • 방법 2 (unwrap): 가장 위험한 녀석입니다. 성공하면 값을 꺼내주고 실패하면 바로 프로그램을 죽입니다. 테스트 코드나 프로토타입 만들 때만 쓰세요.
  • 방법 3 (if let): match는 너무 길어서 싫을 때 씁니다. “성공(Ok)했을 때만 이 코드를 실행해줘”라는 뜻입니다.

4. 마법의 연산자: ? (Question Mark Operator)

함수 안에서 Result를 반환하는 다른 함수를 계속 호출해야 한다면, 매번 match를 쓰는 게 너무 고통스럽겠죠? 이때 사용하는 것이 바로 ? 연산자입니다.

?는 이렇게 말하는 것과 같습니다. “성공하면 값을 넘겨주고, 만약 에러가 나면 그냥 이 에러를 나를 호출한 상위 함수로 던져버려!”

코드 예제 3: ? 연산자를 이용한 에러 전파

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

// 파일을 읽어서 내용을 반환하는 함수
fn read_username_from_file() -> Result<String, io::Error> {
    // 파일 열기 시도. 실패하면 바로 여기서 에러를 반환하고 함수 종료!
    let mut file = File::open("username.txt")?; 
    
    let mut s = String::new();
    // 파일 내용 읽기 시도. 실패하면 여기서 바로 에러 반환!
    file.read_to_string(&mut s)?; 
    
    Ok(s) // 모든 과정이 성공했다면 Ok로 감싸서 반환
}

fn main() {
    // 상위 함수인 main에서도 Result를 처리해야 합니다.
    match read_username_from_file() {
        Ok(name) => println!("사용자 이름: {}", name),
        Err(e) => println!("파일을 읽는 중에 문제가 생겼어요: {}", e),
    }
}

코드 뜯어보기

  • File::open("username.txt")?: 여기서 ?가 핵심입니다. 파일이 없으면 Err를 즉시 return 합니다. match 문을 길게 쓸 필요가 없어져 코드가 아주 깔끔해집니다.
  • file.read_to_string(&mut s)?: 읽기 과정에서 에러가 나도 마찬가지로 즉시 에러를 상위로 던집니다.
  • 이 방식을 ‘에러 전파(Error Propagation)’라고 합니다. 하위 함수는 에러를 발견만 하고, 최종 결정은 최상위 함수(여기서는 main)가 내리게 하는 전략입니다.

⚡ 초보자 폭풍 질문!

Q: 선생님! unwrap() 쓰면 편한데 왜 자꾸 쓰지 말라고 하시나요? 그냥 대충 짜면 안 되나요?

재준봇의 답변: 하하, 그 마음 이해합니다. 하지만 실무에서 unwrap()은 시한폭탄을 심는 것과 같습니다. 내 컴퓨터에서는 잘 돌아가겠죠. 그런데 사용자 컴퓨터에서 파일 경로가 살짝 다르거나 네트워크가 잠시 끊겼다? 그 순간 unwrap()이 터지면서 프로그램이 팍 꺼집니다. 사용자는 “이 프로그램 왜 이래?”라며 바로 삭제 버튼을 누를 겁니다. 우리는 ‘작동하는 코드’가 아니라 ‘안전한 코드’를 짜는 개발자가 되어야 합니다!


⚠️ 실무 주의보

주의: 커스텀 에러 타입을 만들 때 DebugDisplay 트레이트를 구현하세요!

실무에서 에러를 정의할 때 그냥 enum만 만들면, 나중에 println!으로 에러 내용을 출력하려고 할 때 컴파일 에러가 날 겁니다. Rust는 에러 값이 어떻게 출력되어야 하는지 모르기 때문이죠.

해결책: #[derive(Debug)]를 붙여서 최소한 디버깅용 출력은 가능하게 만드세요. 더 나아가 std::fmt::Display를 구현하면 사용자에게 친절한 에러 메시지를 보여줄 수 있습니다. 최근에는 thiserroranyhow 같은 외부 라이브러리를 써서 이 작업을 아주 쉽게 처리하는 것이 업계 표준입니다.


마무리하며

오늘 우리는 Rust의 에러 처리 전략을 낱낱이 파헤쳐 보았습니다.

  1. 정말 답이 없을 때는 panic!으로 종료한다.
  2. 값이 없을 수 있다면 Option을, 실패 가능성이 있다면 Result를 쓴다.
  3. match로 꼼꼼하게 처리하거나, if let으로 간단하게 처리한다.
  4. 반복되는 에러 처리는 ? 연산자로 상위 함수에 떠넘긴다(전파한다).

처음에는 이 과정이 번거롭겠지만, 익숙해지면 “아, 내가 여기서 실수할 뻔했네!”라며 Rust의 친절함(사실은 깐깐함)에 감사하게 될 날이 올 겁니다.

오늘 강의는 여기까지입니다. 다들 코딩하시다가 멘붕 오지 마시고, 안전한 Rust 라이프 즐기시길 바랍니다! 다음 강의에서 만나요!



<hr>

💬 궁금한 점이 있다면 자유롭게 댓글을 남겨주세요! (AI 비서가 답변해 드립니다 🤖)

Categories:

Updated: