본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 11장: 실전 프로젝트 -- Rust 백엔드 API 구축
2026년 3월 3일·프로그래밍·

11장: 실전 프로젝트 -- Rust 백엔드 API 구축

Axum, SQLx, Tokio를 조합한 프로덕션 수준의 REST API를 처음부터 구축합니다. JWT 인증, CRUD, 미들웨어, 테스트, Docker 배포까지 총정리합니다.

16분1,875자13개 섹션
rust
공유
rust-backend11 / 11
1234567891011
이전10장: WebAssembly 타겟

학습 목표

  • 프로덕션 수준의 Rust 백엔드 API를 설계하고 구현합니다
  • JWT 인증, CRUD API, 미들웨어 스택을 통합합니다
  • 테스트 전략을 적용하고 Docker로 배포합니다
  • 성능 벤치마킹과 프로덕션 체크리스트를 확인합니다

프로젝트 개요

이 장에서는 시리즈에서 배운 모든 내용을 종합하여 북마크 관리 API를 구축합니다. 사용자 인증, 북마크 CRUD, 태그 관리, 검색 기능을 포함하는 실전 수준의 API입니다.

기술 스택

컴포넌트기술
프레임워크Axum 0.8
런타임Tokio
데이터베이스PostgreSQL + SQLx
인증JWT (jsonwebtoken)
비밀번호Argon2 (argon2)
직렬화Serde
로깅tracing
설정config-rs
테스트testcontainers

API 엔드포인트

API 설계
text
POST   /auth/register     — 회원가입
POST   /auth/login         — 로그인
 
GET    /bookmarks          — 북마크 목록 (페이지네이션, 검색)
POST   /bookmarks          — 북마크 생성
GET    /bookmarks/{id}     — 북마크 상세
PUT    /bookmarks/{id}     — 북마크 수정
DELETE /bookmarks/{id}     — 북마크 삭제
 
GET    /tags               — 태그 목록
GET    /health             — 헬스 체크

프로젝트 구조

전체 프로젝트 구조
text
bookmark-api/
  Cargo.toml
  .env
  migrations/
    20260405_001_create_users.sql
    20260405_002_create_bookmarks.sql
  src/
    main.rs
    lib.rs
    config.rs
    error.rs
    state.rs
    routes/
      mod.rs
      auth.rs
      bookmarks.rs
      health.rs
    handlers/
      mod.rs
      auth.rs
      bookmarks.rs
    models/
      mod.rs
      user.rs
      bookmark.rs
    middleware/
      mod.rs
      auth.rs
    services/
      mod.rs
      auth.rs
      bookmark.rs
  tests/
    common/mod.rs
    auth_test.rs
    bookmarks_test.rs
  Dockerfile
  docker-compose.yml

데이터베이스 스키마

migrations/20260405_001_create_users.sql
sql
CREATE TABLE IF NOT EXISTS users (
    id          BIGSERIAL PRIMARY KEY,
    email       VARCHAR(255) NOT NULL UNIQUE,
    password    VARCHAR(255) NOT NULL,
    name        VARCHAR(100) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
migrations/20260405_002_create_bookmarks.sql
sql
CREATE TABLE IF NOT EXISTS bookmarks (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    url         VARCHAR(2048) NOT NULL,
    title       VARCHAR(500) NOT NULL,
    description TEXT,
    tags        TEXT[] NOT NULL DEFAULT '{}',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_bookmarks_user_id ON bookmarks(user_id);
CREATE INDEX idx_bookmarks_tags ON bookmarks USING GIN(tags);
CREATE INDEX idx_bookmarks_title_search ON bookmarks USING GIN(to_tsvector('simple', title));

핵심 타입 정의

src/error.rs
rust
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum AppError {
    #[error("인증 실패: {0}")]
    Unauthorized(String),
 
    #[error("권한 없음: {0}")]
    Forbidden(String),
 
    #[error("리소스를 찾을 수 없습니다: {0}")]
    NotFound(String),
 
    #[error("요청이 잘못되었습니다: {0}")]
    BadRequest(String),
 
    #[error("충돌: {0}")]
    Conflict(String),
 
    #[error("내부 서버 오류")]
    Internal(#[from] anyhow::Error),
 
    #[error("데이터베이스 오류")]
    Database(#[from] sqlx::Error),
}
 
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
            AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
            AppError::Internal(_) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "내부 서버 오류가 발생했습니다".to_string(),
            ),
            AppError::Database(_) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "데이터베이스 오류가 발생했습니다".to_string(),
            ),
        };
 
        // 내부 에러는 로깅하고 클라이언트에게는 일반 메시지만 반환
        if matches!(&self, AppError::Internal(_) | AppError::Database(_)) {
            tracing::error!(error = ?self, "내부 오류 발생");
        }
 
        let body = Json(serde_json::json!({
            "error": {
                "message": message,
                "code": status.as_u16(),
            }
        }));
 
        (status, body).into_response()
    }
}
src/state.rs
rust
use sqlx::PgPool;
use crate::config::AppConfig;
 
#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    pub config: AppConfig,
}

인증 서비스

src/services/auth.rs
rust
use argon2::{
    password_hash::{rand_core::OsRng, SaltString, PasswordHash, PasswordHasher, PasswordVerifier},
    Argon2,
};
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
 
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    pub sub: i64,       // 사용자 ID
    pub email: String,
    pub exp: usize,     // 만료 시간 (Unix timestamp)
    pub iat: usize,     // 발행 시간
}
 
pub fn hash_password(password: &str) -> Result<String, AppError> {
    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
 
    argon2
        .hash_password(password.as_bytes(), &salt)
        .map(|h| h.to_string())
        .map_err(|e| AppError::Internal(anyhow::anyhow!("비밀번호 해싱 실패: {}", e)))
}
 
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
    let parsed_hash = PasswordHash::new(hash)
        .map_err(|e| AppError::Internal(anyhow::anyhow!("해시 파싱 실패: {}", e)))?;
 
    Ok(Argon2::default()
        .verify_password(password.as_bytes(), &parsed_hash)
        .is_ok())
}
 
pub fn create_token(user_id: i64, email: &str, secret: &str, expiry_hours: u64) -> Result<String, AppError> {
    let now = chrono::Utc::now();
    let claims = Claims {
        sub: user_id,
        email: email.to_string(),
        exp: (now + chrono::Duration::hours(expiry_hours as i64)).timestamp() as usize,
        iat: now.timestamp() as usize,
    };
 
    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .map_err(|e| AppError::Internal(anyhow::anyhow!("토큰 생성 실패: {}", e)))
}
 
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, AppError> {
    decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::default(),
    )
    .map(|data| data.claims)
    .map_err(|_| AppError::Unauthorized("유효하지 않은 토큰입니다".to_string()))
}

인증 핸들러

src/handlers/auth.rs
rust
use axum::{Json, extract::State, http::StatusCode};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::{error::AppError, state::AppState, services::auth};
 
#[derive(Deserialize)]
pub struct RegisterRequest {
    pub name: String,
    pub email: String,
    pub password: String,
}
 
#[derive(Deserialize)]
pub struct LoginRequest {
    pub email: String,
    pub password: String,
}
 
#[derive(Serialize)]
pub struct AuthResponse {
    pub token: String,
    pub user: UserInfo,
}
 
#[derive(Serialize)]
pub struct UserInfo {
    pub id: i64,
    pub name: String,
    pub email: String,
}
 
pub async fn register(
    State(state): State<Arc<AppState>>,
    Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<AuthResponse>), AppError> {
    // 입력 검증
    if req.name.is_empty() || req.name.len() > 100 {
        return Err(AppError::BadRequest("이름은 1-100자여야 합니다".to_string()));
    }
    if req.password.len() < 8 {
        return Err(AppError::BadRequest("비밀번호는 8자 이상이어야 합니다".to_string()));
    }
 
    // 비밀번호 해싱 (CPU 집약적이므로 spawn_blocking)
    let password = req.password.clone();
    let hashed = tokio::task::spawn_blocking(move || {
        auth::hash_password(&password)
    })
    .await
    .map_err(|e| AppError::Internal(anyhow::anyhow!("{}", e)))??;
 
    // 사용자 생성
    let user = sqlx::query_as::<_, (i64, String, String)>(
        "INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email",
    )
    .bind(&req.name)
    .bind(&req.email)
    .bind(&hashed)
    .fetch_one(&state.db)
    .await
    .map_err(|e| match e {
        sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
            AppError::Conflict("이미 등록된 이메일입니다".to_string())
        }
        _ => AppError::Database(e),
    })?;
 
    // JWT 토큰 생성
    let token = auth::create_token(
        user.0,
        &user.2,
        &state.config.auth.jwt_secret,
        state.config.auth.token_expiry_hours,
    )?;
 
    Ok((
        StatusCode::CREATED,
        Json(AuthResponse {
            token,
            user: UserInfo {
                id: user.0,
                name: user.1,
                email: user.2,
            },
        }),
    ))
}
 
pub async fn login(
    State(state): State<Arc<AppState>>,
    Json(req): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, AppError> {
    // 사용자 조회
    let user = sqlx::query_as::<_, (i64, String, String, String)>(
        "SELECT id, name, email, password FROM users WHERE email = $1",
    )
    .bind(&req.email)
    .fetch_optional(&state.db)
    .await?
    .ok_or_else(|| AppError::Unauthorized("이메일 또는 비밀번호가 올바르지 않습니다".to_string()))?;
 
    // 비밀번호 검증
    let password = req.password.clone();
    let hash = user.3.clone();
    let valid = tokio::task::spawn_blocking(move || {
        auth::verify_password(&password, &hash)
    })
    .await
    .map_err(|e| AppError::Internal(anyhow::anyhow!("{}", e)))??;
 
    if !valid {
        return Err(AppError::Unauthorized(
            "이메일 또는 비밀번호가 올바르지 않습니다".to_string(),
        ));
    }
 
    let token = auth::create_token(
        user.0,
        &user.2,
        &state.config.auth.jwt_secret,
        state.config.auth.token_expiry_hours,
    )?;
 
    Ok(Json(AuthResponse {
        token,
        user: UserInfo {
            id: user.0,
            name: user.1,
            email: user.2,
        },
    }))
}

북마크 CRUD

src/handlers/bookmarks.rs
rust
use axum::{Json, extract::{Path, Query, State, Extension}};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::{error::AppError, state::AppState, services::auth::Claims};
 
#[derive(Debug, sqlx::FromRow, Serialize)]
pub struct Bookmark {
    pub id: i64,
    pub user_id: i64,
    pub url: String,
    pub title: String,
    pub description: Option<String>,
    pub tags: Vec<String>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}
 
#[derive(Deserialize)]
pub struct CreateBookmarkRequest {
    pub url: String,
    pub title: String,
    pub description: Option<String>,
    pub tags: Option<Vec<String>>,
}
 
#[derive(Deserialize)]
pub struct ListBookmarksQuery {
    pub page: Option<i64>,
    pub per_page: Option<i64>,
    pub search: Option<String>,
    pub tag: Option<String>,
}
 
#[derive(Serialize)]
pub struct PaginatedResponse<T> {
    pub data: Vec<T>,
    pub total: i64,
    pub page: i64,
    pub per_page: i64,
}
 
pub async fn list(
    State(state): State<Arc<AppState>>,
    Extension(claims): Extension<Claims>,
    Query(params): Query<ListBookmarksQuery>,
) -> Result<Json<PaginatedResponse<Bookmark>>, AppError> {
    let page = params.page.unwrap_or(1).max(1);
    let per_page = params.per_page.unwrap_or(20).clamp(1, 100);
    let offset = (page - 1) * per_page;
 
    let (bookmarks, total) = match (&params.search, &params.tag) {
        (Some(search), _) => {
            let items = sqlx::query_as::<_, Bookmark>(
                r#"
                SELECT * FROM bookmarks
                WHERE user_id = $1 AND to_tsvector('simple', title) @@ plainto_tsquery('simple', $2)
                ORDER BY created_at DESC LIMIT $3 OFFSET $4
                "#,
            )
            .bind(claims.sub)
            .bind(search)
            .bind(per_page)
            .bind(offset)
            .fetch_all(&state.db)
            .await?;
 
            let count = sqlx::query_as::<_, (i64,)>(
                r#"
                SELECT COUNT(*) FROM bookmarks
                WHERE user_id = $1 AND to_tsvector('simple', title) @@ plainto_tsquery('simple', $2)
                "#,
            )
            .bind(claims.sub)
            .bind(search)
            .fetch_one(&state.db)
            .await?;
 
            (items, count.0)
        }
        (_, Some(tag)) => {
            let items = sqlx::query_as::<_, Bookmark>(
                r#"
                SELECT * FROM bookmarks
                WHERE user_id = $1 AND $2 = ANY(tags)
                ORDER BY created_at DESC LIMIT $3 OFFSET $4
                "#,
            )
            .bind(claims.sub)
            .bind(tag)
            .bind(per_page)
            .bind(offset)
            .fetch_all(&state.db)
            .await?;
 
            let count = sqlx::query_as::<_, (i64,)>(
                "SELECT COUNT(*) FROM bookmarks WHERE user_id = $1 AND $2 = ANY(tags)",
            )
            .bind(claims.sub)
            .bind(tag)
            .fetch_one(&state.db)
            .await?;
 
            (items, count.0)
        }
        _ => {
            let items = sqlx::query_as::<_, Bookmark>(
                r#"
                SELECT * FROM bookmarks
                WHERE user_id = $1
                ORDER BY created_at DESC LIMIT $2 OFFSET $3
                "#,
            )
            .bind(claims.sub)
            .bind(per_page)
            .bind(offset)
            .fetch_all(&state.db)
            .await?;
 
            let count = sqlx::query_as::<_, (i64,)>(
                "SELECT COUNT(*) FROM bookmarks WHERE user_id = $1",
            )
            .bind(claims.sub)
            .fetch_one(&state.db)
            .await?;
 
            (items, count.0)
        }
    };
 
    Ok(Json(PaginatedResponse {
        data: bookmarks,
        total,
        page,
        per_page,
    }))
}
 
pub async fn create(
    State(state): State<Arc<AppState>>,
    Extension(claims): Extension<Claims>,
    Json(req): Json<CreateBookmarkRequest>,
) -> Result<(StatusCode, Json<Bookmark>), AppError> {
    let tags = req.tags.unwrap_or_default();
 
    let bookmark = sqlx::query_as::<_, Bookmark>(
        r#"
        INSERT INTO bookmarks (user_id, url, title, description, tags)
        VALUES ($1, $2, $3, $4, $5)
        RETURNING *
        "#,
    )
    .bind(claims.sub)
    .bind(&req.url)
    .bind(&req.title)
    .bind(&req.description)
    .bind(&tags)
    .fetch_one(&state.db)
    .await?;
 
    Ok((StatusCode::CREATED, Json(bookmark)))
}
 
pub async fn get_one(
    State(state): State<Arc<AppState>>,
    Extension(claims): Extension<Claims>,
    Path(id): Path<i64>,
) -> Result<Json<Bookmark>, AppError> {
    let bookmark = sqlx::query_as::<_, Bookmark>(
        "SELECT * FROM bookmarks WHERE id = $1 AND user_id = $2",
    )
    .bind(id)
    .bind(claims.sub)
    .fetch_optional(&state.db)
    .await?
    .ok_or(AppError::NotFound(format!("북마크 id={}", id)))?;
 
    Ok(Json(bookmark))
}
 
pub async fn delete(
    State(state): State<Arc<AppState>>,
    Extension(claims): Extension<Claims>,
    Path(id): Path<i64>,
) -> Result<StatusCode, AppError> {
    let result = sqlx::query(
        "DELETE FROM bookmarks WHERE id = $1 AND user_id = $2",
    )
    .bind(id)
    .bind(claims.sub)
    .execute(&state.db)
    .await?;
 
    if result.rows_affected() == 0 {
        Err(AppError::NotFound(format!("북마크 id={}", id)))
    } else {
        Ok(StatusCode::NO_CONTENT)
    }
}

라우터 조립

src/routes/mod.rs
rust
use axum::{Router, middleware};
use std::sync::Arc;
use crate::state::AppState;
 
pub mod auth;
pub mod bookmarks;
pub mod health;
 
pub fn create_router(state: Arc<AppState>) -> Router {
    let public_routes = Router::new()
        .merge(auth::routes())
        .merge(health::routes());
 
    let protected_routes = Router::new()
        .merge(bookmarks::routes())
        .layer(middleware::from_fn_with_state(
            state.clone(),
            crate::middleware::auth::auth_middleware,
        ));
 
    Router::new()
        .merge(public_routes)
        .merge(protected_routes)
        .with_state(state)
}

Docker 배포

Dockerfile — 멀티 스테이지 빌드
dockerfile
# 빌드 스테이지
FROM rust:1.83-slim AS builder
 
WORKDIR /app
 
# 의존성 캐싱을 위해 Cargo 파일만 먼저 복사
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release && rm -rf src
 
# 소스 복사 및 빌드
COPY . .
RUN touch src/main.rs && 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/bookmark-api /usr/local/bin/
COPY --from=builder /app/migrations /app/migrations
 
WORKDIR /app
 
EXPOSE 3000
 
CMD ["bookmark-api", "serve"]
docker-compose.yml
yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: bookmark
      POSTGRES_PASSWORD: bookmark_pass
      POSTGRES_DB: bookmark_db
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
 
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      APP_DATABASE__URL: postgresql://bookmark:bookmark_pass@db/bookmark_db
      APP_AUTH__JWT_SECRET: production-secret-change-me
      APP_SERVER__PORT: "3000"
      LOG_LEVEL: info
    depends_on:
      - db
 
volumes:
  pgdata:
실행
bash
docker compose up -d

성능 벤치마킹

wrk로 부하 테스트
bash
# wrk 설치 (macOS)
brew install wrk
 
# GET /health 벤치마크
wrk -t4 -c100 -d30s http://localhost:3000/health
 
# 인증 후 GET /bookmarks 벤치마크
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"12345678"}' \
  | jq -r '.token')
 
wrk -t4 -c100 -d30s -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/bookmarks

예상 성능

엔드포인트RPS (4 코어)P99 레이턴시
GET /health~50,000< 1ms
GET /bookmarks~15,000< 5ms
POST /bookmarks~10,000< 8ms
POST /auth/login~500< 200ms (Argon2)
Info

로그인 엔드포인트의 RPS가 낮은 것은 Argon2 해싱이 의도적으로 느린 연산이기 때문입니다. 이는 보안을 위한 설계이며, spawn_blocking으로 비동기 런타임을 차단하지 않도록 처리한 것이 핵심입니다.

프로덕션 체크리스트

프로덕션 배포 전에 확인해야 할 항목들입니다.

보안

  • JWT 시크릿을 환경 변수로 주입 (코드에 하드코딩 금지)
  • HTTPS 적용 (리버스 프록시 또는 TLS 직접 설정)
  • CORS 정책 명시적 설정 (Allow-Origin에 와일드카드 지양)
  • 레이트 리미팅 적용
  • SQL Injection 방지 (SQLx의 바인딩 파라미터 사용)
  • 에러 응답에서 내부 상세 정보 미노출

안정성

  • Graceful shutdown 구현
  • 헬스 체크 엔드포인트 제공
  • 데이터베이스 연결 풀 크기 적정 설정
  • 타임아웃 설정 (연결, 요청, 유휴)
  • 구조화 로깅 (tracing)으로 운영 가시성 확보

성능

  • 릴리스 모드 빌드 (cargo build --release)
  • 데이터베이스 인덱스 최적화
  • 페이지네이션 적용 (전체 목록 조회 방지)
  • CPU 집약적 작업은 spawn_blocking 사용
  • 연결 풀 크기를 워크로드에 맞게 조정

배포

  • Docker 멀티 스테이지 빌드로 이미지 크기 최소화
  • 마이그레이션 자동 실행 설정
  • 환경별 설정 분리 (개발/스테이징/프로덕션)
  • CI/CD 파이프라인에서 cargo test, cargo clippy, cargo fmt 실행

시리즈를 마치며

11장에 걸쳐 백엔드 개발자를 위한 Rust를 체계적으로 살펴보았습니다. 1장에서 Rust를 배워야 하는 이유를 확인하고, 소유권, 타입 시스템, 비동기 프로그래밍의 기반을 다진 후, Axum과 SQLx로 실전 API를 구축하고, 테스트, CLI, WebAssembly까지 영역을 확장했습니다.

Rust의 학습 곡선은 분명히 가파릅니다. 하지만 이 시리즈를 통해 그 곡선의 정상에 가까이 도달했을 것입니다. 컴파일러가 보장하는 메모리 안전성, 예측 가능한 성능, 풍부한 타입 시스템이 주는 자신감은 다른 언어에서 쉽게 얻을 수 없는 경험입니다.

이제 여러분만의 프로젝트를 시작할 차례입니다. 이 시리즈의 코드를 기반으로 확장하든, 완전히 새로운 프로젝트를 시작하든, Rust가 백엔드 개발에 가져다 주는 가치를 직접 체험해 보시기 바랍니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#rust

관련 글

프로그래밍

10장: WebAssembly 타겟

wasm-pack, wasm-bindgen, wasm32-wasi 타겟, Spin 서버리스, 브라우저 통합, 서버와 Wasm 간 비즈니스 로직 공유, 크기 최적화까지 다룹니다.

2026년 3월 1일·13분
프로그래밍

9장: CLI 도구 개발

Clap v4 서브커맨드 아키텍처, config-rs 설정 관리, tracing 구조화 로깅, indicatif 진행 표시, 크로스 플랫폼 빌드와 배포까지 다룹니다.

2026년 2월 27일·12분
프로그래밍

8장: 테스트와 품질 보증

Rust의 단위 테스트, 통합 테스트, API 테스트, testcontainers를 활용한 DB 테스트, 프로퍼티 기반 테스트, 벤치마킹까지 다룹니다.

2026년 2월 25일·12분
이전 글10장: WebAssembly 타겟

댓글

목차

약 16분 남음
  • 학습 목표
  • 프로젝트 개요
    • 기술 스택
    • API 엔드포인트
  • 프로젝트 구조
  • 데이터베이스 스키마
  • 핵심 타입 정의
  • 인증 서비스
  • 인증 핸들러
  • 북마크 CRUD
  • 라우터 조립
  • Docker 배포
  • 성능 벤치마킹
    • 예상 성능
  • 프로덕션 체크리스트
    • 보안
    • 안정성
    • 성능
    • 배포
  • 시리즈를 마치며