Go to Rust: #6. 에러 처리
이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Rust의 에러 처리는 Go와 철학적으로 유사합니다. 두 언어 모두 예외(exception)를 사용하지 않고 명시적인 에러 반환을 선호합니다. 하지만 Rust는 타입 시스템을 통해 에러 처리를 강제하여, Go에서 흔히 발생하는 “에러 무시” 문제를 원천 차단합니다.
6.1 Rust의 에러 처리 철학#
복구 가능한 에러 vs 복구 불가능한 에러#
Rust는 에러를 두 가지로 구분합니다:
복구 가능한 에러 (Recoverable): Result<T, E>
- 파일을 찾을 수 없음
- 네트워크 연결 실패
- 잘못된 사용자 입력
복구 불가능한 에러 (Unrecoverable): panic!
- 배열 범위 초과 접근
- 프로그래머의 논리적 오류
- 불변 조건(invariant) 위반
// 복구 가능: Result 사용
fn read_file(path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(path)
}
// 복구 불가능: panic 사용
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Division by zero!"); // 프로그래머 오류
}
a / b
}
Go의 error 인터페이스와 비교#
// Go: error 인터페이스
func readFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// 에러 무시 가능 (위험!)
data, _ := readFile("config.txt") // err 무시
// Rust: Result 타입
fn read_file(path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(path)
}
// 에러 무시하면 컴파일 경고 또는 에러
let data = read_file("config.txt"); // Result<String, Error>
// data는 String이 아님! 사용하려면 처리 필수
Rust에서 panic이 적절한 경우#
- 프로토타이핑:
unwrap(),expect()로 빠르게 개발 - 테스트 코드: 실패 시 테스트가 패닉해야 함
- 불가능한 상황: 논리적으로 발생할 수 없는 경우
- 불변 조건 위반: 프로그램 상태가 손상된 경우
// 논리적으로 불가능한 경우
let home = std::env::var("HOME").expect("HOME must be set");
// 파싱이 항상 성공하는 리터럴
let num: i32 = "42".parse().unwrap();
// 슬라이스가 비어있지 않음을 확신
let first = non_empty_slice.first().unwrap();
6.2 Result<T, E> 심화#
Result의 구조#
enum Result<T, E> {
Ok(T), // 성공, 값 T 포함
Err(E), // 실패, 에러 E 포함
}
패턴 매칭으로 처리#
use std::fs::File;
fn main() {
let file = File::open("hello.txt");
let file = match file {
Ok(f) => f,
Err(e) => {
println!("Failed to open: {}", e);
return;
}
};
}
에러 종류별 처리#
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let file = File::open("hello.txt");
let file = match file {
Ok(f) => f,
Err(e) => match e.kind() {
ErrorKind::NotFound => {
// 파일이 없으면 생성
File::create("hello.txt").expect("Failed to create")
}
ErrorKind::PermissionDenied => {
panic!("Permission denied!");
}
other => {
panic!("Other error: {:?}", other);
}
},
};
}
주요 메서드들#
unwrap과 expect:
// unwrap: Ok면 값, Err면 panic
let file = File::open("hello.txt").unwrap();
// expect: 커스텀 panic 메시지
let file = File::open("hello.txt")
.expect("Failed to open hello.txt");
기본값 제공:
// unwrap_or: 기본값 즉시 평가
let port: u16 = env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8080);
// unwrap_or_else: 기본값 지연 평가 (클로저)
let config = load_config()
.unwrap_or_else(|_| Config::default());
// unwrap_or_default: Default 트레이트 사용
let name: String = get_name().unwrap_or_default(); // ""
변환 메서드:
// map: Ok 값 변환
let len: Result<usize, _> = File::open("hello.txt")
.map(|f| f.metadata().unwrap().len() as usize);
// map_err: Err 값 변환
let result = File::open("hello.txt")
.map_err(|e| format!("IO Error: {}", e));
// and_then: 체이닝 (flatMap)
let content: Result<String, _> = File::open("hello.txt")
.and_then(|mut f| {
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
});
언제 unwrap을 사용해도 되는가#
// 1. 리터럴 파싱 (항상 성공)
let num: i32 = "42".parse().unwrap();
// 2. 정적으로 알려진 값
let regex = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
// 3. 테스트 코드
#[test]
fn test_parse() {
let result = parse_config("valid").unwrap();
assert_eq!(result.name, "test");
}
// 4. 프로토타이핑 (나중에 제거)
fn prototype() {
let data = fetch_data().unwrap(); // TODO: proper error handling
}
6.3 ? 연산자#
에러 전파의 편리한 문법#
? 연산자는 Go의 if err != nil { return err } 패턴을 한 줄로 줄입니다:
// Go: 반복적인 에러 검사
func readUsername(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// Rust: ? 연산자 사용
fn read_username(path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(path)?; // 에러면 즉시 반환
let mut username = String::new();
file.read_to_string(&mut username)?; // 에러면 즉시 반환
Ok(username.trim().to_string())
}
// 더 간결하게 체이닝
fn read_username_short(path: &str) -> Result<String, std::io::Error> {
let mut s = String::new();
File::open(path)?.read_to_string(&mut s)?;
Ok(s.trim().to_string())
}
// 가장 간결하게
fn read_username_shortest(path: &str) -> Result<String, std::io::Error> {
Ok(std::fs::read_to_string(path)?.trim().to_string())
}
? 동작 원리#
// 이 코드는...
let file = File::open(path)?;
// 이것과 동등합니다:
let file = match File::open(path) {
Ok(f) => f,
Err(e) => return Err(e.into()), // From 트레이트로 변환
};
From 트레이트와의 연계#
?는 에러 타입을 자동 변환합니다:
use std::num::ParseIntError;
#[derive(Debug)]
enum MyError {
Io(std::io::Error),
Parse(ParseIntError),
}
// From 구현으로 자동 변환
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self {
MyError::Io(e)
}
}
impl From<ParseIntError> for MyError {
fn from(e: ParseIntError) -> Self {
MyError::Parse(e)
}
}
fn read_and_parse(path: &str) -> Result<i32, MyError> {
let content = std::fs::read_to_string(path)?; // io::Error -> MyError
let num: i32 = content.trim().parse()?; // ParseIntError -> MyError
Ok(num)
}
main에서 Result 반환하기#
use std::error::Error;
// main도 Result 반환 가능
fn main() -> Result<(), Box<dyn Error>> {
let content = std::fs::read_to_string("config.txt")?;
println!("{}", content);
Ok(())
}
// 에러 발생 시 출력 예:
// Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
6.4 Option 활용#
null의 부재#
Rust에는 null이 없습니다. 값이 없을 수 있는 경우 Option<T>를 사용합니다:
enum Option<T> {
Some(T), // 값이 있음
None, // 값이 없음
}
Option 주요 메서드들#
let some_number: Option<i32> = Some(5);
let no_number: Option<i32> = None;
// map: Some일 때만 변환
let doubled = some_number.map(|n| n * 2); // Some(10)
// and_then: 체이닝 (flatMap)
let result = some_number
.and_then(|n| if n > 0 { Some(n * 2) } else { None });
// or_else: None일 때 대체값
let with_default = no_number.or_else(|| Some(42));
// filter: 조건 검사
let positive = some_number.filter(|&n| n > 0); // Some(5)
let negative = some_number.filter(|&n| n < 0); // None
// ok_or: Option -> Result 변환
let result: Result<i32, &str> = some_number.ok_or("No value");
// take: 값을 가져가고 None으로 대체
let mut opt = Some(42);
let value = opt.take(); // Some(42)
// opt은 이제 None
Go의 nil 검사 패턴과 비교#
// Go: nil 검사
func findUser(id int) *User {
// ...
if notFound {
return nil
}
return &user
}
func main() {
user := findUser(1)
if user != nil { // nil 검사 필요
fmt.Println(user.Name)
}
// nil 검사 없이 접근하면 패닉!
// fmt.Println(findUser(999).Name)
}
// Rust: Option 사용
fn find_user(id: u32) -> Option<User> {
// ...
if not_found {
return None;
}
Some(user)
}
fn main() {
// 컴파일러가 처리를 강제
if let Some(user) = find_user(1) {
println!("{}", user.name);
}
// 이건 컴파일 에러
// println!("{}", find_user(999).name);
// unwrap_or로 기본값
let user = find_user(999).unwrap_or(User::default());
}
? 연산자와 Option#
?는 Option에도 사용 가능합니다:
fn first_char(s: &str) -> Option<char> {
s.chars().next()
}
fn first_char_of_first_line(text: &str) -> Option<char> {
let first_line = text.lines().next()?; // None이면 즉시 반환
first_line.chars().next() // Option<char> 반환
}
6.5 커스텀 에러 타입#
에러 타입 정의하기#
use std::fmt;
#[derive(Debug)]
enum ConfigError {
NotFound(String),
ParseError { line: usize, message: String },
IoError(std::io::Error),
}
// Display 구현 (사용자 친화적 메시지)
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::NotFound(path) => {
write!(f, "Config file not found: {}", path)
}
ConfigError::ParseError { line, message } => {
write!(f, "Parse error at line {}: {}", line, message)
}
ConfigError::IoError(e) => {
write!(f, "IO error: {}", e)
}
}
}
}
// std::error::Error 구현
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::IoError(e) => Some(e),
_ => None,
}
}
}
// From 구현으로 자동 변환
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::IoError(e)
}
}
thiserror 크레이트#
위의 보일러플레이트를 자동화합니다:
[dependencies]
thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("Config file not found: {0}")]
NotFound(String),
#[error("Parse error at line {line}: {message}")]
ParseError { line: usize, message: String },
#[error("IO error")]
IoError(#[from] std::io::Error), // From 자동 구현
#[error("Invalid value: {0}")]
InvalidValue(#[source] Box<dyn std::error::Error + Send + Sync>),
}
anyhow 크레이트#
애플리케이션 레벨의 빠른 에러 처리:
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result, bail, anyhow};
// anyhow::Result<T>는 Result<T, anyhow::Error>
fn read_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.context("Failed to read config file")?; // 컨텍스트 추가
let config: Config = serde_json::from_str(&content)
.context("Failed to parse config")?;
if config.port == 0 {
bail!("Port cannot be zero"); // 즉시 에러 반환
}
if config.name.is_empty() {
return Err(anyhow!("Name cannot be empty"));
}
Ok(config)
}
fn main() -> Result<()> {
let config = read_config("config.json")?;
println!("Loaded config: {:?}", config);
Ok(())
}
thiserror vs anyhow:
| 용도 | thiserror | anyhow |
|---|---|---|
| 라이브러리 | ✅ 적합 | ❌ 부적합 |
| 애플리케이션 | ⚠️ 과할 수 있음 | ✅ 적합 |
| 구체적 에러 타입 | ✅ 제공 | ❌ 동적 |
| 컨텍스트 추가 | ⚠️ 수동 | ✅ context() |
| 빠른 개발 | ⚠️ 보일러플레이트 | ✅ 간편 |
6.6 에러 처리 패턴과 베스트 프랙티스#
라이브러리 vs 애플리케이션 에러 전략#
라이브러리:
thiserror로 구체적인 에러 타입 정의- 사용자가 에러 종류를 구분할 수 있게
Box<dyn Error>피하기
// 라이브러리: 구체적 에러 타입
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Connection failed: {0}")]
ConnectionFailed(String),
#[error("Query failed: {0}")]
QueryFailed(String),
#[error("Record not found")]
NotFound,
}
pub fn query(sql: &str) -> Result<Vec<Row>, DatabaseError> {
// ...
}
애플리케이션:
anyhow로 빠르게 개발- 에러 컨텍스트 추가에 집중
- 사용자에게 유용한 메시지 제공
// 애플리케이션: 유연한 에러 처리
use anyhow::{Context, Result};
fn process_order(order_id: u64) -> Result<()> {
let order = db::get_order(order_id)
.context("Failed to fetch order")?;
let payment = payment::charge(&order)
.with_context(|| format!("Payment failed for order {}", order_id))?;
shipping::schedule(&order)
.context("Shipping scheduling failed")?;
Ok(())
}
에러 컨텍스트 추가하기#
// Go 스타일: fmt.Errorf로 래핑
// if err != nil {
// return fmt.Errorf("failed to connect to %s: %w", addr, err)
// }
// Rust: context() 사용
let connection = connect(addr)
.with_context(|| format!("Failed to connect to {}", addr))?;
// 체이닝
let user = db::get_user(user_id)
.context("Database query failed")
.with_context(|| format!("Failed to get user {}", user_id))?;
에러 다운캐스팅#
use anyhow::Result;
fn handle_error(result: Result<()>) {
if let Err(e) = result {
// anyhow 에러에서 원래 타입 추출
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
println!("IO error: {}", io_err);
} else if let Some(config_err) = e.downcast_ref::<ConfigError>() {
println!("Config error: {}", config_err);
} else {
println!("Unknown error: {}", e);
}
}
}
에러 처리 계층 설계#
┌─────────────────────────────────────────────┐
│ Application Layer │
│ (anyhow, user-facing errors) │
├─────────────────────────────────────────────┤
│ Service Layer │
│ (domain errors, business logic errors) │
├─────────────────────────────────────────────┤
│ Infrastructure Layer │
│ (thiserror, specific error types) │
└─────────────────────────────────────────────┘
// Infrastructure: 구체적 에러
mod db {
#[derive(thiserror::Error, Debug)]
pub enum DbError {
#[error("Connection pool exhausted")]
PoolExhausted,
#[error("Query timeout")]
Timeout,
}
}
// Service: 도메인 에러
mod service {
#[derive(thiserror::Error, Debug)]
pub enum OrderError {
#[error("Order not found: {0}")]
NotFound(u64),
#[error("Insufficient inventory")]
InsufficientInventory,
#[error("Database error")]
Database(#[from] super::db::DbError),
}
}
// Application: 통합 에러 처리
mod app {
use anyhow::{Context, Result};
pub fn process_order(id: u64) -> Result<()> {
super::service::create_order(id)
.context("Order processing failed")?;
Ok(())
}
}
6.7 요약#
Go vs Rust 에러 처리 비교#
| 측면 | Go | Rust |
|---|---|---|
| 에러 타입 | error 인터페이스 |
Result<T, E> |
| null 표현 | nil |
Option<T> |
| 에러 무시 | 가능 (_) |
컴파일 경고/에러 |
| 전파 문법 | if err != nil { return } |
? 연산자 |
| 패닉 | panic() |
panic!() |
| 컨텍스트 | fmt.Errorf("%w", err) |
.context() |
에러 처리 체크리스트#
- 라이브러리는
thiserror로 구체적 에러 타입 정의 - 애플리케이션은
anyhow로 유연하게 처리 -
unwrap()은 프로토타입/테스트에서만 사용 - 에러 컨텍스트를 충분히 추가
- 복구 가능 여부에 따라
Resultvspanic!선택
연습 문제#
-
Result 사용: 파일에서 숫자를 읽어 합계를 계산하는 함수를 작성하세요. 파일 읽기 에러와 파싱 에러를 모두 처리하세요.
-
커스텀 에러:
thiserror를 사용하여 사용자 인증 에러 타입을 정의하세요 (InvalidCredentials, Expired, Locked 등). -
? 연산자 체이닝: 다음 Go 코드를 Rust로 변환하세요:
func processFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("read failed: %w", err) } result, err := transform(data) if err != nil { return "", fmt.Errorf("transform failed: %w", err) } return result, nil } -
Option 활용: 문자열에서 첫 번째 단어를 찾고, 그 단어의 길이를 반환하는 함수를 작성하세요. 빈 문자열이면
None을 반환하세요. -
anyhow 사용: 설정 파일을 읽고, JSON 파싱하고, 특정 필드를 추출하는 함수를
anyhow로 작성하세요. 각 단계에 적절한 컨텍스트를 추가하세요.