SQL 오류를 빌드 시점에 잡아내는 Rust API 서버 — Axum + SQLx 타입 안전 패턴 정리
Go나 Node.js로 백엔드를 운영하다 보면 한 번쯤은 이런 경험을 합니다. 스키마를 바꿨는데 쿼리 수정을 빠뜨려서 프로덕션에서 500이 터지거나, 타입 캐스팅을 잘못 써서 런타임에야 비로소 에러가 드러나는 상황이요. "테스트 커버리지가 높으면 괜찮겠지"라고 생각했는데, 실제로는 엣지 케이스 쿼리가 테스트를 비껴가는 일이 꽤 있었습니다.
Rust 생태계에서 Axum + SQLx 조합은 이 문제를 근본적으로 다른 방식으로 해결합니다. SQL 문법이 틀리거나, 컬럼명이 바뀌거나, 반환 타입이 달라지면 — cargo build 자체가 실패합니다. 런타임이 아니라 빌드 시점에 DB 스키마와 Rust 타입을 동시에 검증하는 거죠.
GC 없는 Rust의 특성상 메모리 사용량과 tail latency 안정성에서 Go·Node.js 대비 구조적 이점이 있다는 건 여러 벤치마크에서 공통으로 나타나는 경향입니다. 다만 실제 수치는 하드웨어·OS·DB 설정·부하 패턴에 따라 크게 달라지므로, 도입 전 자신의 워크로드에 맞게 직접 측정해보는 것이 중요합니다.
이 글에서는 Axum과 SQLx가 내부적으로 어떻게 동작하는지 설명하고, 실제로 자주 쓰는 패턴들 — State 공유, 커넥션 풀 튜닝, 트랜잭션 처리, CI/CD 환경 셋업 — 을 코드와 함께 살펴봅니다. Go나 Node.js를 써본 분이라면 개념적으로 익숙한 부분도 있고, 처음엔 낯선 부분도 있을 텐데, 최대한 그 차이를 짚어가며 설명하겠습니다.
핵심 개념
Axum: Tower Service 위에 올라탄 웹 프레임워크
Axum은 Tokio 팀이 직접 만들고 관리하는 웹 프레임워크입니다. Express나 Gin처럼 라우터에 핸들러를 연결하는 방식은 비슷한데, 내부 설계가 좀 다릅니다.
Axum의 핵심 설계 철학은 Tower의 Service 트레이트 위에서 타입 안전한 추출기(Extractor) 시스템을 제공하는 것입니다. 모든 미들웨어가 tower::Service 트레이트 위에 올라가 있어서, Tower 생태계의 컴포넌트를 별도 구현 없이 그대로 조합할 수 있습니다. Express의 app.use()나 Go의 net/http 미들웨어 체인과 개념은 비슷하지만, Tower는 타입 레벨에서 체인이 구성되기 때문에 미들웨어 조합의 정합성을 런타임이 아닌 컴파일 시점에 검증합니다.
Axum은 #[debug_handler] 같은 편의 매크로도 공식 제공합니다. 핸들러 타입 오류가 발생했을 때 컴파일러 메시지를 훨씬 읽기 쉽게 만들어주는 도구로, Cargo.toml에서 axum = { features = ["macros"] }를 활성화하면 쓸 수 있습니다.
SQLx: 타입 안전 SQL 툴킷
SQLx는 Diesel처럼 Rust 구조체로 스키마를 정의하는 ORM이 아닙니다. SQL을 직접 씁니다. 다만 그 SQL이 유효한지를 컴파일 타임에 DB에 물어보는 방식입니다.
query! 매크로를 쓰면 cargo build 시점에 DATABASE_URL로 연결된 DB에 SQL을 파싱 요청하고, 반환 컬럼의 타입 메타데이터를 가져옵니다. PostgreSQL의 extended query protocol을 사용해 파라미터 타입을 DB가 직접 추론하기 때문에, SQL 파싱 로직을 Rust 코드가 재구현할 필요가 없습니다. 이 메타데이터를 바탕으로 Rust 타입 코드를 자동 생성합니다.
query!는 익명 구조체를 반환하고, query_as!는 직접 정의한 struct로 바로 매핑합니다. 프로덕션에서는 query_as!가 더 명시적이어서 코드 가독성에 유리합니다.
SQLX_OFFLINE=true일 때도 타입 검증은 동일하게 수행됩니다. DB에 직접 연결하는 대신, 미리 생성해둔 .sqlx/ 캐시 파일에서 메타데이터를 읽어 같은 타입 검사를 거칩니다. DB 없는 CI 환경에서도 타입 안전성을 유지할 수 있는 이유가 여기 있습니다.
Go의 database/sql과 비교하면, 둘 다 SQL을 직접 쓰지만 database/sql은 결과 매핑이 interface{}를 통해 이루어져 타입 오류를 런타임까지 알 수 없습니다. SQLx는 이 간격을 컴파일 시점으로 당깁니다.
커넥션 풀 설정
PgPoolOptions로 풀을 생성하고, Axum의 State 추출기로 핸들러에 주입합니다.
| 파라미터 | 권장값 | 설명 |
|---|---|---|
max_connections |
20 | 동시 연결 상한. PostgreSQL 기본 한도(100)를 고려해 설정 |
min_connections |
5 | 예열 연결 수. 버스트 시 레이턴시 스파이크 방지 |
max_lifetime |
1800s | 커넥션 수명. DB 측 메모리 누수 방지 |
idle_timeout |
300s | 유휴 커넥션 회수 타이머 |
acquire_timeout |
30s | 풀 대기 타임아웃. 이 시간 초과 시 에러 반환 |
max_connections를 무작정 높이면 DB 서버가 연결을 거부할 수 있습니다. PostgreSQL 기본 한도가 100개라, 여러 서버 인스턴스가 뜨는 환경이라면 인스턴스당 연결 수를 (DB 한도 - 관리용 여유) / 인스턴스 수로 계산해서 설정하는 게 중요합니다.
실전 적용
프로젝트 세팅
Cargo.toml에 의존성을 추가합니다.
[dependencies]
axum = { version = "0.8", features = ["macros"] }
sqlx = { version = "0.8", features = [
"runtime-tokio-native-tls",
"postgres",
"macros",
"chrono",
"uuid",
] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "2"sqlx는 기본적으로 아무 DB 드라이버도 포함하지 않습니다. postgres, runtime-tokio-native-tls, macros를 명시적으로 활성화해야 합니다. chrono와 uuid는 해당 타입을 쿼리 결과에 바로 매핑하려면 필요합니다.
애플리케이션 시작과 State 공유
Axum에서 DB 풀을 핸들러에 넘기는 방식은 Go의 의존성 주입과 비슷합니다. 다만 Rust는 타입 시스템이 공유 상태를 컴파일 타임에 검증합니다.
use axum::{Router, routing::get};
use sqlx::PgPool;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("info,sqlx=warn")
.init();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.max_lifetime(std::time::Duration::from_secs(1800))
.idle_timeout(std::time::Duration::from_secs(300))
.acquire_timeout(std::time::Duration::from_secs(30))
.connect(&database_url)
.await?;
// 마이그레이션 실패 시 ? 로 에러가 전파되어 프로세스가 종료됩니다.
// 마이그레이션이 실패한 상태로 서버를 띄우는 것보다 빠르게 실패(fail-fast)하는 편이 안전합니다.
sqlx::migrate!("./migrations").run(&pool).await?;
let state = AppState { db: pool };
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user))
.layer(tower_http::trace::TraceLayer::new_for_http())
.layer(
tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any) // 프로덕션에서는 명시적 출처를 지정할 것
)
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!("서버 시작: http://0.0.0.0:3000");
axum::serve(listener, app).await?;
Ok(())
}AppState에 #[derive(Clone)]이 붙는 이유는 Axum이 내부적으로 상태를 Arc로 감싸서 각 요청에 복사(clone)하기 때문입니다. PgPool 자체가 이미 Arc<내부 상태> 구조라 복사 비용이 거의 없습니다. AppState를 Arc<AppState>로 직접 감싸고 with_state에 넘기면 이중 래핑이 되니 주의하세요.
타입 안전 쿼리 핸들러 작성
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize)]
struct User {
id: Uuid,
email: String,
name: String,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
struct CreateUserRequest {
email: String,
name: String,
}
async fn list_users(
State(state): State<AppState>,
) -> Result<Json<Vec<User>>, AppError> {
let users = sqlx::query_as!(
User,
"SELECT id, email, name, created_at FROM users ORDER BY created_at DESC"
)
.fetch_all(&state.db)
.await?;
Ok(Json(users))
}
async fn get_user(
Path(id): Path<Uuid>,
State(state): State<AppState>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, email, name, created_at FROM users WHERE id = $1",
id
)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(user))
}
async fn create_user(
State(state): State<AppState>,
Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<User>), AppError> {
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, email, name, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id, email, name, created_at
"#,
Uuid::new_v4(),
req.email,
req.name
)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(user)))
}fetch_optional은 결과가 없을 때 None을 반환합니다. Go의 sql.ErrNoRows가 에러 경로로 row 부재를 표현하는 것과 달리, Option<T>는 타입 시스템에서 결과의 유무를 값으로 표현합니다. 두 방식 모두 "row가 없는 경우"를 처리하지만, Rust에서는 이를 에러가 아닌 정상 값의 일부로 다룬다는 점이 다릅니다.
$1, $2 바인드 파라미터는 컴파일 타임에 타입이 검증됩니다. Uuid를 String으로 넘기면 빌드가 실패합니다. 여러 줄 SQL은 r#"..."# raw string literal로 가독성 좋게 작성할 수 있습니다.
에러 처리 패턴
Axum은 핸들러가 IntoResponse를 구현한 타입을 반환하면 됩니다. thiserror로 도메인 에러를 정의하고 IntoResponse를 구현하면 에러 처리가 깔끔해집니다.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("리소스를 찾을 수 없습니다")]
NotFound,
#[error("데이터베이스 오류: {0}")]
Database(#[from] sqlx::Error),
#[error("유효하지 않은 요청: {0}")]
BadRequest(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => {
(StatusCode::NOT_FOUND, "리소스를 찾을 수 없습니다".to_string())
}
AppError::Database(e) => {
// 내부 에러 원인은 클라이언트에 노출하지 않고 서버 로그에만 기록합니다
tracing::error!(error = %e, "데이터베이스 오류 발생");
(StatusCode::INTERNAL_SERVER_ERROR, "내부 서버 오류가 발생했습니다".to_string())
}
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}#[error("데이터베이스 오류: {0}")]로 정의한 Display 구현은 내부 로깅용이고, HTTP 응답 메시지는 클라이언트에 안전하게 노출할 수 있는 sanitized 문자열을 별도로 씁니다. 두 메시지를 구분하는 이유가 바로 여기 있습니다. #[from] sqlx::Error를 추가하면 ? 연산자로 자동 변환됩니다.
트랜잭션 처리
여러 쿼리를 원자적으로 처리할 때는 pool.begin()을 씁니다. 트랜잭션 객체가 Drop될 때 자동으로 롤백되므로, 명시적으로 commit()을 호출해야만 변경사항이 저장됩니다.
async fn transfer_credits(
State(state): State<AppState>,
Json(req): Json<TransferRequest>,
) -> Result<StatusCode, AppError> {
let mut tx = state.db.begin().await?;
let result = sqlx::query!(
"UPDATE accounts SET credits = credits - $1 WHERE id = $2 AND credits >= $1",
req.amount,
req.from_id
)
.execute(&mut *tx)
.await?;
// WHERE 조건 불만족(잔액 부족)이면 affected row가 0 — 명시적으로 확인해야 합니다.
// 이 검사 없이 넘어가면 출금 없이 입금만 실행되는 버그가 생깁니다.
if result.rows_affected() == 0 {
return Err(AppError::BadRequest("잔액이 부족합니다".into()));
// 여기서 함수를 나가면 tx가 Drop되며 자동 롤백됩니다
}
sqlx::query!(
"UPDATE accounts SET credits = credits + $1 WHERE id = $2",
req.amount,
req.to_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(StatusCode::OK)
}rows_affected() 확인이 중요한 이유를 짚어두겠습니다. WHERE credits >= $1 조건이 불만족되면 UPDATE는 아무 행도 수정하지 않고 조용히 성공합니다. 이 결과를 체크하지 않고 넘어가면 출금은 실패했는데 입금 쿼리는 그대로 실행되는 심각한 버그가 생깁니다.
&mut *tx로 트랜잭션 참조를 전달하는 것이 처음엔 낯설 수 있는데, tx가 Executor 트레이트를 구현한 타입이라 역참조해서 넘기는 패턴입니다. 이 역시 컴파일러가 잘못된 사용을 잡아줍니다.
CI/CD에서 Offline 모드
빌드 서버에 DB가 없을 때를 위해 SQLx는 offline 모드를 제공합니다. 로컬에서 메타데이터를 스냅샷해두고, CI에서는 그걸 사용합니다.
# 로컬에서 메타데이터 생성 (DB 연결 필요)
cargo sqlx prepare
# 생성된 .sqlx/ 디렉토리를 git에 커밋
git add .sqlx/
git commit -m "sqlx offline 메타데이터 업데이트"CI 환경에서는 SQLX_OFFLINE=true로 설정하면 DATABASE_URL 없이도 .sqlx/ 파일에서 메타데이터를 읽어 동일한 타입 검증이 수행됩니다.
# .github/workflows/ci.yml
- name: Build
env:
SQLX_OFFLINE: "true"
run: cargo build --release스키마가 바뀌거나 쿼리를 추가·수정할 때마다 cargo sqlx prepare를 재실행하고 커밋해야 합니다. 이걸 빠뜨리면 CI에서 "offline mode is enabled but the query data could not be found" 에러가 납니다. PR 단계에서 .sqlx/와 소스 쿼리의 일관성을 검사하는 CI 스텝을 추가해두면 이 실수를 방지할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 컴파일 타임 SQL 검증 | 런타임 SQL 오류 원천 차단. 스키마 변경 시 즉시 빌드 실패로 회귀 감지 |
| GC 없는 성능 | Tail latency 안정성이 Go·Node.js 대비 구조적으로 유리. 실제 수치는 환경에 따라 달라지므로 직접 측정 필요 |
| 낮은 메모리 사용량 | 컨테이너 환경에서 Node.js·Go 대비 유리. 실 환경 검증 권장 |
| Tower 에코시스템 | 인증·압축·CORS·트레이싱을 레이어로 조합. 미들웨어를 직접 구현하지 않아도 됨 |
| End-to-end 타입 안전성 | 추출기, 핸들러, 미들웨어 모두 컴파일러가 검증 |
단점 및 주의사항
| 항목 | 내용 |
|---|---|
| 컴파일 시간 | 초기 빌드가 Go·Node.js 대비 수 배 느림. Incremental 빌드는 빠르지만 CI cold start가 부담 |
| 학습 곡선 | Tower의 Service/Layer 트레이트, Rust 라이프타임에 익숙해지는 데 시간이 필요 |
| 개발 환경 복잡성 | 기본 모드에서 DATABASE_URL이 필요. Offline 모드는 .sqlx/ 동기화 관리가 필요 |
| ORM 부재 | 복잡한 조인·관계 매핑을 직접 SQL로 작성. Diesel 대비 보일러플레이트가 더 많을 수 있음 |
| 에러 타입 복잡성 | Tower 미들웨어 에러는 Infallible이거나 HandleErrorLayer로 반드시 처리해야 함 |
실무에서 흔히 만나는 실수
1. max_connections 오버프로비저닝
PostgreSQL 기본 연결 한도는 100개입니다. 서버 인스턴스가 3개 뜨고 각각 max_connections(50)으로 설정하면 DB가 연결을 거부하기 시작합니다. (DB 한도 - 관리용 여유) / 인스턴스 수로 계산하세요.
2. .sqlx/ 파일 커밋 누락
쿼리를 추가하거나 수정한 후 cargo sqlx prepare를 실행하지 않으면 CI에서 빌드가 실패합니다. PR에 쿼리 변경이 포함될 때마다 prepare를 실행하는 습관을 들이거나, CI에서 일관성 검사 스텝을 추가하는 게 좋습니다.
3. Arc 이중 래핑
AppState를 Arc<AppState>로 직접 감싸고 with_state에 넘기면, Axum이 내부적으로 다시 Arc로 감싸서 이중 래핑이 됩니다. PgPool은 이미 Arc 기반이라 AppState 자체는 단순히 #[derive(Clone)]으로 충분합니다.
4. 트랜잭션에서 rows_affected() 무시
조건부 UPDATE(예: WHERE stock >= amount)는 조건 불만족 시 조용히 성공합니다. 영향받은 행 수를 확인하지 않으면 잔액 이중 처리 같은 논리 버그로 이어집니다.
마치며
Axum + SQLx + Tokio 트리오는 가장 활발하게 성장하는 Rust 백엔드 스택 중 하나입니다. Actix-web 같은 대안도 건재하고, Rust 백엔드 생태계에는 명시적 "표준"이 없지만, 이 스택의 핵심 가치는 분명합니다.
- SQL 오류를 빌드 시점에 잡는다: 런타임 500 에러가 아닌
cargo build실패로. - GC 없는 성능: 고트래픽 환경에서 tail latency 안정성이 구조적으로 유리합니다.
- 타입 시스템이 아키텍처를 보호한다: 요청 파싱부터 DB 쿼리까지 컴파일러가 end-to-end로 검증합니다.
학습 곡선이 있고 컴파일 시간 비용이 있습니다. 빠른 프로토타이핑이 중요한 팀에게는 Go나 Node.js가 여전히 현실적인 선택지입니다. 하지만 스키마 안정성이 중요하거나, 메모리와 레이턴시가 병목인 서비스라면 충분히 검토할 가치가 있습니다.
지금 바로 시작하고 싶다면 이 순서를 권합니다.
sqlx-cli설치 후cargo sqlx migrate add init으로 마이그레이션 파일 만들기launchbadge/realworld-axum-sqlx레포를 클론해서 구조 훑어보기 — 인증·에러 처리·마이그레이션 패턴이 모두 담겨 있습니다- 기존 Go·Node.js 서비스의 엔드포인트 하나를 Axum으로 재구현하며 차이 체감하기
참고 자료
- SQLx GitHub (launchbadge/sqlx)
- axum 공식 문서 (docs.rs)
- SQLx PoolOptions API 문서 (docs.rs)
- Unraveling sqlx Macros: Compile-Time SQL Verification — Leapcell
- SQLx Offline Mode (Prepare Command) — DeepWiki
- Efficient DB Connection Management with sqlx and bb8/deadpool — Leapcell
- realworld-axum-sqlx (공식 RealWorld 구현체)
- The Ultimate Guide to Axum (Shuttle.dev, 2025)
- How to Write Compile-Time Checked Queries with SQLx — RustFAQ