Step 01: PostgreSQL 데이터베이스 연동
foundations 90 min

PostgreSQL 데이터베이스 연동

SQLx로 PostgreSQL에 연결하고 CRUD 작업을 구현합니다.

Execute this step

Run from project root:
cargo run --bin web

Step 1: PostgreSQL 데이터베이스 연동

학습 목표

  • SQLx로 PostgreSQL에 연결하기
  • 데이터베이스 마이그레이션 작성하기
  • 비동기 쿼리 실행하기

Cargo.toml에 추가

1[dependencies]
2sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
3chrono = { version = "0.4", features = ["serde"] }
4uuid = { version = "1.0", features = ["v4", "serde"] }
5dotenv = "0.15"

PostgreSQL 설치 및 실행

Docker로 실행:

1docker run --name postgres-library \
2  -e POSTGRES_PASSWORD=password \
3  -e POSTGRES_DB=library_db \
4  -p 5432:5432 \
5  -d postgres:15

환경 변수 설정

.env 파일 생성:

DATABASE_URL=postgresql://postgres:password@localhost:5432/library_db

SQLx CLI 설치

cargo install sqlx-cli --no-default-features --features postgres

데이터베이스 마이그레이션

마이그레이션 생성

sqlx migrate add create_books_table

migrations/xxx_create_books_table.sql:

1CREATE TYPE book_category AS ENUM ('fiction', 'nonfiction', 'science', 'history');
2
3CREATE TYPE book_status AS ENUM ('available', 'borrowed', 'reserved', 'lost');
4
5CREATE TABLE books (
6    id SERIAL PRIMARY KEY,
7    title VARCHAR(255) NOT NULL,
8    author VARCHAR(255) NOT NULL,
9    category book_category NOT NULL,
10    status book_status NOT NULL DEFAULT 'available',
11    borrowed_by VARCHAR(255),
12    due_date DATE,
13    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
14    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
15);
16
17CREATE INDEX idx_books_status ON books(status);
18CREATE INDEX idx_books_category ON books(category);

마이그레이션 실행

sqlx migrate run

데이터베이스 모델

src/models.rs 생성:

1use chrono::{NaiveDate, NaiveDateTime};
2use serde::{Deserialize, Serialize};
3use sqlx::FromRow;
4
5#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
6pub struct BookRow {
7    pub id: i32,
8    pub title: String,
9    pub author: String,
10    pub category: String,
11    pub status: String,
12    pub borrowed_by: Option<String>,
13    pub due_date: Option<NaiveDate>,
14    pub created_at: NaiveDateTime,
15    pub updated_at: NaiveDateTime,
16}
17
18impl BookRow {
19    pub fn to_book(&self) -> Book {
20        let category = match self.category.as_str() {
21            "fiction" => Category::Fiction,
22            "nonfiction" => Category::NonFiction,
23            "science" => Category::Science,
24            "history" => Category::History,
25            _ => Category::NonFiction,
26        };
27        
28        let status = match self.status.as_str() {
29            "available" => BookStatus::Available,
30            "borrowed" => BookStatus::Borrowed {
31                by: self.borrowed_by.clone().unwrap_or_default(),
32                due_date: self.due_date
33                    .map(|d| d.to_string())
34                    .unwrap_or_default(),
35            },
36            "reserved" => BookStatus::Reserved {
37                by: self.borrowed_by.clone().unwrap_or_default(),
38            },
39            "lost" => BookStatus::Lost,
40            _ => BookStatus::Available,
41        };
42        
43        Book {
44            id: self.id as u32,
45            title: self.title.clone(),
46            author: self.author.clone(),
47            category,
48            status,
49        }
50    }
51}

데이터베이스 레이어

src/db.rs 생성:

1use sqlx::{PgPool, postgres::PgPoolOptions};
2use crate::{Book, Category, LibraryError, Result, models::BookRow};
3
4pub struct Database {
5    pool: PgPool,
6}
7
8impl Database {
9    pub async fn new(database_url: &str) -> Result<Self> {
10        let pool = PgPoolOptions::new()
11            .max_connections(5)
12            .connect(database_url)
13            .await
14            .map_err(|e| LibraryError::DatabaseError(e.to_string()))?;
15        
16        Ok(Database { pool })
17    }
18    
19    pub async fn add_book(
20        &self,
21        title: &str,
22        author: &str,
23        category: &Category,
24    ) -> Result<i32> {
25        let category_str = match category {
26            Category::Fiction => "fiction",
27            Category::NonFiction => "nonfiction",
28            Category::Science => "science",
29            Category::History => "history",
30        };
31        
32        let row = sqlx::query!(
33            r#"
34            INSERT INTO books (title, author, category)
35            VALUES ($1, $2, $3::book_category)
36            RETURNING id
37            "#,
38            title,
39            author,
40            category_str
41        )
42        .fetch_one(&self.pool)
43        .await
44        .map_err(|e| LibraryError::DatabaseError(e.to_string()))?;
45        
46        Ok(row.id)
47    }
48    
49    pub async fn find_book(&self, id: i32) -> Result<Book> {
50        let row = sqlx::query_as::<_, BookRow>(
51            "SELECT * FROM books WHERE id = $1"
52        )
53        .bind(id)
54        .fetch_optional(&self.pool)
55        .await
56        .map_err(|e| LibraryError::DatabaseError(e.to_string()))?;
57        
58        row.map(|r| r.to_book())
59            .ok_or(LibraryError::BookNotFound(id as u32))
60    }
61    
62    pub async fn list_all_books(&self) -> Result<Vec<Book>> {
63        let rows = sqlx::query_as::<_, BookRow>(
64            "SELECT * FROM books ORDER BY id"
65        )
66        .fetch_all(&self.pool)
67        .await
68        .map_err(|e| LibraryError::DatabaseError(e.to_string()))?;
69        
70        Ok(rows.into_iter().map(|r| r.to_book()).collect())
71    }
72    
73    pub async fn borrow_book(
74        &self,
75        id: i32,
76        borrower: &str,
77        due_date: &str,
78    ) -> Result<()> {
79        let due = chrono::NaiveDate::parse_from_str(due_date, "%Y-%m-%d")
80            .map_err(|_| LibraryError::InvalidDate(due_date.to_string()))?;
81        
82        let result = sqlx::query!(
83            r#"
84            UPDATE books
85            SET status = 'borrowed'::book_status,
86                borrowed_by = $1,
87                due_date = $2,
88                updated_at = NOW()
89            WHERE id = $3 AND status = 'available'::book_status
90            "#,
91            borrower,
92            due,
93            id
94        )
95        .execute(&self.pool)
96        .await
97        .map_err(|e| LibraryError::DatabaseError(e.to_string()))?;
98        
99        if result.rows_affected() == 0 {
100            return Err(LibraryError::AlreadyBorrowed {
101                title: format!("ID {}", id),
102                borrower: "알 수 없음".to_string(),
103            });
104        }
105        
106        Ok(())
107    }
108    
109    pub async fn return_book(&self, id: i32) -> Result<()> {
110        let result = sqlx::query!(
111            r#"
112            UPDATE books
113            SET status = 'available'::book_status,
114                borrowed_by = NULL,
115                due_date = NULL,
116                updated_at = NOW()
117            WHERE id = $1 AND status = 'borrowed'::book_status
118            "#,
119            id
120        )
121        .execute(&self.pool)
122        .await
123        .map_err(|e| LibraryError::DatabaseError(e.to_string()))?;
124        
125        if result.rows_affected() == 0 {
126            return Err(LibraryError::NotBorrowed(format!("ID {}", id)));
127        }
128        
129        Ok(())
130    }
131}

웹 서버 수정

src/bin/web.rs:

1use book_manager::db::Database;
2use dotenv::dotenv;
3use std::env;
4
5struct AppState {
6    db: Database,
7}
8
9#[actix_web::main]
10async fn main() -> std::io::Result<()> {
11    dotenv().ok();
12    
13    let database_url = env::var("DATABASE_URL")
14        .expect("DATABASE_URL must be set");
15    
16    let db = Database::new(&database_url)
17        .await
18        .expect("Failed to connect to database");
19    
20    println!("✅ Database connected");
21    println!("🚀 Server starting at http://localhost:8080");
22    
23    let app_state = web::Data::new(AppState { db });
24    
25    HttpServer::new(move || {
26        App::new()
27            .app_data(app_state.clone())
28            .route("/health", web::get().to(health_check))
29            .route("/books", web::get().to(list_books))
30            .route("/books/{id}", web::get().to(get_book))
31            .route("/books", web::post().to(create_book))
32            .route("/books/{id}/borrow", web::post().to(borrow_book))
33            .route("/books/{id}/return", web::post().to(return_book))
34    })
35    .bind(("127.0.0.1", 8080))?
36    .run()
37    .await
38}
39
40async fn list_books(data: web::Data<AppState>) -> Result<HttpResponse, LibraryError> {
41    let books = data.db.list_all_books().await?;
42    Ok(HttpResponse::Ok().json(ApiResponse::success(books)))
43}
44
45async fn get_book(
46    data: web::Data<AppState>,
47    path: web::Path<i32>,
48) -> Result<HttpResponse, LibraryError> {
49    let id = path.into_inner();
50    let book = data.db.find_book(id).await?;
51    Ok(HttpResponse::Ok().json(ApiResponse::success(book)))
52}
53
54async fn create_book(
55    data: web::Data<AppState>,
56    req: web::Json<CreateBookRequest>,
57) -> Result<HttpResponse, LibraryError> {
58    let category: Category = req.category.as_str().into();
59    let id = data.db.add_book(&req.title, &req.author, &category).await?;
60    Ok(HttpResponse::Created().json(ApiResponse::success(id)))
61}

체크리스트

  • [ ] PostgreSQL을 설치하고 실행했습니다
  • [ ] SQLx로 데이터베이스에 연결했습니다
  • [ ] 마이그레이션을 작성하고 실행했습니다
  • [ ] CRUD 쿼리를 구현했습니다
  • [ ] 웹 서버를 데이터베이스와 연동했습니다

다음 단계

Step 2에서는 JWT 인증을 추가하여 사용자 인증 시스템을 구현합니다.

Did you find this helpful? Give it a cheer!