Rust `sqlx`가 SQL 오류를 런타임이 아닌 빌드 단계에서 잡는 이유
TypeScript 백엔드에서 Prisma로 스키마를 바꾼 뒤 prisma generate를 잊고 배포했다가, 프로덕션 로그에 PrismaClientKnownRequestError: The column 'X' does not exist in the current database가 찍히는 경험은 낯설지 않을 겁니다. Prisma 저장소의 column does not exist 관련 이슈 스레드만 봐도 스키마와 클라이언트, 마이그레이션 상태가 어긋나 런타임에서 터지는 사례가 반복적으로 올라옵니다. TypeORM도 마찬가지로 엔티티 데코레이터와 실제 DB 스키마의 드리프트가 배포 후에 드러나는 구조입니다. TypeScript의 타입 시스템이 아무리 뛰어나도 SQL 문자열 내부까지는 추적하지 못하는 구조적 한계가 있으니까요.
Rust의 sqlx는 이 문제를 정면으로 해결합니다. query! 매크로가 cargo build 시점에 실제 데이터베이스에 접속해 SQL 문법, 컬럼 존재 여부, 타입 매핑까지 모두 검증합니다. 쿼리가 틀리면 컴파일이 아예 안 됩니다. 런타임 SQL 오류를 빌드 단계로 끌어올리는 거죠.
이 글에서는 query! 매크로가 실제로 어떻게 동작하는지, CI/CD처럼 DB가 없는 환경에서는 어떻게 쓰는지(offline mode), 그리고 실무에서 흔히 마주치는 함정들을 짚어봅니다. TypeScript 백엔드 경험이 있고 Rust DB 레이어를 처음 만지는 분들이라면 특히 공감할 내용들입니다.
query! 매크로가 실제로 하는 일
"컴파일 타임 검증"이 구체적으로 무엇인가
sqlx는 ORM이 아닙니다. 직접 SQL을 쓰되, 그 SQL을 빌드 시점에 실제 DB에 보내 검증하는 툴킷입니다. query! 매크로가 실행될 때 일어나는 일을 순서대로 보면 이렇습니다.
실제로 세 가지를 체크합니다. SQL 문법 유효성, 참조한 컬럼이 실제로 존재하는지, 그리고 DB 컬럼 타입과 Rust 타입이 맞는지입니다. PostgreSQL의 INT4는 Rust i32로, INT8은 i64로, TEXT는 String으로 자동 매핑되는데, 이게 어긋나면 역시 컴파일 오류입니다.
매크로 세 종류와 언제 쓰는지
| 매크로 | 반환 타입 | 사용 시점 |
|---|---|---|
sqlx::query! |
익명 구조체 | 빠르게 원샷 쿼리를 날릴 때 |
sqlx::query_as! |
명시적으로 정의한 struct | 재사용할 구조체가 있을 때 |
sqlx::query_scalar! |
단일 스칼라 타입 | COUNT(*) 같은 집계 함수 |
query_as!가 가장 많이 쓰게 될 형태입니다. #[derive(FromRow)] 없이도 struct에 바로 매핑됩니다.
#[derive(Debug)]
struct User {
id: i32,
email: String,
name: Option<String>, // NULL 가능 컬럼은 반드시 Option<T>
}
let user = sqlx::query_as!(
User,
r#"SELECT id, email, name FROM users WHERE id = $1"#,
user_id
)
.fetch_one(&pool)
.await?;저도 처음엔 name: String으로 썼다가 컴파일 오류를 만났습니다. DB 스키마에서 name 컬럼이 NULL 허용이었는데, Rust 쪽에서 Option<String>을 안 써줬거든요. TypeScript였으면 런타임에 null이 들어와서 나중에야 터졌을 텐데, 빌드 단계에서 바로 잡혀서 오히려 반가웠습니다.
셋업부터 쿼리까지
Cargo.toml 설정
[dependencies]
sqlx = { version = "0.8", features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"macros",
] }
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15"2026년 기준 안정 라인은 0.8.x입니다(글 작성 시점 기준 최신 패치는 docs.rs/sqlx에서 직접 확인하는 편이 낫습니다). features에 사용할 DB 드라이버(postgres, mysql, sqlite)와 macros를 명시해야 query! 매크로를 쓸 수 있고, RDS·Supabase·Neon 등 클라우드 PostgreSQL과 TLS로 연결하려면 tls-rustls(또는 tls-native-tls) 피처를 함께 켜야 합니다. 이걸 빠뜨리면 로컬은 되는데 스테이징에서 SSL 핸드셰이크가 실패하는 상황을 만나기 쉽습니다.
커넥션 풀 생성
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
sqlx::migrate!("./migrations")
.run(&pool)
.await?;
Ok(())
}CRUD 예시
단건 조회
let user = sqlx::query_as!(
User,
"SELECT id, email, name FROM users WHERE id = $1",
user_id
)
.fetch_one(&pool)
.await?;목록 조회
let users = sqlx::query_as!(
User,
"SELECT id, email, name FROM users ORDER BY id"
)
.fetch_all(&pool)
.await?;삽입 후 반환
let new_user = sqlx::query_as!(
User,
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name",
email,
name
)
.fetch_one(&pool)
.await?;집계
PostgreSQL에서 COUNT(*)는 BIGINT NOT NULL로 리포팅되지만, sqlx의 매크로 추론기는 안전 쪽으로 기울어 Option<i64>로 잡습니다. 그래서 fetch_one 결과에 .unwrap_or(0)가 필요한 형태가 됩니다. 이걸 i64로 확정하려면 별칭 뒤에 타입 힌트를 붙여야 합니다.
// 방법 1: Option<i64>로 받고 unwrap
let maybe_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM users WHERE email = $1",
email
)
.fetch_one(&pool)
.await?;
let count: i64 = maybe_count.unwrap_or(0);
// 방법 2: 타입 힌트로 NOT NULL임을 명시
let count: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!: i64" FROM users WHERE email = $1"#,
email
)
.fetch_one(&pool)
.await?;as "count!: i64"의 !는 "이 컬럼은 절대 NULL이 아니다"라는 오버라이드입니다. 집계 함수처럼 sqlx가 안전하게 판단할 수 없는 경우에 개발자가 명시적으로 알려주는 문법이에요.
트랜잭션
let mut tx = pool.begin().await?;
sqlx::query!(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount,
from_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount,
to_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;Offline Mode — CI/CD에서 DB 없이 빌드하기
query! 매크로의 컴파일 타임 검증은 강력하지만, 한 가지 문제가 있습니다. 빌드할 때마다 실제 DB가 필요하다는 것입니다. GitHub Actions 같은 CI 환경에서 매번 DB를 띄우거나 외부 DB에 연결하는 건 번거롭고 느립니다.
cargo sqlx prepare가 이 문제를 해결합니다.
로컬에서 한 번 실행하면 됩니다.
# sqlx-cli 설치 (처음 한 번만)
cargo install sqlx-cli
# 현재 모든 query! 매크로의 타입 메타데이터를 .sqlx/ 에 저장
cargo sqlx prepare.sqlx/ 디렉토리가 생기고, 각 쿼리에 대한 JSON 파일이 만들어집니다. 이걸 git에 커밋하면 끝입니다.
CI에서는 환경 변수 하나만 추가하면 됩니다.
# GitHub Actions 예시
- name: Build
env:
SQLX_OFFLINE: "true"
run: cargo build --release.sqlx 드리프트를 CI에서 잡기
Offline mode의 진짜 함정은, 스키마를 바꾸고 cargo sqlx prepare를 다시 실행하는 걸 잊었을 때입니다. CI는 낡은 .sqlx 메타데이터를 기준으로 검증하므로, 실제로는 잘못된 쿼리가 조용히 통과할 수 있습니다.
이걸 예방하는 공식 명령이 있습니다.
cargo sqlx prepare --check--check는 파일을 새로 쓰지 않고, 현재 소스의 쿼리와 커밋된 .sqlx 캐시가 일치하는지 검증만 합니다. 불일치가 있으면 non-zero exit로 실패하죠. 이걸 CI 파이프라인에 별도 단계로 넣으면 .sqlx 동기화 실패가 PR 단계에서 걸립니다.
- name: sqlx offline data check
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app
run: cargo sqlx prepare --check --workspace이 잡만 DB가 필요하고, 이후 실제 빌드/테스트 잡은 SQLX_OFFLINE=true로 DB 없이 돌리는 구성이 일반적입니다. pre-commit hook은 강제성이 없고 로컬 환경마다 다르니, CI에 --check를 넣는 게 확실합니다.
마이그레이션 관리
# 새 마이그레이션 파일 생성
sqlx migrate add create_users_table
# 마이그레이션 실행
sqlx migrate runmigrations/ 디렉토리에 타임스탬프가 붙은 .sql 파일이 생깁니다. sqlx::migrate! 매크로로 앱 시작 시 자동 실행하는 패턴이 일반적입니다.
PostgreSQL 커스텀 타입 다루기
ENUM이나 도메인 타입 같은 커스텀 타입은 추가 처리가 필요합니다. 타입 오버라이드 문법을 씁니다.
// 커스텀 타입 정의
#[derive(Debug, sqlx::Type)]
#[sqlx(type_name = "user_status", rename_all = "lowercase")]
enum UserStatus {
Active,
Inactive,
Banned,
}
// 컬럼 별칭으로 타입 힌트 제공
let rows = sqlx::query!(
r#"SELECT id as "id: i32", status as "status: UserStatus" FROM users"#
)
.fetch_all(&pool)
.await?;여기서 짚어둘 게 있습니다. "sqlx는 SQL 오류를 컴파일 타임에 잡는다"는 이 글의 큰 주장에도, 커스텀 타입은 부분적인 예외 영역입니다. 정확히 구분하자면 이렇습니다.
- 컴파일 타임에 잡히는 것: DB에 해당 타입(
user_status)이 존재하는지, 컬럼이 그 타입인지,sqlx::Type이 구현되어 있는지. - 런타임에
ColumnDecode에러로만 드러나는 것: DB의 enum 라벨('banned')과 Rust variant의 실제 매핑값이 어긋나는 경우. 예를 들어 DB에는pending이라는 값이 존재하는데 Rustenum에는 그 variant가 없으면, 그 행을 읽는 순간에야 터집니다.
즉 type_name을 오타내거나 rename_all 규칙과 실제 DB 라벨이 어긋나면 런타임 에러입니다. 커스텀 타입이 많다면 이 보일러플레이트와 함께 "매핑값이 어긋날 여지"를 관리하는 부담이 쌓입니다.
트레이드오프 — 솔직하게
장점 정리
| 항목 | 실무적 의미 |
|---|---|
| 런타임 SQL 오류 제거 | 배포 후 "column does not exist" 오류가 원천 차단됨 |
| SQL Injection 구조적 방지 | $1, $2 파라미터 바인딩 강제, 문자열 포맷팅 불가 |
| 순수 SQL 작성 | DSL 없이 복잡한 JOIN, CTE, 윈도우 함수 그대로 사용 |
Option<T> 강제 |
NULL 처리 누락을 빌드 단계에서 차단 |
| 완전 비동기 | tokio/async-std 기반, Axum·Actix와 자연스럽게 조합 |
현실적인 단점
| 항목 | 내용 |
|---|---|
| 컴파일 시간 증가 | 쿼리 하나당 80ms 전후의 추가 비용이 보고됨, 수십 개면 cargo check도 체감 |
| 개발 환경에 DB 필요 | offline mode 전까지는 DATABASE_URL이 가리키는 실제 DB가 항상 필요 |
.sqlx 파일 동기화 |
prepare 잊으면 CI가 구버전 메타데이터로 검증 (→ --check로 방어) |
| ORM 기능 부재 | 관계 매핑, 지연 로딩 없음. 복잡한 엔티티 관계는 직접 SQL 작성 |
| 커스텀 타입 verbose | PostgreSQL enum 등에 타입 오버라이드 문법 + 매핑값 관리 |
컴파일 시간 문제는 커뮤니티에서 꾸준히 논의되는 주제로, 개별 쿼리 단위 오버헤드를 다루는 이슈와 블로그가 다수 존재합니다. 쿼리가 많아지면 체감이 됩니다.
다른 라이브러리와 비교
| 라이브러리 | 특징 | 선택 기준 |
|---|---|---|
| sqlx | 비동기, 순수 SQL, 컴파일 타임 검증 | 성능과 SQL 제어권 우선 |
| SeaORM | 비동기, Active Record 스타일, 내부적으로 sqlx 사용 | 빠른 CRUD, ORM 스타일 선호 |
| Diesel | 기본은 동기식, diesel-async 크레이트로 비동기 사용 가능 |
강력한 쿼리 빌더 DSL이 필요할 때 |
| Cornucopia | SQL 파일 분리 후 코드 생성 | 컴파일 시간 줄이면서 타입 안전성 유지 |
TypeScript에서 넘어온다면
Prisma를 쓸 때 schema.prisma를 바꾸고 prisma generate를 잊으면 런타임 오류가 나는 경험, 다들 있을 겁니다. sqlx는 이 관계를 뒤집습니다. 스키마와 쿼리가 맞지 않으면 빌드 자체가 안 됩니다.
파라미터 바인딩 문법도 TypeScript와 다릅니다. Prisma의 where: { id } 스타일 대신 $1, $2 포지셔널 바인딩을 씁니다(PostgreSQL 기준). 처음엔 낯설지만 금방 익숙해지고, SQL을 그대로 쓰니 DB 전문가와 협업할 때 오히려 편합니다.
Rust 백엔드 스택은 하나로 수렴하진 않았습니다. 크레이트 다운로드 기준으로 Actix-web + sqlx도 여전히 널리 쓰이고, Axum + sqlx가 최근 몇 년 문서·튜토리얼에서 눈에 띄게 자주 등장합니다. sqlx 공식 조직 launchbadge가 직접 관리하는 realworld-axum-sqlx 레퍼런스 구현이 있으니, 실제 REST API 구조를 참고하기 좋습니다.
마무리 — 오류가 발생하는 시점을 바꾼다는 것
이 글의 논지는 결국 하나로 좁혀집니다. 오류가 발생하는 시점을 옮긴다.
같은 사고 실험을 한번 해볼게요. 팀원이 users 테이블의 name 컬럼을 full_name으로 리네이밍하는 마이그레이션을 머지했다고 합시다.
- TypeScript + Prisma:
prisma migrate는 돌아갔지만prisma generate가 CI에서 누락되거나 특정 서비스가 캐시된 클라이언트로 빌드됩니다. 배포는 성공합니다.SELECT name FROM users가 살아있는 코드 경로가 실행되는 순간, 프로덕션에서 500과 함께column "name" does not exist가 로그에 찍힙니다. 롤백을 결정할지, 핫픽스를 낼지 알림을 받고 판단합니다. - Rust + sqlx: 마이그레이션을 반영한 뒤
cargo sqlx prepare --check가 CI에서 실패합니다..sqlx를 다시 만들면name을 참조하던 쿼리들이 전부 컴파일 오류가 됩니다. 배포는 애초에 만들어지지 않습니다. 오류는 프로덕션이 아니라 PR 리뷰 화면에서 만납니다.
두 시나리오의 근본 차이는 "얼마나 촘촘한 타입 시스템을 쓰느냐"가 아니라, "SQL이라는 문자열을 언제 검증하느냐"입니다. sqlx가 하는 일은 이 검증 시점을 배포 이후에서 빌드 이전으로 당기는 것, 그 하나입니다. 컴파일 시간이 늘고 개발 환경에 DB가 필요해지는 대가가 붙지만, 프로덕션 SQL 오류 알림 한 번보다는 그쪽이 훨씬 싸다고 판단하는 팀이 많습니다.
참고 자료