7. Option, Result, 그리고 올바른 에러 처리
프로그래밍을 하다 보면 늘 두 가지 일이 생깁니다. 값이 없을 수도 있고, 작업이 실패할 수도 있습니다. 사용자를 찾았는데 없을 수도 있고, 파일을 읽으려 했는데 파일이 없을 수도 있죠. 어떤 언어는 이런 상황을 null이나 예외 뒤에 숨겨 둡니다. 하지만 Rust는 숨기지 않습니다. 없는 값은 Option으로, 실패 가능성은 Result로 드러냅니다.
처음에는 조금 번거롭게 느껴질 수 있습니다. 하지만 바로 그 점이 Rust의 큰 장점입니다. 문제를 나중에 런타임에서 놀라며 만나는 대신, 코드를 쓰는 순간부터 미리 생각하게 만들기 때문입니다. Rust의 안정성은 마법처럼 생기지 않습니다. 에러를 숨기지 않고 다루는 습관에서 시작됩니다.

에러를 감추지 않는 것이 왜 중요할까
Rust에서 함수의 반환 타입은 일종의 약속입니다. 그냥 String을 돌려주면 "문자열이 나온다"는 뜻이지만, Option<String>이면 "문자열이 있을 수도 없을 수도 있다"는 뜻이고, Result<String, E>면 "성공하면 문자열이 나오고, 실패하면 이유도 함께 나온다"는 뜻입니다.
표준 라이브러리에서 Result<T, E>는 성공 값 Ok(T)와 에러 값 Err(E)를 담는 열거형으로 정의되어 있으며, 복구 가능한 실패를 표현할 때 사용됩니다.1 반면 Rust의 에러 처리 규칙을 요약하면, 값의 부재가 자연스러운 상황에는 Option, 호출자가 실패를 다뤄야 하는 상황에는 Result, 복구 불가능한 버그나 테스트 상황에는 panic!이 어울립니다.2
이 차이를 이해하면 함수 시그니처를 읽는 눈이 달라집니다. "이 함수는 왜 실패할 수 있지?", "값이 없다는 게 정상 흐름일까?", "실패 이유를 caller가 알아야 할까?" 같은 질문이 자연스럽게 따라오기 시작합니다. 그게 바로 Rust다운 사고방식입니다.
Option<T>: 값이 없을 수도 있다는 사실을 표현하기
Option<T>는 아주 단순합니다.
enum Option<T> {
Some(T),
None,
}핵심은 None이 꼭 "에러"를 뜻하지는 않는다는 점입니다. 그냥 값이 없는 상태일 뿐입니다. 예를 들어 벡터에서 어떤 위치의 값을 가져올 때, 그 자리에 값이 없을 수도 있습니다. 이때는 "심각한 실패"라기보다 "없네?"에 가깝습니다.
fn main() {
let names = vec!["Alice", "Bob", "Carol"];
let second = names.get(1);
let tenth = names.get(9);
match second {
Some(name) => println!("두 번째 이름: {}", name),
None => println!("두 번째 이름이 없습니다."),
}
match tenth {
Some(name) => println!("열 번째 이름: {}", name),
None => println!("열 번째 이름이 없습니다."),
}
}get이 그냥 값을 바로 반환했다면, 없는 위치를 접근할 때 프로그램이 갑자기 멈추거나 이상한 동작을 할 수도 있습니다. 하지만 Option을 쓰면, 호출하는 쪽은 Some과 None을 모두 생각하게 됩니다.
Option을 쓰기 좋은 경우는 보통 다음과 같습니다.
값이 없어도 자연스러운 경우
실패 이유까지는 굳이 필요하지 않은 경우
호출자가
있음/없음만 알면 충분한 경우
예를 들어 회원 검색 함수에서 해당 회원이 없을 수 있다면 Option<User>가 잘 어울립니다.
fn find_user(id: u64) -> Option<String> {
if id == 42 {
Some(String::from("Alice"))
} else {
None
}
}Option 다루기: match, if let, 기본값
가장 정직한 방법은 match입니다. 모든 경우를 빠짐없이 다루게 해 주기 때문입니다.
match find_user(42) {
Some(name) => println!("찾았다: {}", name),
None => println!("사용자가 없습니다."),
}한쪽 경우만 중요하다면 if let이 더 간결합니다.
if let Some(name) = find_user(42) {
println!("환영합니다, {}님!", name);
}값이 없을 때 기본값을 넣고 싶다면 unwrap_or도 자주 씁니다.
let nickname = find_user(7).unwrap_or(String::from("Guest"));
println!("닉네임: {}", nickname);여기서 unwrap_or는 안전한 편입니다. None이어도 기본값을 돌려주니까요. 반면 unwrap()은 None이면 바로 패닉이 납니다. 초보자일수록 unwrap()을 습관처럼 쓰지 않는 편이 좋습니다. 포장지를 뜯었는데 선물이 아니라 함정일 수도 있으니까요.
Option에서 ? 쓰기
많은 사람이 ?는 Result 전용이라고 생각하지만, Option에서도 쓸 수 있습니다.3 이 기능을 이해하면 코드가 훨씬 읽기 좋아집니다.
fn first_char_of_first_word(text: &str) -> Option<char> {
let word = text.split_whitespace().next()?;
let ch = word.chars().next()?;
Some(ch)
}이 함수는 이렇게 읽으면 됩니다. 첫 번째 단어가 있으면 꺼내고, 없으면 None을 반환합니다. 그다음 첫 글자가 있으면 꺼내고, 없으면 역시 None을 반환합니다. 중첩된 match를 여러 겹 쓸 필요가 없어집니다.
이 예제는 Option이 단순한 "값 상자"가 아니라, 값이 비어 있을 수 있는 흐름을 자연스럽게 이어 주는 도구라는 점을 보여 줍니다.
Result<T, E>: 실패 이유까지 알려 주는 타입
이제 에러 처리의 주인공인 Result를 보겠습니다.
enum Result<T, E> {
Ok(T),
Err(E),
}성공하면 Ok(T), 실패하면 Err(E)입니다. Option과 다른 점은, 실패했을 때 이유를 함께 담는다는 것입니다. 파일 읽기라면 io::Error, 숫자 파싱이라면 ParseIntError 같은 타입이 에러 쪽에 들어갈 수 있습니다.1
예를 들어 0으로 나누는 상황을 안전하게 표현하면 이렇게 됩니다.
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("0으로 나눌 수 없습니다.".to_string())
} else {
Ok(a / b)
}
}호출하는 쪽에서는 성공과 실패를 나눠서 다룹니다.
match divide(10.0, 2.0) {
Ok(value) => println!("결과: {}", value),
Err(message) => println!("에러: {}", message),
}한 문장으로 정리하면 이렇습니다. Option은 "값이 있나 없나"를 말하고, Result는 "성공했나 실패했나, 왜 실패했나"를 말합니다.
Result를 무시하기 어렵게 만드는 Rust
Rust는 Result를 그냥 흘려보내는 일을 좋아하지 않습니다. 표준 라이브러리의 Result에는 must_use 속성이 붙어 있어서, 반환된 값을 무시하면 컴파일러가 경고를 줍니다.1
이건 생각보다 중요합니다. 파일 쓰기에 실패했는데 결과를 확인하지 않으면, 프로그램은 겉보기에는 잘 돌아가는데 실제로는 데이터가 저장되지 않았을 수도 있습니다. Rust는 이런 "조용한 실패"를 줄이기 위해 언어 차원에서 경고를 줍니다. 조금 잔소리 같아도, 미래의 나를 살리는 잔소리입니다.
match에서 시작하고, 메서드로 다듬기
처음에는 match로 처리 흐름을 똑바로 보는 습관이 좋습니다. 익숙해지면 map, map_err, and_then 같은 메서드로 더 간결하게 쓸 수 있습니다.1
fn parse_even(input: &str) -> Result<i32, String> {
input
.parse::<i32>()
.map_err(|_| "숫자를 입력해야 합니다.".to_string())
.and_then(|n| {
if n % 2 == 0 {
Ok(n)
} else {
Err("짝수만 허용합니다.".to_string())
}
})
}이 코드는 두 개의 실패 가능 단계를 이어 붙입니다. 먼저 문자열을 숫자로 바꾸고, 그다음 짝수인지 검사합니다. 첫 단계에서 실패하면 두 번째 단계는 아예 실행되지 않습니다.
메서드들의 감각만 기억해 두면 충분합니다.
map: 성공 값을 바꾼다map_err: 에러 값을 바꾼다and_then: 다음 실패 가능 작업으로 이어 간다
이름이 처음엔 어색하지만, 익숙해지면 "match를 짧게 접은 버전"처럼 느껴집니다.
? 연산자: 에러 전파를 가장 Rust답게 하는 방법
여러 함수가 연달아 Result를 반환하면, 매번 match를 쓰는 건 금방 피곤해집니다. 그래서 Rust에는 ? 연산자가 있습니다. ?는 Ok 안의 값을 꺼내 계속 진행하고, Err이면 현재 함수에서 즉시 반환합니다.1
use std::fs;
fn copy_text(from: &str, to: &str) -> std::io::Result<()> {
let text = fs::read_to_string(from)?;
fs::write(to, text)?;
Ok(())
}이 코드는 짧지만 의미가 분명합니다. 먼저 읽기를 시도하고, 실패하면 곧바로 에러를 돌려줍니다. 읽기에 성공했을 때만 쓰기를 시도합니다. 쓰기마저 성공하면 마지막에 Ok(())를 반환합니다.
중요한 점은 ?가 에러를 없애는 연산자가 아니라는 것입니다. 에러를 더 위로, 더 적절한 위치로 전달하는 연산자입니다. 숨기는 것이 아니라 정직하게 넘기는 것입니다.
Option과 Result를 서로 바꾸기
실전에서는 Option과 Result가 같이 등장합니다. 예를 들어 설정 파일에서 port= 줄을 찾는다고 해 봅시다. 그 줄은 있을 수도, 없을 수도 있으니 처음엔 Option<&str>가 자연스럽습니다. 그런데 포트가 꼭 필요한 설정이라면, 없다는 사실을 에러로 바꿔야 합니다.
fn find_port_line(text: &str) -> Option<&str> {
text.lines().find(|line| line.starts_with("port="))
}
fn required_port_line(text: &str) -> Result<&str, String> {
find_port_line(text).ok_or("port 설정이 없습니다.".to_string())
}ok_or는 Option을 Result로 바꿔 줍니다.3 값이 있으면 Ok, 없으면 우리가 넣은 에러를 담은 Err가 됩니다. 반대로 실패 이유가 더는 필요 없을 때는 Result에 .ok()를 붙여 Option으로 바꿀 수도 있습니다.1
하지만 여기에는 중요한 주의점이 있습니다. 에러 이유가 아직 필요한데 너무 빨리 .ok()로 바꾸면 정보가 사라집니다. 디버깅이 어려워지는 지름길입니다. 정말 이유가 필요 없을 때만 그렇게 바꾸는 습관을 들이세요.
panic!, unwrap(), expect()는 언제 써도 될까
Rust에서 모든 문제를 Result로만 처리하는 것은 아닙니다. 복구 불가능한 버그나 테스트 상황에서는 panic!이 더 어울릴 때도 있습니다.2 다만 초보자라면 기준을 이렇게 잡으면 안전합니다.
외부 요인 때문에 실패할 수 있으면
Result값이 없을 수만 있으면
Option애초에 일어나면 안 되는 버그라면
panic!
unwrap()은 Option의 Some, Result의 Ok를 바로 꺼냅니다. 대신 None이나 Err면 패닉이 납니다. expect()도 비슷하지만 메시지를 직접 붙일 수 있어서 어디서 왜 터졌는지 더 쉽게 알 수 있습니다.2
let port: u16 = "8080"
.parse()
.expect("하드코딩한 포트 문자열은 항상 숫자여야 합니다.");이 코드는 꽤 괜찮은 사용 예입니다. 문자열이 코드에 직접 적혀 있고, 우리가 통제하는 값이기 때문입니다. 실패한다면 사용자 탓이 아니라 프로그래머의 실수에 가깝습니다.
반대로 사용자 입력, 파일 I/O, 네트워크 요청 같은 바깥세상과 연결된 값에 unwrap()을 남발하면, 프로그램은 아주 작은 문제에도 쉽게 쓰러집니다. 실전 코드에서는 특히 조심해야 합니다.
커스텀 에러 타입 첫걸음
작은 예제에서는 String으로 에러를 반환해도 괜찮습니다. 하지만 프로그램이 커지면 "무슨 종류의 에러인지"를 타입으로 구분하는 편이 훨씬 좋습니다.
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
MissingPort,
InvalidPort(std::num::ParseIntError),
}
impl From<std::io::Error> for ConfigError {
fn from(err: std::io::Error) -> Self {
ConfigError::Io(err)
}
}
impl From<std::num::ParseIntError> for ConfigError {
fn from(err: std::num::ParseIntError) -> Self {
ConfigError::InvalidPort(err)
}
}이제 설정 파일에서 포트를 읽는 함수를 조금 더 제대로 만들 수 있습니다.
fn read_port(path: &str) -> Result<u16, ConfigError> {
let text = std::fs::read_to_string(path)?;
let port_text = text
.lines()
.find(|line| line.starts_with("port="))
.and_then(|line| line.split('=').nth(1))
.ok_or(ConfigError::MissingPort)?;
let port: u16 = port_text.trim().parse()?;
Ok(port)
}여기서 ?는 단순히 짧게 쓰기 위한 문법 설탕이 아닙니다. 파일 읽기에서 생긴 io::Error는 From 구현 덕분에 ConfigError::Io로 바뀌고, 숫자 파싱 실패는 ConfigError::InvalidPort로 바뀝니다.4 즉, 호출하는 쪽은 에러 종류별로 다른 대응을 할 수 있습니다.
파일이 없는 것과 포트 형식이 잘못된 것은 같은 실패가 아닙니다. 사용자에게 보여 줄 메시지도 달라야 합니다. 커스텀 에러 타입은 바로 그 차이를 코드에 새겨 넣는 도구입니다.
초보자가 자주 하는 실수
Option과 Result를 배우는 초반에는 비슷한 실수를 많이 합니다.
일단
unwrap()부터 쓰고 나중에 고치겠다고 미루기에러 이유가 필요한데도
Result를 너무 빨리Option으로 바꾸기반환된
Result를 확인하지 않고 흘려보내기
첫 번째 습관은 연습 코드에서는 편할 수 있지만, 실전 감각을 늦춥니다. 두 번째 습관은 나중에 "왜 실패했지?" 앞에서 발목을 잡습니다. 세 번째 습관은 Rust가 경고로 어느 정도 막아 주지만, 일부러 무시하는 코드가 늘면 결국 문제를 직접 키우게 됩니다.
어떤 타입을 고를지 빠르게 판단하는 법
처음에는 함수 반환 타입을 고르는 일이 어렵습니다. 그럴 때는 질문을 하나씩 던져 보세요.
값이 없어도 자연스러운가? 그렇다면 Option<T>가 어울립니다. 실패 이유를 호출자에게 알려 줘야 하는가? 그렇다면 Result<T, E>가 맞습니다. 이 상황은 애초에 일어나면 안 되는 버그인가? 그때는 panic!이나 assert!가 더 잘 맞을 수 있습니다.
이 판단이 익숙해지면 함수 설계가 훨씬 선명해집니다. 그리고 그 순간부터 Rust의 에러 처리는 귀찮은 규칙이 아니라, API를 정직하게 만드는 설계 도구처럼 보이기 시작합니다.
정리
이 장의 핵심은 하나입니다. Rust의 안정성은 에러를 없애는 데서 시작하지 않습니다. 에러를 숨기지 않고, 타입으로 드러내고, 처리하게 만드는 데서 시작됩니다.
Option은 없는 값을 솔직하게 말해 줍니다. Result는 실패 이유까지 담아서 돌려줍니다. ?는 그 실패를 적절한 위치로 전파해 줍니다. 그리고 unwrap()이나 panic!은 정말 확신이 있을 때만 써야 하는 도구입니다.
처음에는 손이 조금 더 갑니다. 하지만 그 몇 줄 덕분에 나중에 몇 시간을 절약하게 됩니다. Rust가 원하는 것은 완벽한 프로그래머가 아닙니다. 실패 가능성을 정직하게 다루는 프로그래머입니다. 그리고 바로 그 습관이, Rust 코드를 Rust답게 만듭니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.