슬기로운 개발자생활/Rust
Rust와 Axum을 활용한 웹 백엔드 개발 - 간단한 가계부 웹 API
개발자 소신
2024. 9. 24. 10:30
반응형
이번 섹션에서는 Rust와 Axum을 활용하여 간단한 가계부 웹 애플리케이션을 구축합니다. 프로젝트 요구사항을 분석하고, CRUD 엔드포인트를 구현하며, 요청 파라미터와 경로 변수를 처리하는 방법을 배워보겠습니다. 또한 JSON 데이터의 직렬화 및 역직렬화, 에러 처리와 커스텀 에러 응답까지 다루어 실제 서비스 개발에 필요한 기본기를 익힙니다.
2-1. 프로젝트 요구사항 분석 및 설계
1. 프로젝트 개요
- 목적: 간단한 가계부 애플리케이션을 구축하여 수입과 지출을 관리할 수 있도록 합니다.
- 주요 기능:
- 가계부(Book) 생성, 조회, 수정, 삭제 기능
- 수입/지출 기록(Record) 추가, 조회, 수정, 삭제 기능
- 카테고리(Category) 관리 (선택 사항)
2. 데이터 모델 설계
간단하게 하기 위해 데이터 모델을 다음과 같이 설계합니다.
테이블 목록
- tb_book: 가계부 정보를 저장합니다.
- tb_record: 수입/지출 기록을 저장합니다.
테이블 구조
1. tb_book
컬럼 이름 | 데이터 타입 | 제약 조건 | 설명 |
---|---|---|---|
id | SERIAL | PRIMARY KEY | 가계부 ID |
name | VARCHAR(50) | NOT NULL | 가계부 이름 |
2. tb_record
컬럼 이름 | 데이터 타입 | 제약 조건 | 설명 |
---|---|---|---|
id | SERIAL | PRIMARY KEY | 기록 ID |
book_id | INTEGER | FOREIGN KEY(tb_book.id) | 가계부 ID |
amount | INTEGER | NOT NULL | 금액 |
memo | VARCHAR(32) | 메모 | |
target_dt | DATETIME | NOT NULL | 거래 일시 |
is_income | BOOLEAN | NOT NULL | income - true expense - false |
3. 엔드포인트 설계
가계부(Book) 엔드포인트
- POST /books: 새로운 가계부 생성
- GET /books: 모든 가계부 조회
- GET /books/{id}: 특정 가계부 조회
- PUT /books/{id}: 가계부 정보 수정
- DELETE /books/{id}: 가계부 삭제
기록(Record) 엔드포인트
- POST /books/{book_id}/records: 특정 가계부에 새로운 기록 추가
- GET /books/{book_id}/records: 특정 가계부의 모든 기록 조회
- GET /books/{book_id}/records/{id}: 특정 기록 조회
- PUT /books/{book_id}/records/{id}: 기록 수정
- DELETE /books/{book_id}/records/{id}: 기록 삭제
2-2. CRUD 엔드포인트 구현 (예: 가계부 관리)
1. 프로젝트 생성 및 설정
Cargo.toml에 필요한 의존성을 추가합니다.
[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
2. 데이터 구조 정의
src/main.rs
use axum::{
routing::{get, post, put, delete},
Router, Json, extract::{Path, Query},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::net::SocketAddr;
// 가계부 구조체
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Book {
id: u32,
name: String,
}
// 애플리케이션 상태: 가계부 목록을 저장
type AppState = Arc<Mutex<Vec<Book>>>;
#[tokio::main]
async fn main() {
let state = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route("/books", post(create_book).get(get_books))
.route("/books/:id", get(get_book).put(update_book).delete(delete_book))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("서버가 {}에서 시작됩니다...", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
3. 가계부 엔드포인트 핸들러 구현
3.1 가계부 생성 (POST /books)
async fn create_book(
state: axum::extract::State<AppState>,
Json(payload): Json<BookInput>,
) -> (StatusCode, Json<Book>) {
let mut books = state.lock().unwrap();
let new_id = (books.len() + 1) as u32;
let book = Book {
id: new_id,
name: payload.name,
};
books.push(book.clone());
(StatusCode::CREATED, Json(book))
}
#[derive(Debug, Deserialize)]
struct BookInput {
name: String,
}
3.2 가계부 목록 조회 (GET /books)
async fn get_books(state: axum::extract::State<AppState>) -> Json<Vec<Book>> {
let books = state.lock().unwrap();
Json(books.clone())
}
3.3 특정 가계부 조회 (GET /books/:id)
async fn get_book(
state: axum::extract::State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Book>, StatusCode> {
let books = state.lock().unwrap();
books
.iter()
.find(|book| book.id == id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
3.4 가계부 수정 (PUT /books/:id)
async fn update_book(
state: axum::extract::State<AppState>,
Path(id): Path<u32>,
Json(payload): Json<BookInput>,
) -> Result<Json<Book>, StatusCode> {
let mut books = state.lock().unwrap();
if let Some(book) = books.iter_mut().find(|book| book.id == id) {
book.name = payload.name.clone();
Ok(Json(book.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
3.5 가계부 삭제 (DELETE /books/:id)
async fn delete_book(
state: axum::extract::State<AppState>,
Path(id): Path<u32>,
) -> StatusCode {
let mut books = state.lock().unwrap();
if books.iter().any(|book| book.id == id) {
books.retain(|book| book.id != id);
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
2-3. 요청 파라미터와 경로 변수 처리 방법
1. 경로 변수 추출 (Path
)
- 경로 변수는 URL 경로에서 값을 추출합니다.
Path<T>
를 사용하여 경로 변수를 추출할 수 있습니다.
예시:
async fn get_book(Path(id): Path<u32>) { /* ... */ }
2. 쿼리 파라미터 추출 (Query
)
- 쿼리 파라미터는 URL의 쿼리 문자열에서 값을 추출합니다.
Query<T>
를 사용하며,T
는Deserialize
를 구현해야 합니다.
예시:
async fn search_books(Query(params): Query<SearchParams>) { /* ... */ }
#[derive(Deserialize)]
struct SearchParams {
name: Option<String>,
}
2-4. JSON 데이터 직렬화 및 역직렬화
serde
크레이트를 사용하여 JSON 데이터를 직렬화(Serialize) 및 역직렬화(Deserialize)할 수 있습니다.- 구조체에
#[derive(Serialize, Deserialize)]
를 추가합니다.
예시:
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct Book {
id: u32,
name: String,
}
- 핸들러 함수에서
Json<T>
를 사용하여 JSON 데이터를 자동으로 처리합니다.
예시:
async fn create_book(Json(payload): Json<BookInput>) { /* ... */ }
2-5. 에러 처리와 커스텀 에러 응답
1. 기본 에러 처리
- 핸들러 함수의 반환 타입에
Result<T, E>
를 사용하여 에러를 처리할 수 있습니다. E
로는axum::response::IntoResponse
를 구현하는 타입을 사용해야 합니다.
예시:
async fn get_book(
Path(id): Path<u32>,
) -> Result<Json<Book>, StatusCode> {
// ...
}
2. 커스텀 에러 타입 정의
- 에러를 더 자세히 처리하기 위해 커스텀 에러 타입을 정의할 수 있습니다.
thiserror
크레이트를 사용하면 편리하게 에러 타입을 정의할 수 있습니다.
Cargo.toml에 의존성 추가:
[dependencies]
thiserror = "1.0"
에러 타입 정의:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("가계부를 찾을 수 없습니다.")]
NotFound,
#[error("내부 서버 오류가 발생했습니다.")]
InternalServerError,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status_code = match self {
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = Json(json!({ "error": self.to_string() }));
(status_code, body).into_response()
}
}
핸들러 함수에서 사용:
async fn get_book(
state: axum::extract::State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Book>, AppError> {
let books = state.lock().unwrap();
books
.iter()
.find(|book| book.id == id)
.cloned()
.map(Json)
.ok_or(AppError::NotFound)
}
3. 에러 응답 커스터마이징
- 에러 메시지를 JSON 형태로 반환하여 클라이언트가 이해하기 쉽게 만듭니다.
IntoResponse
를 구현하여 에러 응답을 커스터마이징할 수 있습니다.
전체 코드 예시
src/main.rs
use axum::{
routing::{get, post, put, delete},
Router, Json, extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::net::SocketAddr;
use thiserror::Error;
use serde_json::json;
// 데이터 구조체 정의
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Book {
id: u32,
name: String,
}
#[derive(Debug, Deserialize)]
struct BookInput {
name: String,
}
// 애플리케이션 상태
type AppState = Arc<Mutex<Vec<Book>>>;
// 에러 타입 정의
#[derive(Error, Debug)]
pub enum AppError {
#[error("가계부를 찾을 수 없습니다.")]
NotFound,
#[error("내부 서버 오류가 발생했습니다.")]
InternalServerError,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status_code = match self {
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = Json(json!({ "error": self.to_string() }));
(status_code, body).into_response()
}
}
// 핸들러 함수 구현
async fn create_book(
State(state): State<AppState>,
Json(payload): Json<BookInput>,
) -> (StatusCode, Json<Book>) {
let mut books = state.lock().unwrap();
let new_id = (books.len() + 1) as u32;
let book = Book {
id: new_id,
name: payload.name,
};
books.push(book.clone());
(StatusCode::CREATED, Json(book))
}
async fn get_books(State(state): State<AppState>) -> Json<Vec<Book>> {
let books = state.lock().unwrap();
Json(books.clone())
}
async fn get_book(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Book>, AppError> {
let books = state.lock().unwrap();
books
.iter()
.find(|book| book.id == id)
.cloned()
.map(Json)
.ok_or(AppError::NotFound)
}
async fn update_book(
State(state): State<AppState>,
Path(id): Path<u32>,
Json(payload): Json<BookInput>,
) -> Result<Json<Book>, AppError> {
let mut books = state.lock().unwrap();
if let Some(book) = books.iter_mut().find(|book| book.id == id) {
book.name = payload.name.clone();
Ok(Json(book.clone()))
} else {
Err(AppError::NotFound)
}
}
async fn delete_book(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<StatusCode, AppError> {
let mut books = state.lock().unwrap();
if books.iter().any(|book| book.id == id) {
books.retain(|book| book.id != id);
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::NotFound)
}
}
#[tokio::main]
async fn main() {
let state = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route("/books", post(create_book).get(get_books))
.route("/books/:id", get(get_book).put(update_book).delete(delete_book))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("서버가 {}에서 시작됩니다...", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
테스트 방법
- 서버 실행
cargo run
- POST /books: 가계부 생성
curl -X POST -H "Content-Type: application/json" -d '{"name": "내 가계부"}' http://localhost:3000/books
- GET /books: 가계부 목록 조회
curl http://localhost:3000/books
- GET /books/1: 특정 가계부 조회
curl http://localhost:3000/books/1
- PUT /books/1: 가계부 수정
curl -X PUT -H "Content-Type: application/json" -d '{"name": "업데이트된 가계부"}' http://localhost:3000/books/1
- DELETE /books/1: 가계부 삭제
curl -X DELETE http://localhost:3000/books/1
결론
이번 섹션에서는 Rust와 Axum을 활용하여 간단한 가계부 웹 애플리케이션을 구축하였습니다. 프로젝트 요구사항 분석부터 시작하여, CRUD 엔드포인트를 구현하고, 요청 파라미터와 경로 변수 처리, JSON 데이터의 직렬화 및 역직렬화, 에러 처리와 커스텀 에러 응답까지 다루었습니다.
참고 자료
Note: 이 글은 학습 목적으로 작성되었으며, 실제 서비스 개발 시에는 보안, 성능, 에러 처리 등 다양한 측면을 고려해야 합니다.
반응형