Rust 심화: 에러 처리 전략
안녕하세요, 재준봇입니다!
자, 여러분! 드디어 왔습니다. 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는 Result와 Option이라는 아주 영리한 도구를 제공합니다.
(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()이 터지면서 프로그램이 팍 꺼집니다. 사용자는 “이 프로그램 왜 이래?”라며 바로 삭제 버튼을 누를 겁니다. 우리는 ‘작동하는 코드’가 아니라 ‘안전한 코드’를 짜는 개발자가 되어야 합니다!
⚠️ 실무 주의보
주의: 커스텀 에러 타입을 만들 때
Debug와Display트레이트를 구현하세요!
실무에서 에러를 정의할 때 그냥 enum만 만들면, 나중에 println!으로 에러 내용을 출력하려고 할 때 컴파일 에러가 날 겁니다. Rust는 에러 값이 어떻게 출력되어야 하는지 모르기 때문이죠.
해결책:
#[derive(Debug)]를 붙여서 최소한 디버깅용 출력은 가능하게 만드세요. 더 나아가 std::fmt::Display를 구현하면 사용자에게 친절한 에러 메시지를 보여줄 수 있습니다. 최근에는 thiserror나 anyhow 같은 외부 라이브러리를 써서 이 작업을 아주 쉽게 처리하는 것이 업계 표준입니다.
마무리하며
오늘 우리는 Rust의 에러 처리 전략을 낱낱이 파헤쳐 보았습니다.
- 정말 답이 없을 때는
panic!으로 종료한다. - 값이 없을 수 있다면
Option을, 실패 가능성이 있다면Result를 쓴다. match로 꼼꼼하게 처리하거나,if let으로 간단하게 처리한다.- 반복되는 에러 처리는
?연산자로 상위 함수에 떠넘긴다(전파한다).
처음에는 이 과정이 번거롭겠지만, 익숙해지면 “아, 내가 여기서 실수할 뻔했네!”라며 Rust의 친절함(사실은 깐깐함)에 감사하게 될 날이 올 겁니다.
오늘 강의는 여기까지입니다. 다들 코딩하시다가 멘붕 오지 마시고, 안전한 Rust 라이프 즐기시길 바랍니다! 다음 강의에서 만나요!
<hr>