Rust `sqlx` 매크로가 SQL 오류를 빌드 시점으로 끌어올리는 원리 — `query!` vs `query_as!`와 오프라인 모드
백엔드를 오래 하다 보면 이런 장면이 몇 번씩 있을 겁니다. 스테이징에선 멀쩡하던 API가 프로덕션에서 column "user_namee" does not exist 같은 오류를 토해내는 순간이요. TypeScript 진영의 Prisma·TypeORM은 물론이고, Go의 database/sql이나 Python의 SQLAlchemy를 쓰더라도 결이 같습니다 — 런타임 전까지는 알 수 없는 DB 오류가 분명히 존재합니다. 스키마를 바꾸고 엔티티 파일을 깜빡하거나, 쿼리에서 컬럼 이름에 오타를 냈을 때, 그 사실을 아는 순간은 항상 배포 이후였습니다.
Rust의 sqlx는 이 문제를 근본적으로 다른 방식으로 접근합니다. 쿼리의 유효성을 런타임이 아닌 cargo build 단계에서 검증하는 것이 핵심 설계 철학입니다. 컬럼 오타, 타입 불일치, 존재하지 않는 컬럼 참조 — 이 모든 오류가 빌드 실패로 바뀝니다. ORM 없이 순수 SQL을 그대로 쓰면서도요. TypeScript 배경이라면 any로 덮어두다 런타임에 터지던 경험이, Go/Python 배경이라면 통합 테스트가 없으면 감지되지 않던 오타가 얼마나 불안한 지점이었는지 새삼 느끼게 됩니다.
이 글에서는 query!와 query_as! 매크로의 타입 추론 방식 차이, 실제 프로젝트에서 어떤 상황에 어떤 걸 쓰면 좋은지, 그리고 CI 환경에서 DB 없이 빌드하는 오프라인 모드 설정 방법을 다룹니다. 2026년 5월에 출시된 sqlx 0.9.0 기준입니다.
컴파일 타임 검증이 실제로 동작하는 방식
매크로가 cargo build 때 뭘 하는가
query! 매크로를 처음 봤을 때 저도 솔직히 "이게 어떻게 컴파일 타임에 DB 오류를 잡지?" 싶었습니다. 동작 원리는 생각보다 직관적입니다.
매크로가 확장되는 시점에 DATABASE_URL 환경변수로 DB에 연결한 뒤, PostgreSQL의 확장 쿼리 프로토콜(Parse 메시지)로 쿼리를 준비(prepare)만 시킵니다. 실제 데이터가 읽히거나 쓰이지 않고, DB가 돌려주는 컬럼 이름·타입·nullable 여부 같은 메타데이터만 수집합니다. 이 메타데이터가 Rust 타입으로 변환되고, 문제가 있으면 컴파일이 거기서 멈춥니다.
zod 같은 런타임 스키마 검증과 개념은 비슷하지만, 실행 전에 끝난다는 점이 결정적인 차이입니다.
다른 언어 대비 무엇이 달라지나
TypeORM이나 SQLAlchemy에서 컬럼 이름에 오타를 냈을 때를 생각해보면, 개발 서버를 켜고 해당 엔드포인트를 직접 호출하거나 테스트를 돌려봐야 알 수 있었습니다. sqlx를 쓰면 cargo build 한 줄로 끝납니다.
error: column `user_namee` does not exist
--> src/handlers/user.rs:15:5SQL 인젝션 관점에서도 차이가 있습니다. 파라미터 바인딩($1, $2)을 강제하는 매크로 구조라서 매크로 API 안에서는 문자열 보간 자체가 API 설계 수준에서 막혀 있습니다. 뒤에서 다룰 QueryBuilder처럼 문자열을 이어 붙일 수 있는 API를 쓰면 이야기가 달라지지만, query!/query_as!에 한해서는 "권장 사항"이 아니라 구조적으로 막혀 있습니다.
query! vs query_as! — 무엇을 언제 쓸까
한 줄로 판단 기준을 먼저 정리하면: **결과 타입을 함수 시그니처로 노출하거나 여러 곳에서 재사용해야 한다면 query_as!, 그 외의 국지적인 쿼리는 query!**가 기본값입니다. 아래는 세부 설명입니다.
query! — 국지적으로 소비되는 쿼리에
query!는 결과를 익명 구조체로 반환합니다. 컬럼 이름이 필드 이름이 되고, DB 타입이 Rust 타입으로 자동 추론됩니다. 별도 구조체 정의 없이 바로 쓸 수 있다는 게 장점입니다.
let row = sqlx::query!("SELECT id, name FROM users WHERE id = $1", user_id)
.fetch_one(&pool)
.await?;
println!("{}", row.name);PostgreSQL의 RETURNING을 활용한 INSERT도 깔끔합니다.
let rec = sqlx::query!(
"INSERT INTO posts (title, body) VALUES ($1, $2) RETURNING id",
title, body
)
.fetch_one(&pool)
.await?;
println!("생성된 ID: {}", rec.id);한 가지 자주 걸리는 제약이 있는데, 매크로에 넘기는 SQL은 반드시 문자열 리터럴이어야 합니다. const 상수나 변수에 담아 전달하면 expected string literal 오류가 납니다. 컴파일 타임에 DB에 물어보려면 매크로가 SQL 텍스트를 직접 파싱해야 하기 때문인데, 처음 쓸 때 가장 자주 부딪히는 지점입니다.
query_as! — 재사용 가능한 타입이 필요한 곳에
query_as!는 명시적으로 정의한 Rust 구조체로 결과를 매핑합니다. 같은 쿼리 결과를 여러 곳에서 쓰거나, API 응답 타입을 명확하게 관리하고 싶을 때 이쪽이 낫습니다.
#[derive(sqlx::FromRow)]
struct User {
id: i64,
name: String,
email: Option<String>,
}
let user = sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", user_id)
.fetch_one(&pool)
.await?;DB에서 nullable로 정의된 컬럼은 반드시 Option<T>로 선언해야 합니다. 이걸 빠트리면 sqlx의 FromRow 유도 매크로가 타입 불일치를 감지해서 매크로 확장 단계에서 오류를 냅니다(엄밀히 말하면 rustc가 직접 잡아내는 게 아니라, 매크로가 만들어낸 코드가 컴파일에 실패하는 형태). 에러 메시지는 어느 필드가 nullable로 잡혔는지 알려주므로 고치는 것 자체는 어렵지 않습니다.
복잡한 JOIN 결과도 자연스럽게 처리됩니다.
#[derive(sqlx::FromRow)]
struct OrderSummary {
order_id: i64,
user_name: String,
total: f64,
}
let orders = sqlx::query_as!(
OrderSummary,
r#"SELECT o.id as order_id, u.name as user_name, o.total
FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.status = $1"#,
"pending"
)
.fetch_all(&pool)
.await?;타입 오버라이드 — "col: _" 구문
타입 추론이 애매한 경우, query_as!에서 "col: _" 구문으로 구조체 필드 타입을 그대로 쓰게 할 수 있습니다. _ 플레이스홀더는 "내가 정의한 구조체 필드 타입을 그대로 써"라는 의미입니다.
sqlx::query_as!(MyStruct, r#"SELECT id as "id: _" FROM users"#)query!에서는 타입을 명시적으로 지정합니다.
sqlx::query!(r#"SELECT id as "id: uuid::Uuid" FROM users"#)어떤 걸 쓸지 판단하는 흐름
비교 요약
| 항목 | query! |
query_as! |
|---|---|---|
| 반환 타입 | 익명 구조체 (매크로 생성) | 명명된 구조체 |
| 구조체 사전 정의 | 불필요 | 필요 (#[derive(FromRow)]) |
| 함수 시그니처 노출 | 불편 (익명 타입) | 자연스러움 |
| 재사용성 | 낮음 | 높음 |
| 타입 오버라이드 | r#"SELECT id as "id: uuid::Uuid" ..."# |
r#"SELECT id as "id: _" ..."# |
| nullable 처리 | DB 메타데이터 자동 반영 | 구조체 필드 Option<T> 선언 필요 |
| 적합한 상황 | 국지적 소비, 프로토타이핑 | API 응답 타입, 재사용 쿼리 결과 |
실전 시나리오별 코드
snake_case ↔ DB 컬럼명 변환
DB 컬럼이 camelCase로 설계된 레거시 프로젝트라면 rename_all 속성이 유용합니다.
#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "camelCase")]
struct ApiResponse {
user_id: i64,
created_at: chrono::DateTime<chrono::Utc>,
}UUID, DateTime, JSONB 타입 연동
sqlx는 uuid, chrono, serde_json 크레이트와 직접 연동됩니다. Cargo.toml에서 feature flag를 켜면 DB 컬럼을 Rust 타입으로 바로 매핑할 수 있습니다.
[dependencies]
sqlx = { version = "0.9", features = ["postgres", "runtime-tokio", "uuid", "chrono", "json"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }#[derive(sqlx::FromRow)]
struct Event {
id: uuid::Uuid,
payload: serde_json::Value,
created_at: chrono::DateTime<chrono::Utc>,
}
let event = sqlx::query_as!(
Event,
"SELECT id, payload, created_at FROM events WHERE id = $1",
event_id
)
.fetch_one(&pool)
.await?;오프라인 모드 — DB 없는 CI 빌드
컴파일 타임 검증의 가장 큰 현실적 단점은 DATABASE_URL이 없으면 빌드 자체가 실패한다는 점입니다. 신규 팀원 온보딩이나 CI 파이프라인에서 매번 DB를 띄울 수 없는 상황이 있습니다. 이걸 해결하는 게 오프라인 모드입니다.
동작 방식
cargo sqlx prepare를 실행하면 .sqlx/ 디렉토리에 쿼리별 메타데이터 JSON 파일이 생성됩니다. 이 파일을 git에 커밋해두면, CI에서 SQLX_OFFLINE=true만 설정하고 DB 연결 없이 빌드할 수 있습니다.
단계별 설정
# 1. sqlx-cli 설치
cargo install sqlx-cli --no-default-features --features postgres
# 2. DB가 연결된 로컬 환경에서 메타데이터 생성
cargo sqlx prepare
# 워크스페이스 전체라면
cargo sqlx prepare --workspace
# 3. 생성된 파일을 git에 커밋
git add .sqlx/
git commit -m "chore: update sqlx prepared queries"GitHub Actions 설정
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: password
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Rust 빌드 캐시
uses: Swatinem/rust-cache@v2
- name: sqlx-cli 설치
run: cargo install sqlx-cli --no-default-features --features postgres --locked
- name: 빌드 (오프라인 모드)
env:
SQLX_OFFLINE: true
run: cargo build --release
- name: prepare 파일 최신 여부 확인
env:
DATABASE_URL: postgres://postgres:password@localhost:5432/testdb
run: cargo sqlx prepare --check호스트 러너에서 localhost로 서비스 컨테이너에 접근하려면 ports: - 5432:5432 매핑이 반드시 필요합니다. 이걸 빠트리면 --check 단계에서 연결이 실패합니다. Swatinem/rust-cache로 ~/.cargo와 빌드 산출물을 캐싱하면 매 실행마다 sqlx-cli를 새로 빌드하는 시간도 줄일 수 있습니다.
--check 플래그는 현재 .sqlx/ 파일이 실제 DB 스키마와 일치하는지 검증합니다. 쿼리를 수정했는데 cargo sqlx prepare를 다시 안 돌리면 여기서 실패합니다 — 저도 이걸 한두 번 까먹어서 CI를 빨간불 만든 적이 있습니다.
트레이드오프와 흔한 함정
장단점 한눈에
| 항목 | 내용 |
|---|---|
| 컴파일 타임 검증 | 런타임 DB 오류를 빌드 시점으로 끌어올림 |
| 순수 SQL | ORM DSL 없이 PostgreSQL RETURNING, WITH, LATERAL 등 DB 고유 기능 그대로 사용 |
| async 지원 | Tokio, async-std 모두 지원, 커넥션 풀링 내장 |
| SQL 인젝션 차단 | 매크로 API에 한해 파라미터 바인딩 강제 |
| 동적 쿼리의 어려움 | WHERE 절이 런타임에 결정되면 QueryBuilder 필요, 매크로 수준 검증 포기 |
| 관계 미지원 | Eager/Lazy loading 없음, N+1 문제 직접 관리 |
| 오프라인 파일 관리 | 쿼리 변경 시 cargo sqlx prepare 재실행 필수 |
| 버전 안정성 | 2026년 8월 기준 0.9.x, 1.0 미만이라 breaking change 가능성 존재 |
놓치기 쉬운 함정들
.env에 SQLX_OFFLINE=true를 넣으면 안 됩니다
이건 실제 이슈로 보고된 버그입니다 (이슈 #3836). .env 파일에 SQLX_OFFLINE=true를 설정하면 cargo sqlx prepare 명령 자체도 오프라인으로 동작해서 메타데이터가 갱신되지 않습니다. 로컬에서 상시로 오프라인 모드를 켜두고 싶다면 아래 중 하나를 쓰는 게 안전합니다.
- 쉘 프로파일에
export SQLX_OFFLINE=true(~/.zshrc,~/.bashrc등) - 프로젝트 루트의
.cargo/config.toml에[env]섹션 추가
# .cargo/config.toml
[env]
SQLX_OFFLINE = "true"CI에서는 잡(job) 단위 환경변수로 주입하면 됩니다.
cargo sqlx prepare 재실행을 잊는 것
쿼리를 수정했는데 .sqlx/ 파일을 갱신하지 않으면 로컬에선 문제없이 빌드되다가 CI에서 --check에 걸립니다. 팀 내에서 pre-commit hook으로 강제하거나, PR 체크리스트에 넣어두는 게 좋습니다.
query!에 문자열 리터럴이 아닌 값을 넘기는 것
앞서 짚었듯이 SQL은 반드시 리터럴이어야 합니다. const SQL: &str = "..." 상수 재사용 습관이 있다면 sqlx 매크로에서는 통하지 않습니다. 재사용이 필요하다면 함수로 감싸는 쪽이 자연스럽습니다.
nullable 컬럼을 Option<T> 없이 선언하는 것
DB nullable 컬럼을 String으로 선언하면 매크로 확장 코드가 컴파일되지 않습니다. 오류 메시지가 처음엔 낯설 수 있지만, 어느 필드의 매핑이 어긋났는지는 명확히 표시됩니다.
동적 쿼리에 매크로를 쓰려는 시도
필터 조건이 런타임에 결정되는 검색 API처럼 동적 쿼리가 필요한 경우 sqlx::QueryBuilder를 써야 합니다. 이때 값은 반드시 push_bind로 넣어야 하고, push로 사용자 입력을 직접 이어 붙이면 SQL 인젝션 위험이 생깁니다. 즉 매크로의 강제 안전성은 이 영역에서 사라지고, 개발자가 명시적으로 지켜야 합니다.
ORM 대안 선택 기준
Rust 생태계의 다른 DB 라이브러리와 비교하면 대략 이렇게 정리됩니다.
- 높은 수준의 추상화와 ActiveModel 스타일 쿼리 빌더가 필요하면 → SeaORM (
ActiveModel을 통한 Active Record 스타일 API 제공. 다만 내부 아키텍처는 Data Mapper에 가깝습니다) - 강타입 DSL과 동기식 쿼리 빌더가 필요하면 → Diesel
- SQL 제어권을 유지하면서 컴파일 타임 검증을 원하면 → sqlx
정확한 다운로드 수치나 채택률 비교는 crates.io에서 직접 확인하는 게 가장 정확합니다. 써드파티 비교 글들은 시점과 집계 기준이 제각각이라 그대로 인용하기 애매합니다.
sqlx 0.9.0에서 달라진 것
2026년 5월 21일에 출시된 0.9.0에서 주목할 변화가 있습니다.
sqlx.toml 설정 파일이 추가됐습니다. 멀티 DB나 멀티 테넌트 환경에서 DB별 설정과 전역 타입 오버라이드를 한 파일에서 관리할 수 있게 됐습니다. 커스텀 타입이나 서드파티 크레이트 연동을 매 쿼리마다 오버라이드하던 번거로움이 줄었습니다. 구체적인 스키마와 키 이름은 릴리스마다 다듬어지고 있으니, 실제 적용 전에는 반드시 공식 릴리스 노트와 저장소의 예시 sqlx.toml을 확인하고 그대로 옮기는 걸 권합니다(글에서 예시 TOML을 직접 붙이지 않은 이유입니다 — 잘못된 형식을 복사해 파싱 오류가 나는 게 가장 흔한 초기 사고입니다).
이 외에 SQLite에서 컴파일 타임 extension 로딩이 가능해졌고, 0.8.x에서 sqlx-cli의 .env 자동 인식 관련 이슈 수정과 --no-dotenv 옵션도 추가됐습니다.
도입 후 실제로 마주치는 지점
sqlx로 넘어온 뒤 팀 안에서 실제로 반복해서 마주쳤던 지점 몇 가지를 남기며 마무리합니다.
첫째, CI 파이프라인의 신뢰가 급격히 올라갑니다. 예전에는 "빌드 통과했지만 프로덕션 DB에서 오타가 터진" 케이스가 배포 후 15분 뒤 알림으로 왔는데, sqlx 도입 후에는 그런 종류의 오류가 PR 단계에서 컴파일 실패로 잡힙니다. 대신 CI 러너에 서비스 컨테이너를 띄우는 게 부담이라 오프라인 모드와 --check를 별도 잡으로 분리하는 구조를 자주 씁니다.
둘째, 도입 초기에는 오히려 개발 속도가 느려집니다. 스키마를 바꿀 때마다 cargo sqlx prepare를 돌리고 .sqlx/를 커밋하는 흐름이 낯설고, query!의 문자열 리터럴 제약이나 nullable 매핑 오류에 부딪히면서 "TypeORM은 그냥 넘어갔는데"라는 저항이 팀에서 나옵니다. 이 시기를 넘기는 데 도움이 됐던 건, 마이그레이션 PR에 cargo sqlx prepare 실행을 명시한 체크리스트를 붙이고 pre-commit hook을 공유하는 정도의 아주 실무적인 장치들이었습니다.
셋째, 동적 쿼리가 정말 필요한 순간을 미리 식별해두는 게 좋습니다. 검색 API처럼 필터 조합이 폭증하는 지점을 매크로로 억지로 풀려고 하면 코드가 금방 지저분해집니다. 그 영역만 QueryBuilder로 분리하고 나머지는 매크로로 유지하는 게 실용적인 균형이었습니다.
참고 자료
- launchbadge/sqlx — GitHub
- sqlx::query! 매크로 문서 — docs.rs
- sqlx::query_as! 매크로 문서 — docs.rs
- FromRow 트레이트 — docs.rs
- Compile-time Query Checking — DeepWiki
- Offline Mode & sqlx prepare — DeepWiki
- CI/CD Workflow with sqlx — DeepWiki
- Raw SQL in Rust with SQLx — Shuttle.dev
- Unraveling sqlx Macros — Leapcell Blog
- SQLx 0.9.0 릴리스 공지 — GitHub Discussions
- SQLX_OFFLINE .env 버그 이슈 #3836
- Swatinem/rust-cache — GitHub Actions