이전 글에서는 Actix-web 프레임워크를 활용해 간단한 웹 서버 애플리케이션을 구현하며, HTTP 요청 처리와 JSON 응답, 비동기 패턴 등을 익혔습니다. 이번 글에서는 여기서 한 걸음 더 나아가 데이터베이스 연동을 통해 간단한 REST API를 구축해보겠습니다. 이를 통해 실제 백엔드 서비스 개발에 한 걸음 더 다가설 수 있습니다.
이번 프로젝트의 주요 목표는 다음과 같습니다.
- 데이터베이스 연동: SQLite 등 간단한 로컬 DB를 연계, CRUD(생성, 읽기, 업데이트, 삭제) 중 일부를 구현해 실전적인 REST API 구조를 익힙니다.
- SQLx 크레이트 활용: Rust 비동기 데이터베이스 드라이버인 sqlx를 사용해 비동기 DB 접근을 구현합니다.
- 마이그레이션(Migration) & 스키마 정의: DB 스키마 관리 및 초기화를 통해 안정적인 개발 환경 구축.
- C++와 비교: C++로 DB 연동 시 ODBC나 별도 라이브러리를 설정하고 에러 관리에 신경 써야 하는 반면, 러스트는 타입 시스템과 async/await을 통해 더 안전하고 명확한 DB 연동 코드를 작성할 수 있습니다.
프로젝트 개요
구체적인 예제로는 간단한 To-Do 리스트 API를 구현해봅시다.
- 기능:
- POST /api/todos → JSON 바디로 { "title": "..." }를 받으면 새로운 todo를 DB에 저장
- GET /api/todos → 현재 DB에 저장된 모든 todo 리스트를 JSON으로 반환
- DB: SQLite 사용
- 아키텍처:
- main.rs에서 서버 초기화 및 DB Pool 생성
- /api/todos를 처리하는 라우트 및 핸들러 함수 작성
- 모델(Struct) 정의 및 serde로 직렬화/역직렬화
- SQLx로 쿼리 수행
이 과정을 통해 실전 서비스에서 중요한 DB 연동, 데이터 모델 관리, 에러 처리, 테스트 패턴을 체험할 수 있습니다.
프로젝트 초기화 및 의존성 추가
새 프로젝트를 생성하고, sqlx, actix-web, serde, anyhow 등을 의존성에 추가합니다.
cargo new todo_api
cd todo_api
Cargo.toml:
[package]
name = "todo_api"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
# SQLx와 SQLite 관련
sqlx = { version = "0.6", features = ["sqlite", "runtime-tokio-native-tls", "macros"] }
데이터베이스 초기화 및 마이그레이션
sqlx는 마이그레이션 파일을 통해 DB 스키마를 관리할 수 있습니다. 프로젝트 루트에 migrations 디렉토리를 만들고 스키마 정의를 위한 SQL 파일을 생성해봅시다.
mkdir migrations
touch migrations/20230101000000_create_todos.sql
migrations/20230101000000_create_todos.sql 내용 예시:
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
이로써 todos 테이블이 생성되는 마이그레이션을 준비했습니다.
메인 함수: DB Pool 초기화 및 마이그레이션 실행
sqlx는 런타임에 마이그레이션을 실행할 수 있습니다. main.rs에서 DB Pool을 생성하고, 마이그레이션을 적용한 뒤, 서버를 시작해봅시다.
// src/main.rs
use actix_web::{web, App, HttpServer};
use anyhow::Result;
use sqlx::{SqlitePool, migrate::Migrator};
use std::path::Path;
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
mod routes;
#[actix_web::main]
async fn main() -> Result<()> {
let db_url = "sqlite:./todo.db";
let pool = SqlitePool::connect(db_url).await?;
// 마이그레이션 실행
MIGRATOR.run(&pool).await?;
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
.configure(routes::init)
})
.bind("127.0.0.1:8080")?
.run()
.await?;
Ok(())
}
여기서 MIGRATOR.run(&pool)로 마이그레이션을 적용합니다. routes 모듈에 실제 라우트를 정의할 예정입니다.
sqlx::migrate! 매크로는 컴파일 타임에 ./migrations 디렉토리를 읽어 마이그레이션 정보를 임베드합니다. 러스트의 컴파일 타임 안전성 덕분에 마이그레이션 누락, 오탈자 등을 조기에 감지할 수 있습니다.
Routes 및 Handlers 정의
routes 모듈을 생성하고, /api/todos 라우트를 위한 핸들러를 작성해봅시다.
mkdir src/routes
touch src/routes/mod.rs
src/routes/mod.rs:
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use sqlx::SqlitePool;
use anyhow::Result;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/api/todos")
.route(web::post().to(create_todo))
.route(web::get().to(list_todos))
);
}
#[derive(Deserialize)]
struct CreateTodo {
title: String,
}
async fn create_todo(pool: web::Data<SqlitePool>, json: web::Json<CreateTodo>) -> impl Responder {
let title = json.title.clone();
match sqlx::query!("INSERT INTO todos (title) VALUES (?)", title)
.execute(pool.get_ref())
.await
{
Ok(_) => HttpResponse::Ok().body("Todo created."),
Err(e) => {
eprintln!("Error creating todo: {:?}", e);
HttpResponse::InternalServerError().body("Error creating todo.")
}
}
}
#[derive(serde::Serialize)]
struct Todo {
id: i64,
title: String,
created_at: String,
}
async fn list_todos(pool: web::Data<SqlitePool>) -> impl Responder {
match sqlx::query!("SELECT id, title, created_at FROM todos")
.fetch_all(pool.get_ref())
.await
{
Ok(rows) => {
let todos: Vec<Todo> = rows.into_iter().map(|r| Todo {
id: r.id,
title: r.title,
created_at: r.created_at,
}).collect();
HttpResponse::Ok().json(todos)
}
Err(e) => {
eprintln!("Error listing todos: {:?}", e);
HttpResponse::InternalServerError().body("Error listing todos.")
}
}
}
- create_todo: POST 요청의 JSON 바디에서 title을 추출해 DB에 삽입
- list_todos: DB에서 모든 todos를 SELECT로 조회, JSON 배열 형태로 응답
서버 실행 및 확인
cargo run으로 서버 실행 후, 다음 명령을 시도해보세요.
- curl -X POST -H "Content-Type: application/json" -d '{"title":"Learn Rust"}' http://127.0.0.1:8080/api/todos
→ "Todo created." 응답 - curl http://127.0.0.1:8080/api/todos
→ [{"id":1,"title":"Learn Rust","created_at":"2023-01-01 00:00:00"}] 같은 식의 응답
DB에 데이터가 잘 들어가고, 목록이 정상적으로 반환되는지 확인할 수 있습니다.
테스트 코드 작성
테스트를 위해 tests 디렉토리에서 테스트 서버를 띄우고, 가상의 DB(메모리 DB)를 사용해 통합 테스트를 해볼 수 있습니다.
mkdir tests
touch tests/integration_test.rs
tests/integration_test.rs (간단 예):
use actix_web::{test, App};
use todo_api::routes::init;
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
#[actix_web::test]
async fn test_create_and_list_todos() {
// 임시 in-memory DB
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query!("CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);")
.execute(&pool).await.unwrap();
let app = test::init_service(
App::new()
.app_data(actix_web::web::Data::new(pool))
.configure(init)
).await;
let req = test::TestRequest::post()
.uri("/api/todos")
.set_json(&serde_json::json!({"title": "Test Todo"}))
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let req = test::TestRequest::get().uri("/api/todos").to_request();
let resp: serde_json::Value = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.as_array().unwrap().len(), 1);
assert_eq!(resp[0]["title"], "Test Todo");
}
cargo test를 실행하면 DB를 포함한 통합 테스트도 성공적으로 수행할 수 있습니다.
다음 단계
이번 예제를 통해 러스트와 Actix-web, sqlx를 조합해 DB 연동 REST API를 구축하는 과정을 경험했습니다. 앞으로 다음과 같은 확장과 개선을 고려할 수 있습니다.
- PUT, DELETE 라우트 추가: CRUD 기능 완성
- 에러 처리 개선: thiserror를 사용해 커스텀 에러 타입 정의
- 환경 변수 관리: DB 경로, 포트 번호 등 환경 변수로 설정
- C++ 대비 장점 재확인: C++로 DB 연동 시 발생할 수 있는 메모리 관리 복잡성을 러스트의 타입 시스템이 얼마나 완화하는지 재인식
결론
이번 글에서는 데이터베이스 연동을 통한 REST API 구현을 예제로 들어, 러스트가 단순한 HTTP 서버 수준을 넘어 실제 백엔드 서비스 개발에도 적합하다는 점을 확인했습니다. 비동기 패턴, 타입 안전성, 풍부한 라이브러리 생태계 덕분에 C++ 대비 안정적이며 유지보수하기 쉬운 코드를 작성할 수 있습니다.
다음 글에서는 더 고급 주제(예: 인증/인가, 로깅/모니터링, WebAssembly 연동, 혹은 C++ FFI 접목) 등을 다루어 실전적인 서비스 개발 과정을 더욱 넓혀보겠습니다.
유용한 링크와 리소스
- SQLx 문서: https://docs.rs/sqlx
- Actix-web 문서: https://actix.rs/
- Serde 문서: https://serde.rs/
- Rust Async Book: https://rust-lang.github.io/async-book/