Rust로 만드는 Axum 웹 API
이 프로젝트에서는 진짜 웹 서버를 만들어요. 작은 할 일 API예요. 다른 프로그램(웹사이트, 휴대폰 앱, 아니면 그냥 터미널의 curl)이 HTTP 요청을 보내면, 서버가 JSON으로 대답해요. 여러분이 쓰는 대부분의 앱에서 “백엔드”를 움직이는 것과 같은 종류의 프로그램이에요.
여기서는 Tokio 팀이 만든 인기 있고 친절한 웹 프레임워크인 axum을 써요. axum은 비동기 Rust와 serde 위에서 동작하기 때문에, 이 프로젝트에서는 지금까지 배운 것들을 많이 엮어서 쓰게 돼요. 구조체, Result, 스마트 포인터, 그리고 스레드 사이의 공유 상태예요. 할 일 목록 CLI를 만들어 봤다면 아이디어가 익숙할 거예요. 이번에는 할 일 목록이 웹 주소 뒤에 있다는 점만 달라요.
무엇을 만들까요
섹션 제목: “무엇을 만들까요”다 만들고 나면 서버는 다음 요청에 대답해요.
| 메서드와 경로 | 하는 일 | 성공했을 때 상태 코드 |
|---|---|---|
GET /todos |
모든 할 일 목록 보기 | 200 OK |
GET /todos/{id} |
할 일 하나 가져오기 | 200 OK, 또는 404 Not Found |
POST /todos |
JSON으로 할 일 만들기 | 201 Created |
DELETE /todos/{id} |
할 일 삭제하기 | 204 No Content, 또는 404 Not Found |
할 일 하나는 JSON으로 이렇게 생겼어요.
{"id":1,"title":"우유 사기","done":false}중요한 점 하나: 웹 서버는 요청을 끝없이 기다리고 네트워크 포트가 필요하기 때문에 Rust Playground에서는 실행할 수 없어요. 이 페이지의 서버 코드는 여러분의 컴퓨터에서 cargo run으로 실행하는 용도예요. 아래 단계는 모두 실제로 컴파일하고 실행해 봤고, 보여 드리는 응답도 진짜예요. 프로그램 중 Playground에서 실행할 수 있는 부분에는 “직접 실행해 보기” 버튼이 있어요.
프로젝트 준비하기
섹션 제목: “프로젝트 준비하기”새 프로젝트를 만들고 크레이트 네 개를 추가해요.
cargo new todo-apicd todo-apicargo add serde --features derivecargo add serde_json이제 Cargo.toml에 이런 내용이 들어 있어요(버전 번호는 더 새로울 수 있어요).
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }각 크레이트가 하는 일은 이래요.
- axum은 HTTP를 처리해요. 요청을 읽고 응답을 보내요.
- tokio는 모든 것을 실행하는 비동기 런타임이에요. async/await를 참고하세요.
- serde와 serde_json은 구조체를 JSON으로 바꾸고 다시 되돌려요. serde로 JSON 다루기를 참고하세요.
요청을 보내려면 curl도 필요해요. macOS, Linux, 그리고 Windows 10 이상에는 기본으로 들어 있어요. (Windows의 PowerShell에서는 curl.exe라고 입력하세요. 거기서 그냥 curl은 다른 명령을 뜻하거든요.)
1단계: Hello, world!
섹션 제목: “1단계: Hello, world!”src/main.rs를 다음 코드로 바꿔 주세요.
use axum::{routing::get, Router};
#[tokio::main]async fn main() { let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("서버 시작: http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello() -> &'static str { "안녕하세요, 세상!"}여기서는 세 가지가 함께 일해요.
- 핸들러(handler)는 요청에 대답하는
async fn이에요.hello가 텍스트를 돌려주면 axum이 그걸 응답으로 보내 줘요. - 라우터(router)는 경로와 핸들러를 연결해요.
.route("/", get(hello))는 “누군가/로GET요청을 보내면hello를 부른다”는 뜻이에요. - 리스너(listener)는 여러분의 컴퓨터(
127.0.0.1)에서 3000번 포트를 열고,axum::serve가 요청에 대답하기 시작해요. 여러분이 멈출 때까지 계속 실행돼요.
cargo run으로 실행하세요. 프로그램은 한 줄을 출력한 다음 기다려요.
서버 시작: http://127.0.0.1:3000서버를 그대로 둔 채 두 번째 터미널을 열고 요청을 보내 보세요.
curl http://127.0.0.1:3000/안녕하세요, 세상!서버는 텍스트 뒤에 줄바꿈을 붙이지 않기 때문에, 터미널 프롬프트가 안녕하세요, 세상! 바로 뒤에 나타날 수도 있어요. 정상이에요. 이 페이지에서는 응답을 한 줄씩 따로 보여 드려요. 브라우저에서 http://127.0.0.1:3000/을 열어 봐도 돼요. 서버를 멈추려면 첫 번째 터미널에서 Ctrl + C를 누르세요. 코드를 바꿀 때마다 서버를 멈췄다가 다시 시작해야 해요.
2단계: 구조체를 JSON으로 돌려주기
섹션 제목: “2단계: 구조체를 JSON으로 돌려주기”API는 보통 JSON으로 대답해요. axum에서는 serde의 Serialize를 구현한 값을 Json(...)으로 감싸기만 하면, axum이 JSON으로 바꾸고 알맞은 content-type 헤더도 붙여 줘요.
use axum::{routing::get, Json, Router};use serde::Serialize;
#[derive(Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[tokio::main]async fn main() { let app = Router::new() .route("/", get(hello)) .route("/example", get(example_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("서버 시작: http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello() -> &'static str { "안녕하세요, 세상!"}
async fn example_todo() -> Json<Todo> { let todo = Todo { id: 1, title: String::from("axum 배우기"), done: false, }; Json(todo)}-i 플래그를 붙이면 curl이 상태 줄과 헤더도 함께 보여 줘요.
curl -i http://127.0.0.1:3000/exampleHTTP/1.1 200 OKcontent-type: application/jsoncontent-length: 46
{"id":1,"title":"axum 배우기","done":false}(curl은 date 헤더도 출력하는데, 이 페이지에서는 생략했어요.) content-length는 글자 수가 아니라 바이트 수예요. 한글은 UTF-8에서 한 글자에 3바이트라서 글자 수보다 큰 값이 나와요. JSON의 키는 구조체의 필드 이름 그대로예요. 이 JSON 모양은 그냥 serde일 뿐이라서 서버 없이 Playground에서도 확인할 수 있어요. 4단계에서 들어오는 데이터를 받을 때 쓸 NewTodo 구조체도 여기에 함께 넣었어요.
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Debug, Deserialize)]struct NewTodo { title: String,}
fn main() { let todo = Todo { id: 1, title: String::from("axum 배우기"), done: false }; println!("{}", serde_json::to_string(&todo).unwrap());
let list = vec![todo.clone(), Todo { id: 2, title: String::from("우유 사기"), done: true }]; println!("{}", serde_json::to_string(&list).unwrap());
let input: NewTodo = serde_json::from_str(r#"{"title":"강아지 산책시키기"}"#).unwrap(); println!("{:?}", input);
let bad = serde_json::from_str::<NewTodo>(r#"{"name":"강아지 산책시키기"}"#); println!("{}", bad.unwrap_err());}실행 결과:
{"id":1,"title":"axum 배우기","done":false}[{"id":1,"title":"axum 배우기","done":false},{"id":2,"title":"우유 사기","done":true}]NewTodo { title: "강아지 산책시키기" }missing field `title` at line 1 column 36마지막 줄은 serde_json이 출력한 에러 메시지로, “title 필드가 없다”는 뜻이에요. 위치(column)도 바이트 단위로 세기 때문에 한글이 들어가면 숫자가 커져요. Vec<Todo>는 JSON 배열이 돼요. 바로 GET /todos가 돌려줄 모양이에요.
3단계: 경로 매개변수
섹션 제목: “3단계: 경로 매개변수”/todos/2 같은 경로에서 2는 요청마다 달라져요. axum 0.8에서는 라우트의 그 부분을 중괄호로 {id}처럼 표시하고, 핸들러에서 Path 추출기(extractor)로 읽어요.
use axum::{extract::Path, routing::get, Router};
#[tokio::main]async fn main() { let app = Router::new() .route("/hello/{name}", get(hello_name)) .route("/add/{a}/{b}", get(add));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("서버 시작: http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello_name(Path(name): Path<String>) -> String { format!("안녕하세요, {name}님!")}
async fn add(Path((a, b)): Path<(i32, i32)>) -> String { format!("{a} + {b} = {}", a + b)}추출기는 요청에서 필요한 부분을 하나 꺼내 주는 핸들러 매개변수예요. Path(name): Path<String>은 패턴이에요. Path를 풀어서 안에 든 String을 건네줘요. 매개변수가 두 개라면 튜플로 받아요.
curl http://127.0.0.1:3000/hello/Anacurl http://127.0.0.1:3000/add/2/3안녕하세요, Ana님!2 + 3 = 5반가운 소식이 하나 있어요. a와 b가 i32라고 적었으니까 axum이 그걸 대신 검사해 줘요. 숫자가 아닌 값을 보내 보세요.
curl -i http://127.0.0.1:3000/add/2/xHTTP/1.1 400 Bad Requestcontent-type: text/plain; charset=utf-8content-length: 68
Invalid URL: Cannot parse value at index 1 with value `x` to a `i32`이 메시지는 axum이 보낸 것으로, “1번 위치의 값 x를 i32로 바꿀 수 없다”는 뜻이에요. 여러분의 핸들러는 실행조차 되지 않았어요. 함수 시그니처에 적은 타입이 곧 입력 검증인 셈이에요.
4단계: POST로 JSON 받기
섹션 제목: “4단계: POST로 JSON 받기”할 일을 만들 때 클라이언트는 요청 본문(body)에 JSON을 담아 보내요. Json 추출기는 본문을 읽어서 구조체로 역직렬화해 줘요. 클라이언트는 제목만 보내니까 입력용으로 따로 NewTodo 구조체를 써요.
use axum::{routing::post, Json, Router};use serde::{Deserialize, Serialize};
#[derive(Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Deserialize)]struct NewTodo { title: String,}
#[tokio::main]async fn main() { let app = Router::new().route("/echo", post(echo_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("서버 시작: http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn echo_todo(Json(input): Json<NewTodo>) -> Json<Todo> { let todo = Todo { id: 1, title: input.title, done: false, }; Json(todo)}라우트에서는 get(...) 대신 post(...)를 써요. curl에서는 -X POST로 메서드를 정하고, -H로 헤더를 추가하고, -d로 본문을 보내요.
curl -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"title":"우유 사기"}'{"id":1,"title":"우유 사기","done":false}이번에도 axum은 여러분의 코드가 실행되기 전에 입력을 검사해요. JSON에 title이 없으면 422 Unprocessable Entity를 받아요.
curl -i -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"name":"우유 사기"}'HTTP/1.1 422 Unprocessable Entitycontent-type: text/plain; charset=utf-8content-length: 99
Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 24“JSON 본문을 대상 타입으로 역직렬화하지 못했고, title 필드가 없다”는 뜻이에요. 그리고 Content-Type 헤더를 빠뜨리면 Expected request with `Content-Type: application/json` 이라는 메시지와 함께 415 Unsupported Media Type을 받아요. curl로 테스트할 때 가장 많이 빠뜨리는 게 바로 이 헤더예요.
5단계: 진짜 할 일 API를 위한 공유 상태
섹션 제목: “5단계: 진짜 할 일 API를 위한 공유 상태”지금까지는 핸들러마다 데이터를 그 자리에서 지어냈어요. 진짜 API라면 요청과 요청 사이에 할 일을 기억해야 해요. 요청은 동시에 들어올 수 있고 서로 다른 스레드에서 처리되니까, 목록을 안전하게 공유할 수 있어야 해요. 동시성 페이지에서 만난 두 친구가 바로 그 일을 해요.
Mutex<Vec<Todo>>는 한 번에 하나의 요청만 목록을 바꾸도록 보장해요.Arc<...>는 여러 핸들러가 같은 목록을 가리키는 포인터를 함께 소유하게 해 줘요(스마트 포인터 참고).
type Db = Arc<Mutex<Vec<Todo>>>;로 타입에 짧은 이름을 붙이고, .with_state(db)로 라우터에 넘기고, 핸들러에서는 State 추출기로 읽어요. axum은 요청마다 Arc를 복제해요(포인터만 복사하니까 비용이 싸요).
완성된 프로그램이에요. src/main.rs를 이 코드로 바꿔 주세요.
use std::sync::{Arc, Mutex};
use axum::{ extract::{Path, State}, http::StatusCode, routing::get, Json, Router,};use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Deserialize)]struct NewTodo { title: String,}
// "데이터베이스": 모든 요청이 함께 쓰는 할 일 목록이에요.type Db = Arc<Mutex<Vec<Todo>>>;
#[tokio::main]async fn main() { let db: Db = Arc::new(Mutex::new(Vec::new()));
let app = Router::new() .route("/todos", get(list_todos).post(create_todo)) .route("/todos/{id}", get(get_todo).delete(delete_todo)) .with_state(db);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("서버 시작: http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
// GET /todosasync fn list_todos(State(db): State<Db>) -> Json<Vec<Todo>> { let todos = db.lock().unwrap(); Json(todos.clone())}
// GET /todos/{id}async fn get_todo(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Todo>, StatusCode> { let todos = db.lock().unwrap(); match todos.iter().find(|todo| todo.id == id) { Some(todo) => Ok(Json(todo.clone())), None => Err(StatusCode::NOT_FOUND), }}
// POST /todosasync fn create_todo(State(db): State<Db>, Json(input): Json<NewTodo>) -> (StatusCode, Json<Todo>) { let mut todos = db.lock().unwrap(); let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1; let todo = Todo { id, title: input.title, done: false, }; todos.push(todo.clone()); (StatusCode::CREATED, Json(todo))}
// DELETE /todos/{id}async fn delete_todo(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode { let mut todos = db.lock().unwrap(); let before = todos.len(); todos.retain(|todo| todo.id != id); if todos.len() < before { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND }}새로 나온 아이디어를 하나씩 살펴볼게요.
- 경로 하나에 메서드 여러 개.
get(list_todos).post(create_todo)는GET /todos와POST /todos를 서로 다른 핸들러로 보내요. - 상태 코드는 반환값이에요.
StatusCode는 axum의http모듈에서 가져와요. 핸들러는Result를 돌려줄 수 있어요.Ok(Json(todo))는 JSON이 담긴 200이 되고,Err(StatusCode::NOT_FOUND)는 본문이 빈 404가 돼요. 튜플(StatusCode::CREATED, Json(todo))는 상태 코드와 본문을 함께 정해요.StatusCode::NO_CONTENT(204)만 돌려주면 “처리했고, 돌려보낼 내용은 없다”는 뜻이에요. lock()과 가드.db.lock().unwrap()은 다른 요청이 목록을 쓰지 않을 때까지 기다렸다가, 평범한Vec처럼 읽고 바꿀 수 있는 가드(guard)를 건네줘요. 함수가 끝나서 가드가 스코프를 벗어나면 잠금이 풀려요.- 새 id. 다음 id는 목록에서 가장 큰 id에 1을 더한 값이고, 목록이 비어 있으면 1이에요.
- 삭제.
retain은 id가 일치하지 않는 할 일만 남겨요. 길이가 그대로라면 아무것도 지워지지 않은 거니까 404를 돌려줘요.
왜 비동기용이 아니라 std::sync::Mutex를 쓸까요? .await를 하는 동안 잠금을 쥐고 있지만 않으면 이걸로 충분하고, 더 빠르기도 해요. 우리 핸들러는 모두 잠그고, 목록으로 작업하고, 돌아갈 뿐이라서 그런 일이 없어요.
목록과 잠금을 다루는 로직에는 웹 서버가 필요 없으니까 Playground에서 시험해 볼 수 있어요. 다음 함수들은 axum 없이 핸들러와 같은 일을 해요.
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]struct Todo { id: u32, title: String, done: bool,}
type Db = Arc<Mutex<Vec<Todo>>>;
fn create(db: &Db, title: &str) -> Todo { let mut todos = db.lock().unwrap(); let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1; let todo = Todo { id, title: title.to_string(), done: false }; todos.push(todo.clone()); todo}
fn get(db: &Db, id: u32) -> Option<Todo> { let todos = db.lock().unwrap(); todos.iter().find(|todo| todo.id == id).cloned()}
fn delete(db: &Db, id: u32) -> bool { let mut todos = db.lock().unwrap(); let before = todos.len(); todos.retain(|todo| todo.id != id); todos.len() < before}
fn main() { let db: Db = Arc::new(Mutex::new(Vec::new())); let second_handle = Arc::clone(&db); // axum이 요청마다 하는 일이에요
println!("{:?}", create(&db, "우유 사기")); println!("{:?}", create(&second_handle, "axum 배우기")); println!("{:?}", get(&db, 2)); println!("{:?}", get(&db, 99)); println!("1번을 삭제했나요? {}", delete(&db, 1)); println!("1번을 또 삭제했나요? {}", delete(&db, 1)); println!("남은 개수: {}", db.lock().unwrap().len());}실행 결과:
Todo { id: 1, title: "우유 사기", done: false }Todo { id: 2, title: "axum 배우기", done: false }Some(Todo { id: 2, title: "axum 배우기", done: false })None1번을 삭제했나요? true1번을 또 삭제했나요? false남은 개수: 1second_handle로 만든 할 일이 db로 읽을 때도 보이죠? 두 Arc가 같은 목록을 가리키고 있기 때문이에요.
6단계: curl로 테스트하기
섹션 제목: “6단계: curl로 테스트하기”cargo run으로 서버를 시작하고, 두 번째 터미널에서 모든 엔드포인트를 시험해 보세요. 처음에는 목록이 비어 있어요.
curl http://127.0.0.1:3000/todos[]할 일을 하나 만들어요. 201 Created 상태 코드를 눈여겨보세요.
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"우유 사기"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 45
{"id":1,"title":"우유 사기","done":false}하나 더 만들고 전체 목록을 봐요.
curl -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"axum 배우기"}'curl http://127.0.0.1:3000/todos{"id":2,"title":"axum 배우기","done":false}[{"id":1,"title":"우유 사기","done":false},{"id":2,"title":"axum 배우기","done":false}]id로 하나를 가져오고, 없는 할 일도 요청해 봐요.
curl http://127.0.0.1:3000/todos/2curl -i http://127.0.0.1:3000/todos/99{"id":2,"title":"axum 배우기","done":false}HTTP/1.1 404 Not Foundcontent-length: 01번 할 일을 지우고, 한 번 더 지워 봐요.
curl -i -X DELETE http://127.0.0.1:3000/todos/1curl -i -X DELETE http://127.0.0.1:3000/todos/1HTTP/1.1 204 No Content
HTTP/1.1 404 Not Foundcontent-length: 0curl http://127.0.0.1:3000/todos[{"id":2,"title":"axum 배우기","done":false}]axum은 몇 가지 실수도 대신 처리해 줘요. JSON에 타입이 잘못된 값이 있으면 문제를 설명하는 422를 돌려주고, 경로에 설정하지 않은 메서드로 요청하면 405 Method Not Allowed와 함께 쓸 수 있는 메서드를 알려 주는 헤더를 돌려줘요.
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title": 42}'curl -i -X PUT http://127.0.0.1:3000/todos/2HTTP/1.1 422 Unprocessable Entitycontent-type: text/plain; charset=utf-8content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12HTTP/1.1 405 Method Not Allowedallow: GET,HEAD,DELETEcontent-length: 0첫 번째 메시지는 “title에 문자열이 와야 하는데 정수 42가 왔다”는 뜻이에요. Ctrl + C로 서버를 멈췄다가 다시 시작해 보세요. 목록이 비어 있어요. 데이터가 메모리에만 있기 때문이에요. 데이터를 저장하는 방법은 아래 첫 번째 확장 아이디어에서 다뤄요.
자주 하는 실수
섹션 제목: “자주 하는 실수”예전 :id 라우트 문법 쓰기. 인터넷에 있는 튜토리얼 중에는 /todos/:id 문법을 쓰던 axum 0.7 기준으로 쓰인 글이 많아요. axum 0.8에서는 이 코드가 컴파일은 되지만, 프로그램이 시작하자마자 패닉이 일어나요.
thread 'main' (12345) panicked at src/main.rs:32:10:Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.“경로 조각은 :로 시작하면 안 되고, 캡처하려면 {capture}를 쓰라”는 메시지예요. 해결 방법은 /todos/{id}라고 쓰는 거예요.
Json을 다른 추출기보다 앞에 두기. 요청 본문은 한 번만 읽을 수 있기 때문에, 본문을 읽는 추출기(Json)는 마지막 매개변수여야 해요. create_todo(Json(input): Json<NewTodo>, State(db): State<Db>)처럼 쓰면 에러 메시지가 이 점을 직접 알려 주지는 않아요. 중요한 줄은 이거예요.
error[E0277]: the trait bound `fn(Json<NewTodo>, State<...>) -> ... {create_todo}: Handler<_, ...>` is not satisfied |31 | .route("/todos", get(list_todos).post(create_todo)) | ---- ^^^^^^^^^^^ unsatisfied trait bound | = note: Consider using `#[axum::debug_handler]` to improve the error message“Handler is not satisfied”가 보이면 매개변수 순서를 확인하고, 모든 반환 타입이 axum이 응답으로 바꿀 수 있는 타입인지도 확인하세요.
curl에서 -H "Content-Type: application/json"을 빠뜨리기. 서버는 415로 대답해요. Json 추출기에는 이 헤더가 꼭 필요해요.
다시 시작하는 걸 잊기. 실행 중인 서버는 예전 프로그램이에요. 코드를 바꿀 때마다 Ctrl + C로 멈추고 다시 cargo run 하세요.
확장 아이디어
섹션 제목: “확장 아이디어”기본 API가 동작하면 다음 단계로 이런 걸 해 보세요. 한 번에 하나씩 도전해 보세요.
- 할 일을 완료로 표시하기. 두 번째 라우트에
.patch(...)로PATCH /todos/{id}를 추가하세요.iter_mut()으로 할 일을 찾아서done = true로 바꾸고 돌려주면 돼요. - 입력 검증. 빈 제목은 허용하지 않아야 해요. 핸들러가
Result<(StatusCode, Json<Todo>), (StatusCode, String)>을 돌려주게 하고,Err((StatusCode::BAD_REQUEST, String::from("제목은 비워 둘 수 없어요")))를 보내세요. 검사에는title.trim().is_empty()를 쓰세요. - 절대 겹치지 않는 id. “가장 큰 id에 1 더하기” 방식에서는 가장 최근 할 일을 지우면 그 id가 다시 쓰일 수 있어요. 대신 상태에 카운터를 따로 두세요. 예를 들어
Mutex안에todos: Vec<Todo>와next_id: u32를 가진 구조체를 넣으면 돼요. - JSON 파일로 저장하기.
Todo에Deserialize도 derive하세요. 시작할 때std::fs::read_to_string과serde_json::from_str로todos.json을 읽고(파일이 없으면 빈 목록으로 시작), 바뀔 때마다serde_json::to_string_pretty와std::fs::write로 목록을 다시 써요. 필요한 내용은 파일 입출력과 serde로 JSON 다루기 페이지에 다 있어요. 나중에는 파일 대신 SQLite 같은 진짜 데이터베이스로 바꿀 수도 있어요. - tower-http로 로그 남기기.
cargo add tower-http --features trace와cargo add tracing-subscriber를 실행하고 로깅 레이어를 추가하세요. 그러면 서버가 요청마다 메서드, 경로, 상태 코드, 걸린 시간을 한 줄씩 출력해요.
use tower_http::trace::TraceLayer;
// main의 시작 부분에:tracing_subscriber::fmt() .with_max_level(tracing_subscriber::filter::LevelFilter::DEBUG) .init();
// 라우터를 만들 때:let app = Router::new() .route("/todos", get(list_todos).post(create_todo)) .route("/todos/{id}", get(get_todo).delete(delete_todo)) .layer(TraceLayer::new_for_http()) .with_state(db);- 배포 참고 사항.
127.0.0.1은 여러분의 컴퓨터에서 오는 요청만 받아요. 다른 기계에서 오는 요청도 받으려면(예를 들어 Docker 안이나 서버에서) 대신0.0.0.0에 바인딩하세요. 많은 호스팅 서비스가PORT환경 변수로 사용할 포트를 알려 주는데, 이 값은std::env::var로 읽을 수 있어요(환경 변수와 프로세스 참고).cargo build --release로 빌드하면 훨씬 빠른 서버가 만들어져요. 결과물은target/release/안의 파일 하나라서, Linux 서버에 복사하거나 작은 Docker 이미지에 넣을 수 있어요. 공개 사이트라면 HTTPS를 처리해 주는 리버스 프록시(Caddy나 nginx 같은) 뒤에 두세요.
- 핸들러는
async fn이에요. 라우터는 메서드와 경로를 핸들러에 연결해요.axum::serve가tokio의TcpListener위에서 라우터를 실행해요. - 추출기는 요청에서 데이터를 꺼내요. URL의 일부(
/todos/{id})는Path, 본문은Json(항상 마지막), 공유 데이터는State로 꺼내요. - JSON은
Json(value), 상태 코드는StatusCode, 둘 다 정할 때는 튜플을 돌려주고, 성공과 에러 상태 중에서 고를 때는Result를 돌려줘요. Arc<Mutex<Vec<Todo>>>는 하나의 목록을 모든 요청이 안전하게 공유하게 해 줘요. 잠그고, 쓰고, 가드가 사라지게 두세요..await를 하는 동안에는 잠금을 쥐고 있지 마세요.- API는
curl로 테스트해요.-i는 상태 코드를 보여 주고,-X는 메서드를 정하고,-H는 헤더를 추가하고,-d는 본문을 보내요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. axum 0.8에서 경로에 id가 들어간 라우트는 어떻게 쓸까요?
2. 핸들러가 (StatusCode::CREATED, Json(todo))를 돌려줘요. 클라이언트는 무엇을 받을까요?
3. 공유 목록을 Arc<Mutex<...>>로 감싸는 이유는 무엇일까요?
4. 핸들러 매개변수에서 마지막에 와야 하는 추출기는 무엇일까요?
5. curl -X POST로 JSON을 보냈는데 Content-Type: application/json 헤더를 빠뜨렸어요. axum은 어떻게 대답할까요?
점수: 0 / 5
더 알아보기
섹션 제목: “더 알아보기”- axum 문서 — docs.rs(영어) — 라우팅, 핸들러, 응답을 다루는 공식 가이드예요.
- axum 추출기 — docs.rs(영어) — 기본으로 들어 있는 모든 추출기와 순서 규칙을 설명해요.
- Tokio 튜토리얼(영어) — 공유 상태를 포함해 axum을 움직이는 비동기 런타임을 배울 수 있어요.
- tower-http 문서 — docs.rs(영어) — 로깅, CORS, 압축, 타임아웃을 위한 미리 만들어진 레이어들이에요.
- HTTP 상태 코드 — MDN — 200, 201, 204, 404, 422 같은 코드가 무슨 뜻인지 알려 줘요.