구조체, 열거형, 트레이트부터 Result/Option, thiserror/anyhow를 활용한 에러 처리 패턴까지 Rust 타입 시스템의 핵심을 다룹니다.
? 연산자를 활용한 에러 전파 패턴을 학습합니다Rust의 **구조체(struct)**는 관련 데이터를 묶는 기본 단위입니다. 백엔드 개발에서 도메인 모델, 요청/응답 DTO, 설정 등을 표현할 때 사용합니다.
#[derive(Debug, Clone)]
struct User {
id: u64,
name: String,
email: String,
active: bool,
}
impl User {
// 연관 함수 (associated function) — 생성자 역할
fn new(id: u64, name: String, email: String) -> Self {
Self {
id,
name,
email,
active: true,
}
}
// 메서드 — &self로 불변 빌림
fn display_name(&self) -> &str {
&self.name
}
// 가변 메서드 — &mut self로 가변 빌림
fn deactivate(&mut self) {
self.active = false;
}
}#[derive(Debug, Clone)]은 **매크로(derive macro)**로, 컴파일러가 Debug와 Clone 트레이트의 구현을 자동으로 생성합니다. 백엔드에서 자주 사용하는 derive 조합은 다음과 같습니다.
use serde::{Deserialize, Serialize};
// API 응답 모델
#[derive(Debug, Clone, Serialize)]
struct UserResponse {
id: u64,
name: String,
email: String,
}
// API 요청 모델
#[derive(Debug, Deserialize)]
struct CreateUserRequest {
name: String,
email: String,
}Rust의 **열거형(enum)**은 다른 언어의 열거형과 차원이 다릅니다. 각 변형(variant)이 데이터를 포함할 수 있어, **대수적 데이터 타입(Algebraic Data Type, ADT)**을 표현합니다.
#[derive(Debug)]
enum PaymentMethod {
CreditCard { number: String, expiry: String },
BankTransfer { account: String, bank_code: String },
DigitalWallet(String), // 지갑 주소
Cash,
}
fn process_payment(method: &PaymentMethod) {
match method {
PaymentMethod::CreditCard { number, .. } => {
println!("카드 결제: ****{}", &number[number.len()-4..]);
}
PaymentMethod::BankTransfer { account, bank_code } => {
println!("계좌이체: {} ({})", account, bank_code);
}
PaymentMethod::DigitalWallet(address) => {
println!("디지털 월렛: {}", address);
}
PaymentMethod::Cash => {
println!("현금 결제");
}
}
}match 표현식은 **패턴 매칭(pattern matching)**으로, 모든 변형을 빠짐없이 처리해야 합니다. 하나라도 빠뜨리면 컴파일 에러가 발생합니다. 이를 **소진적 매칭(exhaustive matching)**이라 하며, 새 변형을 추가할 때 처리하지 않은 곳을 컴파일러가 알려줍니다.
**트레이트(trait)**는 공유 동작을 정의하는 메커니즘입니다. 다른 언어의 인터페이스와 유사하지만, 기본 구현을 제공할 수 있고 기존 타입에도 트레이트를 구현할 수 있습니다.
trait Repository {
type Item;
type Error;
async fn find_by_id(&self, id: u64) -> Result<Option<Self::Item>, Self::Error>;
async fn save(&self, item: &Self::Item) -> Result<(), Self::Error>;
async fn delete(&self, id: u64) -> Result<(), Self::Error>;
// 기본 구현
async fn exists(&self, id: u64) -> Result<bool, Self::Error> {
Ok(self.find_by_id(id).await?.is_some())
}
}
struct PostgresUserRepository {
pool: String, // 실제로는 sqlx::PgPool
}
impl Repository for PostgresUserRepository {
type Item = User;
type Error = String; // 실제로는 sqlx::Error
async fn find_by_id(&self, id: u64) -> Result<Option<User>, String> {
// 데이터베이스 조회 로직
todo!()
}
async fn save(&self, item: &User) -> Result<(), String> {
todo!()
}
async fn delete(&self, id: u64) -> Result<(), String> {
todo!()
}
// exists()는 기본 구현을 사용
}Rust에서 트레이트는 **의존성 역전(Dependency Inversion)**을 구현하는 핵심 도구입니다. 위 예제처럼 Repository 트레이트를 정의하면, 테스트에서는 인메모리 구현을, 프로덕션에서는 PostgreSQL 구현을 사용할 수 있습니다.
Rust에는 null이 없습니다. 대신 값이 있을 수도 없을 수도 있는 상황을 Option 열거형으로 표현합니다.
// Option은 표준 라이브러리에 다음과 같이 정의되어 있습니다
// enum Option<T> {
// Some(T),
// None,
// }
fn find_user(users: &[User], id: u64) -> Option<&User> {
users.iter().find(|u| u.id == id)
}
fn main() {
let users = vec![
User::new(1, "Alice".into(), "alice@example.com".into()),
User::new(2, "Bob".into(), "bob@example.com".into()),
];
// 패턴 매칭으로 처리
match find_user(&users, 1) {
Some(user) => println!("찾음: {:?}", user),
None => println!("사용자 없음"),
}
// 메서드 체이닝으로 간결하게
let name = find_user(&users, 3)
.map(|u| u.display_name())
.unwrap_or("알 수 없음");
println!("이름: {}", name);
}Option의 유용한 메서드들은 다음과 같습니다.
| 메서드 | 설명 |
|---|---|
unwrap() | Some이면 값 반환, None이면 패닉 (프로덕션에서 지양) |
unwrap_or(default) | Some이면 값, None이면 기본값 |
map(f) | Some이면 f 적용, None이면 None |
and_then(f) | Some이면 f 적용 (f가 Option 반환), None이면 None |
ok_or(err) | Option을 Result로 변환 |
is_some() / is_none() | 값 존재 여부 확인 |
Result는 성공 또는 실패를 표현하는 열거형입니다. 백엔드 개발에서 가장 많이 사용하는 타입 중 하나입니다.
// Result는 표준 라이브러리에 다음과 같이 정의되어 있습니다
// enum Result<T, E> {
// Ok(T),
// Err(E),
// }
use std::fs;
use std::io;
fn read_config(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}
fn main() {
match read_config("config.toml") {
Ok(content) => println!("설정 내용: {}", content),
Err(e) => eprintln!("설정 파일 읽기 실패: {}", e),
}
}? 연산자로 에러 전파? 연산자는 Result를 반환하는 함수에서 에러를 간결하게 전파합니다. Ok이면 값을 추출하고, Err이면 즉시 현재 함수에서 에러를 반환합니다.
use std::fs;
use std::io;
// ? 연산자 없이 — 중첩된 match
fn load_config_verbose(path: &str) -> Result<Config, io::Error> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e),
};
// 파싱 로직...
Ok(Config { content })
}
// ? 연산자 사용 — 깔끔한 코드
fn load_config(path: &str) -> Result<Config, io::Error> {
let content = fs::read_to_string(path)?;
// 파싱 로직...
Ok(Config { content })
}
struct Config { content: String }? 연산자는 에러 타입 간의 From 변환도 자동으로 수행합니다. 이를 통해 서로 다른 에러 타입을 하나로 통합할 수 있습니다.
thiserror는 커스텀 에러 타입을 쉽게 정의할 수 있게 해주는 derive 매크로 크레이트입니다. 주로 라이브러리나 공유 모듈에서 사용합니다.
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("사용자를 찾을 수 없습니다: id={0}")]
UserNotFound(u64),
#[error("인증 실패: {reason}")]
AuthenticationFailed { reason: String },
#[error("데이터베이스 오류")]
Database(#[from] sqlx::Error),
#[error("잘못된 요청: {0}")]
BadRequest(String),
#[error("내부 서버 오류")]
Internal(#[from] anyhow::Error),
}주요 특징은 다음과 같습니다.
#[error("...")]: Display 트레이트를 자동 구현합니다#[from]: From 트레이트를 자동 구현하여 ? 연산자로 자동 변환을 지원합니다anyhow는 애플리케이션 수준에서 다양한 에러를 유연하게 처리할 수 있게 해주는 크레이트입니다.
use anyhow::{Context, Result, bail};
// anyhow::Result<T>는 Result<T, anyhow::Error>의 축약
async fn initialize_app() -> Result<AppState> {
let config = load_config("config.toml")
.context("설정 파일 로드 실패")?;
let db_pool = create_db_pool(&config.database_url)
.await
.context("데이터베이스 연결 실패")?;
if config.max_connections == 0 {
bail!("max_connections는 0보다 커야 합니다");
}
Ok(AppState { config, db_pool })
}2026년 Rust 커뮤니티의 모범 사례는 두 크레이트를 함께 사용하는 것입니다.
| 계층 | 에러 처리 | 도구 |
|---|---|---|
| main / 핸들러 | anyhow::Result | anyhow |
| 서비스 / 비즈니스 로직 | 커스텀 에러 열거형 | thiserror |
| 라이브러리 / 공유 모듈 | 커스텀 에러 열거형 | thiserror |
// 도메인 에러 (thiserror)
#[derive(Debug, thiserror::Error)]
enum UserError {
#[error("사용자를 찾을 수 없습니다: id={0}")]
NotFound(u64),
#[error("이메일이 이미 존재합니다: {0}")]
DuplicateEmail(String),
#[error("데이터베이스 오류")]
Database(#[from] sqlx::Error),
}
// Axum 핸들러에서 에러를 HTTP 응답으로 변환
impl axum::response::IntoResponse for UserError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match &self {
UserError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
UserError::DuplicateEmail(_) => (StatusCode::CONFLICT, self.to_string()),
UserError::Database(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"내부 서버 오류".to_string(),
),
};
let body = Json(serde_json::json!({
"error": message,
}));
(status, body).into_response()
}
}데이터베이스 에러와 같은 내부 에러를 API 응답에 그대로 노출하면 보안 취약점이 됩니다. 클라이언트에게는 일반적인 메시지를 반환하고, 상세 에러는 서버 로그에 기록하세요.
? 연산자가 에러를 자동 변환하는 원리는 From 트레이트에 있습니다.
#[derive(Debug)]
enum ServiceError {
Database(String),
Validation(String),
}
// io::Error를 ServiceError로 자동 변환
impl From<std::io::Error> for ServiceError {
fn from(err: std::io::Error) -> Self {
ServiceError::Database(err.to_string())
}
}
fn read_data() -> Result<String, ServiceError> {
let data = std::fs::read_to_string("data.txt")?; // io::Error → ServiceError 자동 변환
Ok(data)
}thiserror의 #[from] 속성이 바로 이 From 구현을 자동 생성하는 것입니다.
use anyhow::{Context, Result};
async fn handle_create_user(
Json(req): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>, AppError> {
// 1. 입력 검증
validate_email(&req.email)
.map_err(|e| AppError::BadRequest(e.to_string()))?;
// 2. 비즈니스 로직 — ?로 에러 전파
let user = create_user(&req)
.await?; // UserError가 AppError로 자동 변환 (From 트레이트)
// 3. 응답 변환
Ok(Json(UserResponse::from(user)))
}이 장에서는 Rust의 타입 시스템과 에러 처리를 다루었습니다.
? 연산자로 에러를 간결하게 전파하며, From 트레이트가 자동 변환을 담당합니다다음 장에서는 async/await와 Tokio 런타임을 다룹니다. 비동기 프로그래밍의 기초부터 Tokio의 동시성 패턴까지 살펴봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Rust의 비동기 프로그래밍 기초부터 Future 트레이트, Tokio 런타임, spawn/select/join, 채널 기반 동시성 패턴까지 체계적으로 다룹니다.
Rust의 핵심 개념인 소유권 규칙 3가지, Move 시맨틱스, 불변/가변 빌림, 라이프타임 기초를 백엔드 개발자 관점에서 체계적으로 다룹니다.
Axum의 Tower 기반 아키텍처, 라우팅, 핸들러, 추출자, 응답 타입, 미들웨어까지 Hello World에서 CRUD API까지 실습합니다.