hyunsu blog

홈강좌Rust 실무 16편 중 7편째

Rust axum — 작은 웹 API 서버를 만든다

목차 · 3
  1. 01경로 둘짜리 서버
  2. 02메모 API — 만들기·목록·하나
  3. 03실습 — 지우기 경로 더하기

axum 은 tokio(입문 46편) 위에서 도는 Rust 웹 프레임워크다. Router 에 경로와 핸들러 함수를 묶고, Json·Path·State 같은 추출기로 요청에서 필요한 것을 꺼낸다. 메모 목록 API 를 만들어 127.0.0.1 에서 만들기 201·조회 200·없음 404·잘못된 JSON 422 응답을 확인한다.

01 경로 둘짜리 서버

toml
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "=0.8.9"
tokio = { version = "=1.54.1", features = ["rt-multi-thread", "macros", "net", "io-util"] }
serde = { version = "=1.0.229", features = ["derive"] }
rust
use axum::{Json, Router, routing::get};
use serde::Serialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

#[derive(Serialize)]
struct Health {
    status: &'static str,
    version: &'static str,
}

async fn health() -> Json<Health> {
    Json(Health { status: "ok", version: "0.1.0" })
}

async fn fetch(path: &str) -> String {
    let mut stream = TcpStream::connect("127.0.0.1:7878").await.unwrap();
    let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
    stream.write_all(request.as_bytes()).await.unwrap();
    let mut response = String::new();
    stream.read_to_string(&mut response).await.unwrap();
    let status = response.lines().next().unwrap_or("").to_string();
    let body = response.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
    format!("{status} | {body}")
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(|| async { "notes api" }))
        .route("/health", get(health));

    let listener = TcpListener::bind("127.0.0.1:7878").await.unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

    println!("{}", fetch("/").await);
    println!("{}", fetch("/health").await);
    println!("{}", fetch("/nope").await);
}
text
     Running `target/debug/notes-api`
HTTP/1.1 200 OK | notes api
HTTP/1.1 200 OK | {"status":"ok","version":"0.1.0"}
HTTP/1.1 404 Not Found | 
  • Router::new().route("/", get(…)) 는 "GET / 요청이 오면 이 함수를 부른다" 다. 핸들러는 async 함수나 클로저(입문 36편)다.
  • 핸들러가 &str 을 돌려주면 글자 응답, Json(값) 을 돌려주면 JSON 응답이 된다. Json 안에는 serde 의 Serialize(5편)를 갖춘 값이면 무엇이든 넣는다.
  • 정하지 않은 경로(/nope)는 axum 이 알아서 404 로 답한다.
  • axum::serve(listener, app) 가 서버를 돌린다. 이 예제는 서버를 tokio::spawn 으로 뒤에 띄우고, 같은 프로그램 안에서 fetch 로 요청을 보내 응답 첫 줄과 본문을 찍었다(외부로 나가지 않게 127.0.0.1 만 쓴다).

02 메모 API — 만들기·목록·하나

rust
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::{Json, Router, routing::get};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

#[derive(Clone, Serialize, Deserialize)]
struct Note {
    id: usize,
    title: String,
}

#[derive(Deserialize)]
struct NewNote {
    title: String,
}

type Db = Arc<Mutex<Vec<Note>>>;

async fn list(State(db): State<Db>) -> Json<Vec<Note>> {
    Json(db.lock().unwrap().clone())
}

async fn create(State(db): State<Db>, Json(input): Json<NewNote>) -> (StatusCode, Json<Note>) {
    let mut notes = db.lock().unwrap();
    let note = Note { id: notes.len() + 1, title: input.title };
    notes.push(note.clone());
    (StatusCode::CREATED, Json(note))
}

async fn one(State(db): State<Db>, Path(id): Path<usize>) -> Result<Json<Note>, StatusCode> {
    let notes = db.lock().unwrap();
    notes.iter().find(|n| n.id == id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

pub fn app() -> Router {
    let db: Db = Arc::new(Mutex::new(Vec::new()));
    Router::new()
        .route("/notes", get(list).post(create))
        .route("/notes/{id}", get(one))
        .with_state(db)
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:7878").await.unwrap();
    tokio::spawn(async move { axum::serve(listener, app()).await.unwrap() });

    let client = Client;
    println!("{}", client.send("POST", "/notes", r#"{"title":"learn axum"}"#).await);
    println!("{}", client.send("POST", "/notes", r#"{"title":"write tests"}"#).await);
    println!("{}", client.send("GET", "/notes", "").await);
    println!("{}", client.send("GET", "/notes/2", "").await);
    println!("{}", client.send("GET", "/notes/9", "").await);
    println!("{}", client.send("POST", "/notes", r#"{"name":"oops"}"#).await);
}

struct Client;

impl Client {
    async fn send(&self, method: &str, path: &str, body: &str) -> String {
        use tokio::io::{AsyncReadExt, AsyncWriteExt};
        let mut s = tokio::net::TcpStream::connect("127.0.0.1:7878").await.unwrap();
        let req = format!(
            "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
            body.len()
        );
        s.write_all(req.as_bytes()).await.unwrap();
        let mut res = String::new();
        s.read_to_string(&mut res).await.unwrap();
        let status = res.lines().next().unwrap_or("").replace("HTTP/1.1 ", "");
        let body = res.split("\r\n\r\n").nth(1).unwrap_or("");
        format!("{method} {path} -> {status} {body}")
    }
}
text
     Running `target/debug/notes-api`
POST /notes -> 201 Created {"id":1,"title":"learn axum"}
POST /notes -> 201 Created {"id":2,"title":"write tests"}
GET /notes -> 200 OK [{"id":1,"title":"learn axum"},{"id":2,"title":"write tests"}]
GET /notes/2 -> 200 OK {"id":2,"title":"write tests"}
GET /notes/9 -> 404 Not Found 
POST /notes -> 422 Unprocessable Entity Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 15

핸들러의 매개변수 타입이 곧 요청에서 꺼낼 것이다.

  • State(db): State<Db> — with_state(db) 로 넣어 둔 공유 저장소를 꺼낸다. 요청은 여러 스레드에서 동시에 오니 Arc<Mutex<…>>(입문 45편)로 감쌌다.
  • Json(input): Json<NewNote> — 요청 본문을 JSON 으로 읽어 NewNote 로 만든다. 5편의 serde 가 일한다.
  • Path(id): Path<usize> — /notes/{id} 의 {id} 자리를 숫자로 꺼낸다.

돌려주는 값으로 응답이 정해진다.

  • (StatusCode::CREATED, Json(note)) — 201 과 JSON 을 함께.
  • Result<Json<Note>, StatusCode> — Ok 면 200 과 JSON, Err(StatusCode::NOT_FOUND) 면 404. ok_or 는 Option 을 Result 로 바꾸는 메서드다.

마지막 줄을 보자. title 대신 name 을 보냈더니, 우리가 검사 코드를 한 줄도 쓰지 않았는데 axum 이 422(처리할 수 없는 내용)로 막고 이유(missing field title)를 답했다. 5편에서 본 serde 의 오류가 그대로 응답이 된 것이다. 핸들러는 올바른 NewNote 가 왔을 때만 불린다.

03 실습 — 지우기 경로 더하기

DELETE /notes/{id} 를 더해 보자.

  1. async fn remove(State(db): State<Db>, Path(id): Path<usize>) -> StatusCode 를 만든다. 있으면 지우고 StatusCode::NO_CONTENT(204), 없으면 NOT_FOUND.
  2. .route("/notes/{id}", get(one).delete(remove)) 처럼 같은 경로에 메서드를 잇는다.
  3. 같은 방법으로 요청을 보내 204 뒤에 목록에서 빠졌는지 본다.

실제 서비스에서는 Vec 대신 DB(sqlx 등)를 쓰고, 이 예제처럼 unwrap 하는 대신 에러를 응답으로 바꾼다. 그 에러 처리를 다음 편에서 다룬다.