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

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

이 글의 핵심

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

들어가며

Rust로 웹 API를 만들 때 Actix-web을 고르는 경우가 많습니다. Tokio 기반 비동기 런타임 위에서 동작하며, 라우팅·JSON·미들웨어·테스트를 한 프레임워크 안에서 다룰 수 있습니다. 이 글에서는 Hello World·CRUD·Todo로 기본을 익힌 뒤, CORS·인증·에러 미들웨어, Arc/Mutex/RwLock 공유 상태, Result·커스텀 에러, sqlx, 통합 테스트, 로깅·환경 변수·서버 설정 등 실무 주제를 이어서 정리합니다.

Rust와의 첫 만남

“빌려주기 검사기(Borrow Checker)와 싸우는 게 프로그래밍의 반”이라는 농담이 있을 정도로, Rust는 처음에 정말 어렵습니다. 저도 첫 프로젝트에서 컴파일러 에러와 씨름하며 “이게 정말 생산성이 높은 언어인가?” 의심했습니다. 하지만 몇 주간 고생 끝에 컴파일이 통과된 코드는 런타임 에러가 거의 없다는 걸 깨달았습니다. C++에서는 세그멘테이션 폴트가 프로덕션에서 터지는 악몽을 자주 겪었는데, Rust는 그런 걱정이 없습니다. 컴파일러가 미리 잡아주니까요. 특히 멀티스레드 코드를 작성할 때 이 차이가 극명합니다. C++에서는 데이터 레이스를 찾느라 디버거와 씨름했지만, Rust는 컴파일 단계에서 “이 코드는 스레드 안전하지 않아”라고 알려줍니다. 처음엔 답답했지만, 지금은 이 엄격함이 감사합니다.

1. Actix-web 설정

Cargo.toml

아래 코드는 toml를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

2. 기본 서버

Hello World

다음은 rust를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

데이터 모델

아래 코드는 rust를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

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

CRUD 핸들러

다음은 rust를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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("사용자 없음"))
    }
}

라우팅 설정

다음은 rust를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#[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. 미들웨어

로깅 미들웨어

다음은 rust를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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

다음은 rust를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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로 한 번에 설정합니다. 다음은 rust를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 실행 예제
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 으로 비교적 짧게 작성할 수 있습니다. 다음은 rust를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 실행 예제
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를 씁니다. 다음은 rust를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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 를 감쌉니다. 여러 워커 스레드가 같은 상태를 공유하므로, Send + Sync 가 보장되는 타입을 넣어야 합니다.

방식특징언제 쓰나
Mutex동시에 한 스레드만 T에 접근쓰기가 잦거나, 락 구간이 짧을 때
RwLock읽기는 여러 스레드, 쓰기는 하나조회 ≫ 수정 인 캐시·설정 등
CRUD 예제의 Mutex<Vec<User>>는 구현이 단순하지만, 읽기만 많은 엔드포인트에서는 RwLock이 경합을 줄일 수 있습니다.
다음은 rust를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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" 을 추가합니다. 다음은 rust를 활용한 상세한 구현 코드입니다. 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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로 올라갑니다. 아래 코드는 rust를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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"] }

연결 풀을 앱 상태에 넣기

다음은 rust를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

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실제 서비스 파이프라인에 요청을 보내 검증합니다. 다음은 rust를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#[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: "a@b.c".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·시크릿을 주입하고, 코드에 비밀을 넣지 않습니다. 아래 코드는 rust를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

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·바인드·프록시

다음 단계


관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3