TypeScript 백엔드에서 Rust로 넘어올 때 가장 먼저 풀리는 것: 컴파일 타임 SQL 검증
TypeScript로 백엔드를 짜다 보면 한 번쯤은 이런 상황을 경험하게 됩니다. 프로덕션에서 500 에러가 터졌는데 로그를 열어보니 column "user_name" does not exist — 알고 보면 컬럼명을 userName으로 잘못 적었던 것. 테스트도 있었고 스테이징도 거쳤는데 그냥 통과해버린 거죠. ORM을 쓰면 타입이 어느 정도 잡아주지만, 동적으로 생성된 쿼리는 런타임 전까지는 알 방법이 없습니다.
Rust 전환을 고민하면서 sqlx의 컴파일 타임 쿼리 검증을 처음 봤을 땐 솔직히 좀 과장 광고 아닌가 싶었습니다. 그런데 직접 마이그레이션 스크립트를 돌리다가 SELECT crated_at FROM posts처럼 흔한 오타를 냈을 때 cargo build가 그 자리에서 튕겨나가는 걸 보고, "아, 이건 CI에서 잡아주는 게 아니라 내 에디터에서 잡아주는 거구나" 싶어졌습니다. 심지어 i32를 TEXT 컬럼에 바인딩하려 했을 때도 타입 미스매치로 빌드가 실패했습니다.
이 글은 2026년 기준 최신 조합인 Axum 0.8과 sqlx 0.8로 커넥션 풀을 타입 시스템에 얹고 SQL 오류를 빌드 타임에 잡는 방법을, TypeScript 백엔드 경험을 바탕으로 풀어봅니다. 이론보다는 "이렇게 하면 돌아간다"에 무게를 뒀습니다.
왜 이 조합인가
TypeScript에서 자꾸 걸리던 지점
TypeScript + Prisma/TypeORM 조합은 정말 생산적입니다. 타입 추론도 되고 마이그레이션도 편하죠. 그런데 트래픽이 올라가거나 쿼리가 복잡해지면 몇 가지 지점에서 계속 걸립니다.
- ORM이 생성하는 쿼리를 눈으로 못 보고 신뢰해야 하는 상황
- JOIN이나 윈도우 함수처럼 복잡한 쿼리는 raw query로 빠져나와야 하는데, 그 순간 타입 안전성이 사라짐
- 런타임에야 발견되는 컬럼 오타·타입 미스매치
Rust + sqlx는 이 지점을 다르게 접근합니다. ORM DSL 없이 순수 SQL을 그대로 쓰되, 매크로가 컴파일 타임에 실제 DB와 통신해서 쿼리를 검증합니다.
Axum 0.8이 바꿔놓은 것
Axum 0.8은 2025년 1월 릴리즈된 버전인데, 가장 큰 변화는 #[async_trait] 매크로 의존성 제거입니다. 0.7 이하에서는 커스텀 추출자(Extractor)를 만들 때 이 매크로를 붙여야 했고, 이게 타입 오류 메시지를 복잡하게 만들고 IDE 지원도 어색하게 했습니다. 0.8부터는 Rust 네이티브 impl Future로 재작성되어 표준 문법 그대로 씁니다.
경로 파라미터 문법도 바뀌었습니다. /:id가 /{id}로, /*path가 /{*path}로 변경되어 0.7에서 올라온다면 라우트 정의를 일괄 수정해야 합니다.
sqlx 0.8에서 달라진 점
sqlx 0.8도 그냥 버전만 올린 게 아니라, 0.7 대비 몇 가지 실무에 영향을 주는 변화가 있습니다.
- 드라이버 크레이트가 분리되어
sqlx-postgres·sqlx-mysql·sqlx-sqlite로 나뉘었습니다. 기존처럼sqlx크레이트의 피처 플래그로도 활성화 가능하지만 내부 구조가 정리됐습니다. AnyPoolAPI가 재설계되어 여러 DB를 동시에 다루는 코드가 더 명확해졌습니다.- 매크로가 인식하는 타입 매핑이 넓어졌습니다(예: PostgreSQL 배열, 커스텀 enum 지원 개선).
- 오프라인 캐시 디렉토리 이름이
sqlx-data.json단일 파일에서.sqlx/디렉토리로 바뀌었습니다(0.7 후반부터 도입, 0.8에서 안정화). 이건 CI 워크플로에 영향을 줍니다.
이 글 코드는 전부 0.8 기준입니다.
준비: 스키마와 프로젝트 셋업
예제에서 사용할 스키마
이 글의 모든 예제는 아래 스키마를 가정합니다. 재현하려면 로컬 DB에 먼저 적용해주세요.
-- migrations/20260101000000_init.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
balance BIGINT NOT NULL DEFAULT 0
);sqlx migrate add init로 생성한 파일에 넣고 sqlx migrate run으로 적용하거나, psql로 직접 실행하면 됩니다.
Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = [
"runtime-tokio",
"tls-rustls", # 프로덕션 DB가 TLS를 요구할 때 필수
"postgres",
"macros",
"migrate",
"uuid",
"chrono",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["trace", "cors"] }
dotenvy = "0.15"
thiserror = "2"
tracing = "0.1"두 가지가 특히 중요합니다. macros 피처를 빼면 query!·query_as! 매크로를 못 씁니다. 그리고 tls-rustls(또는 tls-native-tls)를 빼면 로컬 평문 연결만 되고, TLS를 요구하는 실 DB(대부분의 매니지드 PostgreSQL이 여기 해당)에서는 런타임에 연결이 거부됩니다. runtime-tokio만으로는 TLS가 켜지지 않으니 주의하세요.
환경 설정
# .env
DATABASE_URL=postgres://user:password@localhost:5432/myappdotenvy가 이 파일을 로드해줍니다. sqlx 매크로는 컴파일 타임에 이 값을 읽어 실제 DB에 접속합니다.
커넥션 풀을 타입에 얹기
TypeScript에서는 보통 싱글톤 DB 클라이언트를 모듈 레벨에서 export하거나, Express에서는 req.app.locals에 붙입니다. Axum에서는 이걸 State<T> 추출자로 처리하는데, 여기서 "타입으로 관리한다"는 게 단순히 상태를 넣고 꺼내는 것 이상으로 몇 가지 실질적 보장을 줍니다.
PgPool은Clone+ 내부Arc: 핸들러마다 값으로 받아도 연결이 복사되지 않고 참조 카운트만 오릅니다. 실수로 여러 풀을 만들 여지가 줄어듭니다.PoolConnection<Postgres>RAII: 풀에서 연결을 꺼내면 이 타입으로 감싸져 있고, 스코프를 벗어나 드롭되는 순간 풀로 자동 반납됩니다. TypeScript에서finally { client.release() }를 빼먹어 커넥션 누수를 낸 경험이 있다면, 이 자체가 큰 안전망입니다.Transaction<'_, Postgres>라이프타임: 트랜잭션 핸들이 드롭될 때까지commit()이나rollback()이 호출되지 않으면 드롭 시점에 자동 롤백됩니다. "커밋 안 하고 함수를 빠져나가는" 실수를 타입 시스템이 방지합니다.
즉 "커넥션 풀을 타입으로 관리한다"는 건 단지 DI 얘기가 아니라, 자원 해제와 트랜잭션 완결성이 소유권 규칙으로 강제된다는 뜻입니다.
단순한 경우: Pool을 State로 직접 주입
use axum::{extract::{Path, State}, routing::get, Router};
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::time::Duration;
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let pool = PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(Duration::from_secs(3))
.connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap();
let app = Router::new()
.route("/users/{id}", get(get_user))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}실무 수준: AppState 구조체
설정값이나 다른 공유 리소스가 생기면 구조체로 묶습니다.
use std::sync::Arc;
use axum::{extract::{Path, State}, response::IntoResponse, routing::get, Router};
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::time::Duration;
#[derive(Clone)]
struct Config {
jwt_secret: String,
base_url: String,
}
#[derive(Clone)]
struct AppState {
db: PgPool,
config: Arc<Config>,
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let pool = PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(Duration::from_secs(3))
.connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap();
let config = Arc::new(Config {
jwt_secret: std::env::var("JWT_SECRET").unwrap(),
base_url: std::env::var("BASE_URL").unwrap_or_default(),
});
let state = AppState { db: pool, config };
let app = Router::new()
.route("/users/{id}", get(get_user))
.route("/users", get(list_users).post(create_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}핸들러에서는 구조 분해로 바로 꺼내 씁니다.
async fn get_user(
State(AppState { db, config }): State<AppState>,
Path(id): Path<i64>,
) -> impl IntoResponse {
// db, config를 그대로 사용
}요청 하나에 추출자 여러 개가 병렬로
Axum 핸들러 시그니처에 붙는 추출자들은 "여러 개 중 하나 선택"이 아니라, 한 요청에 대해 각자 독립적으로 실행되어 인자로 전달됩니다.
이 병렬성이 "그냥 함수 인자 순서대로 실행" 이상의 의미를 갖는 이유는, 각 추출자가 실패하면 그 자체가 응답이 되어 핸들러가 아예 호출되지 않기 때문입니다. 즉 Path 파싱 실패는 400, JSON 역직렬화 실패는 422로 자동 매핑되어, 핸들러 안에서 방어 코드를 짤 필요가 없습니다.
컴파일 타임 SQL 검증 실전
기본: query_as! 매크로
use axum::{extract::{Path, State}, Json};
#[derive(serde::Serialize, sqlx::FromRow)]
struct User {
id: i64,
name: String,
email: String,
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
id
)
.fetch_one(&state.db)
.await?;
Ok(Json(user))
}sqlx::FromRow는 쿼리 결과 컬럼을 구조체 필드로 매핑해주는 derive 매크로이고, serde::Serialize는 Axum의 Json<T> 응답에 필요합니다.
여기서 SELECT id, nmae, email ...처럼 오타를 내면 이런 에러가 뜹니다.
error: error returned from database: column "nmae" does not exist
--> src/main.rs:42:13프로덕션 배포는커녕 커밋도 하기 전에 잡힙니다.
목록 조회와 Optional 결과
일관성을 위해 모든 쿼리 함수를 AppError 기반으로 통일합니다.
async fn list_users(
State(state): State<AppState>,
) -> Result<Json<Vec<User>>, AppError> {
let users = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE active = $1 ORDER BY id",
true
)
.fetch_all(&state.db)
.await?;
Ok(Json(users))
}
async fn find_user_by_email(
State(state): State<AppState>,
Path(email): Path<String>,
) -> Result<Json<Option<User>>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE email = $1",
email
)
.fetch_optional(&state.db)
.await?;
Ok(Json(user))
}fetch_one은 결과가 없으면 에러, fetch_optional은 Option<T>, fetch_all은 Vec<T>를 돌려줍니다.
INSERT와 RETURNING
#[derive(serde::Deserialize)]
struct CreateUserRequest {
name: String,
email: String,
}
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email",
payload.name,
payload.email
)
.fetch_one(&state.db)
.await?;
Ok(Json(user))
}PostgreSQL의 RETURNING을 그대로 씁니다. MySQL이라면 LAST_INSERT_ID() 패턴으로 달라집니다.
트랜잭션: 타입이 커밋을 강제한다
#[derive(serde::Deserialize)]
struct TransferRequest {
from_account_id: i64,
to_account_id: i64,
amount: i64,
}
async fn transfer(
State(state): State<AppState>,
Json(payload): Json<TransferRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut tx = state.db.begin().await?;
sqlx::query!(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
payload.amount,
payload.from_account_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
payload.amount,
payload.to_account_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}여기서 tx는 Transaction<'_, Postgres> 타입입니다. commit()을 호출하지 않고 함수가 리턴되거나 중간에 ?로 에러가 튀어나가면, tx가 드롭되면서 자동으로 롤백됩니다. TypeScript에서 try { BEGIN; ...; COMMIT } catch { ROLLBACK } 패턴을 손으로 짜다가 catch 블록에서 ROLLBACK을 빼먹는 실수가 원천 차단됩니다.
에러 처리: 클라이언트에는 최소 정보만
Express의 next(err) 대신 Axum은 핸들러가 Result<T, E>를 돌려주면 됩니다. 단, E가 IntoResponse를 구현해야 합니다. 여기서 흔한 실수가 하나 있는데, DB 에러 메시지를 응답 바디에 그대로 흘려보내는 것입니다. sqlx의 에러에는 테이블명·컬럼명·SQL 구조가 그대로 들어있는 경우가 많아, 정보 노출 취약점으로 이어질 수 있습니다.
로그는 상세하게, 클라이언트에는 일반 메시지만 반환하는 게 원칙입니다.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("not found")]
NotFound,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match &self {
AppError::Database(e) => {
tracing::error!(error = %e, "database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal_error" })),
).into_response()
}
AppError::NotFound => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "not_found" })),
).into_response(),
}
}
}sqlx::Error::RowNotFound를 NotFound로 분리하고 싶다면 From 구현을 손으로 쓰거나 헬퍼 함수를 만들면 됩니다. thiserror의 #[from]은 자동 변환을 만들어주지만 분기 로직은 넣지 못하니, 세밀한 매핑이 필요하면 별도 함수로 뽑는 게 낫습니다.
CI에서의 오프라인 빌드
문제
컴파일 타임 검증이 CI에서는 걸림돌이 됩니다. cargo build가 실제 DB에 붙으려 하는데, 빌드 서버에 DB가 없으면 빌드 자체가 안 됩니다.
해결: cargo sqlx prepare
로컬(DB 있는 상태)에서 메타데이터를 미리 만들어 저장소에 커밋합니다.
# 로컬에서 (DATABASE_URL이 설정된 상태)
cargo sqlx prepare
# .sqlx/ 디렉토리가 생성됨 — 이걸 git에 커밋
git add .sqlx/
git commit -m "Update sqlx query metadata"CI에서는 이 캐시를 참조하도록 환경변수를 설정합니다.
SQLX_OFFLINE=true cargo build --release주의할 점은 스키마가 바뀔 때마다 cargo sqlx prepare를 다시 돌려 커밋해야 한다는 것입니다. 이걸 잊으면 오프라인 캐시가 이전 스키마 기준으로 통과되고, 프로덕션 런타임에 컬럼 미존재 에러가 터집니다. PR 체크리스트에 넣거나, CI에서 cargo sqlx prepare --check로 캐시가 최신인지 검증하는 스텝을 추가하는 팀이 많습니다.
커넥션 풀 튜닝
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(600))
.max_lifetime(Duration::from_secs(1800))
.connect(&database_url)
.await?;acquire_timeout은 반드시 설정하세요. 이걸 빼면 모든 연결이 사용 중일 때 핸들러가 무한 대기에 빠져 백프레셔가 걸리지 않습니다.
max_connections는 DB 서버의 max_connections와 애플리케이션 인스턴스 수를 함께 봐야 합니다. 예를 들어 PostgreSQL 쪽 max_connections가 100이고 앱 인스턴스가 4개라면, 인스턴스당 20 정도가 상한이 됩니다(관리 도구·다른 클라이언트 몫도 남겨둬야 하므로 실제로는 더 낮게).
트레이드오프
| 항목 | TypeScript + Prisma/TypeORM | Rust + Axum + sqlx |
|---|---|---|
| SQL 오류 발견 시점 | 런타임 | 컴파일 타임 |
| 쿼리 작성 방식 | ORM DSL 또는 raw query | 순수 SQL |
| 복잡한 쿼리 | raw query로 이탈 시 타입 안전성 상실 | SQL 그대로, 타입 안전성 유지 |
| 트랜잭션 완결성 보장 | 개발자 규율 | 타입/드롭 시 자동 롤백 |
| 학습 곡선 | 낮음 | 높음(소유권, async 생태계) |
| 로컬 개발 요구 | DB 선택 | 로컬 DB 사실상 필수 |
| CI 빌드 | DB 불필요 | SQLX_OFFLINE 설정 필요 |
| 자동 관계 매핑 | 있음 | 없음(SQL 직접 작성) |
자주 밟는 지뢰
acquire_timeout미설정: 트래픽 스파이크에 핸들러가 통째로 굳습니다..sqlx/미커밋: 팀원 로컬 DB와 CI가 어긋나 빌드가 깨집니다.- 스키마 변경 후 캐시 재생성 누락: 오프라인 빌드는 통과, 프로덕션에서 폭발.
max_connections과잉: DB 쪽 커넥션 한도를 초과해 다른 서비스까지 영향.
sqlx가 안 맞는 경우
sqlx에는 본격적인 쿼리 빌더가 없습니다. 조건에 따라 WHERE 절을 동적으로 조립해야 한다면 query_builder 모듈로 어느 정도 가능하지만, 그 순간 컴파일 타임 검증 대신 런타임 검증으로 넘어갑니다. 이런 요구가 큰 프로젝트라면 대안을 검토해볼 만합니다.
- 관계 자동 매핑과 쿼리 빌더가 필수 → SeaORM
- 매크로 기반 타입 안전성과 관계 매핑을 동시에 → Diesel(async는
diesel-async) - 동적 쿼리 비중이 압도적 → sqlx의 이점이 옅어짐
여기서 시작한다면
TypeScript에서 Rust로 넘어오면서 sqlx + Axum 조합이 매력적인 이유는 "런타임에야 알던 것들을 빌드 타임으로 당긴다"는 지점입니다. 컬럼 오타, 타입 미스매치, 없는 테이블 참조가 배포 전에 걸립니다. 트랜잭션 롤백을 잊는 실수도 소유권 규칙이 막아줍니다. 대신 로컬 DB가 필요하고, CI 파이프라인에 오프라인 캐시 워크플로를 정착시켜야 하고, 소유권과 tokio/tower 생태계에 익숙해질 시간이 듭니다.
지금 이 글까지 읽고 "일단 손으로 한번 해보자" 싶어졌다면, 다음 순서를 권합니다.
6번을 실제로 해보는 걸 특히 권합니다. 오타를 내는 순간 cargo build가 어디서 어떻게 실패하는지 눈으로 봐야, 이 조합이 왜 "런타임 방어 코드가 줄어든다"는 얘기를 하는지 몸으로 체감됩니다. 그 다음부터는 프로덕션에서 500 로그를 뒤지는 시간이 조금씩 줄어드는 걸 발견하게 됩니다.