Rust 웹 개발 | Actix-web으로 REST API 만들기

Rust 웹 개발 | Actix-web으로 REST API 만들기

이 글의 핵심

Rust 웹 개발에 대해 정리한 개발 블로그 글입니다. use actix_web::{web, App, HttpServer, Responder};

들어가며

Rust로 웹 API를 만들 때 Actix-web을 고르는 경우가 많습니다. Tokio 기반 비동기 런타임 위에서 동작하며, 라우팅·JSON·미들웨어·테스트를 한 프레임워크 안에서 다룰 수 있습니다.

이 글에서는 Hello World·CRUD·Todo로 기본을 익힌 뒤, CORS·인증·에러 미들웨어, Arc/Mutex/RwLock 공유 상태, Result·커스텀 에러, sqlx, 통합 테스트, 로깅·환경 변수·서버 설정 등 실무 주제를 이어서 정리합니다.


1. Actix-web 설정

Cargo.toml

[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

2. 기본 서버

Hello World

use actix_web::{web, App, HttpServer, Responder};

async fn hello() -> impl Responder {
    "Hello, Rust!"
}

async fn greet(name: web::Path<String>) -> impl Responder {
    format!("안녕하세요, {}님!", name)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    println!("서버 시작: http://127.0.0.1:8080");
    
    HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(hello))
            .route("/greet/{name}", web::get().to(greet))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

3. REST API

데이터 모델

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone)]
struct User {
    id: u32,
    name: String,
    email: String,
}

CRUD 핸들러

use actix_web::{web, HttpResponse, Result};
use std::sync::Mutex;

struct AppState {
    users: Mutex<Vec<User>>,
}

// GET /users
async fn get_users(data: web::Data<AppState>) -> Result<HttpResponse> {
    let users = data.users.lock().unwrap();
    Ok(HttpResponse::Ok().json(&*users))
}

// GET /users/{id}
async fn get_user(
    data: web::Data<AppState>,
    id: web::Path<u32>,
) -> Result<HttpResponse> {
    let users = data.users.lock().unwrap();
    
    if let Some(user) = users.iter().find(|u| u.id == *id) {
        Ok(HttpResponse::Ok().json(user))
    } else {
        Ok(HttpResponse::NotFound().body("사용자 없음"))
    }
}

// POST /users
async fn create_user(
    data: web::Data<AppState>,
    user: web::Json<User>,
) -> Result<HttpResponse> {
    let mut users = data.users.lock().unwrap();
    users.push(user.into_inner());
    Ok(HttpResponse::Created().finish())
}

// DELETE /users/{id}
async fn delete_user(
    data: web::Data<AppState>,
    id: web::Path<u32>,
) -> Result<HttpResponse> {
    let mut users = data.users.lock().unwrap();
    
    if let Some(pos) = users.iter().position(|u| u.id == *id) {
        users.remove(pos);
        Ok(HttpResponse::NoContent().finish())
    } else {
        Ok(HttpResponse::NotFound().body("사용자 없음"))
    }
}

라우팅 설정

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let app_state = web::Data::new(AppState {
        users: Mutex::new(vec![]),
    });
    
    HttpServer::new(move || {
        App::new()
            .app_data(app_state.clone())
            .route("/users", web::get().to(get_users))
            .route("/users/{id}", web::get().to(get_user))
            .route("/users", web::post().to(create_user))
            .route("/users/{id}", web::delete().to(delete_user))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

4. 미들웨어

로깅 미들웨어

use actix_web::middleware::Logger;
use env_logger::Env;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    env_logger::init_from_env(Env::default().default_filter_or("info"));
    
    HttpServer::new(|| {
        App::new()
            .wrap(Logger::default())
            .route("/", web::get().to(hello))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

5. 실전 예제

예제: Todo API

use actix_web::{web, App, HttpResponse, HttpServer, Result};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;

#[derive(Serialize, Deserialize, Clone)]
struct Todo {
    id: u32,
    title: String,
    completed: bool,
}

struct AppState {
    todos: Mutex<Vec<Todo>>,
}

async fn get_todos(data: web::Data<AppState>) -> Result<HttpResponse> {
    let todos = data.todos.lock().unwrap();
    Ok(HttpResponse::Ok().json(&*todos))
}

async fn create_todo(
    data: web::Data<AppState>,
    todo: web::Json<Todo>,
) -> Result<HttpResponse> {
    let mut todos = data.todos.lock().unwrap();
    todos.push(todo.into_inner());
    Ok(HttpResponse::Created().json(&*todos))
}

async fn toggle_todo(
    data: web::Data<AppState>,
    id: web::Path<u32>,
) -> Result<HttpResponse> {
    let mut todos = data.todos.lock().unwrap();
    
    if let Some(todo) = todos.iter_mut().find(|t| t.id == *id) {
        todo.completed = !todo.completed;
        Ok(HttpResponse::Ok().json(todo.clone()))
    } else {
        Ok(HttpResponse::NotFound().body("Todo 없음"))
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let app_state = web::Data::new(AppState {
        todos: Mutex::new(vec![]),
    });
    
    HttpServer::new(move || {
        App::new()
            .app_data(app_state.clone())
            .route("/todos", web::get().to(get_todos))
            .route("/todos", web::post().to(create_todo))
            .route("/todos/{id}/toggle", web::put().to(toggle_todo))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

6. 미들웨어 심화: CORS, 인증, 에러 핸들링

프로덕션 API에서는 브라우저 클라이언트(CORS), 토큰 검증, 공통 에러 응답을 미들웨어로 묶는 경우가 많습니다.

Cargo.toml (추가)

actix-cors = "0.7"

CORS

다른 출처(origin)의 프론트엔드가 API를 호출하려면 Access-Control-* 헤더가 필요합니다. actix-cors로 한 번에 설정합니다.

use actix_cors::Cors;
use actix_web::http::header;
use actix_web::{web, App, HttpServer};

HttpServer::new(move || {
    App::new()
        .wrap(
            Cors::default()
                .allowed_origin("http://localhost:3000")
                .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
                .allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT])
                .max_age(3600),
        )
        .route("/users", web::get().to(get_users))
        // ...
})

개발 환경에서만 모든 출처를 허용하려면 allowed_origin_fn으로 호스트를 검사하는 편이 안전합니다. 운영에서는 화이트리스트 도메인만 허용하세요.

인증: Bearer 토큰 검사 (간단 패턴)

무거운 OAuth 대신, 헤더에서 토큰을 읽어 검증하는 패턴입니다. 실제로는 JWT 검증·세션 조회 등을 여기에 넣습니다. Actix-web 4에서는 middleware::from_fn 으로 비교적 짧게 작성할 수 있습니다.

use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::http::header;
use actix_web::middleware::{from_fn, Next};
use actix_web::Error;

async fn bearer_guard(
    req: ServiceRequest,
    next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
    let token_ok = req
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|h| h.to_str().ok())
        .and_then(|s| s.strip_prefix("Bearer "))
        .map(|t| !t.is_empty())
        .unwrap_or(false);

    if token_ok {
        // 검증 성공 시: req.extensions_mut().insert(AuthUser { ... });
        next.call(req).await
    } else {
        Err(actix_web::error::ErrorUnauthorized("invalid or missing token"))
    }
}

// App::new().wrap(from_fn(bearer_guard))

표준화된 Bearer 추출이 필요하면 actix-web-httpauthHttpAuthentication::bearer() 와 검증 클로저를 쓰는 편이 깔끔합니다.

핵심은 검증 성공 시 req.extensions_mut()에 사용자 정보를 넣고, 핸들러에서는 req.extensions().get::<AuthUser>()로 꺼내 쓰는 패턴입니다.

에러 핸들링 미들웨어

전역으로 4xx/5xx 응답을 꾸미거나 로깅할 때 ErrorHandlers를 씁니다.

use actix_web::dev::ServiceResponse;
use actix_web::http::{header, StatusCode};
use actix_web::middleware::{ErrorHandlerResponse, ErrorHandlers};

fn on_internal_error<B>(
    mut res: ServiceResponse<B>,
) -> actix_web::Result<ErrorHandlerResponse<B>> {
    // 공통 JSON 스키마·헤더 보강 등
    res.response_mut().headers_mut().insert(
        header::CONTENT_TYPE,
        header::HeaderValue::from_static("application/json; charset=utf-8"),
    );
    Ok(ErrorHandlerResponse::Response(res.map_into_left_body()))
}

App::new().wrap(
    ErrorHandlers::new().handler(StatusCode::INTERNAL_SERVER_ERROR, on_internal_error),
)

운영에서는 trace id를 헤더에 넣고, 5xx 시에만 상세 로그를 남기는 식으로 조합합니다.


7. 상태 관리: Arc, Mutex vs RwLock

web::Data<T>는 내부적으로 Arc<T> 를 감쌉니다. 여러 워커 스레드가 같은 상태를 공유하므로, Send + Sync 가 보장되는 타입을 넣어야 합니다.

방식특징언제 쓰나
Mutex<T>동시에 한 스레드만 T에 접근쓰기가 잦거나, 락 구간이 짧을 때
RwLock<T>읽기는 여러 스레드, 쓰기는 하나조회 ≫ 수정 인 캐시·설정 등

CRUD 예제의 Mutex<Vec<User>>는 구현이 단순하지만, 읽기만 많은 엔드포인트에서는 RwLock이 경합을 줄일 수 있습니다.

use std::sync::{Arc, RwLock};

struct AppState {
    users: RwLock<Vec<User>>,
}

async fn get_users(data: web::Data<AppState>) -> Result<HttpResponse> {
    let users = data.users.read().map_err(|_| actix_web::error::ErrorInternalServerError("lock"))?;
    Ok(HttpResponse::Ok().json(&*users))
}

async fn create_user(
    data: web::Data<AppState>,
    user: web::Json<User>,
) -> Result<HttpResponse> {
    let mut users = data.users.write().map_err(|_| actix_web::error::ErrorInternalServerError("lock"))?;
    users.push(user.into_inner());
    Ok(HttpResponse::Created().finish())
}

실전 패턴

  • 인메모리만으로는 한계가 있으므로, 장기 저장은 DB(sqlx 등)로 넘기고 앱 상태에는 연결 풀이나 설정만 둡니다.
  • 락 안에서 await 하지 않기: 락을 잡은 채로 I/O를 하면 전체 처리량이 무너집니다. DB 작업은 락 밖에서 하고, 공유 구조체에는 필요한 최소 데이터만 보관합니다.
  • unwrap() 대신 map_err로 500 매핑하거나, 아래처럼 커스텀 에러 타입으로 일원화합니다.

8. 에러 처리: Result, ?, 커스텀 에러 타입

핸들러는 Result<impl Responder, E>를 반환할 수 있고, E: ResponseError 이면 프레임워크가 HTTP 응답으로 변환합니다.

?로 전파

아래 예시에서 anyhow 를 쓰려면 Cargo.tomlanyhow = "1" 을 추가합니다.

use actix_web::{error::ResponseError, http::StatusCode, HttpResponse};
use std::fmt;

#[derive(Debug)]
pub enum ApiError {
    NotFound,
    BadRequest(String),
    Internal(anyhow::Error),
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ApiError::NotFound => write!(f, "not found"),
            ApiError::BadRequest(s) => write!(f, "{s}"),
            ApiError::Internal(e) => write!(f, "{e}"),
        }
    }
}

impl ResponseError for ApiError {
    fn error_response(&self) -> HttpResponse {
        match self {
            ApiError::NotFound => HttpResponse::new(StatusCode::NOT_FOUND),
            ApiError::BadRequest(msg) => HttpResponse::BadRequest().body(msg.clone()),
            ApiError::Internal(_) => HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR),
        }
    }

    fn status_code(&self) -> StatusCode {
        match self {
            ApiError::NotFound => StatusCode::NOT_FOUND,
            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

DB·외부 API에서 sqlx::Error 등을 받았을 때 From을 구현해 두면 ? 한 번ApiError로 올라갑니다.

impl From<sqlx::Error> for ApiError {
    fn from(e: sqlx::Error) -> Self {
        ApiError::Internal(e.into())
    }
}

anyhow는 애플리케이션 내부 편의용이고, HTTP로 노출할 메시지는 별도 필드로 제어하는 것이 안전합니다.


9. 데이터베이스 연동: sqlx 예제

sqlx는 컴파일 타임에 쿼리를 검사할 수 있고(선택), 비동기 런타임과 잘 맞습니다. 여기서는 파일 하나로 돌아가는 SQLite 예시를 둡니다.

Cargo.toml

FromRow 매크로를 쓰려면 macros 기능을 켭니다.

sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros"] }

연결 풀을 앱 상태에 넣기

use sqlx::{sqlite::SqlitePoolOptions, FromRow, Pool, Sqlite};

#[derive(Clone, FromRow)]
struct UserRow {
    id: i64,
    name: String,
}

#[derive(Clone)]
struct AppStateDb {
    pool: Pool<Sqlite>,
}

async fn get_user_db(
    data: web::Data<AppStateDb>,
    path: web::Path<i64>,
) -> Result<HttpResponse, actix_web::error::Error> {
    let row: Option<UserRow> = sqlx::query_as(
        "SELECT id, name FROM users WHERE id = ?",
    )
    .bind(*path)
    .fetch_optional(&data.pool)
    .await
    .map_err(actix_web::error::ErrorInternalServerError)?;

    match row {
        Some(u) => Ok(HttpResponse::Ok().json(serde_json::json!({ "id": u.id, "name": u.name }))),
        None => Err(actix_web::error::ErrorNotFound("not found")),
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect("sqlite://app.db") // 경로·버전에 맞게 조정 (sqlx 문서의 SQLite URL 참고)
        .await
        .expect("db");

    sqlx::query("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
        .execute(&pool)
        .await
        .expect("migrate");

    let state = web::Data::new(AppStateDb { pool });

    HttpServer::new(move || {
        App::new()
            .app_data(state.clone())
            .route("/users/{id}", web::get().to(get_user_db))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Diesel은 동기 중심·강한 ORM 스타일이라, Tokio 액터 모델과 함께 쓸 때는 spawn_blocking 으로 풀 밖에서 실행하는 패턴이 흔합니다. 새 프로젝트는 sqlx + 마이그레이션 조합을 많이 택합니다.


10. 테스트: actix-web

actix_web::test실제 서비스 파이프라인에 요청을 보내 검증합니다.

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::{http::StatusCode, test, web, App};

    #[actix_web::test]
    async fn get_users_returns_ok() {
        let state = web::Data::new(AppState {
            users: Mutex::new(vec![User {
                id: 1,
                name: "a".into(),
                email: "[email protected]".into(),
            }]),
        });

        let app = test::init_service(
            App::new()
                .app_data(state.clone())
                .route("/users", web::get().to(get_users)),
        )
        .await;

        let req = test::TestRequest::get().uri("/users").to_request();
        let resp = test::call_service(&app, req).await;
        assert_eq!(resp.status(), StatusCode::OK);
    }
}
  • #[actix_web::test]: 내부적으로 런타임을 맞춰 줍니다.
  • JSON 본문 검증은 test::read_body_json 등으로 이어갈 수 있습니다.
  • DB가 있으면 테스트 전용 DB 파일이나 sqlx 테스트 트랜잭션 롤백으로 격리합니다.

11. 배포·프로덕션 설정

로깅

RUST_LOG로 모듈별 레벨을 줍니다.

RUST_LOG=actix_web=info,my_crate=debug

tracing + tracing-subscriber를 쓰면 JSON 로그·OpenTelemetry 연동에 유리합니다.

환경 변수

std::env::var 또는 dotenvy포트·DB URL·시크릿을 주입하고, 코드에 비밀을 넣지 않습니다.

let host = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0".into());
let port: u16 = std::env::var("PORT")
    .ok()
    .and_then(|s| s.parse().ok())
    .unwrap_or(8080);

성능·운영

  • HttpServer::workers(n): CPU 코어 수에 맞게 조정(기본은 논리 코어 수).
  • 바인드 주소: 컨테이너·클라우드에서는 0.0.0.0으로 리슨해야 외부에서 접근 가능합니다.
  • 리버스 프록시(Nginx 등) 뒤에 두고 TLS는 프록시에서 종료하는 구성이 일반적입니다.
  • 타임아웃·바디 크기 제한HttpServer·App 설정 또는 프록시에서 제한합니다.

정리

핵심 요약

  1. Actix-web: 고성능 웹 프레임워크
  2. 라우팅: web::get(), web::post() 등
  3. 핸들러: async fn, impl Responder
  4. 상태 관리: web::Data (Arc 기반), Mutex/RwLock 선택
  5. JSON: serde, web::Json
  6. 미들웨어: CORS, 인증, ErrorHandlers 등으로 횡단 관심사 분리
  7. 에러: ResponseError 구현 + ? 전파로 핸들러 단순화
  8. DB: sqlx 풀을 Data에 두고 await (락 안에서 await 금지)
  9. 테스트: test::init_service, #[actix_web::test]
  10. 프로덕션: RUST_LOG, 환경 변수, workers·바인드·프록시

다음 단계

  • Rust 테스팅
  • Rust CLI 도구
  • Rust vs C++

관련 글

  • Flask 기초 | Python 웹 프레임워크 시작하기
  • C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴
  • C++ JSON 파싱 완벽 가이드 | nlohmann·RapidJSON·커스텀 타입·에러 처리·프로덕션 패턴
  • C++ REST API 클라이언트 완벽 가이드 | CRUD·인증·에러 처리·프로덕션 패턴 [#21-3]
  • C++와 Rust: 두 언어의 상호 운용성과 Memory Safety 논쟁의 실체 [#44-2]