Rust 데이터베이스: sqlx, Diesel, SeaORM으로 SQLite와 Postgres 쓰기
언젠가는 프로그램이 뭔가를 기억해야 해요. 사용자, 주문, 최고 점수, 할 일 같은 것들이요. 프로그램이 멈추면 Vec이나 HashMap에 있던 데이터는 사라져요. 데이터베이스는 데이터를 디스크에 안전하게 보관하고, 여러 프로그램이 함께 쓸 수 있게 해 주고, “아직 안 끝난 할 일을 최신순으로 전부 보여 줘” 같은 질문에 아주 빠르게 답해 줘요.
Rust에는 아주 좋은 데이터베이스 크레이트가 있어요. 이 페이지에서는 먼저 크레이트를 고르고, 실제 코드를 써 볼 거예요. 평범한 Rust로 만든 작은 “리포지토리”(여기 Playground에서 바로 실행돼요)부터 시작해서, sqlx로 SQLite 쓰기, Postgres, 마이그레이션, 그리고 Diesel과 SeaORM을 짧게 둘러봐요. 데이터베이스 크레이트는 Playground에서 쓸 수 없어서, 그 예제들은 여러분 컴퓨터에서 cargo run으로 실행해야 해요. 모든 예제는 직접 빌드하고 실행해 봤고, 보여 드리는 결과는 실제 결과예요.
무엇을 만들 수 있을까요?
섹션 제목: “무엇을 만들 수 있을까요?”- 웹 API의 데이터 계층. 프로젝트: 웹 API 만들기의 할 일 서버를 가져와서, 데이터를 메모리 대신 Postgres에 저장해 보세요.
- 크고 붐비는 웹사이트. 모든 Rust 크레이트를 호스팅하는 사이트인 crates.io는 Diesel을 통해 Postgres에 데이터를 저장해요. 오픈 소스 커뮤니티 플랫폼인 Lemmy(영어)도 마찬가지예요.
- 데스크톱 앱과 명령줄 도구. 로컬 SQLite 파일 하나로 메모 앱, 가계부, 캐시 같은 걸 만들어요.
- 백그라운드 워커와 데이터 작업. 행(row)을 읽고, 처리하고, 결과를 다시 써요.
어려운 점도 있어요. Rust에는 .NET의 Entity Framework나 Rails의 ActiveRecord 같은 하나뿐인 “공식” 스택이 없어서, 괜찮은 크레이트 몇 개 중에서 골라야 해요. 데이터베이스 크레이트를 처음 빌드하면 1~2분은 걸려요. 그리고 컴파일 타임에 SQL을 검사하는 기능(정말 좋은 기능이에요)은 약간의 준비가 필요해요.
어떤 크레이트를 쓸까요?
섹션 제목: “어떤 크레이트를 쓸까요?”| 크레이트 | 동기 또는 비동기 | 쿼리를 컴파일 타임에 검사하나요? | ORM인가요? | 이럴 때 골라요 |
|---|---|---|---|---|
| rusqlite | 동기 | 아니요 | 아니요, 그냥 SQL | SQLite 파일 하나를 쓰는 작은 도구나 데스크톱 앱. async가 필요 없을 때. |
| sqlx | 비동기 | 네, query! 매크로를 쓰면요(선택) |
아니요, 그냥 SQL | SQL을 직접 쓰는 게 좋고 async가 필요할 때. SQLite, Postgres, MySQL을 지원해요. 웹 앱에서 보통 가장 먼저 고르는 크레이트예요. |
| Diesel | 동기(diesel-async로 비동기도 가능) |
네, 쿼리 빌더 전체가 타입 검사를 받아요 | ORM이자 쿼리 빌더 | 모든 쿼리를 컴파일러가 검사해 주길 원하고, Diesel만의 쿼리 스타일을 배우는 게 괜찮을 때. 아주 성숙해요. |
| SeaORM | 비동기(sea-orm-sync도 있어요) |
타입은 네, SQL은 아니요(실행할 때 검사해요) | 네, 완전한 ORM | Entity Framework처럼 엔티티, 관계, “이 객체를 저장해 줘”를 쓰고 싶을 때. sqlx 위에서 만들어졌어요. |
넷 다 성숙하고 널리 쓰이는 크레이트예요. 잘 모르겠다면 sqlx로 시작하세요. 평범한 SQL을 쓰니까, 여기서 배운 건 어떤 언어에서든 통해요. C#을 쓰다 오셨나요? C# 개발자를 위한 Rust 페이지에서 Entity Framework, Dapper, ADO.NET이 이 크레이트들 중 무엇에 해당하는지 정리해 두었어요.
먼저 모양부터: 평범한 Rust로 만드는 리포지토리
섹션 제목: “먼저 모양부터: 평범한 Rust로 만드는 리포지토리”데이터베이스를 다루기 전에, 거의 모든 앱이 쓰는 패턴 하나를 살펴볼게요. 바로 리포지토리(repository)예요. 데이터를 저장하고 불러오는 방법을 아는 곳은 리포지토리 한 곳뿐이에요. 프로그램의 나머지 부분은 add나 list_open 같은 메서드만 호출하고, 데이터가 HashMap에 있는지, SQLite에 있는지, Postgres에 있는지는 신경 쓰지 않아요.
Rust에서는 트레이트로 리포지토리가 할 수 있는 일을 정하고, 구조체가 실제 일을 해요. 여기서는 메모리 속 HashMap이 저장소예요.
use std::collections::HashMap;
#[derive(Debug, Clone)]struct Task { id: u32, title: String, done: bool,}
// 앱이 저장소에 바라는 것. 여기에는 "어떻게" 저장하는지가 전혀 없어요.trait TaskRepository { fn add(&mut self, title: &str) -> u32; fn mark_done(&mut self, id: u32) -> Result<(), String>; fn list_open(&self) -> Vec<Task>;}
struct InMemoryRepo { tasks: HashMap<u32, Task>, next_id: u32,}
impl TaskRepository for InMemoryRepo { fn add(&mut self, title: &str) -> u32 { let id = self.next_id; self.tasks.insert(id, Task { id, title: title.to_string(), done: false }); self.next_id += 1; id }
fn mark_done(&mut self, id: u32) -> Result<(), String> { match self.tasks.get_mut(&id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("no task with id {id}")), } }
fn list_open(&self) -> Vec<Task> { let mut open: Vec<Task> = self.tasks.values().filter(|t| !t.done).cloned().collect(); open.sort_by_key(|t| t.id); // HashMap에는 순서가 없으니까 정렬해요 open }}
// 어떤 리포지토리와도 동작해요. 오늘은 메모리, 내일은 SQLite나 Postgres.fn print_open(repo: &impl TaskRepository) { for task in repo.list_open() { println!("[ ] {}: {}", task.id, task.title); }}
fn main() { let mut repo = InMemoryRepo { tasks: HashMap::new(), next_id: 1 }; repo.add("Buy milk"); repo.add("Learn sqlx"); repo.add("Walk the dog");
repo.mark_done(1).unwrap(); print_open(&repo); println!("{:?}", repo.mark_done(99));}실행 결과:
[ ] 2: Learn sqlx[ ] 3: Walk the dogErr("no task with id 99")할 일 제목(“Buy milk”는 우유 사기, “Learn sqlx”는 sqlx 배우기, “Walk the dog”은 강아지 산책)은 영어로 두었어요. 아래 sqlx 예제의 실제 실행 결과에도 같은 제목이 나오거든요. 마지막 줄은 “id가 99인 할 일이 없다”는 에러예요.
데이터베이스를 쓰면 여기 있는 것들이 무엇이 되는지 보세요. HashMap은 테이블(table)이 되고, Task 하나하나는 행(row)이 되고, next_id는 자동으로 번호가 매겨지는 id 열이 돼요. list_open은 WHERE done = FALSE ORDER BY id가 붙은 쿼리가 되고요. 메모리 버전은 나중에도 쓸모가 있어요. 테스트에서 쓸 빠른 가짜 저장소가 되거든요.
SQLite와 sqlx: 처음 써 보는 진짜 데이터베이스
섹션 제목: “SQLite와 sqlx: 처음 써 보는 진짜 데이터베이스”SQLite는 파일 하나(아니면 그냥 메모리)에 들어가는 완전한 데이터베이스예요. 따로 설치할 서버가 없어서, 공부할 때도, 테스트할 때도, 실제 앱 대부분에도 딱 좋아요. 프로젝트를 만들고 sqlx와 tokio를 추가하세요.
cargo new todo-dbcd todo-dbcargo add sqlx --features runtime-tokio,sqlitecargo add tokio --features full[dependencies]sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }tokio = { version = "1", features = ["full"] }sqlite 기능을 켜면 SQLite가 프로그램 안에 함께 빌드돼서, 따로 설치할 게 없어요. sqlx는 비동기라서 tokio가 필요해요. 이제 src/main.rs를 바꿔 보세요.
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { // 메모리 속 데이터베이스: 공부하기에 딱 좋고, 프로그램이 끝나면 사라져요. let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&pool) .await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&pool) .await?; println!("Inserted task {}", result.last_insert_rowid()); }
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(1_i64) .execute(&pool) .await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id") .fetch_all(&pool) .await?;
for task in &tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.title); }
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?") .bind(42_i64) .fetch_optional(&pool) .await?; println!("Task 42: {open:?}");
Ok(())}cargo run으로 실행하면 이렇게 나와요.
Inserted task 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 42: None하나씩 살펴볼게요.
sqlx::query("...")에는 SQL이 들어가요.?하나하나가 플레이스홀더(placeholder)이고,.bind(value)가 순서대로 그 자리를 채워요..execute(&pool)은 행을 돌려주지 않는 문장(CREATE,INSERT,UPDATE,DELETE)을 실행해요. 결과에서rows_affected()를 알 수 있고, SQLite라면last_insert_rowid()도 알 수 있어요.#[derive(sqlx::FromRow)]를 붙이면 sqlx가 각 행을Task로 바꿔 줘요. 열 이름이 필드 이름과 같아야 해요.query_as+fetch_all은Vec<Task>를 줘요. 행이 반드시 딱 하나 있어야 하면fetch_one을, 없을 수도 있으면fetch_optional을 쓰세요. 없는 행이 크래시가 아니라Option인None이 된 걸 보세요.- 모든 호출이
Result를 돌려주니까,?가 데이터베이스 에러를main까지 올려 보내요.
프로그램이 끝난 뒤에도 데이터를 남기려면 파일을 쓰세요. SqlitePool::connect("sqlite:todos.db?mode=rwc")처럼요. mode=rwc는 “읽고, 쓰고, 파일이 없으면 만들어라”라는 뜻이에요.
컴파일 타임에 검사하는 쿼리. sqlx에는 sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done) 같은 매크로도 있어요. 빌드할 때 이 매크로가 개발용 데이터베이스(DATABASE_URL에 적힌 곳)에 접속해서 SQL을 검사해요. titel 같은 오타가 있으면 no such column: titel(그런 열은 없다)이라며 빌드가 멈춰요. 사용자가 보기도 전에요. 이렇게 하려면 빌드하는 동안 테이블이 있는 데이터베이스가 필요해요. 그러니 처음에는 평범한 query 함수로 시작하고, 마이그레이션(아래에서 다뤄요)을 갖춘 다음에 매크로를 더하세요.
Postgres에 접속하기
섹션 제목: “Postgres에 접속하기”Postgres(PostgreSQL)는 웹 앱에서 가장 인기 있는 선택이에요. 서버로 돌아가니까 서버가 하나 실행 중이어야 해요. postgresql.org(영어)에서 설치하거나, 클라우드 데이터베이스를 쓰거나, Docker로 하나 띄우세요.
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres코드는 거의 바뀌지 않아요. postgres 기능을 켜세요(클라우드 데이터베이스는 보통 암호화를 요구하니까 tls-rustls 같은 TLS 기능도 추가하세요).
cargo add sqlx --features runtime-tokio,postgres,tls-rustls그다음 연결 문자열(connection string)로 접속해요. 데이터베이스가 어디 있고 어떻게 로그인하는지 적은 URL이에요.
postgres://USER:PASSWORD@HOST:PORT/DATABASEpostgres://postgres:secret@localhost:5432/postgres비밀번호를 절대 코드에 적지 마세요. URL은 DATABASE_URL 환경 변수에 넣고, 프로그램이 시작할 때 읽으세요(환경 변수를 보세요).
use sqlx::postgres::PgPoolOptions;use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(Duration::from_secs(3)) .connect(&url) .await?;
// Postgres는 플레이스홀더에 번호를 붙여요: $1, $2, ...let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1") .bind(false) .fetch_all(&pool) .await?;SQLite와 다른 점 두 가지를 기억하세요. Postgres의 플레이스홀더는 ? 대신 $1, $2이고, 자동으로 번호가 매겨지는 id는 보통 BIGINT GENERATED ALWAYS AS IDENTITY(또는 BIGSERIAL)라서 i64에 맞아요. 비밀번호에 @나 / 같은 문자가 있으면 URL에서 퍼센트 인코딩하세요(@는 %40이 돼요). sqlx-cli 같은 도구는 프로젝트 폴더의 .env 파일에서도 DATABASE_URL을 읽어요. .env는 .gitignore에 추가해서 GitHub에 절대 올라가지 않게 하세요.
sqlx-cli로 마이그레이션하기
섹션 제목: “sqlx-cli로 마이그레이션하기”테이블은 시간이 지나면서 바뀌어요. 여기에 열 하나, 저기에 테이블 하나가 생기죠. 마이그레이션(migration)은 각 변경 사항을 적어 둔, 번호가 붙은 SQL 파일이에요. 모든 데이터베이스(여러분 것, 팀원 것, 운영 서버 것)가 같은 파일을 같은 순서로 실행하고, 데이터베이스는 어떤 파일을 이미 실행했는지 기억해요. 명령줄 도구를 한 번만 설치하세요. 첫 번째 명령은 모든 데이터베이스를 지원하는 버전이고, 두 번째 명령은 필요한 것만 넣어서 더 빨리 빌드되는 버전이에요.
# all databasescargo install sqlx-cli
# or only what you need, for a faster buildcargo install sqlx-cli --no-default-features --features sqlite,rustls그다음 프로젝트 폴더에서 실행해요.
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating migrations/20261018035858_create_tasks.sql(여러분의 번호는 다를 거예요. 날짜와 시간이거든요.) 그 파일에 변경 내용을 적으세요.
CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);그리고 적용해요.
sqlx migrate runApplied 20261018035858/migrate create tasks (1.449995ms)다시 실행해도 아무 일도 일어나지 않아요. 그 마이그레이션은 이미 적용됐으니까요. sqlx migrate info는 무엇이 적용됐고 무엇이 남았는지 보여 줘요. 어딘가에서 이미 실행된 마이그레이션은 절대 고치지 마세요. 대신 새 마이그레이션을 추가하세요.
프로그램이 시작할 때 마이그레이션을 적용하게 할 수도 있어요. migrate! 매크로는 ./migrations의 파일들을 바이너리 안에 구워 넣어요.
let pool = SqlitePool::connect("sqlite::memory:").await?;sqlx::migrate!().run(&pool).await?; // 이 데이터베이스가 아직 실행하지 않은 마이그레이션만 실행해요한눈에 보는 Diesel
섹션 제목: “한눈에 보는 Diesel”Diesel은 다른 길을 가요. SQL 문자열을 쓰는 대신 Rust 메서드로 쿼리를 만들고, 컴파일러가 그 쿼리를 테이블 설명과 대조해서 검사해요. 예를 들어 done은 bool 열이니까 tasks::done.eq(true)는 컴파일되지만, tasks::done.eq("yes")는 컴파일 에러예요. Diesel은 동기 방식인데, 도구나 많은 서버에서는 그걸로 충분해요.
[dependencies]diesel = { version = "2.3", features = ["sqlite"] }# Builds SQLite into your program. Only needed if you don't have SQLite installed:libsqlite3-sys = { version = "0.38", features = ["bundled"] }libsqlite3-sys의 bundled 기능은 SQLite를 프로그램 안에 함께 빌드해요. 컴퓨터에 SQLite가 설치되어 있지 않을 때만 필요해요.
use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
// 보통은 `diesel migration run`이 이 코드를 src/schema.rs에 만들어 줘요.diesel::table! { tasks (id) { id -> Integer, title -> Text, done -> Bool, }}
#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = tasks)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct Task { id: i32, title: String, done: bool,}
#[derive(Insertable)]#[diesel(table_name = tasks)]struct NewTask<'a> { title: &'a str,}
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut conn = SqliteConnection::establish(":memory:")?;
// 보통은 마이그레이션이 이 일을 해요. diesel::sql_query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY NOT NULL, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&mut conn)?;
let new_tasks = [NewTask { title: "Buy milk" }, NewTask { title: "Learn Diesel" }]; diesel::insert_into(tasks::table) .values(&new_tasks[..]) .execute(&mut conn)?;
diesel::update(tasks::table.find(1)) .set(tasks::done.eq(true)) .execute(&mut conn)?;
let all: Vec<Task> = tasks::table .order(tasks::id) .select(Task::as_select()) .load(&mut conn)?; for task in &all { println!("{} {} done={}", task.id, task.title, task.done); }
let open: i64 = tasks::table .filter(tasks::done.eq(false)) .count() .get_result(&mut conn)?; println!("{open} task(s) still open"); Ok(())}1 Buy milk done=true2 Learn Diesel done=false1 task(s) still open실제 프로젝트에서는 diesel_cli를 설치하고(cargo install diesel_cli --no-default-features --features sqlite), diesel setup, diesel migration generate create_tasks, diesel migration run을 차례로 실행해요. CLI가 table! 코드가 담긴 src/schema.rs를 만들어 주니까, 스키마가 늘 데이터베이스와 일치해요. Getting Started 가이드(영어)가 이 과정을 처음부터 끝까지 안내해 줘요.
한눈에 보는 SeaORM
섹션 제목: “한눈에 보는 SeaORM”SeaORM은 비동기 ORM이에요. 테이블마다 엔티티(entity, 속성이 붙은 구조체)를 정의하고, SQL 대신 객체로 작업해요. Entity Framework와 느낌이 비슷해요. 2.0 버전은 엔티티를 보고 테이블을 만들어 주기까지 해요.
[dependencies]sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }tokio = { version = "1", features = ["full"] }mod task { use sea_orm::entity::prelude::*;
#[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "task")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub title: String, pub done: bool, }
impl ActiveModelBehavior for ActiveModel {}}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]async fn main() -> Result<(), DbErr> { let db = &Database::connect("sqlite::memory:").await?;
// 이 크레이트에 있는 모든 엔티티의 테이블을 만들어요. "seaorm_demo"는 // Cargo.toml에 적힌 패키지 이름에서 -를 _로 바꾼 거예요. db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] { task::ActiveModel { title: Set(title.to_owned()), done: Set(false), ..Default::default() } .insert(db) .await?; }
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model(); milk.done = Set(true); milk.update(db).await?;
let open = task::Entity::find() .filter(task::Column::Done.eq(false)) .all(db) .await?; for t in &open { println!("open: {} {}", t.id, t.title); } Ok(())}open: 2 Learn SeaORMModel은 읽어 온 행이에요. ActiveModel은 바꾸고 있는 행이에요. Set(...)은 저장할 필드를 표시하고, ..Default::default()는 id를 비워 둬서 데이터베이스가 채우게 해요. 더 큰 프로젝트에서는 sea-orm-cli가 기존 데이터베이스에서 엔티티를 만들어 주고 마이그레이션도 관리해 줘요. SeaORM 문서(영어)를 보세요.
SQL 인젝션: 항상 바인드 파라미터를 쓰세요
섹션 제목: “SQL 인젝션: 항상 바인드 파라미터를 쓰세요”이 페이지에서 가장 중요한 규칙이에요. 사용자 입력을 format!으로 SQL 문자열에 붙여 넣어서 SQL을 만들지 마세요. 왜 그런지 평범한 Rust로(데이터베이스 없이) 보여 드릴게요.
// 데이터베이스가 받게 되는 것을 두 가지 방법으로 만들어 봐요.fn pasted_query(name: &str) -> String { format!("SELECT * FROM users WHERE name = '{name}'")}
fn bound_query(name: &str) -> (&'static str, Vec<String>) { ("SELECT * FROM users WHERE name = ?", vec![name.to_string()])}
fn main() { for input in ["ana", "x' OR '1'='1"] { println!("input: {input}"); println!(" pasted: {}", pasted_query(input)); let (sql, params) = bound_query(input); println!(" bound: {sql} params: {params:?}"); }}실행 결과:
input: ana pasted: SELECT * FROM users WHERE name = 'ana' bound: SELECT * FROM users WHERE name = ? params: ["ana"]input: x' OR '1'='1 pasted: SELECT * FROM users WHERE name = 'x' OR '1'='1' bound: SELECT * FROM users WHERE name = ? params: ["x' OR '1'='1"]input은 입력, pasted는 붙여 넣은 쿼리, bound는 바인딩한 쿼리예요. 줄이 깔끔하게 맞도록 이 라벨들은 영어로 두었어요.
붙여 넣는 방식에서는 입력에 든 따옴표가 문자열을 일찍 끝내 버리고, OR '1'='1'이 SQL의 일부가 돼요. 이 조건은 항상 참이라서, 쿼리가 모든 사용자를 돌려줘요. 공격자는 같은 수법으로 다른 사람의 데이터를 읽거나 테이블을 지울 수도 있어요. 이걸 SQL 인젝션(SQL injection)이라고 하고, 웹에서 가장 흔한 보안 버그 중 하나예요.
바인드 파라미터를 쓰면 SQL 텍스트는 절대 바뀌지 않아요. 입력은 따로 전달되고, 데이터베이스는 그걸 값으로만 다뤄요. 아래는 두 방식을 sqlx로 쓴 거예요. 사용자 ana와 root가 들어 있는 실제 SQLite 테이블에 실행했어요.
let user_input = "nobody' OR '1'='1"; // 이게 로그인 폼에서 들어왔다고 상상해 보세요
// 하지 마세요: 입력을 SQL 텍스트에 붙여 넣기.let sql = format!("SELECT name FROM users WHERE name = '{user_input}'");let rows: Vec<(String,)> = sqlx::query_as(AssertSqlSafe(sql)).fetch_all(&pool).await?;println!("format!: {rows:?}");
// 이렇게 하세요: ? 플레이스홀더가 있는 고정된 SQL 텍스트, 그리고 따로 보내는 입력.let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM users WHERE name = ?") .bind(user_input) .fetch_all(&pool) .await?;println!("bind: {rows:?}");format!: [("ana",), ("root",)]bind: []붙여 넣은 쿼리는 두 사용자를 모두 흘려 버렸어요. 바인딩한 쿼리는 그런 이상한 이름의 사용자가 없다는 걸 제대로 알아냈고요. 그리고 나쁜 버전에 있는 AssertSqlSafe(...) 래퍼를 눈여겨보세요. sqlx 0.9는 기본으로 여러분을 지켜 줘요. sqlx::query와 query_as는 고정된 &'static str만 받아서, format! 문자열을 래퍼 없이 넘기면 컴파일이 안 돼요.
error[E0277]: dynamic SQL strings should be audited for possible injections = help: the trait `SqlSafeStr` is not implemented for `&std::string::String` = note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.이 에러는 “동적 SQL 문자열은 인젝션 가능성이 없는지 검토해야 한다”는 뜻이고, 리터럴 SQL 문자열과 바인드 파라미터, 또는 QueryBuilder를 쓰라고 권해요. 그럼 AssertSqlSafe는 언제 쓸까요? 실행 중에 SQL을 꼭 만들어야 할 때뿐이에요. 예를 들어 사용자가 정렬할 열을 고를 때요. 먼저 그 값을 여러분이 정해 둔 허용 목록과 대조해 확인한 다음, 문자열을 래퍼로 감싸서 검토를 마쳤다고 sqlx에게 알려 주세요. 값에는 항상 .bind()를 쓰세요. Diesel과 SeaORM은 쿼리 빌더를 쓰는 한(날것의 SQL 문자열이 아니라면) 값을 알아서 바인딩해 줘요.
커넥션 풀
섹션 제목: “커넥션 풀”데이터베이스 연결을 여는 건 느려요. 네트워크를 한 번 오가야 하고, 로그인도 하고, 준비 작업도 있거든요. 풀(pool)은 연결 몇 개를 한 번만 열어 두고 빌려줘요. 코드는 쿼리 하나를 실행하는 동안 연결을 빌렸다가 바로 돌려줘요. 그래서 위의 모든 예제가 연결 하나가 아니라 &pool을 넘긴 거예요.
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new() .filename("visits.db") .create_if_missing(true);
let pool = SqlitePoolOptions::new() .max_connections(4) // 한 번에 절대 4개를 넘지 않아요 .connect_with(options) .await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)") .execute(&pool) .await?;
// 태스크 20개가 풀 하나를 함께 써요. 풀 복제는 저렴해요. 그냥 핸들이거든요.let mut handles = Vec::new();for n in 0..20 { let pool = pool.clone(); handles.push(tokio::spawn(async move { sqlx::query("INSERT INTO visits (page) VALUES (?)") .bind(format!("/page/{}", n % 3)) .execute(&pool) .await }));}for handle in handles { handle.await.expect("task panicked")?; // 20개의 INSERT가 모두 끝날 때까지 기다려요}태스크 20개가 동시에 돌지만, 어느 순간이든 데이터베이스와 이야기하는 건 많아야 4개예요. 나머지는 빈 연결이 생길 때까지 아주 잠깐 기다려요. 나중에 행 수를 세어 보면(새 visits.db에서 시작했을 때) 이렇게 나와요.
/page/0: 7 visits/page/1: 7 visits/page/2: 6 visits경험에서 나온 규칙 몇 가지예요.
- 프로그램이 시작할 때 풀을 하나 만들어서 함께 쓰세요. axum 앱이라면 앱 상태(state)에 넣으세요(웹 API 프로젝트를 보세요).
- 요청마다 새 풀을 만들지 마세요. 풀의 장점을 통째로 버리는 거예요.
max_connections는 데이터베이스가 허용하는 수보다 한참 낮게 두세요. Postgres 서버는 기본으로 연결 100개를 허용하는데, 앱을 여러 개 띄우면 각자 자기 풀을 가져요.acquire_timeout을 설정하세요. 그러면 모든 연결이 바쁠 때 요청이 끝없이 기다리지 않고 분명한 에러로 실패해요.
연습 문제
섹션 제목: “연습 문제”1. 리포지토리 검색하기
섹션 제목: “1. 리포지토리 검색하기”메모리 리포지토리 아이디어를 가져와서 search 메서드를 추가해 보세요. 제목에 어떤 단어가 들어 있는 할 일을 대소문자 구분 없이 찾아서 id 순으로 돌려줘요. “milk”로 테스트하세요. 힌트: title.to_lowercase().contains(&word.to_lowercase())를 쓰고, 여기서는 저장소로 Vec<Task>면 충분해요.
정답 보기
#[derive(Debug, Clone)]struct Task { id: u32, title: String,}
trait TaskRepository { fn add(&mut self, title: &str) -> u32; fn search(&self, word: &str) -> Vec<Task>;}
struct VecRepo { tasks: Vec<Task>,}
impl TaskRepository for VecRepo { fn add(&mut self, title: &str) -> u32 { let id = self.tasks.len() as u32 + 1; self.tasks.push(Task { id, title: title.to_string() }); id }
fn search(&self, word: &str) -> Vec<Task> { let word = word.to_lowercase(); let mut found: Vec<Task> = self .tasks .iter() .filter(|t| t.title.to_lowercase().contains(&word)) .cloned() .collect(); found.sort_by_key(|t| t.id); found }}
fn main() { let mut repo = VecRepo { tasks: Vec::new() }; repo.add("Buy milk"); repo.add("Walk the dog"); repo.add("Milk the cow");
for task in repo.search("milk") { println!("{}: {}", task.id, task.title); } println!("{} result(s) for \"cat\"", repo.search("cat").len());}실행 결과:
1: Buy milk3: Milk the cow0 result(s) for "cat"한글에는 대문자와 소문자가 없어서, 대소문자를 무시하는 검색을 보여 주려고 제목을 영어로 두었어요. “milk”로 찾으면 “Buy milk”와 대문자로 시작하는 “Milk the cow”가 모두 나오고, “cat”으로는 결과가 0개예요.
저장소가 HashMap에서 Vec으로 바뀌었지만, 트레이트를 호출하는 코드는 그걸 알 필요가 없어요. SQL에서는 같은 검색이 WHERE title LIKE ?와 .bind(format!("%{word}%"))가 돼요. % 기호가 SQL 텍스트가 아니라 바인딩하는 값에 들어간다는 점을 눈여겨보세요.
2. sqlx로 만드는 진짜 리포지토리 (내 컴퓨터에서)
섹션 제목: “2. sqlx로 만드는 진짜 리포지토리 (내 컴퓨터에서)”SqlitePool을 가지고 있고 async 메서드 세 개를 가진 구조체 SqliteTaskRepo를 만들어 보세요. add(title)은 새 id를 돌려주고, mark_done(id)는 행이 바뀌었으면 true를 돌려주고, list_open()은 아직 안 끝난 할 일을 돌려줘요. main에서 테이블을 만들고, 할 일 세 개를 추가하고, 1번과 99번 할 일을 완료로 표시하세요. 힌트: execute 결과의 rows_affected()는 UPDATE가 몇 개의 행을 바꿨는지 알려 줘요.
정답 보기
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
struct SqliteTaskRepo { pool: SqlitePool,}
impl SqliteTaskRepo { async fn add(&self, title: &str) -> Result<i64, sqlx::Error> { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&self.pool) .await?; Ok(result.last_insert_rowid()) }
async fn mark_done(&self, id: i64) -> Result<bool, sqlx::Error> { let result = sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(id) .execute(&self.pool) .await?; Ok(result.rows_affected() == 1) }
async fn list_open(&self) -> Result<Vec<Task>, sqlx::Error> { sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = FALSE ORDER BY id") .fetch_all(&self.pool) .await }}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { let pool = SqlitePool::connect("sqlite::memory:").await?; sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE)") .execute(&pool) .await?;
let repo = SqliteTaskRepo { pool }; repo.add("Buy milk").await?; repo.add("Learn sqlx").await?; repo.add("Walk the dog").await?;
println!("mark 1 done: {}", repo.mark_done(1).await?); println!("mark 99 done: {}", repo.mark_done(99).await?); for task in repo.list_open().await? { println!("[{}] {}: {}", if task.done { "x" } else { " " }, task.id, task.title); } Ok(())}mark 1 done: truemark 99 done: false[ ] 2: Learn sqlx[ ] 3: Walk the dog1번 할 일은 완료로 바뀌었고(true), 99번은 그런 행이 없어서 아무것도 바뀌지 않았어요(false). 메모리 버전과 모양은 똑같아요. 다만 async이고 Result를 돌려줘요. 진짜 데이터베이스는 실패할 수 있으니까요(디스크가 꽉 차거나, 네트워크가 끊기거나요). 다음 단계로, 웹 API 프로젝트의 Vec을 이 리포지토리로 바꿔 보세요.
- 간단한 동기 SQLite에는 rusqlite, 비동기로 평범한 SQL을 쓰려면 sqlx(보통 첫 선택), 컴파일 타임에 검사하는 쿼리 빌더에는 Diesel, Entity Framework 같은 비동기 ORM에는 SeaORM을 써요.
- 리포지토리는 메서드 몇 개 뒤에 데이터가 어디 있는지를 숨겨요.
HashMap으로 시작하고 나중에 데이터베이스로 바꾸면 돼요. - sqlx에서는 변경에는
query+.bind()+.execute()를, 읽기에는query_as+fetch_all/fetch_one/fetch_optional로 행을#[derive(FromRow)]구조체에 담아요. - 연결 문자열은 코드가 아니라
DATABASE_URL에 두세요. SQLite는?플레이스홀더를, Postgres는$1,$2를 써요. - 테이블은 손으로 고치지 말고 마이그레이션(
sqlx migrate add/run)으로 바꾸세요. - 사용자 입력은 항상 바인딩하세요.
format!으로 SQL에 붙여 넣으면 절대 안 돼요. 시작할 때 풀 하나를 만들어서 함께 쓰세요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. 평범한 SQL을 쓰고, async를 쓰고, 컴파일 타임에 쿼리를 검사하는 기능도 쓸 수 있으면 좋겠어요. 어떤 크레이트가 가장 잘 맞을까요?
2. 쿼리가 행을 0개 또는 1개 돌려줄 수 있을 때는 sqlx의 어떤 메서드를 써야 할까요?
3. format!("... WHERE name = '{name}'")는 왜 위험할까요?
4. 커넥션 풀은 무슨 일을 하나요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- docs.rs의 sqlx (영어):
query,query_as,query!매크로, 풀, 지원하는 모든 데이터베이스를 다룬 API 문서예요. - docs.rs의 sqlx-cli (영어): CLI 설치, 데이터베이스 만들기, 마이그레이션 실행과 되돌리기,
query!매크로를 위한 오프라인 모드를 설명해요. - Getting Started with Diesel (영어):
diesel setup부터 행 추가, 수정, 삭제까지 안내하는 공식 단계별 가이드예요. - SeaORM 문서 (영어): 엔티티, 관계, 마이그레이션, 그리고 코드 생성기
sea-orm-cli를 다뤄요. - OWASP SQL Injection Prevention Cheat Sheet (영어): SQL 인젝션이 어떻게 일어나는지, 파라미터화된 쿼리가 어떻게 막아 주는지를 언어와 상관없이 설명해요.