
- 1. 서론: 왜 Rust에서 에러 처리는 특별할까?
- 2. Option
: 값이 없을 수도 있다는 명시적 표현 - 3. Result
: 에러를 값으로 다루는 방식 - 4. ? 연산자: 에러 전파를 단순하고 명료하게
- 5. Option과 Result를 함께 사용할 때의 패턴
- 6. 에러 처리 스타일: 패턴 조합과 체이닝 기법 실전 예제
- 7. 결론: Rust다운 에러 처리는 결국 읽기 쉬운 코드에서 시작된다
1. 서론: 왜 Rust에서 에러 처리는 특별할까?
프로그래밍에서 ‘에러 처리’는 늘 신경 써야 하는 골칫거리 중 하나입니다. 특히 시스템 프로그래밍처럼 낮은 수준의 리소스를 다루는 영역에서는 사소한 에러 하나가 전체 시스템을 불안정하게 만들 수 있습니다. Rust는 이런 문제를 원천적으로 방지하기 위해 언어 차원에서 안전한 에러 처리 방식을 설계했습니다. 그 핵심이 바로 Option
과 Result
입니다.
Rust는 널 포인터(null pointer)나 예외(exception)를 사용하지 않고, 값을 가지거나 가지지 않을 수 있는 경우를 Option
으로, 성공 혹은 실패의 결과를 Result
로 명시적으로 표현합니다. 이는 개발자가 예상 가능한 모든 흐름을 코드로 표현하도록 강제하며, 그만큼 버그의 가능성은 줄어듭니다.
하지만 안전하다는 것은 곧 복잡하다는 뜻이기도 합니다. 처음 Rust를 배우는 이들이 가장 많이 부딪히는 벽이 바로 이 Option
과 Result
타입입니다. 어떤 상황에서 unwrap
을 써야 하고, 언제 match
로 분기해야 하며, ?
연산자는 어떤 함수에서 쓸 수 있을까요? 이 글은 바로 그 해답을 제시하기 위해 쓰였습니다.
이제부터 우리는 Rust에서 에러를 안전하고 효율적으로 다루는 실전적인 방법들을 하나씩 살펴보겠습니다. 단순한 문법 설명을 넘어서, 실무에서 자주 마주치는 코드 패턴과 에러 처리 전략까지 함께 알아볼 것입니다.
2. Option<T>: 값이 없을 수도 있다는 명시적 표현
Option<T>
는 Rust에서 매우 널리 사용되는 열거형(enum)으로, 값이 존재할 수도 있고 존재하지 않을 수도 있다는 가능성을 명시적으로 표현합니다. 이는 C/C++이나 Java 등에서 흔히 발생하는 ‘null dereferencing’ 문제를 원천적으로 방지합니다.
Option
은 다음과 같이 정의되어 있습니다:
enum Option<T> {
Some(T),
None,
}
가장 기본적인 사용 예로, 어떤 함수가 유효한 값을 반환할 수도 있고, 실패하거나 조건을 만족하지 않아 값을 반환하지 못할 수도 있을 때 Option
이 사용됩니다.
2.1 match 문을 이용한 안전한 분기 처리
Option
을 처리할 때 가장 기본적이며 안전한 방법은 match
표현식을 활용하는 것입니다. 이 방식은 모든 가능한 경우를 명확하게 처리하게끔 강제하기 때문에, 코드의 안정성과 명료성이 높아집니다.
fn divide(numerator: f64, denominator: f64) -> Option<f64> {
if denominator == 0.0 {
None
} else {
Some(numerator / denominator)
}
}
fn main() {
let result = divide(10.0, 2.0);
match result {
Some(value) => println!("Result: {}", value),
None => println!("Cannot divide by zero"),
}
}
2.2 unwrap_or로 기본값 지정
Option
이 None
일 경우를 대비해, 기본값을 지정하여 코드의 안정성을 높일 수 있습니다. 이때 사용하는 것이 unwrap_or
메서드입니다.
let value = divide(10.0, 0.0).unwrap_or(0.0);
println!("Safe result: {}", value);
unwrap_or
는 Some
값이면 그 값을 반환하고, None
이면 제공한 기본값을 반환합니다. 이를 통해 panic을 방지하고, 디폴트 전략을 명시적으로 설계할 수 있습니다.
2.3 map과 and_then으로 체이닝
함수형 스타일로 Option
값을 변형하거나 연결하고 싶을 때는 map
과 and_then
을 사용합니다. map
은 Some
에 대해 값을 변형하고, None
은 그대로 유지합니다. and_then
은 결과값이 또 다른 Option
일 때 적절합니다.
let result = divide(10.0, 2.0)
.map(|x| x + 1.0)
.and_then(|x| if x > 5.0 { Some(x * 2.0) } else { None });
match result {
Some(val) => println!("Transformed result: {}", val),
None => println!("No valid result after transformation"),
}
이처럼 Option
은 다양한 방식으로 안전하게 값을 처리하고, 에러 없는 코드를 작성할 수 있게 도와줍니다. 다음 단락에서는 Result<T, E>
타입을 중심으로, 오류 정보를 담고 전달하는 방법에 대해 살펴보겠습니다.
3. Result<T, E>: 에러를 값으로 다루는 방식
Result<T, E>
는 Rust에서 함수가 성공 또는 실패할 수 있음을 나타내는 열거형입니다. 이 타입은 단순히 에러의 발생 여부만 알려주는 것이 아니라, 에러의 원인까지 담아 전달함으로써 문제 해결을 더 수월하게 만들어 줍니다.
Result
는 다음과 같이 정의됩니다:
enum Result<T, E> {
Ok(T),
Err(E),
}
이 구조는 성공했을 때는 Ok
에 결과값을 담고, 실패했을 때는 Err
에 에러 정보를 담습니다. 이를 통해 사용자는 함수 호출 이후의 흐름을 명확히 제어할 수 있습니다.
3.1 match를 통한 분기
Result
를 처리할 때도 match
는 기본적인 방식입니다. 모든 가능성을 명시적으로 다루게 하므로 매우 안전하고 읽기 쉬운 코드 작성이 가능합니다.
fn parse_number(text: &str) -> Result<i32, std::num::ParseIntError> {
text.parse()
}
fn main() {
match parse_number("42") {
Ok(n) => println!("Parsed number: {}", n),
Err(e) => println!("Failed to parse: {}", e),
}
}
3.2 unwrap_or_else와 expect
간단하게 에러 처리 로직을 삽입하거나, 사용자 친화적인 메시지를 출력하고자 할 때 unwrap_or_else
와 expect
를 사용할 수 있습니다. 다만 unwrap
, expect
는 panic을 발생시킬 수 있으므로 반드시 사용 의도를 명확히 해야 합니다.
let number = parse_number("abc").unwrap_or_else(|err| {
println!("Handling error: {}", err);
0
});
println!("Result: {}", number);
// 또는 expect 사용
let number = parse_number("42").expect("Should parse a number");
3.3 체이닝 메서드 활용
Result
는 다양한 체이닝 메서드를 통해 더 간결하고 우아한 흐름 제어가 가능합니다. 특히 map
, and_then
, or_else
등을 통해 조건에 따라 연속된 작업을 구성할 수 있습니다.
fn double_number(text: &str) -> Result<i32, std::num::ParseIntError> {
text.parse::().map(|n| n * 2)
}
let result = double_number("21")
.map(|n| n + 1)
.and_then(|n| if n % 2 == 0 { Ok(n / 2) } else { Err("Not divisible by 2".into()) });
match result {
Ok(val) => println!("Final result: {}", val),
Err(e) => println!("Error occurred: {}", e),
}
이처럼 Result
를 활용하면 함수형 스타일로 흐름을 자연스럽게 구성하면서도, 각 단계의 에러를 안전하게 제어할 수 있습니다.
다음 단락에서는 Rust에서 에러 전파를 우아하게 처리해주는 ?
연산자의 사용법을 다뤄보겠습니다.
4. ? 연산자: 에러 전파를 단순하고 명료하게
Rust에서 에러 처리 코드를 더 간결하고 명확하게 작성하고 싶을 때 사용하는 대표적인 문법이 바로 ?
연산자입니다. 이 연산자는 Result
또는 Option
타입의 값을 처리할 때, 에러 발생 시 자동으로 현재 함수를 빠져나가도록 하여 에러를 상위 호출자로 전파해 줍니다.
4.1 ? 연산자의 동작 원리
?
연산자는 Result<T, E>
혹은 Option<T>
타입에만 사용할 수 있으며, 해당 값이 Err
또는 None
일 경우 이를 즉시 반환합니다. 반대로 Ok
또는 Some
일 경우에는 내부의 값을 꺼내어 그대로 다음 연산에 넘겨줍니다.
다음은 Result
타입에서 ?
를 사용하는 예시입니다:
fn read_number_from_str(s: &str) -> Result<i32, std::num::ParseIntError> {
let n = s.parse()?; // Err가 발생하면 여기서 함수 종료
Ok(n)
}
위 코드에서 s.parse()?
는 내부적으로 match
를 사용하는 것과 동일한 의미를 가지며, 실패 시 해당 에러를 그대로 호출자에게 반환합니다. 이로써 불필요한 match
반복을 줄이고, 코드가 훨씬 깔끔해집니다.
4.2 ? 연산자 사용 조건
?
연산자를 사용하려면 함수 반환 타입이 Result
또는 Option
이어야 하며, 해당 연산자와 동일한 타입으로 반환할 수 있어야 합니다. 그렇지 않으면 컴파일 오류가 발생합니다.
예를 들어 다음 코드는 Result
를 반환하지 않기 때문에 컴파일되지 않습니다:
fn broken_function() {
let n = "123".parse()?; // 오류: 함수가 Result를 반환하지 않음
}
이 경우 함수의 반환 타입을 Result<i32, std::num::ParseIntError>
로 지정해줘야 합니다. 즉, ?
는 함수 수준에서 에러 전파가 가능할 때에만 사용할 수 있습니다.
4.3 여러 에러 타입을 ? 연산자로 처리할 때
여러 종류의 에러를 다루는 함수들 사이에서 ?
를 사용하고자 할 경우, 모든 에러가 하나의 타입으로 수렴되어야 합니다. 이를 위해 보통 Box<dyn Error>
또는 anyhow::Result
와 같은 범용 에러 타입을 사용하거나, From
트레이트를 구현한 사용자 정의 에러를 활용합니다.
use std::fs::File;
use std::io::{self, Read};
fn read_file_content(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut file = File::open(path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content)
}
이 예제에서는 파일 열기와 읽기 모두 ?
연산자를 통해 간결하게 에러를 전파하고 있으며, 다양한 에러를 Box<dyn Error>
하나로 통합하여 처리하고 있습니다.
?
연산자는 Rust 코드의 가독성을 획기적으로 향상시켜 주며, 에러 처리 흐름을 한눈에 파악할 수 있도록 만들어 줍니다. 다음 단락에서는 Option
과 Result
를 함께 사용하는 복합적인 상황을 다뤄보겠습니다.
5. Option과 Result를 함께 사용할 때의 패턴
Rust에서는 현실 세계의 다양한 불확실성을 표현하기 위해 Option
과 Result
를 함께 사용하는 경우가 자주 발생합니다. 예를 들어, 어떤 연산은 값이 없을 수도 있고(Option
), 또 다른 연산은 에러가 발생할 수도 있는(Result
) 상황입니다. 이 두 타입을 함께 사용할 때는 타입 간 변환을 적절히 활용해야 합니다.
5.1 Option을 Result로 변환: ok_or, ok_or_else
Option
타입은 자체적으로 에러 정보를 포함하지 않기 때문에, Result
타입으로 변환할 때는 어떤 에러를 넣어줄 것인지 명시해야 합니다. 이때 사용하는 메서드가 ok_or
와 ok_or_else
입니다.
fn get_username(id: u32) -> Option<&'static str> {
if id == 1 {
Some("alice")
} else {
None
}
}
fn get_username_result(id: u32) -> Result<&'static str, String> {
get_username(id).ok_or("User not found".to_string())
}
ok_or_else
는 지연 계산(lazy evaluation)을 지원하기 때문에, 복잡한 에러 생성 로직이 있을 경우에 적합합니다.
let result = get_username(2).ok_or_else(|| format!("No user found with id {}", 2));
5.2 Result를 Option으로 변환: ok, err
반대로 Result
에서 에러 여부만 관심 있고 실제 에러 내용을 무시하고 싶다면 ok
또는 err
메서드를 사용할 수 있습니다.
let value: Option<i32> = "123".parse().ok();
let error: Option<std::num::ParseIntError> = "abc".parse::<i32>().err();
이 방법은 중간 연산에서 에러 처리에 관심 없고, 성공 여부만 필요할 때 유용합니다.
5.3 transpose를 이용한 중첩 해소
복잡한 데이터 처리 중에는 Option<Result<T, E>>
혹은 Result<Option<T>, E>
와 같은 중첩된 타입이 발생할 수 있습니다. 이런 경우 transpose
를 활용하여 타입을 변환하면 더 직관적인 흐름을 만들 수 있습니다.
let opt_result: Option<Result<i32, &str>> = Some(Ok(5));
let result_opt: Result<Option<i32>, &str> = opt_result.transpose();
이와 같은 패턴들은 복잡한 로직 속에서도 에러 흐름을 명확히 파악하고, 필요한 수준의 처리만을 선택적으로 적용할 수 있게 해줍니다. 다음 단락에서는 이 모든 요소들을 종합하여 실전 예제와 함께 체이닝 기법을 살펴보겠습니다.
6. 에러 처리 스타일: 패턴 조합과 체이닝 기법 실전 예제
지금까지 소개한 Option
, Result
, match
, unwrap_or
, ?
등의 기법은 각각 독립적으로도 유용하지만, 실전에서는 이들을 적절히 조합하여 강력하고 우아한 에러 처리 흐름을 설계하는 것이 중요합니다. 이 단락에서는 실제 상황을 가정한 예제와 함께, 에러 처리 스타일과 체이닝 기법을 어떻게 활용할 수 있는지 살펴보겠습니다.
6.1 실전 예제: 사용자 입력을 읽고 파일에서 값을 파싱하기
아래는 사용자로부터 파일 경로를 입력받고, 그 파일의 내용을 숫자로 파싱한 후 계산 결과를 반환하는 예제입니다. 다양한 실패 가능성이 존재하므로 Result
를 중심으로 ?
와 체이닝을 적극 활용합니다.
use std::fs::File;
use std::io::{self, Read};
use std::num::ParseIntError;
fn read_input_file(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn parse_and_compute(data: &str) -> Result<i32, ParseIntError> {
let number: i32 = data.trim().parse()?;
Ok(number * 2)
}
fn process(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let content = read_input_file(path)?;
let result = parse_and_compute(&content)?;
Ok(result)
}
fn main() {
match process("data.txt") {
Ok(val) => println!("Computation result: {}", val),
Err(e) => eprintln!("An error occurred: {}", e),
}
}
이 예제에서는 세 가지 주요 에러 가능성이 존재합니다:
- 파일이 없거나 읽기에 실패하는
io::Error
- 파일 내용이 정수가 아닌 경우 발생하는
ParseIntError
- 이들을 상위
process
함수에서Box<dyn Error>
로 래핑하여 일관된 에러 처리 흐름 제공
6.2 체이닝 메서드를 사용한 간결한 흐름
위 예제에서는 함수 단위에서 ?
연산자를 활용했지만, 간단한 데이터 흐름에서는 map
, and_then
, unwrap_or_else
등의 체이닝 메서드를 적극적으로 활용할 수 있습니다. 아래는 사용자 입력 값을 조건에 따라 가공하는 간결한 예입니다:
fn transform_input(input: Option<&str>) -> Result<i32, String> {
input
.ok_or("No input provided".to_string())?
.trim()
.parse::<i32>()
.map_err(|_| "Invalid number format".to_string())
}
이 코드에서는 Option
을 ok_or
로 변환하고, 이후 Result
체이닝을 통해 간결하게 파싱 및 에러 처리 흐름을 구성합니다. 복잡한 분기 없이도 매우 읽기 쉬운 코드가 완성됩니다.
6.3 스타일 가이드라인 요약
기법 | 용도 |
---|---|
match | 모든 경우를 명시적으로 처리할 때 |
unwrap_or / unwrap_or_else | 기본값으로 fallback할 때 |
map / and_then | 값 변환 및 체이닝 처리 시 |
? | 상위 함수로 에러 전파 시 |
이처럼 Rust에서는 다양한 패턴과 체이닝 기법을 통해 안전성과 가독성을 동시에 만족하는 코드를 작성할 수 있습니다. 다음 마지막 단락에서는 지금까지의 내용을 정리하고, 실무에서의 적용 관점을 함께 살펴보겠습니다.
7. 결론: Rust다운 에러 처리는 결국 읽기 쉬운 코드에서 시작된다
Rust는 에러 처리 방식에서 타 언어와 뚜렷하게 구분되는 철학을 가지고 있습니다. Option
과 Result
는 그 자체로 값이자 제어 흐름의 일부이며, 이를 명시적으로 다루는 과정을 통해 개발자는 에러 가능성을 처음부터 끝까지 의식하게 됩니다. 이는 단지 안전함을 넘어서, 코드의 예측 가능성과 유지보수성을 극대화합니다.
우리는 이 글에서 다음과 같은 핵심 개념을 살펴보았습니다:
- Option을 통한 값 존재 유무의 표현과
match
,unwrap_or
,map
등의 안전한 처리 방식 - Result를 통한 에러 정보의 전달과
unwrap_or_else
,expect
,and_then
등의 체이닝 - ? 연산자를 활용한 간결한 에러 전파와 함수 레벨의 명료한 흐름 제어
- Option ↔ Result 간 변환 기법,
transpose
와 같은 고급 활용 - 실전 예제를 통한 체이닝 및 에러 처리 스타일의 적용 방식
Rust의 에러 처리 문법은 처음에는 다소 생소하고 번거롭게 느껴질 수 있습니다. 하지만 일단 이 철학을 이해하고 나면, 단순히 오류를 방지하는 차원을 넘어 **코드의 의도를 명확하게 드러내고, 실수를 줄이는 효과**를 체감하게 됩니다.
에러 처리는 단지 예외를 피하기 위한 도구가 아니라, 프로그래머의 의도를 명확히 표현하는 언어의 일부입니다. Rust는 이 점을 가장 철저하게 구현한 언어이며, 따라서 Rust에서 에러를 다루는 방식은 우리가 코드를 대하는 태도 자체를 바꾸는 계기가 되기도 합니다.
이 글이 여러분이 Rust의 에러 처리 철학을 이해하고, 실무에서 자신감 있게 적용하는 데 도움이 되기를 바랍니다. 에러 없는 코드가 아니라, 예상 가능한 흐름 속에서 오류를 품은 코드를 작성하는 것. 그것이 Rust다운 개발입니다.