8장: 실전 프로젝트

8.1 프로젝트: REST API 서버

Axum을 사용하여 완전한 기능의 REST API를 구축합니다.

8.1.1 프로젝트 설정

$ cargo new api-server
$ cd api-server

# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["runtime-tokio-native-tls", "sqlite"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
thiserror = "1"
anyhow = "1"

8.1.2 도메인 모델

// src/models.rs
use serde::{Deserialize, Serialize};
use sqlx::FromRow;

#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct User {
    pub id: i64,
    pub username: String,
    pub email: String,
    pub created_at: String,
}

#[derive(Debug, Deserialize)]
pub struct CreateUser {
    pub username: String,
    pub email: String,
}

#[derive(Debug, Deserialize)]
pub struct UpdateUser {
    pub username: Option<String>,
    pub email: Option<String>,
}

8.1.3 데이터베이스 레이어

// src/db.rs
use sqlx::{sqlite::SqlitePool, Error};
use crate::models::{User, CreateUser, UpdateUser};

pub struct Database {
    pool: SqlitePool,
}

impl Database {
    pub async fn new(database_url: &str) -> Result<Self, Error> {
        let pool = SqlitePool::connect(database_url).await?;

        // 테이블 생성
        sqlx::query(
            r#"
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                username TEXT NOT NULL UNIQUE,
                email TEXT NOT NULL UNIQUE,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP
            )
            "#,
        )
        .execute(&pool)
        .await?;

        Ok(Database { pool })
    }

    pub async fn create_user(&self, user: CreateUser) -> Result<User, Error> {
        let user = sqlx::query_as::<_, User>(
            r#"
            INSERT INTO users (username, email)
            VALUES (?, ?)
            RETURNING id, username, email, created_at
            "#,
        )
        .bind(&user.username)
        .bind(&user.email)
        .fetch_one(&self.pool)
        .await?;

        Ok(user)
    }

    pub async fn get_user(&self, id: i64) -> Result<Option<User>, Error> {
        let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
            .bind(id)
            .fetch_optional(&self.pool)
            .await?;

        Ok(user)
    }

    pub async fn list_users(&self) -> Result<Vec<User>, Error> {
        let users = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
            .fetch_all(&self.pool)
            .await?;

        Ok(users)
    }

    pub async fn update_user(&self, id: i64, update: UpdateUser) -> Result<User, Error> {
        let mut query = String::from("UPDATE users SET ");
        let mut updates = vec![];

        if let Some(ref username) = update.username {
            updates.push(format!("username = '{}'", username));
        }
        if let Some(ref email) = update.email {
            updates.push(format!("email = '{}'", email));
        }

        query.push_str(&updates.join(", "));
        query.push_str(&format!(" WHERE id = {} RETURNING *", id));

        let user = sqlx::query_as::<_, User>(&query)
            .fetch_one(&self.pool)
            .await?;

        Ok(user)
    }

    pub async fn delete_user(&self, id: i64) -> Result<(), Error> {
        sqlx::query("DELETE FROM users WHERE id = ?")
            .bind(id)
            .execute(&self.pool)
            .await?;

        Ok(())
    }
}

8.1.4 에러 처리

// src/error.rs
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("데이터베이스 에러: {0}")]
    Database(#[from] sqlx::Error),

    #[error("찾을 수 없음: {0}")]
    NotFound(String),

    #[error("잘못된 요청: {0}")]
    BadRequest(String),

    #[error("내부 서버 에러")]
    Internal,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            ApiError::Database(ref e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
            ApiError::NotFound(ref msg) => (StatusCode::NOT_FOUND, msg.clone()),
            ApiError::BadRequest(ref msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            ApiError::Internal => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Internal server error".to_string(),
            ),
        };

        let body = Json(json!({
            "error": error_message,
        }));

        (status, body).into_response()
    }
}

8.1.5 핸들러

// src/handlers.rs
use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use std::sync::Arc;

use crate::{
    db::Database,
    error::ApiError,
    models::{CreateUser, UpdateUser, User},
};

pub async fn create_user(
    State(db): State<Arc<Database>>,
    Json(payload): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), ApiError> {
    let user = db.create_user(payload).await?;
    Ok((StatusCode::CREATED, Json(user)))
}

pub async fn get_user(
    State(db): State<Arc<Database>>,
    Path(id): Path<i64>,
) -> Result<Json<User>, ApiError> {
    let user = db
        .get_user(id)
        .await?
        .ok_or_else(|| ApiError::NotFound(format!("User {} not found", id)))?;

    Ok(Json(user))
}

pub async fn list_users(
    State(db): State<Arc<Database>>,
) -> Result<Json<Vec<User>>, ApiError> {
    let users = db.list_users().await?;
    Ok(Json(users))
}

pub async fn update_user(
    State(db): State<Arc<Database>>,
    Path(id): Path<i64>,
    Json(payload): Json<UpdateUser>,
) -> Result<Json<User>, ApiError> {
    let user = db.update_user(id, payload).await?;
    Ok(Json(user))
}

pub async fn delete_user(
    State(db): State<Arc<Database>>,
    Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
    db.delete_user(id).await?;
    Ok(StatusCode::NO_CONTENT)
}

pub async fn health_check() -> &'static str {
    "OK"
}

8.1.6 라우팅

// src/main.rs
mod db;
mod error;
mod handlers;
mod models;

use axum::{
    routing::{get, post, put, delete},
    Router,
};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tracing_subscriber;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 로깅 초기화
    tracing_subscriber::fmt::init();

    // 데이터베이스 연결
    let db = Arc::new(db::Database::new("sqlite::memory:").await?);

    // 라우터 설정
    let app = Router::new()
        .route("/health", get(handlers::health_check))
        .route("/users", get(handlers::list_users).post(handlers::create_user))
        .route(
            "/users/:id",
            get(handlers::get_user)
                .put(handlers::update_user)
                .delete(handlers::delete_user),
        )
        .layer(CorsLayer::permissive())
        .with_state(db);

    // 서버 시작
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    println!("🚀 Server running on http://localhost:3000");

    axum::serve(listener, app).await?;

    Ok(())
}

8.2 WebAssembly 통합

Rust 코드를 WebAssembly로 컴파일하여 브라우저에서 실행합니다.

8.2.1 wasm-pack 프로젝트

$ cargo install wasm-pack
$ wasm-pack new wasm-demo
$ cd wasm-demo

# Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# src/lib.rs
use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u32 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

#[derive(Serialize, Deserialize)]
pub struct User {
    name: String,
    age: u32,
}

#[wasm_bindgen]
pub fn process_user(json: &str) -> Result<String, JsValue> {
    let user: User = serde_json::from_str(json)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;

    let response = format!("{} is {} years old", user.name, user.age);
    Ok(response)
}

8.2.2 빌드 및 사용

$ wasm-pack build --target web

# index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Rust WASM Demo</title>
</head>
<body>
    <h1>Rust WebAssembly Demo</h1>
    <div id="output"></div>

    <script type="module">
        import init, { greet, fibonacci, process_user } from './pkg/wasm_demo.js';

        async function run() {
            await init();

            // greet 함수 호출
            const greeting = greet('World');
            console.log(greeting);

            // fibonacci 계산
            const fib = fibonacci(10);
            console.log(`Fibonacci(10) = ${fib}`);

            // JSON 처리
            const user = JSON.stringify({ name: "Alice", age: 30 });
            const result = process_user(user);
            console.log(result);

            document.getElementById('output').innerHTML = `
                <p>${greeting}</p>
                <p>Fibonacci(10) = ${fib}</p>
                <p>${result}</p>
            `;
        }

        run();
    </script>
</body>
</html>

8.3 WIA 표준 통합

WIA-INTENT와 WIA-OMNI-API를 사용하여 표준화된 인터페이스를 구현합니다.

8.3.1 WIA-INTENT 구현

// src/wia/intent.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Intent {
    pub action: String,
    pub params: serde_json::Value,
    pub context: Option<Context>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Context {
    pub user_id: Option<String>,
    pub session_id: Option<String>,
    pub timestamp: i64,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct IntentResponse {
    pub success: bool,
    pub data: serde_json::Value,
    pub error: Option<String>,
}

pub trait IntentHandler {
    fn handle(&self, intent: Intent) -> IntentResponse;
}

// 예제 핸들러
pub struct UserIntentHandler;

impl IntentHandler for UserIntentHandler {
    fn handle(&self, intent: Intent) -> IntentResponse {
        match intent.action.as_str() {
            "user.create" => {
                // 사용자 생성 로직
                IntentResponse {
                    success: true,
                    data: serde_json::json!({"id": 123, "created": true}),
                    error: None,
                }
            }
            "user.get" => {
                // 사용자 조회 로직
                IntentResponse {
                    success: true,
                    data: serde_json::json!({"id": 123, "name": "Alice"}),
                    error: None,
                }
            }
            _ => IntentResponse {
                success: false,
                data: serde_json::Value::Null,
                error: Some(format!("Unknown action: {}", intent.action)),
            },
        }
    }
}

8.3.2 WIA-OMNI-API 통합

// src/wia/omni.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct OmniRequest {
    pub endpoint: String,
    pub method: String,
    pub headers: std::collections::HashMap<String, String>,
    pub body: Option<serde_json::Value>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct OmniResponse {
    pub status: u16,
    pub headers: std::collections::HashMap<String, String>,
    pub body: serde_json::Value,
}

pub async fn call_omni_api(request: OmniRequest) -> Result<OmniResponse, anyhow::Error> {
    let client = reqwest::Client::new();

    let mut req = match request.method.as_str() {
        "GET" => client.get(&request.endpoint),
        "POST" => client.post(&request.endpoint),
        "PUT" => client.put(&request.endpoint),
        "DELETE" => client.delete(&request.endpoint),
        _ => return Err(anyhow::anyhow!("Unsupported method: {}", request.method)),
    };

    for (key, value) in request.headers {
        req = req.header(&key, &value);
    }

    if let Some(body) = request.body {
        req = req.json(&body);
    }

    let response = req.send().await?;
    let status = response.status().as_u16();

    let headers = response
        .headers()
        .iter()
        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
        .collect();

    let body = response.json().await?;

    Ok(OmniResponse {
        status,
        headers,
        body,
    })
}

8.4 배포

8.4.1 Dockerfile

# Dockerfile
FROM rust:1.75 as builder

WORKDIR /app
COPY . .

RUN cargo build --release

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/api-server /usr/local/bin/

EXPOSE 3000

CMD ["api-server"]

8.4.2 빌드 및 실행

$ docker build -t api-server .
$ docker run -p 3000:3000 api-server

# Docker Compose
version: '3.8'

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=sqlite:data.db
    volumes:
      - ./data:/data
✅ 프로젝트 완료!

축하합니다! 다음을 구현했습니다:

8.5 다음 단계

계속 학습하기:

8.6 추천 리소스

🎉 WIA-RUST-INTERMEDIATE 완료!

중급 Rust 개발자로서 필요한 핵심 개념들을 모두 배웠습니다. 이제 실제 프로덕션 프로젝트를 만들 준비가 되었습니다.

다음 단계는 WIA-RUST-ADVANCED입니다. 계속 도전하세요!