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-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 시에만 상세 로그를 남기는 식으로 조합합니다.
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.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로 노출할 메시지는 별도 필드로 제어하는 것이 안전합니다.
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설정 또는 프록시에서 제한합니다.
정리
핵심 요약
- Actix-web: 고성능 웹 프레임워크
- 라우팅: web::get(), web::post() 등
- 핸들러: async fn, impl Responder
- 상태 관리: web::Data
( Arc기반),Mutex/RwLock선택 - JSON: serde, web::Json
- 미들웨어: CORS, 인증, ErrorHandlers 등으로 횡단 관심사 분리
- 에러:
ResponseError구현 +?전파로 핸들러 단순화 - DB: sqlx 풀을
Data에 두고 await (락 안에서 await 금지) - 테스트:
test::init_service,#[actix_web::test] - 프로덕션:
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]