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

이 글의 핵심

Hello World 서버에서 시작해 데이터 모델과 CRUD 핸들러를 붙이고, 실서비스에 필요한 미들웨어와 에러 처리를 한 단계씩 더해 갑니다. 공유 상태를 어떤 락으로 감쌀지, ? 연산자로 에러를 HTTP 응답에 매핑하는 방법, sqlx 연결 풀을 앱 상태에 넣는 방식과 로깅·환경 변수 설정까지 운영 관점으로 연결합니다.

시리즈 안내

#09 | 📋 전체 목차 | 이전: #08 비동기 · 다음: #10 테스팅


들어가며

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


Rust와의 첫 만남

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

Actix-web 의존성 설정

Cargo.toml

[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
env_logger = "0.11"   # 아래 로깅 미들웨어 예제용

Actix-web은 자체 런타임 설정(#[actix_web::main])을 제공하므로 tokio를 따로 넣지 않아도 서버는 돌아갑니다. 그래도 tokio::time, tokio::fs처럼 Tokio 유틸리티를 직접 쓰는 경우가 많아 함께 추가해 두었습니다. 주의할 점은 Actix-web이 워커 스레드마다 단일 스레드 Tokio 런타임을 띄우는 구조라는 것입니다. 일반적인 #[tokio::main] 멀티스레드 런타임과 달리 한 요청의 future는 처음 배정된 워커 스레드에서만 실행되므로, 핸들러 안에서 CPU를 오래 쓰는 동기 작업을 하면 그 워커에 배정된 다른 요청이 모두 멈춥니다. 이미지 처리나 해시 계산 같은 작업은 web::block(내부적으로 블로킹 스레드 풀 사용)으로 넘겨야 합니다.


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
}

HttpServer::new에 넘기는 것은 App 자체가 아니라 App을 만드는 클로저입니다. 서버는 워커 스레드 수만큼 이 클로저를 호출해 워커마다 독립된 App을 만듭니다. 그래서 클로저 안에서 만든 값은 워커마다 따로 존재하게 되고, 이 사실이 뒤에서 다룰 공유 상태 버그의 원인이 됩니다. 핸들러의 인자(web::Path<String>)는 추출기(extractor)로, 요청에서 해당 타입을 꺼내는 방법을 프레임워크가 알고 있습니다. 경로의 {name}이 없거나 타입으로 파싱되지 않으면 핸들러는 호출되지도 않고 404나 400이 바로 돌아갑니다. 예를 들어 web::Path<u32>인데 /users/abc로 요청하면 핸들러 코드에 도달하기 전에 에러 응답이 나갑니다.

bind(("127.0.0.1", 8080))는 루프백에만 바인딩하므로 같은 머신에서만 접속됩니다. Docker 컨테이너 안에서 이 코드를 실행하면 포트를 publish해도 호스트에서 접속되지 않는데(curl: (52) Empty reply from server 또는 연결 거부), 컨테이너의 루프백은 호스트와 분리되어 있기 때문입니다. 배포 설정 절에서 0.0.0.0 바인딩을 다시 다룹니다.


데이터 모델·CRUD 핸들러·라우팅으로 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("사용자 없음"))
    }
}

이 핸들러들은 개념을 보여 주기 위해 몇 가지를 단순화했습니다. create_user는 클라이언트가 보낸 id를 그대로 저장하므로 같은 id가 두 번 들어갈 수 있고, 이후 get_user는 먼저 들어간 것만 찾습니다. 실제로는 요청 본문용 구조체(CreateUser { name, email })를 따로 두고 서버가 id를 부여해야 합니다. 응답용 구조체와 입력용 구조체를 분리하는 것은 id뿐 아니라 비밀번호 해시처럼 응답에 절대 나가면 안 되는 필드를 실수로 직렬화하지 않기 위해서도 중요합니다.

lock().unwrap()에도 함정이 있습니다. 어떤 핸들러가 락을 잡은 채 패닉하면 Mutex가 포이즈닝되고, 이후 모든 요청의 lock()이 Err를 돌려줍니다. unwrap()을 쓰면 그 순간부터 이 상태를 쓰는 모든 엔드포인트가 패닉해 500을 반환합니다. Actix-web은 핸들러 패닉을 잡아 워커를 살려 두기 때문에 서버는 떠 있는데 특정 API만 영원히 실패하는 상태가 됩니다. 뒤의 RwLock 예제처럼 map_err로 에러 응답으로 바꾸거나, 포이즈닝이 없는 parking_lot::Mutex를 쓰는 선택지가 있습니다.

라우팅 설정

#[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
}

여기서 app_state를 HttpServer::new 밖에서 만들고 클로저 안에서 clone()하는 것이 핵심입니다. web::Data는 Arc이므로 clone()은 같은 상태를 가리키는 참조 카운트만 늘립니다. 처음 Actix-web을 쓸 때 거의 누구나 한 번은 겪는 버그가 이 줄을 클로저 안으로 옮기는 것입니다. HttpServer::new(|| App::new().app_data(web::Data::new(AppState { ... })))로 쓰면 컴파일도 되고 테스트에서도 잘 동작하지만, 실제로는 워커마다 별도의 Vec이 생깁니다. POST로 사용자를 추가한 뒤 GET을 보내면 다른 워커가 요청을 받았을 때 빈 목록이 돌아오므로, “가끔 데이터가 사라진다”는 재현하기 어려운 증상으로 나타납니다. 워커가 하나뿐인 개발 환경에서는 절대 드러나지 않는다는 점이 이 버그를 더 찾기 어렵게 만듭니다.

라우트를 늘리다 보면 .route() 나열 대신 web::scope("/users").service(...)나 #[get("/users/{id}")] 매크로와 .service(get_user) 조합으로 정리하게 됩니다. 매크로 방식은 경로가 핸들러 바로 위에 붙어 있어 찾기 쉽고, route 방식은 전체 라우팅 표를 한곳에서 볼 수 있다는 장점이 있습니다.


로깅 미들웨어 붙이기

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
}

예제: 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
}

CORS, Bearer 토큰 인증, 에러 핸들링 미들웨어

프로덕션 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))

이 가드는 토큰이 “비어 있지 않은지”만 확인하므로 그대로 쓰면 아무 문자열이나 통과합니다. 실제 검증(JWT 서명·만료 확인, 세션 저장소 조회)을 넣는 자리를 보여 주는 뼈대로 이해해야 합니다. 미들웨어를 붙일 때 헷갈리는 부분은 wrap 순서입니다. Actix-web에서 나중에 wrap한 미들웨어가 바깥쪽에 위치해 요청을 먼저 받습니다. 그래서 인증 가드를 Cors보다 바깥에 두면 브라우저의 CORS 사전 요청(OPTIONS, Authorization 헤더 없음)이 401로 막혀, 프런트엔드에서는 실제 원인과 무관한 CORS 에러만 보이게 됩니다. Cors를 가장 마지막에 wrap해 가장 바깥에 두는 것이 안전합니다. 또 from_fn 미들웨어는 Actix-web 4.9 이상에서 제공되므로, 그보다 오래된 버전이라면 Transform/Service 트레이트를 직접 구현하거나 actix-web-lab의 같은 기능을 써야 합니다. 표준화된 Bearer 추출이 필요하면 actix-web-httpauth 의 HttpAuthentication::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 시에만 상세 로그를 남기는 식으로 조합합니다.


공유 상태: Arc, Mutex vs RwLock

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

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

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

락 안에서 await하지 않기와 상태에 둘 것

  • 인메모리만으로는 한계가 있으므로, 장기 저장은 DB(sqlx 등)로 넘기고 앱 상태에는 연결 풀이나 설정만 둡니다.
  • 락 안에서 await 하지 않기: 락을 잡은 채로 I/O를 하면 전체 처리량이 무너집니다. DB 작업은 락 밖에서 하며, 공유 구조체에는 필요한 최소 데이터만 보관합니다.

이 규칙은 Actix-web에서 특히 스스로 지켜야 합니다. #[tokio::main] 기반 프레임워크(axum 등)에서는 std::sync::MutexGuard를 .await 너머로 들고 있으면 future가 Send가 아니게 되어 future cannot be sent between threads safely 컴파일 에러가 나므로 실수가 바로 드러납니다. 그런데 Actix-web은 워커마다 단일 스레드 런타임을 쓰기 때문에 핸들러 future에 Send를 요구하지 않고, 같은 코드가 경고 없이 컴파일됩니다. 그 결과 락을 잡은 요청이 await로 양보한 사이 같은 워커의 다른 요청이 같은 락을 잡으려 하면 워커 스레드 전체가 블로킹되어 교착에 빠질 수 있습니다. 락 구간 안에서 await가 꼭 필요하다면 tokio::sync::Mutex를 쓰고, 그렇지 않다면 필요한 값을 복사해 락을 먼저 풀고 나서 await하는 구조로 만드는 편이 좋습니다.

  • unwrap() 대신 map_err로 500 매핑하거나, 아래처럼 커스텀 에러 타입으로 일원화합니다.

?와 커스텀 에러 타입으로 HTTP 에러 응답 만들기

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

?로 전파

아래 예시에서 anyhow 를 쓰려면 Cargo.toml에 anyhow = "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로 노출할 메시지는 별도 필드로 제어하는 것이 안전합니다.

위 error_response가 Internal일 때 본문 없이 500만 돌려주는 것은 의도된 선택입니다. sqlx::Error의 Display에는 SQL 구문, 테이블 이름, 때로는 연결 정보까지 들어 있어서, 이를 그대로 응답 본문에 넣으면 공격자에게 내부 구조를 알려 주게 됩니다. 원인은 서버 로그에만 남기고 클라이언트에게는 추적 ID 정도만 주는 것이 일반적입니다. 반대로 actix_web::error::ErrorInternalServerError(e)처럼 에러를 감싸는 헬퍼는 기본적으로 에러의 Display 문자열을 응답 본문에 넣으므로, 앞 절의 map_err(actix_web::error::ErrorInternalServerError) 패턴은 개발용으로만 쓰는 편이 안전합니다. 실무에서는 thiserror로 에러 열거형의 Display를 선언적으로 정의하고, 이처럼 한 곳에서 HTTP 응답으로 변환하는 조합이 가장 흔합니다.


sqlx와 SQLite 연동하기

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?mode=rwc") // rwc: 파일이 없으면 생성
        .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
}

?mode=rwc를 빼면 파일이 없을 때 error returned from database: (code: 14) unable to open database file로 시작부터 실패합니다. 처음 실행하는 환경마다 이 에러를 보게 되므로, SqliteConnectOptions::new().filename("app.db").create_if_missing(true)처럼 옵션 객체로 명시하는 방법도 있습니다. 예제의 CREATE TABLE IF NOT EXISTS는 편의용이고, 스키마가 바뀌는 실서비스에서는 sqlx migrate add로 마이그레이션 파일을 만들고 시작 시 sqlx::migrate!().run(&pool)로 적용하는 편이 안전합니다.

연결 풀은 앞의 Vec과 달리 락으로 감쌀 필요가 없습니다. Pool은 내부적으로 이미 Arc이고 스스로 동시 접근을 관리하며 Clone도 싸기 때문입니다. 풀을 Mutex<Pool>로 감싸는 코드를 가끔 보는데, 그러면 동시에 한 요청만 DB를 쓰게 되어 풀을 둔 의미가 사라집니다. max_connections는 SQLite라면 쓰기가 파일 락 하나로 직렬화되므로 크게 잡아도 이점이 없고, 쓰기가 몰리면 database is locked 에러가 날 수 있어 WAL 모드(journal_mode=WAL)와 busy_timeout 설정을 함께 검토합니다.

Diesel은 동기 API 중심의 ORM이라, 비동기 핸들러 안에서 쓸 때는 web::block이나 tokio::task::spawn_blocking으로 블로킹 스레드 풀에서 실행하는 패턴이 흔합니다(비동기 래퍼인 diesel-async도 있습니다). 새 프로젝트는 sqlx + 마이그레이션 조합을 많이 택합니다. sqlx의 query! 매크로를 쓰면 컴파일 시점에 실제 DB(또는 cargo sqlx prepare로 만든 오프라인 메타데이터)에 쿼리를 검사해 오타나 타입 불일치를 빌드 단계에서 잡을 수 있다는 점이 큰 장점입니다. 대신 CI에서 DB나 .sqlx 디렉터리가 없으면 빌드가 실패하므로 준비 과정이 필요합니다.


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 테스트 트랜잭션 롤백으로 격리합니다.

test::init_service는 실제 네트워크 포트를 열지 않고 App의 서비스 파이프라인(라우팅, 추출기, 미들웨어)을 그대로 통과시키므로, 빠르면서도 추출기 실패나 미들웨어 순서 같은 통합 수준의 문제를 잡아냅니다. 테스트에서 App을 따로 조립하다 보면 본 코드의 라우팅과 어긋나기 쉬우므로, 라우트 등록을 fn config(cfg: &mut web::ServiceConfig) 함수로 빼고 main과 테스트 양쪽에서 .configure(config)로 같은 설정을 쓰는 것을 권합니다. 그러면 “테스트는 통과했는데 실제 서버에서는 404”인 경우가 사라집니다.


로깅·환경 변수·워커 수 등 배포 설정

로깅

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 설정 또는 프록시에서 제한합니다.

Actix-web 서버 구성 요약

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

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. web::Data에 넣은 공유 상태는 Mutex와 RwLock 중 무엇으로 감싸야 하나요?

A. web::Data는 내부적으로 Arc로 감싸져 여러 워커 스레드가 공유하므로, 수정이 필요한 상태는 Mutex나 RwLock으로 보호해야 합니다. 쓰기가 잦거나 락 구간이 짧으면 Mutex가 단순하고, 조회가 수정보다 훨씬 많은 캐시·설정이라면 RwLock이 경합을 줄여 줍니다. 어느 쪽이든 락을 잡은 채 DB 호출 같은 I/O를 하지 말고, 장기 저장은 DB로 넘긴 뒤 앱 상태에는 연결 풀이나 설정만 두는 편이 낫습니다.