- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — null도 예외도 없는 세계
- 열거형은 대수적 데이터 타입
- Option — null을 타입으로
- match — 빠짐없이(exhaustively) 해체하기
- if let과 let else — 한 경우만 다룰 때
- Result — 실패를 값으로
- ? 연산자 — 에러 전파의 간결함
- unwrap과 expect — 언제 패닉해도 되는가
- thiserror와 anyhow — 실전 에러 타입 설계
- 손으로 익히기
- 마치며
- 참고 자료
들어가며 — null도 예외도 없는 세계
토니 호어는 null 참조를 발명한 것을 "10억 달러짜리 실수"라고 불렀습니다. 대부분의 주류 언어는 아직도 그 실수를 안고 삽니다. 값이 있을 수도, 없을 수도 있는데 타입은 그걸 구분하지 못하니까요. 그래서 NullPointerException이 프로덕션에서 우리를 기다립니다.
예외도 비슷한 문제가 있습니다. 함수 시그니처만 봐서는 이 함수가 어떤 예외를 던지는지, 아니 던지기는 하는지 알 수 없습니다(자바의 checked exception이 이걸 고치려다 대부분 미움만 샀죠). 에러 처리가 타입 시스템 바깥에 있으니, 컴파일러가 "이 에러 처리 안 했는데?"라고 짚어 줄 수가 없습니다.
Rust는 이 둘을 아예 없앴습니다. null 대신 Option, 예외 대신 Result. 둘 다 평범한 **열거형(enum)**이고, 값의 "없음"과 "실패 가능성"을 타입에 담습니다. 그리고 그 열거형을 안전하게 여는 도구가 **패턴 매칭(pattern matching)**입니다. 컴파일러가 "이 경우를 처리 안 했다"고 잡아 주기 때문에, 잊어버리는 게 불가능해집니다.
열거형은 대수적 데이터 타입
다른 언어의 enum은 대개 이름 붙은 정수 상수 집합입니다. Rust의 enum은 훨씬 강력합니다. 각 변형(variant)이 서로 다른 종류의 데이터를 품을 수 있습니다.
enum Shape {
Circle { radius: f64 }, // 이름 있는 필드
Rectangle { w: f64, h: f64 }, // 두 필드
Point, // 데이터 없음
}
Shape 값은 원이거나, 사각형이거나, 점이거나 — 정확히 셋 중 하나입니다. 이런 "A이거나 B이거나 C"인 타입을 타입 이론에서는 합 타입(sum type) 혹은 **태그된 유니온(tagged union)**이라 부릅니다. 구조체(struct)가 "A이고 B이고 C"인 곱 타입(product type)이라면, enum은 그 대칭입니다. 둘을 합쳐 **대수적 데이터 타입(algebraic data type, ADT)**이라 하고, 이게 Rust 데이터 모델링의 핵심입니다.
핵심은 각 변형이 자기만의 데이터를 가진다는 점입니다. C 스타일 enum처럼 "종류"만 나타내는 게 아니라, 그 종류에 딱 맞는 데이터를 함께 담습니다. 이게 뒤에 나올 Option과 Result를 가능하게 합니다.
Option — null을 타입으로
Option<T>는 "T 값이 있거나, 없거나"를 표현하는 표준 라이브러리 enum입니다. 정의는 놀랄 만큼 단순합니다.
enum Option<T> {
Some(T), // 값이 있음, 그 값은 T
None, // 값이 없음
}
이게 null을 대체합니다. 값이 없을 수 있는 곳에는 T가 아니라 Option<T>를 씁니다. 그러면 컴파일러가 강제로 "없는 경우"를 처리하게 만듭니다.
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("Alice"))
} else {
None
}
}
fn main() {
let user = find_user(1);
// user는 Option<String> — 바로 문자열처럼 못 씀
// println!("{}", user.len()); // 컴파일 에러! Option에는 len이 없음
}
null과의 결정적 차이는 이것입니다. null은 어떤 참조 타입에나 몰래 들어올 수 있어서 "이 값이 null일 수 있나?"를 코드만 봐선 알 수 없습니다. 반면 Option<T>는 타입에 명시적으로 드러나 있고, 안의 T를 꺼내려면 반드시 None인 경우를 먼저 처리해야 합니다. "없을 수 있음"이 타입에 적혀 있고, 컴파일러가 그 처리를 강제합니다. 십억 달러짜리 실수가 타입 시스템 안으로 들어온 겁니다.
match — 빠짐없이(exhaustively) 해체하기
Option 안의 값을 꺼내는 정석은 match입니다. match는 값을 패턴들과 대조해서 맞는 가지를 실행하는데, Rust의 결정적 안전장치는 match가 빠짐없어야(exhaustive) 한다는 것입니다. 가능한 모든 경우를 다루지 않으면 컴파일이 안 됩니다.
fn main() {
let user = find_user(1);
match user {
Some(name) => println!("찾음: {name}"), // Some이면 안의 name을 바인딩
None => println!("사용자 없음"), // None인 경우
}
}
Some(name)에서 name은 Some 안에 들어 있던 String을 꺼내 바인딩한 것입니다. 이렇게 패턴이 값의 구조를 해체하면서 동시에 내부 값을 변수로 뽑아내는 걸 **구조 분해(destructuring)**라 합니다.
만약 None 가지를 빼먹으면 이렇게 됩니다.
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:4:11
|
4 | match user {
| ^^^^ pattern `None` not covered
|
= help: ensure that all possible cases are being handled
이 exhaustiveness 검사가 조용한 위력입니다. 나중에 enum에 변형을 하나 추가하면, 그 enum을 다루는 모든 match가 컴파일 에러를 냅니다. "새 경우를 처리하라"고 컴파일러가 전수 조사를 해 주는 겁니다. 이건 리팩터링 안전망으로서 엄청난 가치가 있습니다. 새 상태를 추가했는데 처리를 깜빡한 곳이 프로덕션에서 터지는 일이 원천 봉쇄됩니다.
모든 경우를 일일이 쓰기 싫으면 와일드카드 _로 "나머지 전부"를 잡을 수 있습니다. 다만 _를 남발하면 exhaustiveness의 이점이 사라지니, 정말 무관심한 나머지에만 쓰는 게 좋습니다.
fn describe(n: i32) -> &'static str {
match n {
0 => "영",
1 | 2 | 3 => "작은 수", // 여러 패턴을 | 로
4..=9 => "한 자리", // 범위 패턴
_ => "그 외", // 나머지 전부
}
}
if let과 let else — 한 경우만 다룰 때
match가 모든 경우를 요구하는 게 과할 때가 있습니다. "Some일 때만 뭔가 하고 None이면 아무것도 안 함" 같은 경우죠. 이럴 때 if let이 간결합니다.
fn main() {
let config = find_user(1);
if let Some(name) = config {
println!("설정된 사용자: {name}"); // Some일 때만 실행
}
// None이면 그냥 넘어감 (else 생략 가능)
}
if let Some(name) = config는 "config가 Some 패턴에 맞으면 name을 바인딩하고 블록을 실행하라"는 뜻입니다. match의 한 가지만 떼어 쓰는 문법 설탕입니다. else도 붙일 수 있습니다.
반대 상황도 흔합니다. "값이 있어야 정상이고, 없으면 여기서 일찍 빠져나간다." 이건 let else가 우아합니다(Rust 1.65부터).
fn greet(id: u32) -> String {
let Some(name) = find_user(id) else {
return String::from("손님"); // None이면 여기서 함수 종료
};
// 이 아래로는 name이 String으로 확정되어 그냥 쓸 수 있음
format!("환영합니다, {name}!")
}
let else의 미덕은 성공 경로를 들여쓰기 밖으로 빼낸다는 것입니다. if let으로 성공을 감싸면 코드가 오른쪽으로 계속 밀려나는데(화살표 코드), let else는 실패를 먼저 처리하고 빠져나가서, 나머지 본문이 평평하게 유지됩니다. "가드 절(guard clause)"을 Rust 타입 시스템과 결합한 형태죠.
Result — 실패를 값으로
이제 에러 처리입니다. Result<T, E>는 "성공하면 T, 실패하면 E"를 담는 enum입니다.
enum Result<T, E> {
Ok(T), // 성공, 결과는 T
Err(E), // 실패, 에러는 E
}
예외와의 핵심 차이는 이것입니다. 예외는 함수 밖으로 던져져서 어딘가에서 잡히거나 프로그램을 죽입니다. 반면 Result는 그냥 반환값입니다. 실패 가능성이 함수 시그니처에 -> Result<T, E>로 적혀 있고, 호출자는 그 값을 받아 Ok인지 Err인지 처리해야 합니다. 에러 처리가 제어 흐름의 숨은 옆길이 아니라, 눈에 보이는 정상적인 값의 흐름이 됩니다.
use std::num::ParseIntError;
fn parse(s: &str) -> Result<i64, ParseIntError> {
s.parse::<i64>() // parse는 Result를 반환
}
fn main() {
match parse("42") {
Ok(n) => println!("숫자: {n}"),
Err(e) => println!("파싱 실패: {e}"),
}
}
Rust는 예외가 없으므로, 실패할 수 있는 표준 라이브러리 함수(파일 열기, 문자열 파싱, 네트워크 요청)는 거의 다 Result를 반환합니다. 그리고 Result는 #[must_use]로 표시되어 있어서, 반환된 Result를 무시하면 컴파일러가 경고합니다. 에러를 조용히 삼키는 게 어렵게 설계된 것입니다.
? 연산자 — 에러 전파의 간결함
Result를 일일이 match로 풀면 코드가 금세 지저분해집니다. 특히 실패할 수 있는 연산을 여러 개 이어 붙일 때요. 그래서 ? 연산자가 있습니다.
?는 Result(또는 Option)에 붙여서 이렇게 동작합니다. Ok(v)면 안의 v를 꺼내 식의 값으로 쓰고, Err(e)면 그 Err를 현재 함수에서 즉시 return 한다. 즉 "성공하면 계속, 실패하면 이 에러를 위로 던진다"를 한 글자로 표현합니다.
use std::fs;
use std::io;
// ? 없이 — 장황함
fn read_len_verbose(path: &str) -> Result<usize, io::Error> {
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e), // 실패를 손으로 전파
};
Ok(contents.len())
}
// ? 사용 — 같은 동작, 훨씬 간결
fn read_len(path: &str) -> Result<usize, io::Error> {
let contents = fs::read_to_string(path)?; // 실패하면 자동으로 Err 반환
Ok(contents.len())
}
두 함수는 정확히 같은 일을 합니다. ?가 그 match-후-조기반환 패턴을 대신해 줄 뿐입니다. 여러 연산을 이을 때 진가가 드러납니다.
fn process(path: &str) -> Result<i64, Box<dyn std::error::Error>> {
let text = fs::read_to_string(path)?; // io::Error 전파
let first_line = text.lines().next().unwrap_or("");
let number: i64 = first_line.trim().parse()?; // ParseIntError 전파
Ok(number * 2)
}
여기서 ?가 하나 더 해 주는 일이 있습니다. 에러 타입 변환입니다. read_to_string은 io::Error를, parse는 ParseIntError를 내는데, 함수 반환 타입은 Box<dyn std::error::Error>입니다. ?는 From 트레잇을 통해 각 에러를 반환 타입으로 자동 변환합니다. 서로 다른 에러들을 하나의 반환 타입으로 매끄럽게 모아 주는 거죠. 이게 다음 절의 에러 타입 설계와 이어집니다.
주의: ?는 반환 타입이 Result(또는 Option)인 함수 안에서만 쓸 수 있습니다. 반환 타입이 맞아야 Err를 return할 수 있으니까요.
unwrap과 expect — 언제 패닉해도 되는가
Option이나 Result에서 값을 강제로 꺼내는 .unwrap()과 .expect()도 있습니다. Ok/Some이면 값을 주고, Err/None이면 **패닉(panic)**하여 프로그램을 종료합니다.
let n: i64 = "42".parse().unwrap(); // Ok(42) → 42
let m: i64 = "abc".parse().unwrap(); // Err → 패닉!
let k: i64 = "abc".parse()
.expect("설정값은 정수여야 합니다"); // 패닉 + 이 메시지
unwrap은 편하지만 위험합니다. 실패가 진짜로 불가능한 곳(방금 검증한 값 등)이나, 프로토타입·예제·테스트에서는 괜찮습니다. 하지만 프로덕션 코드의 정상 경로에서 남용하면 안 됩니다. 사용자 입력이나 파일·네트워크처럼 실패할 수 있는 것에 unwrap을 걸면, 그건 예외를 안 잡고 방치하는 것과 다를 바 없습니다. 그럴 땐 ?로 전파하거나 match로 다루세요. expect는 최소한 "왜 여기서 실패가 불가능하다고 봤는지"를 메시지로 남기니, unwrap보다 expect가 낫습니다.
정리하면 panic!(그리고 unwrap)은 회복 불가능한 버그 상황용이고, Result는 회복 가능한 예상된 실패용입니다. "파일이 없을 수 있다"는 회복 가능(→ Result), "배열 인덱스가 음수다"는 프로그램 로직 버그(→ 패닉)입니다.
thiserror와 anyhow — 실전 에러 타입 설계
표준 라이브러리만으로도 되지만, 실전 프로젝트는 대개 두 크레이트를 씁니다. 역할이 명확히 갈립니다.
thiserror는 라이브러리용입니다. 라이브러리는 호출자가 에러를 종류별로 구분해 처리할 수 있도록, 구체적인 에러 타입을 노출해야 합니다. thiserror는 그 커스텀 에러 enum을 보일러플레이트 없이 만들어 줍니다.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("설정 파일을 읽을 수 없음")]
Io(#[from] std::io::Error), // #[from]으로 io::Error 자동 변환
#[error("잘못된 포트 번호: {0}")]
InvalidPort(u16), // 데이터를 품는 변형
#[error("필수 키 누락: {key}")]
MissingKey { key: String },
}
#[derive(Error)]가 std::error::Error 트레잇 구현을, #[error("...")]가 사람이 읽을 메시지(Display)를 자동 생성합니다. #[from]을 붙인 변형은 그 에러 타입에서 자동 변환을 지원하므로, 함수 안에서 ? 하나로 io::Error가 ConfigError::Io로 바뀝니다. 라이브러리 사용자는 이 enum을 match해서 "IO 문제인지, 포트가 잘못됐는지"를 구분해 대응할 수 있습니다.
anyhow는 애플리케이션용입니다. 최종 애플리케이션(CLI, 서버)의 상당수 지점에서는 에러를 종류별로 구분할 필요 없이 "실패했고, 이런 맥락이었다"만 알면 됩니다. anyhow::Error는 어떤 에러든 담는 단일 타입이라, 다양한 에러를 ?로 자유롭게 섞어 전파할 수 있습니다.
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> { // anyhow::Result
let text = std::fs::read_to_string(path)
.with_context(|| format!("설정 파일 열기 실패: {path}"))?; // 맥락 추가
let port_line = text.lines().next()
.context("파일이 비어 있음")?;
Ok(port_line.to_string())
}
anyhow의 강점은 .context()입니다. 에러에 "무엇을 하려다 실패했는지" 맥락을 층층이 쌓아 주므로, 최종 에러 메시지가 "파일 없음" 같은 저수준 메시지가 아니라 "설정 파일 열기 실패: /etc/app.conf → 파일 없음" 같은 추적 가능한 사슬이 됩니다.
정리하면: 라이브러리를 만든다면 thiserror로 구체적 에러 타입을 노출하고, 애플리케이션을 만든다면 anyhow로 맥락을 붙여 편하게 전파하세요. 이 두 크레이트의 조합이 오늘날 Rust 에러 처리의 사실상 표준입니다.
손으로 익히기
match의 exhaustiveness나 ?의 전파 동작은 직접 컴파일해 보며 에러를 만나 봐야 확실히 이해됩니다. Rust 학습 랩에서 Option·Result 해체와 ? 연산자를 다양한 시나리오로 실험해 보면, "컴파일러가 나 대신 경우를 빠뜨리지 않게 지켜본다"는 감각이 손에 붙습니다.
마치며
Rust의 에러 처리 철학은 한 문장으로 요약됩니다. 실패는 값이다. null과 예외라는 두 개의 숨은 옆문을 닫고, "없음"과 "실패"를 Option·Result라는 평범한 값으로 타입에 담습니다. 그 값을 여는 열쇠가 패턴 매칭이고, 컴파일러의 exhaustiveness 검사가 "이 경우를 잊었다"를 대신 잡아 줍니다.
그 대가로 처음엔 코드가 조금 장황해 보일 수 있습니다. 하지만 ? 연산자가 그 장황함의 대부분을 걷어 가고, 남는 것은 어디서 무엇이 실패할 수 있는지가 코드에 정직하게 드러난 프로그램입니다. 프로덕션 새벽에 NullPointerException 스택트레이스를 들여다보는 대신, 컴파일 타임에 "이 경우 처리 안 했어요"를 듣는 쪽을 택하는 것 — 그게 이 설계의 거래입니다.
참고 자료
- The Rust Programming Language, 6장 "Enums and Pattern Matching": https://doc.rust-lang.org/book/ch06-00-enums.html
- The Rust Programming Language, 9장 "Error Handling": https://doc.rust-lang.org/book/ch09-00-error-handling.html
- thiserror 크레이트: https://docs.rs/thiserror/
- anyhow 크레이트: https://docs.rs/anyhow/
- Rust by Example — Error handling: https://doc.rust-lang.org/rust-by-example/error.html