슬기로운 개발자생활/Rust

Rust와 Axum을 활용한 웹 백엔드 개발 - 간단한 가계부 웹 API

개발자 소신 2024. 9. 24. 10:30
반응형

이번 섹션에서는 Rust와 Axum을 활용하여 간단한 가계부 웹 애플리케이션을 구축합니다. 프로젝트 요구사항을 분석하고, CRUD 엔드포인트를 구현하며, 요청 파라미터와 경로 변수를 처리하는 방법을 배워보겠습니다. 또한 JSON 데이터의 직렬화 및 역직렬화, 에러 처리와 커스텀 에러 응답까지 다루어 실제 서비스 개발에 필요한 기본기를 익힙니다.


2-1. 프로젝트 요구사항 분석 및 설계

1. 프로젝트 개요

  • 목적: 간단한 가계부 애플리케이션을 구축하여 수입과 지출을 관리할 수 있도록 합니다.
  • 주요 기능:
    • 가계부(Book) 생성, 조회, 수정, 삭제 기능
    • 수입/지출 기록(Record) 추가, 조회, 수정, 삭제 기능
    • 카테고리(Category) 관리 (선택 사항)

2. 데이터 모델 설계

간단하게 하기 위해 데이터 모델을 다음과 같이 설계합니다.

테이블 목록

  1. tb_book: 가계부 정보를 저장합니다.
  2. 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>를 사용하며, TDeserialize를 구현해야 합니다.

예시:

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();
}

테스트 방법

  1. 서버 실행
  2. cargo run
  3. POST /books: 가계부 생성
  4. curl -X POST -H "Content-Type: application/json" -d '{"name": "내 가계부"}' http://localhost:3000/books
  5. GET /books: 가계부 목록 조회
  6. curl http://localhost:3000/books
  7. GET /books/1: 특정 가계부 조회
  8. curl http://localhost:3000/books/1
  9. PUT /books/1: 가계부 수정
  10. curl -X PUT -H "Content-Type: application/json" -d '{"name": "업데이트된 가계부"}' http://localhost:3000/books/1
  11. DELETE /books/1: 가계부 삭제
  12. curl -X DELETE http://localhost:3000/books/1

결론

이번 섹션에서는 Rust와 Axum을 활용하여 간단한 가계부 웹 애플리케이션을 구축하였습니다. 프로젝트 요구사항 분석부터 시작하여, CRUD 엔드포인트를 구현하고, 요청 파라미터와 경로 변수 처리, JSON 데이터의 직렬화 및 역직렬화, 에러 처리와 커스텀 에러 응답까지 다루었습니다.


참고 자료


Note: 이 글은 학습 목적으로 작성되었으며, 실제 서비스 개발 시에는 보안, 성능, 에러 처리 등 다양한 측면을 고려해야 합니다.

반응형