이번 글에서는 SQLx를 활용하여 Rust 애플리케이션과 PostgreSQL 데이터베이스를 연동하는 방법을 알아보겠습니다. SQLx는 Rust에서 비동기적으로 SQL 데이터베이스에 접근할 수 있도록 도와주는 강력한 크레이트입니다. 이전 글에서 Docker를 활용하여 PostgreSQL 환경을 설정했으므로, 이제 실제로 데이터베이스에 연결하고 데이터를 조작해 보겠습니다.
2-1. SQLx 소개 및 설치
1. SQLx란?
SQLx는 Rust에서 비동기 SQL 데이터베이스 작업을 수행할 수 있게 해주는 크레이트입니다. 주요 특징은 다음과 같습니다.
- 비동기 지원: Tokio와 같은 비동기 런타임과 함께 사용하여 비동기 데이터베이스 작업을 수행할 수 있습니다.
- 커넥션 풀링: 효율적인 데이터베이스 연결 관리를 제공합니다.
- 안전한 SQL 쿼리: 컴파일 시점에 SQL 문법과 타입을 체크하는 매크로를 제공합니다.
- 다중 데이터베이스 지원: PostgreSQL, MySQL, SQLite 등 여러 데이터베이스를 지원합니다.
2. 프로젝트에 SQLx 추가
Cargo.toml
파일에 SQLx와 관련된 의존성을 추가합니다.
[dependencies]
sqlx = { version = "0.6", features = [ "runtime-tokio-native-tls", "postgres" ] }
dotenv = "0.15"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
axum = "0.6"
thiserror = "1.0"
의존성 설명
- sqlx: SQL 데이터베이스에 접근하기 위한 주요 크레이트입니다.
- runtime-tokio-native-tls: Tokio 런타임과 native-tls를 사용하기 위한 기능입니다.
- postgres: PostgreSQL을 사용하기 위한 기능입니다.
- dotenv:
.env
파일에서 환경 변수를 로드하기 위한 크레이트입니다. - tokio: 비동기 런타임입니다.
- serde, serde_json: JSON 직렬화 및 역직렬화를 위한 크레이트입니다.
- axum: 웹 애플리케이션 프레임워크입니다.
- thiserror: 에러 핸들링을 위한 크레이트입니다.
Note:
sqlx
의macros
피처는 컴파일 시점에 쿼리 검증을 위한 매크로를 사용하기 위해 필요하지만, 매크로를 사용하지 않을 경우 피처를 제거해도 됩니다.
3. sqlx-cli 설치 (선택 사항)
sqlx-cli
는 SQLx에서 제공하는 커맨드라인 도구로, 컴파일 시점에 쿼리를 검증하는 데 사용됩니다. 매크로를 사용하지 않을 경우 필수는 아닙니다.
cargo install sqlx-cli --no-default-features --features postgres,runtime-tokio-native-tls
2-2. 데이터베이스 연결 설정 및 환경 변수 관리
1. 환경 변수 설정
애플리케이션에서 데이터베이스 연결 정보를 안전하게 관리하기 위해 환경 변수를 사용합니다.
.env
파일 생성
프로젝트 루트 디렉토리에 .env
파일을 생성하고 다음 내용을 추가합니다.
DATABASE_URL=postgres://test:test1234@localhost:5432/test_db
DATABASE_URL
: 데이터베이스 연결 문자열로, 다음과 같은 형식입니다.postgres://username:password@host:port/database_name
2. 환경 변수 로드
dotenv
크레이트를 사용하여 애플리케이션에서 환경 변수를 로드합니다.
use dotenv::dotenv;
use std::env;
fn main() {
// .env 파일 로드
dotenv().ok();
// 환경 변수 가져오기
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
// ...
}
3. 데이터베이스 연결 풀 생성
sqlx::PgPool
을 사용하여 PostgreSQL에 대한 연결 풀을 생성합니다.
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
// 연결 풀 생성
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
// ...
Ok(())
}
max_connections
: 연결 풀에서 유지할 최대 연결 수를 지정합니다.
2-3. 간단한 CRUD 구현 (Book 엔티티 활용)
이제 Book
엔티티를 활용하여 데이터베이스에 CRUD(Create, Read, Update, Delete) 기능을 구현해 보겠습니다.
1. 데이터베이스 테이블 생성
먼저, books
테이블을 생성합니다.
1-1. 마이그레이션 방식 (sqlx-cli 필요)
마이그레이션 파일 작성
migrations
디렉토리를 생성하고, 20230101000000_create_books_table.sql
파일을 생성합니다.
CREATE TABLE books (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
author VARCHAR(100) NOT NULL
);
마이그레이션 적용
sqlx-cli
를 설치했다면 다음 명령어로 마이그레이션을 적용할 수 있습니다.
sqlx migrate run
또는 애플리케이션 코드에서 실행 시 마이그레이션을 적용할 수 있습니다.
1-2. DB 생성 시 테이블 초기화 방식
테스트 데이터베이스를 띄울 때 init.db 파일을 활용해 데이터베이스 생성 시점에서 테이블 초기화를 진행할 수 있습니다.
2. 데이터 모델 정의
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct Book {
id: i32,
title: String,
author: String,
}
#[derive(Debug, Deserialize)]
struct CreateBook {
title: String,
author: String,
}
3. 핸들러 함수 구현
3.1. Book 생성 (Create)
use axum::{Extension, Json};
use axum::response::IntoResponse;
use sqlx::PgPool;
use hyper::StatusCode;
async fn create_book(
Extension(pool): Extension<PgPool>,
Json(payload): Json<CreateBook>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let rec = sqlx::query_as::<_, Book>(
"INSERT INTO books (title, author) VALUES ($1, $2) RETURNING id, title, author"
)
.bind(&payload.title)
.bind(&payload.author)
.fetch_one(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("데이터베이스 오류: {}", e),
)
})?;
Ok((StatusCode::CREATED, Json(rec)))
}
3.2. 모든 Book 조회 (Read)
async fn get_books(
Extension(pool): Extension<PgPool>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let recs = sqlx::query_as::<_, Book>(
"SELECT id, title, author FROM books"
)
.fetch_all(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("데이터베이스 오류: {}", e),
)
})?;
Ok(Json(recs))
}
3.3. 특정 Book 조회
use axum::extract::Path;
async fn get_book(
Extension(pool): Extension<PgPool>,
Path(id): Path<i32>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let rec = sqlx::query_as::<_, Book>(
"SELECT id, title, author FROM books WHERE id = $1"
)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| {
(
StatusCode::NOT_FOUND,
format!("책을 찾을 수 없습니다: {}", e),
)
})?;
Ok(Json(rec))
}
3.4. Book 업데이트 (Update)
async fn update_book(
Extension(pool): Extension<PgPool>,
Path(id): Path<i32>,
Json(payload): Json<CreateBook>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let rec = sqlx::query_as::<_, Book>(
"UPDATE books SET title = $1, author = $2 WHERE id = $3 RETURNING id, title, author"
)
.bind(&payload.title)
.bind(&payload.author)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| {
(
StatusCode::NOT_FOUND,
format!("책을 업데이트할 수 없습니다: {}", e),
)
})?;
Ok(Json(rec))
}
3.5. Book 삭제 (Delete)
async fn delete_book(
Extension(pool): Extension<PgPool>,
Path(id): Path<i32>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let result = sqlx::query(
"DELETE FROM books WHERE id = $1"
)
.bind(id)
.execute(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("책을 삭제할 수 없습니다: {}", e),
)
})?;
if result.rows_affected() == 0 {
return Err((
StatusCode::NOT_FOUND,
format!("책을 찾을 수 없습니다."),
));
}
Ok(StatusCode::NO_CONTENT)
}
4. 라우터 설정
use axum::{routing::get, routing::post, routing::put, routing::delete, Router};
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
// 연결 풀 생성
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
let app = Router::new()
.route("/books", get(get_books).post(create_book))
.route("/books/:id", get(get_book).put(update_book).delete(delete_book))
.layer(Extension(pool));
println!("서버가 0.0.0.0:3000에서 시작됩니다...");
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
Ok(())
}
5. 매크로 사용에 대한 고려
매크로를 사용하면 컴파일 시점에 SQL 문법과 타입을 검증할 수 있어 런타임 오류를 줄일 수 있습니다. 그러나 매크로를 사용하지 않아도 query_as::<_, ReturnType>()
패턴으로 타입 안정성을 확보할 수 있습니다. 매크로를 사용하지 않는 경우에도 다음과 같은 이점이 있습니다.
- 코드의 명확성: 매크로 없이 일반 함수 호출로 쿼리를 작성하면 코드가 더 명확해질 수 있습니다.
- 유연성: 런타임에 동적으로 쿼리를 생성해야 하는 경우 매크로를 사용하지 않는 것이 더 적합합니다.
2-4. SQLx의 쿼리 빌더와 매핑 방법
1. 매크로 없이 쿼리 작성
sqlx
의 매크로를 사용하지 않고도 query_as::<_, ReturnType>()
함수를 사용하여 쿼리를 작성하고 결과를 구조체로 매핑할 수 있습니다.
예시:
let rec = sqlx::query_as::<_, Book>(
"SELECT id, title, author FROM books WHERE id = $1"
)
.bind(id)
.fetch_one(&pool)
.await?;
query_as::<_, Book>()
에서 첫 번째 타입 매개변수는 일반적으로_
로 지정하여 생략할 수 있습니다.- 반환되는 결과를
Book
구조체로 매핑합니다.
2. 쿼리 파라미터 바인딩
- 쿼리 내에서
$1
,$2
등의 플레이스홀더를 사용하고,bind()
메서드를 통해 값을 바인딩합니다. - 이를 통해 SQL 인젝션 공격을 방지할 수 있습니다.
예시:
sqlx::query(
"INSERT INTO books (title, author) VALUES ($1, $2)"
)
.bind(&payload.title)
.bind(&payload.author)
.execute(&pool)
.await?;
3. 타입 매핑과 오류 방지
- 매크로를 사용하지 않으면 컴파일 시점에 쿼리 검증은 불가능하지만, 런타임에 타입 불일치로 인한 오류가 발생할 수 있습니다.
- 이를 방지하기 위해 쿼리 결과와 구조체 필드의 타입이 일치하는지 주의해야 합니다.
2-5. 트랜잭션 처리 및 에러 핸들링
1. 트랜잭션 처리
트랜잭션은 데이터베이스 작업의 원자성을 보장합니다.
트랜잭션 시작
let mut tx = pool.begin().await?;
트랜잭션 내에서 작업 수행
sqlx::query("INSERT INTO books (title, author) VALUES ($1, $2)")
.bind(&book.title)
.bind(&book.author)
.execute(&mut tx)
.await?;
트랜잭션 커밋
tx.commit().await?;
트랜잭션 롤백
에러가 발생하면 트랜잭션을 롤백합니다.
tx.rollback().await?;
2. 에러 핸들링
에러 타입 정의
thiserror
크레이트를 사용하여 커스텀 에러 타입을 정의할 수 있습니다.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("데이터베이스 오류: {0}")]
DatabaseError(#[from] sqlx::Error),
#[error("입력 오류: {0}")]
ValidationError(String),
#[error("내부 서버 오류")]
InternalServerError,
}
에러를 핸들러 함수에서 반환
async fn create_book(
Extension(pool): Extension<PgPool>,
Json(payload): Json<CreateBook>,
) -> Result<impl IntoResponse, AppError> {
if payload.title.is_empty() || payload.author.is_empty() {
return Err(AppError::ValidationError("제목과 저자는 필수 항목입니다.".into()));
}
let rec = sqlx::query_as::<_, Book>(
"INSERT INTO books (title, author) VALUES ($1, $2) RETURNING id, title, author"
)
.bind(&payload.title)
.bind(&payload.author)
.fetch_one(&pool)
.await?;
Ok((StatusCode::CREATED, Json(rec)))
}
에러 응답 커스터마이징
IntoResponse
를 구현하여 에러에 대한 HTTP 응답을 커스터마이징할 수 있습니다.
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match self {
AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
AppError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = Json(json!({ "error": self.to_string() }));
(status, body).into_response()
}
}
결론
이번 글에서는 SQLx를 활용하여 Rust 애플리케이션과 PostgreSQL 데이터베이스를 연동하는 방법을 살펴보았습니다. 환경 변수 관리부터 데이터베이스 연결, CRUD 구현, 비동기 코드 작성, 쿼리 매핑, 트랜잭션 처리, 에러 핸들링까지 전반적인 내용을 다루었습니다. 특히 매크로를 사용하지 않고도 query_as::<_, ReturnType>()
패턴을 통해 타입 안정성을 확보하고 쿼리를 실행하는 방법을 배웠습니다.
참고 자료
주의: 이 글은 학습 목적으로 작성되었으며, 실제 애플리케이션 개발 시 보안, 성능, 에러 처리 등에 대한 추가 고려가 필요합니다.
'슬기로운 개발자생활 > Rust' 카테고리의 다른 글
&Axum SQLx를 활용한 데이터베이스 중급 - 마이그레이션 (Migration) (0) | 2024.09.24 |
---|---|
&Axum SQLx를 활용한 데이터베이스 중급 - Docker를 활용한 PostgreSQL 환경 설정 (0) | 2024.09.24 |
Rust와 Axum을 활용한 웹 백엔드 개발 - 간단한 가계부 웹 API (0) | 2024.09.24 |