왜 Rust로 백엔드를 만드는가
Rust는 시스템 프로그래밍 언어로 출발했지만, 2026년 현재 백엔드 개발 영역에서도 빠르게 채택되고 있습니다. 그 이유는 크게 세 가지입니다.
- 성능: 가비지 컬렉터 없이 C/C++에 준하는 성능을 제공합니다. 동일한 하드웨어에서 Java나 Go 대비 2~5배 적은 메모리를 사용하면서도 더 높은 처리량을 보입니다.
- 안전성: 소유권 시스템(Ownership System)과 빌림 검사기(Borrow Checker)를 통해 컴파일 타임에 메모리 안전성과 데이터 레이스를 방지합니다. 런타임에 Null Pointer Exception이나 동시성 버그가 발생하지 않습니다.
- 운영 비용 절감: 적은 리소스로 높은 처리량을 달성하므로 인프라 비용이 절감됩니다. Discord는 Go에서 Rust로 전환하여 메모리 사용량을 10배 줄인 사례가 있습니다.
Axum 프레임워크 소개
Axum은 Tokio 팀이 개발한 웹 프레임워크로, Rust 생태계에서 가장 활발하게 성장하고 있는 프레임워크입니다. Tower 미들웨어 생태계와 완벽하게 호환되며, 매크로 의존도가 낮아 컴파일 에러 메시지가 명확하다는 장점이 있습니다.
Rust 백엔드 프레임워크 비교
┌──────────┬────────────┬────────────┬───────────────┐
│ 프레임워크 │ 특징 │ 생태계 │ 추천 대상 │
├──────────┼────────────┼────────────┼───────────────┤
│ Axum │ 타입 안전, │ Tower 호환 │ 새 프로젝트, │
│ │ 매크로 최소 │ Tokio 기반 │ API 서버 │
├──────────┼────────────┼────────────┼───────────────┤
│ Actix-web│ 높은 성능, │ 자체 생태계 │ 최고 성능 필요 │
│ │ Actor 모델 │ │ │
├──────────┼────────────┼────────────┼───────────────┤
│ Rocket │ 사용 편의, │ 독자적 │ 빠른 프로토 │
│ │ 매크로 활용 │ │ 타이핑 │
└──────────┴────────────┴────────────┴───────────────┘
프로젝트 셋업
Cargo를 사용하여 새 프로젝트를 생성하고 필요한 의존성을 추가합니다.
프로젝트 생성
# Rust 설치 (아직 안 했다면)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 프로젝트 생성
cargo new todo-api
cd todo-api
Cargo.toml 설정
[package]
name = "todo-api"
version = "0.1.0"
edition = "2021"
[dependencies]
# 웹 프레임워크
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
# 직렬화
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 데이터베이스
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
# 유틸리티
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "2.0"
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
라우팅과 핸들러
Axum의 라우팅은 함수 시그니처 기반으로 동작합니다. 핸들러 함수의 매개변수 타입에 따라 요청 데이터를 자동으로 추출(extract)합니다.
기본 서버 구조
use axum::{
Router, Json,
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post, put, delete},
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
// 애플리케이션 상태
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 환경 변수 로드
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter("todo_api=debug,tower_http=debug")
.init();
// DB 연결
let database_url = std::env::var("DATABASE_URL")?;
let pool = PgPool::connect(&database_url).await?;
sqlx::migrate!().run(&pool).await?;
let state = AppState { db: pool };
// 라우터 구성
let app = Router::new()
.route("/api/todos", get(list_todos).post(create_todo))
.route("/api/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
.route("/health", get(health_check))
.layer(
tower_http::cors::CorsLayer::permissive()
)
.layer(
tower_http::trace::TraceLayer::new_for_http()
)
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!("서버가 포트 3000에서 실행 중입니다");
axum::serve(listener, app).await?;
Ok(())
}
핸들러 구현
// 요청/응답 모델
#[derive(Serialize, Deserialize)]
struct Todo {
id: uuid::Uuid,
title: String,
description: Option<String>,
completed: bool,
created_at: chrono::NaiveDateTime,
updated_at: chrono::NaiveDateTime,
}
#[derive(Deserialize)]
struct CreateTodoRequest {
title: String,
description: Option<String>,
}
#[derive(Deserialize)]
struct UpdateTodoRequest {
title: Option<String>,
description: Option<String>,
completed: Option<bool>,
}
#[derive(Deserialize)]
struct ListParams {
page: Option<u32>,
size: Option<u32>,
completed: Option<bool>,
}
// 목록 조회
async fn list_todos(
State(state): State<AppState>,
Query(params): Query<ListParams>,
) -> Result<Json<Vec<Todo>>, AppError> {
let page = params.page.unwrap_or(0);
let size = params.size.unwrap_or(20).min(100);
let offset = (page * size) as i64;
let todos = sqlx::query_as!(
Todo,
r#"
SELECT id, title, description, completed, created_at, updated_at
FROM todos
WHERE ($1::bool IS NULL OR completed = $1)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
params.completed,
size as i64,
offset
)
.fetch_all(&state.db)
.await?;
Ok(Json(todos))
}
// 단일 조회
async fn get_todo(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<Todo>, AppError> {
let todo = sqlx::query_as!(
Todo,
"SELECT * FROM todos WHERE id = $1",
id
)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound(format!("Todo {} not found", id)))?;
Ok(Json(todo))
}
// 생성
async fn create_todo(
State(state): State<AppState>,
Json(req): Json<CreateTodoRequest>,
) -> Result<(StatusCode, Json<Todo>), AppError> {
let todo = sqlx::query_as!(
Todo,
r#"
INSERT INTO todos (id, title, description, completed, created_at, updated_at)
VALUES ($1, $2, $3, false, NOW(), NOW())
RETURNING *
"#,
uuid::Uuid::new_v4(),
req.title,
req.description
)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(todo)))
}
// 수정
async fn update_todo(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<UpdateTodoRequest>,
) -> Result<Json<Todo>, AppError> {
let todo = sqlx::query_as!(
Todo,
r#"
UPDATE todos SET
title = COALESCE($2, title),
description = COALESCE($3, description),
completed = COALESCE($4, completed),
updated_at = NOW()
WHERE id = $1
RETURNING *
"#,
id,
req.title,
req.description,
req.completed
)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound(format!("Todo {} not found", id)))?;
Ok(Json(todo))
}
// 삭제
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<StatusCode, AppError> {
let result = sqlx::query!("DELETE FROM todos WHERE id = $1", id)
.execute(&state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Todo {} not found", id)));
}
Ok(StatusCode::NO_CONTENT)
}
// 헬스 체크
async fn health_check() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
JSON 직렬화 (serde)
Rust에서 JSON 처리는 serde와 serde_json 크레이트를 사용합니다. #[derive(Serialize, Deserialize)] 매크로만 추가하면 자동으로 직렬화/역직렬화가 구현됩니다.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] // JSON은 camelCase로 변환
struct ApiResponse<T: Serialize> {
success: bool,
data: Option<T>,
error_message: Option<String>, // JSON: errorMessage
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<Metadata>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Metadata {
total_count: i64,
page: u32,
page_size: u32,
}
// 사용 예시
fn success_response<T: Serialize>(data: T) -> Json<ApiResponse<T>> {
Json(ApiResponse {
success: true,
data: Some(data),
error_message: None,
metadata: None,
})
}
// 커스텀 직렬화
#[derive(Serialize)]
struct UserResponse {
name: String,
#[serde(serialize_with = "serialize_date")]
created_at: chrono::NaiveDateTime,
#[serde(skip)] // JSON에 포함하지 않음
password_hash: String,
}
SQLx로 PostgreSQL 연동
SQLx는 Rust의 비동기 SQL 라이브러리로, 컴파일 타임에 SQL 쿼리의 유효성을 검증하는 독특한 기능을 제공합니다.
마이그레이션
# SQLx CLI 설치
cargo install sqlx-cli --no-default-features --features postgres
# 마이그레이션 생성
sqlx migrate add create_todos_table
-- migrations/20260215_create_todos_table.sql
CREATE TABLE IF NOT EXISTS todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
description TEXT,
completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_todos_completed ON todos(completed);
CREATE INDEX idx_todos_created_at ON todos(created_at DESC);
컴파일 타임 쿼리 검증
// sqlx::query_as!는 컴파일 시점에 DB에 연결하여 쿼리를 검증합니다
// 잘못된 컬럼명이나 타입 불일치를 컴파일 에러로 잡아냅니다!
// 이 코드는 컴파일 에러 발생 (titl 오타)
// let todo = sqlx::query_as!(Todo, "SELECT titl FROM todos WHERE id = $1", id);
// ^^^^ 컬럼 'titl'이 존재하지 않음
// 올바른 쿼리
let todo = sqlx::query_as!(Todo, "SELECT * FROM todos WHERE id = $1", id)
.fetch_one(&pool)
.await?;
cargo sqlx prepare 명령으로 쿼리 메타데이터를 미리 생성하면 DB 없이도 컴파일 타임 검증이 가능합니다.
미들웨어 (Tower)
Axum은 Tower 미들웨어 생태계를 그대로 활용합니다. 인증, 로깅, 압축, CORS 등 다양한 미들웨어를 레이어로 조합할 수 있습니다.
use axum::middleware::{self, Next};
use axum::extract::Request;
use axum::response::Response;
use std::time::Instant;
// 커스텀 미들웨어: 요청 처리 시간 측정
async fn timing_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let start = Instant::now();
let response = next.run(req).await;
let duration = start.elapsed();
tracing::info!(
"{} {} - {} - {:?}",
method, uri, response.status(), duration
);
response
}
// 인증 미들웨어
async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let token = req.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized("토큰이 필요합니다".into()))?;
let claims = validate_jwt(token, &state.jwt_secret)?;
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
// 미들웨어 적용
let app = Router::new()
.route("/api/todos", get(list_todos).post(create_todo))
.route_layer(middleware::from_fn_with_state(
state.clone(), auth_middleware
))
.route("/health", get(health_check)) // 인증 불필요
.layer(middleware::from_fn(timing_middleware))
.layer(tower_http::compression::CompressionLayer::new())
.layer(tower_http::cors::CorsLayer::permissive())
.with_state(state);
에러 핸들링 패턴
Rust의 에러 핸들링은 Result 타입과 ? 연산자를 기반으로 합니다. Axum에서는 IntoResponse 트레이트를 구현하여 에러를 HTTP 응답으로 변환합니다.
use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("리소스를 찾을 수 없습니다: {0}")]
NotFound(String),
#[error("인증이 필요합니다: {0}")]
Unauthorized(String),
#[error("잘못된 요청: {0}")]
BadRequest(String),
#[error("데이터베이스 오류")]
Database(#[from] sqlx::Error),
#[error("내부 서버 오류")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Database(e) => {
tracing::error!("DB 오류: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR,
"데이터베이스 오류가 발생했습니다".into())
}
AppError::Internal(e) => {
tracing::error!("내부 오류: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR,
"내부 서버 오류가 발생했습니다".into())
}
};
let body = serde_json::json!({
"success": false,
"error": message,
});
(status, Json(body)).into_response()
}
}
Docker 배포
Rust 바이너리는 정적 링크가 가능하여 매우 작은 Docker 이미지를 만들 수 있습니다. 멀티스테이지 빌드를 활용하면 최종 이미지 크기를 10MB 이하로 줄일 수 있습니다.
# ===== 빌드 스테이지 =====
FROM rust:1.85-bookworm AS builder
WORKDIR /app
# 의존성 캐시를 위해 먼저 Cargo 파일만 복사
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release && rm -rf src
# 소스 코드 복사 후 빌드
COPY . .
RUN touch src/main.rs # 타임스탬프 갱신
ENV SQLX_OFFLINE=true # 오프라인 모드 (DB 없이 빌드)
RUN cargo build --release
# ===== 실행 스테이지 =====
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/todo-api /usr/local/bin/
COPY --from=builder /app/migrations /app/migrations
ENV RUST_LOG=info
EXPOSE 3000
CMD ["todo-api"]
docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://postgres:secret@db:5432/tododb
- RUST_LOG=todo_api=debug,tower_http=debug
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: tododb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Java/Go 대비 성능 비교
동일한 Todo API를 Java(Spring Boot), Go(Gin), Rust(Axum)로 구현하여 벤치마크한 결과입니다. 테스트는 wrk 도구로 100 동시 연결, 30초간 수행했습니다.
┌─────────────────┬──────────────┬──────────────┬──────────────┐
│ 지표 │ Spring Boot │ Go (Gin) │ Rust (Axum) │
├─────────────────┼──────────────┼──────────────┼──────────────┤
│ 요청/초 (RPS) │ 28,400 │ 89,200 │ 142,000 │
│ 평균 응답 시간 │ 3.52ms │ 1.12ms │ 0.70ms │
│ P99 응답 시간 │ 12.8ms │ 4.21ms │ 2.15ms │
│ 메모리 사용량 │ 280MB │ 32MB │ 12MB │
│ Docker 이미지 │ 320MB │ 18MB │ 8MB │
│ 콜드 스타트 │ 3.2초 │ 0.05초 │ 0.02초 │
└─────────────────┴──────────────┴──────────────┴──────────────┘
Rust 백엔드 도입 시 고려사항
Rust 백엔드를 도입하기 전에 현실적으로 고려해야 할 사항들입니다.
- 학습 곡선: 소유권과 라이프타임 개념은 처음 접하면 매우 어렵습니다. 팀원 전체가 일정 수준에 도달하는 데 2~3개월이 필요합니다.
- 컴파일 시간: 대규모 프로젝트에서 풀 빌드는 수 분이 걸릴 수 있습니다. 증분 컴파일과
cargo-watch,mold링커 등으로 개선할 수 있습니다. - 생태계 성숙도: Java/Spring이나 Node.js에 비하면 라이브러리 선택지가 적습니다. 다만 핵심 라이브러리(Tokio, Serde, SQLx 등)는 매우 안정적입니다.
- 채용: Rust 개발자 풀이 아직 작습니다. 기존 팀원을 교육하거나, Rust에 관심 있는 개발자를 채용하는 전략이 필요합니다.
- 점진적 도입: 전체를 Rust로 전환하기보다, 성능이 중요한 마이크로서비스부터 시작하는 것을 권장합니다.
마무리
Rust와 Axum으로 REST API를 만드는 것은 처음에는 진입 장벽이 높게 느껴지지만, 컴파일러가 가이드해주는 안전한 코드 작성 경험은 다른 언어에서 얻기 어려운 가치입니다. 한번 컴파일되면 런타임 에러가 극히 드물고, 프로덕션에서의 안정성은 탁월합니다.
성능이 중요한 서비스, 인프라 비용을 줄여야 하는 상황, 또는 높은 안전성이 요구되는 프로젝트에서 Rust 백엔드를 고려해 보세요. 학습 비용을 투자한 만큼, 안정적이고 효율적인 서비스를 운영할 수 있을 것입니다.
10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.
GitHub →
💬 댓글