null 대신 Option, 예외 대신 Result
Rust에는 null이 없습니다. 정확히는 안전한 코드(unsafe 블록 밖)에서는 null을 만들 방법 자체가 없습니다. 그리고 예외(exception)도 없습니다. try/catch에 해당하는 문법이 언어에 없습니다. 두 개념 모두 다른 방식으로 대체되어 있는데, 그 대체재가 Option과 Result입니다.
null 대신 Option
값이 없을 수도 있는 상황을 Rust는 이렇게 표현합니다.
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("Alice"))
} else {
None
}
}
fn main() {
let user = find_user(1);
match user {
Some(name) => println!("찾음: {name}"),
None => println!("없음"),
}
}Option<T>는 Some(T) 아니면 None, 둘 중 하나입니다. 자바에서 null을 반환하면 호출하는 쪽이 null 체크를 깜빡해도 컴파일은 됩니다 — 그러다 NullPointerException으로 터지는 게 바로 그 유명한 "십억 달러짜리 실수"입니다(이 표현을 만든 사람이 바로 null 참조를 발명한 토니 호어 본인입니다). Option을 쓰면 얘기가 다릅니다. Option<String>에서 바로 .len()을 호출하는 코드는 애초에 컴파일되지 않습니다. Some인지 None인지 먼저 분기해야만 안에 든 값을 꺼낼 수 있습니다. 체크를 깜빡하는 게 아니라, 체크 없이는 코드를 쓸 방법이 없습니다.
match 대신 자주 쓰는 짧은 방법들도 있습니다.
fn main() {
let user = find_user(2);
let name = user.unwrap_or(String::from("손님"));
println!("{name}");
}unwrap_or는 None일 때 기본값을 씁니다. unwrap()도 있지만 이건 None일 때 프로그램을 즉시 패닉시킵니다 — 프로토타입이나 "여기서 절대 None이 아님을 내가 보장한다"는 확신이 있을 때가 아니면 실무 코드에서는 피하는 게 좋습니다.
예외 대신 Result
실패할 수 있는 연산은 Result<T, E>로 표현합니다.
use std::num::ParseIntError;
fn parse_port(input: &str) -> Result<u16, ParseIntError> {
input.parse::<u16>()
}
fn main() {
match parse_port("8080") {
Ok(port) => println!("포트: {port}"),
Err(e) => println!("파싱 실패: {e}"),
}
match parse_port("not-a-number") {
Ok(port) => println!("포트: {port}"),
Err(e) => println!("파싱 실패: {e}"),
}
}Result<T, E>는 Ok(T) 아니면 Err(E)입니다. 자바나 파이썬의 예외는 호출 스택을 타고 어디까지든 전파될 수 있고, 함수 시그니처만 봐서는 그 함수가 뭘 던질 수 있는지 알 방법이 없습니다(자바의 checked exception이 이걸 시도했지만 실무에서는 대체로 기피 대상이 됐습니다). Result는 실패 가능성을 반환 타입에 그대로 새깁니다. parse_port의 시그니처만 보고 "아, 이 함수는 실패할 수 있고, 실패하면 ParseIntError를 받는구나"를 바로 알 수 있습니다.
? 연산자 — 에러를 그대로 위로 넘기기
에러 처리를 함수마다 match로 다 쓰면 코드가 금방 계단처럼 깊어집니다. 그래서 실무에서는 ? 연산자를 훨씬 많이 씁니다.
use std::num::ParseIntError;
fn double_port(input: &str) -> Result<u16, ParseIntError> {
let port = input.parse::<u16>()?;
Ok(port * 2)
}
fn main() {
println!("{:?}", double_port("8080"));
println!("{:?}", double_port("abc"));
}input.parse::<u16>()?는 이렇게 동작합니다 — 결과가 Ok(v)면 v를 꺼내서 다음 줄로 넘어가고, Err(e)면 그 즉시 double_port 함수 자체를 Err(e)로 반환하며 종료합니다. match로 풀어 쓴 것과 결과는 같지만 코드가 훨씬 짧습니다. 대신 ?를 쓰려면 그걸 쓰는 함수의 반환 타입도 Result(또는 Option)여야 합니다 — ?가 실패 시 조기 반환하는 대상이 바로 그 함수이기 때문입니다.
여러 종류의 에러를 한 함수에서 ?로 다루려면 에러 타입을 통일해야 하는데, 이때 Box<dyn std::error::Error>를 쓰거나 thiserror, anyhow 같은 크레이트를 쓰는 게 실무 관행입니다. 이 책에서는 다루지 않지만, Result와 ?에 익숙해진 다음 바로 찾아볼 만한 주제입니다.
unwrap()을 볼 때마다 물어야 할 것
코드를 리뷰하다 .unwrap()이 보이면 저는 항상 같은 질문을 던집니다 — "이게 실패하면 프로그램이 그냥 죽어도 되는가?" 커맨드라인 도구에서 필수 인자가 없으면 즉시 죽는 게 맞을 수 있습니다. 하지만 서버 코드에서 요청 하나 처리하다 .unwrap()이 실패하면 서버 전체가 죽거나(패닉이 스레드를 타고 전파되면) 최소한 그 요청을 보낸 사용자는 아무 설명 없이 연결이 끊깁니다. Option과 Result 자체는 안전하지만, .unwrap()은 그 안전장치를 그 자리에서 걷어내는 행위라는 걸 기억해두면 좋습니다.