오늘의 주제

이번엔 Actix 웹 서버로 REST API를 만들어 볼거다
나도 Rust로 이런 거 처음 만들어보니까, 일단 맛보기로 DB없이 인메모리로 간단하게 ㄱ
일단 Actix인지 REST API 나발인지 잘 모르는 사람 있을 수 있으니까
설명 간단하게 한번 하고
용어 1줄 요약 설명
1. Actix는 웹 프레임워크고, 쓰는 이유는 이전 글에서도 말했지만 비동기를 지원해서다.
2. REST API가 뭐냐면, API를 어떤 리소스가 뭔 상태고 이걸로 뭘 하겠다는 건지 딱 보면 알 수 있게 하는 거임.
3. API는 특정한 기능(ex. 주문, 등록, 조회 등) 수행에 필요한 정보들.
솔직히 이정도면 설명 개쉬웠다 인정?
오늘 해볼 거는 강사들이 신규 강의 등록하고, 그걸 조회하는 API들을 만드는 거다.
딱 3개만 만들 거임
1. 신규 강의 등록하는 API
2. 특정 강사 모든 강의 조회하는 API
3. 강사랑 강의 번호 지정해서 특정 강의 하나만 조회하는 API
그리고 각 API 만들 때, 매번 아래 4단계를 거쳐서 진행할 건데
1. 라우트 구성 정의
2. 핸들러 함수 작성
3. 자동화 테스트 스크립트 작성
4. 서비스 빌드하고 API 테스트 (난 Postman 쓸거임)
라우트가 컨트롤러, 핸들러가 서비스 역할이라고 보면 된다.
API 만들고 간단하게 테스트도 해볼 수 있다는 점에서 테스트 주도 개발 방식(TDD)랑 비슷하다고 볼 수 있다
이 정도면 소개는 마치고 이제 들어가면 되겠다
여기는 한국이고 예상 독자도 아마 한국인이겠지?
대부분이 rust actix보단 java spring이 더 친숙하고,
Controller, Service, Repository로 역할 나눠서 개발하는 MVC 패턴도 익숙할테니까
진행하면서 나오는 개념들을 최대한 스프링 백엔드 흐름이랑 비슷하게 매핑해서 설명해볼게
티스토리 스킨때문에 코드 블럭 내용 잘 안 보이면
오른쪽 하단에 있는 버튼으로 다크 모드에서 라이트 모드로 변경하면 된다
구조도
tutor-nodb\
├── src\
│ └── bin\
│ │ ├── basic-server.rs
│ │ └── tutor-service.rs: @SpringBootApplication붙은 (메인 클래스)
│ ├── handlers.rs: 각 HTTP 요청을 수행하는 핸들러 함수 (서비스 역할)
│ ├── main.rs
│ ├── model.rs: 웹 서비스 데이터 모델 Rust 구조체 정의 (DTO라고 보면 됨)
│ ├── routes.rs: 각 라우트 정의 (컨트롤러 역할)
│ └── state.rs: 애플리케이션 상태 정의 (스프링 빈 컨테이너 정도)
└── Cargo.toml: 프로젝트 구성 파일 및 디펜던시 명세 (build.gradle)
끝에 \ 이렇게 역슬래시 표시된 건 디렉토리(폴더), 없는 건 파일
보이는 것처럼 tutor-nodb라는 워크스페이스 프로젝트를 하나 만들어놓고 진행할거다.
basic-server는 간단한 HTTP 헬스 체크 만들면서 연습해본거니까 무시하셈
역할 및 웹 서비스 흐름
구조도에서 route니, model이니 뭐가 너무 많아서 좀 놀랬을텐데,
뭐가 무슨 역할인지, 어떤 흐름으로 돌아가는지 설명해줄테니까 일단 한번 들어보자

tutor-serivce.rs
cargo run을 실행하면 돌아갈 웹 애플리케이션.
얘는 스프링에서 @SpringBootApplication 애너테이션 붙어있는 메인 클래스라고 보면 된다.
cargo run 실행하면 기본적으로 얘가 돌아가도록 Cargo.toml 파일에서 지정해줄 거임
Cargo.toml은 build.gradle처럼 프로젝트 구성 설정하는 파일이고, toml은 토믈 이라고 읽으면 됨
routes.rs
얘는 컨트롤러 같은 애라고 보면 된다.
HTTP request 메시지 확인하고, 각각의 url 경로에 맞게 서비스 호출(여기서는 핸들러 함수 호출)
GetMapping, PostMapping 해주던 그 느낌 알제
handler.rs
라우트가 호출하는 얘는 우리가 알던 서비스 역할을 수행한다.
models.rs
얘는 HTTP request 데이터를 Rust 구조체 데이터로 변환해서 우리가 다룰 수 있게 해주는 DTO다
그렇게 변환하는 걸 직렬화(Serialize)라고 한다.
반대로 응답을 전송할 때는 Rust 구조체 데이터를 스트림 형태로 바꾸는 거고, 역직렬화(Deserialize)라고 함
state.rs
얘는 웹 애플리케이션에서 사용하는 공유변수 관리하는 앤데,
공유변수는 잘못 다루면 아주 위험하잖아?
그래서 이런 공유변수를 안전하게 다룰 수 있게 해주는 스프링 빈 컨테이너로 보면 되겠다.
Spring에서 쓰는 방식이랑은 좀 다른데, 그래도 하는 역할이 비슷하다
수정가능한(mutable) 공유 변수는 Mutex로 제어권을 가져야 쓸 수 있게 해준다.
프로젝트 흐름

클라이언트에서 HTTP request 날아오면 -> Actix HTTP 서버가 받고 웹 애플리케이션에 넘긴다
-> 그리고 요청별로 미리 정해둔 라우트에 매칭 -> 라우트가 각 핸들러 함수 호출하고
-> 핸들러 함수가 기능 수행한 다음에 -> HTTP response 반환
프로젝트 구성 및 디펜던시 세팅 - Cargo.toml
// tutor-nodb/Cargo.toml
[package]
name = "tutor-nodb"
version = "0.1.0"
authors = ["zzaekkii"]
edition = "2021"
default-run = "tutor-service"
[[bin]]
name = "basic-server"
[[bin]]
name = "tutor-service"
[dependencies]
# Actix 웹 프레임워크 및 런타임.
actix-web = "4.9.0"
actix-rt = "2.10.0"
# Data 직렬화 라이브러리.
serde = {version = "1.0.209", features = ["derive"]}
# 시간 관련 유틸리티.
chrono = {version = "0.4.38", features = ["serde"]}
build.gradle 같은 거라고 생각하삼
일단 Cargo.toml 파일에다가 필요한 디펜던시 세팅해주고
cargo run으로 빌드하면 기본적으로 tutor-service가 실행되도록 지정해주자
application state 정의 - state.rs
// tutor-nodb/src/state.rs
use std::sync::Mutex;
use super::models::Course;
// 애플리케이션 상태 정의.
pub struct AppState {
pub health_check_response: String, // 서버 상태 - 이뮤터블 공유변수.
pub visit_count: Mutex<u32>,
pub courses: Mutex<Vec<Course>>, // 강의 목록 - 뮤터블 공유변수.
}
헬스 체크 값이랑, 방문 횟수 조회하는 visit_count는 헬스 체크 연습용으로 썼던 거라 무시해도 괜찮다
아무튼 중요한 건, 등록한 강의 저장할 courses 공유 벡터하나 만들어주자
강사가 신규 강의 등록하면, 여기다가 차곡차곡 넣어두고 저장할 거임
DB없이 메모리에 저장하는 방식이라 서버 닫으면 날라가겠지만~ 그래도 연습이니까

데이터 모델 정의 - models.rs
// tutor-nodb/src/models.rs
// 강의를 위한 데이터 모델.
use actix_web::web;
// 등록 시간 기록하려고 쓰는 서드파티 크레이트.
use chrono::NaiveDateTime;
// Rust 데이터 구조 - HTTP msg 전송용 포맷 역직렬화, 직렬화.
use serde::{Deserialize, Serialize};
// 강의 데이터 구조체.
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Course {
pub tutor_id: i32, // 강사 고유번호.
pub course_id: Option<i32>, // 강의 고유번호.
pub course_name: String, // 강의 이름.
pub posted_time: Option<NaiveDateTime>, // 등록 시간.
}
// HTTP request 데이터를 Rust 구조체로 변환.
impl From<web::Json<Course>> for Course {
fn from(course: web::Json<Course>) -> Self {
Course {
tutor_id: course.tutor_id,
course_id: course.course_id,
course_name: course.course_name.clone(),
posted_time: course.posted_time,
}
}
}
다음은 강의 엔티티랑 DTO 한번 만들어줘 볼까
#[derive(Deserialize, Serialize, Debug, Clone)]가 붙어 있는 게 보이나?
이건 유도 트레이트라는 건데, #[derive()] 안에 있는 트레이트들을 자동으로 구현해서 쓸 수 있게끔 해주는 거임.
근데 트레이트는 뭐냐고?
트레이트란?
Deserialize, Serialize, Debug, Clone 얘네가 트레이트(Traits)인데, 인터페이스라고 이해하면 된다.
- Deserialize 트레이트는 rust 구조체에서 스트림으로 역직렬화가 되게 하는 트레이트.
- Serialize 트레이트는 스트림에서 rust 구조체로 직렬화 해주는 트레이트.
- Debug 트레이트는 디버깅 목적으로 구조체를 출력하게 해주는 트레이트.
- Clone 트레이트는 깊은 복사로 Rust 소유권 문제를 해결해주는 트레이트.
이런 트레이들을 저렇게 유도 트레이트에 넣어주는건 무슨 의미일까?

간단히 말하면 무기에다가 기능성 부품 여러개 장착하는 거임.
그럼 그 무기는 그 기능까지 쓸 수 있겠지? 기능이 더해지는 개념이라고 이해하면 쉽다.
그렇게 점점 트레이트가 하나둘 늘어나면..

지금처럼 직접 커스텀 구조체를 만들 때야 뭐, 트레이트 이것저것 덕지덕지 붙이면 세상 편하고 좋다
계에에에에에속 힘을 부여해주니까 알아서 강력해지잖아
근데 함수에서 매개변수 타입으로 특정 트레이트를 구현한 타입을 지정할 때는
기본 타입 중에 그렇게 강력한 녀석은 드물기 때문에 지정할 수 있는 타입이 줄어든다.
사실 지금도 우리가 필요한 트레이트들을 구현한 구조체가 없어서 이렇게 직접 커스텀 구조체를 만들어 주는 거다.
뭔 말인지 모르겠으면 그대로 패쓰~
지금 이해 못 해도 ㄱㅊ
// 강의 데이터 구조체.
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Course {
pub tutor_id: i32, // 강사 고유번호.
pub course_id: Option<i32>, // 강의 고유번호.
pub course_name: String, // 강의 이름.
pub posted_time: Option<NaiveDateTime>, // 등록 시간.
}
아무튼 강의 엔티티 course 구조체는 강사 고유번호, 강의 고유번호, 강의 이름, 등록 시간 필드로 구성한다.
여기서 강의 고유번호는 자동으로 생성(인조키)해줄 거고, 등록 시간은 chrono 데이터 타입으로 넣어줄 거니까
날아온 request자체에는 아무것도 들어가있지 않을 예정이다.
아무것도 없으면 뭐, Null로 처리해줘야 하나?
맞다, 그러면 된다
근데 Rust에는 Null이 없다
그럼 어떻게 해야할까?
Option 타입을 써서 아무것도 없음을 표현할 수 있는 None을 넣어주면 된다.
Option에 대한건 Rust 기초 카테고리에 나중에 포스팅할 거니까 그거 참고
// HTTP request 데이터를 Rust 구조체로 변환.
impl From<web::Json<Course>> for Course {
fn from(course: web::Json<Course>) -> Self {
Course {
tutor_id: course.tutor_id,
course_id: course.course_id,
course_name: course.course_name.clone(),
posted_time: course.posted_time,
}
}
}
그리고 Json으로 날아온 request 메시지를 Rust 구조체 데이터로 변환해 저장할 DTO를 정의해 줄건데,
From 트레이트 구현체를 사용해서 Course 구조체로 변환해줄 수 있다.
web::Json<Course>는 뭐냐면,
날아온 request body에 접근해서 Json 데이터를 Rust의 Course 구조체 데이터로 파싱하는데,
파싱해서 web::Json<Course>라는 Actix 객체로 추출한다.
그리고 이 Actix 객체를 Course 타입으로 변환할 때 From 트레이트가 필요하다.
그렇게 타입 변환이 끝나면, course DTO에다가 필드별로 각각 넣어주면 된다.
근데 여기서 강의 이름은 String 타입이니까, Copy말고 Clone으로 깊은 복사 해줘야 소유권 넘어가지?
그래서 아까 Clone 트레이트 derive해준거임
여기까지 이해됐나?
지금 진짜 쉽게 설명하고 있는거임 ㄹㅇ로다가
웹 애플리케이션 main() 함수 작성 - tutor-serivce.rs
// tutor-nodb/src/bin/tutor-service.rs
use actix_web::{web, App, HttpServer}; // Actix 웹서버 관련 기능.
use std::io; // 표준 입출력 기능.
use std::sync::Mutex; // thread safe하게 공유변수 사용할 수 있도록.
#[path = "../handlers.rs"]
mod handlers;
#[path ="../models.rs"]
mod models;
#[path = "../routes.rs"]
mod routes;
#[path = "../state.rs"]
mod state;
use routes::*; // route.rs에서 정의했던 거 전부 임포트.
use state::AppState; // state.rs에서 정의했던 AppState 임포트.
#[actix_rt::main]
async fn main() -> io::Result<()> {
// 애플리케이션 상태 초기화.
let shared_data = web::Data::new(AppState {
health_check_response: "I'm good. You've already asked me ".to_string(),
visit_count: Mutex::new(0),
courses: Mutex::new(vec![]),
});
// 웹 애플리케이션 정의.
let app = move || {
App::new()
.app_data(shared_data.clone()) // 웹 애플리케이션 상태 등록.
.configure(general_routes)
.configure(course_routes) // course관련 라우트 그룹 등록.
};
HttpServer::new(app).bind("127.0.0.1:3000")?.run().await // 실행~
}
#[path = "경로"] 어트리뷰트는 지정된 경로에서 아까 만들었던 / 곧 만들 모듈 찾아오는 거다
#[actix_rt::main]은 main 함수가 Actix 런타임을 사용하는 비동기 함수라는 걸 나타낸다
AppState내 공유 변수 초기화부분에서 헬스 체크랑 방문 횟수는 마찬가지로 무시하고,
courses에 빈 vector 컬렉션으로 초기화해주자
// 웹 애플리케이션 정의.
let app = move || {
App::new()
.app_data(shared_data.clone()) // 웹 애플리케이션 상태 등록.
.configure(general_routes)
.configure(course_routes) // course_routes 그룹 등록.
};
그리고 app 변수는 클로저(익명함수)인데, 실제 애플리케이션 인스턴스 생성에 사용한다.
move는 소유권 이동시키는 키워드고, 공유변수 소유권을 클로저 내로 이동시키는 데 쓸 거임.
App::new()로 새 인스턴스 만들어주고,
.app_data()로 아까 초기화한 공유 변수들 등록해주고,
.configure()로 웹 애플리케이션에 라우트 그룹 추가해준다.
라우트 구성 - routes.rs
// tutor-nodb/src/routes.rs
use super::handlers::*;
use actix_web::web;
pub fn general_routes(cfg: &mut web::ServiceConfig) {
cfg.route("/health", web::get().to(health_check_handler));
}
pub fn course_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
// courses라는 새 리소스 스코프 생성.
web::scope("/courses")
// 요청을 new_course 핸들러로 전달.
.route("/", web::post().to(new_course))
// 강사(tutor_id)의 강의들을 받아오는 라우트.
.route("/{tutor_id}", web::get().to(get_courses_for_tutor))
// 강사 세부 정보를 얻는 라우트.
.route("/{tutor_id}/{course_id}", web::get().to(get_course_detail)),
);
}
라우트 그룹이 general_routes랑 course_routes 2개인 걸 확인할 수있다.
general_routes는 헬스 체크 기능 담당하는 라우트고 무시해도 괜찮
course_routes는 강의 관련 라우트 그룹이다
이렇게 목적에 맞게 라우트 그룹을 나눠주면 가독성도 높고 관리하기 편하다.

결국 routes.rs는 컨트롤러처럼, 들어온 요청에 맞게 기능을 수행하는 핸들러(서비스)를 호출하는 애다
물론 그 핸들러는 좀 이따 만들거고.
.route()써서 목적에 맞게 핸들러 함수 지정해주면 된다
간-단
핸들러 함수 작성 - handlers.rs
// tutor-nodb/src/handlers.rs
use super::state::AppState;
use actix_web::{web, HttpResponse};
use super::models::Course;
use chrono::Utc; // 등록 시간.
pub async fn health_check_handler(app_state: web::Data<AppState>) -> HttpResponse {
let health_check_response = &app_state.health_check_response;
let mut visit_count = app_state.visit_count.lock().unwrap();
let response = format!("{} {} times", health_check_response, visit_count);
*visit_count += 1;
HttpResponse::Ok().json(&response)
}
위에 있는 건 연습용 헬스 체크 함수인데 내부 코드를 보면,
헬스 체크 응답은 이뮤터블 공유변수였으니까 appstate에 있던 내용 참조로 그대로 넣어줬고,
방문 횟수는 뮤터블 공유변수니까, lock()을 걸어서 내가 수정하는 동안 다른 애들 접근 못 하게 막는 걸 볼 수 있다.
그리곤 마지막에 HTTP Response 200 OK 반환~
보면 알겠지만 진짜 필요한 로직 그대로 짜주면 된다
간--단
등록, 조회 API 3개도 그냥 간단하게 만들어주면 된다
신규 강의 등록하는 함수
// 신규 강의 등록하는 기능.
pub async fn new_course(
// HTTP 요청의 데이터 페이로드 + 애플리케이션 상태를 받음.
new_course: web::Json<Course>,
app_state: web::Data<AppState>,
) -> HttpResponse {
println!("Received new course");
let course_count_for_user = app_state
.courses
.lock() // 데이터 접근 시, 잠가주기.
.unwrap()
.clone()
.into_iter() // course 컬렉션을 iterator로 변환.
.filter(|course| course.tutor_id == new_course.tutor_id) // 요청으로 받은 tutor_id와 일치하는 것만.
.count(); // 강의 수를 세고, 다음 강의 id 생성에 사용.
// usize에서 i32로 변환. 그냥 오류 처리 로직도 포함된 컨버터임.
let new_course_id: i32 = (course_count_for_user + 1).try_into().unwrap_or_else(|_| {
panic!("Conversion from usize to i32 failed. Maybe overflow i32..")
});
// 새 강의 인스턴스 생성.
let new_course = Course {
tutor_id: new_course.tutor_id,
course_id: Some(new_course_id), // 여기다가 변환된 값 넣어주기.
course_name: new_course.course_name.clone(),
posted_time: Some(Utc::now().naive_utc()),
};
// 새 강의 인스턴스를 강의 컬렉션(AppState에 포함)에 추가.
app_state.courses.lock().unwrap().push(new_course);
HttpResponse::Ok().json("Added course")
}
강사 id로 특정 강사의 강의들 조회하는 함수
// 강사 id로 검색 기능.
pub async fn get_courses_for_tutor(
app_state: web::Data<AppState>,
params: web::Path<i32>,
) -> HttpResponse {
let tutor_id: i32 = params.into_inner();
let filtered_courses = app_state
.courses
.lock()
.unwrap()
.clone()
.into_iter()
.filter(|course| course.tutor_id == tutor_id)
.collect::<Vec<Course>>();
if filtered_courses.len() > 0 { // 강의가 있다면,
HttpResponse::Ok().json(filtered_courses)
} else { // 강의가 없다면,
HttpResponse::Ok().json("No courses found for tutor".to_string())
}
}
강사 id랑 강의 id로 특정 강의 검색하는 함수
// 강사 id + 강의 id 검색 기능.
pub async fn get_course_detail(
app_state: web::Data<AppState>,
params: web::Path<(i32, i32)>,
) -> HttpResponse {
let (tutor_id, course_id) = params.into_inner();
let selected_course = app_state
.courses
.lock()
.unwrap()
.clone()
.into_iter()
.find(|x| x.tutor_id == tutor_id && x.course_id == Some(course_id))
.ok_or("Course not found"); // Option<T>를 Result<T, E>로 변환.
if let Ok(course) = selected_course {
HttpResponse::Ok().json(course)
} else {
HttpResponse::Ok().json("Course not found".to_string())
}
}
자동화 테스트 스크립트 작성 - handlers.rs
이제 위에서 만들어준 핸들러 함수가 내가 의도한대로 돌아가나 테스트해봐야지
#[cfg(test)]를 붙여주면 cargo test 명령어 실행할 때만 실행된다
// tutor-nodb/src/handlers.rs
#[cfg(test)] // cargo test 실행 시에만 실행됨.
mod tests {
use super::*; // 부모 모듈로부터 모든 핸들러 선언 import.
use actix_web::http::StatusCode;
use std::sync::Mutex;
// 비동기 test를 위해 actix web의 비동기 런타임이 이 함수를 실행하도록 지정.
#[actix_rt::test]
// 신규 강의 등록 api 테스트.
async fn post_course_test() {
let course = web::Json(Course {
tutor_id: 1,
course_name: "스프링 MVC 2편 - 백엔드 웹 개발 활용 기술".into(),
course_id: None,
posted_time: None,
});
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
courses: Mutex::new(vec![]),
});
// 핸들러 호출.
let resp = new_course(course, app_state).await;
assert_eq!(resp.status(), StatusCode::OK);
}
// 강사 id로 특정 강사의 모든 강의 조회하는 api 테스트.
#[actix_rt::test]
async fn get_all_courses_success() {
let app_state: web::Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
courses: Mutex::new(vec![]),
});
// 요청 매개변수 시뮬레이션.
let tutor_id: web::Path<i32> = web::Path::from(1);
// 핸들러 호출.
let resp = get_courses_for_tutor(app_state, tutor_id).await;
// 응답 확인.
assert_eq!(resp.status(), StatusCode::OK);
}
// 강사 id랑 강의 id로 특정 강의 하나만 조회하는 api 테스트.
#[actix_rt::test]
async fn get_one_course_success() {
let app_state: web:: Data<AppState> = web::Data::new(AppState {
health_check_response: "".to_string(),
visit_count: Mutex::new(0),
courses: Mutex::new(vec![]),
});
// 2개 매개변수를 가진 요청 시뮬레이션을 위한 Path 타입 객체.
let params: web::Path<(i32, i32)> = web::Path::from((1, 1));
// 핸들러 호출.
let resp = get_course_detail(app_state, params).await;
assert_eq!(resp.status(), StatusCode::OK);
}
}
이제 그냥 cargo test만 딱 치면 테스트가 알아서 돌아간다

이번에 만든 테스트들은 간단하게 해보는거니까 이렇게 만들어봤는데,
실제로 테스트 만들 때는 더 많은 케이스들을 생각해줘야됨. 오케?
위에처럼 테스트하는 거 말고, 직접 내가 확인해보고 싶다하면 Postman으로 해볼 수 있다.

cargo run으로 서비스 빌드 한 다음에
Postman켜고 요청 날려주면서 직접 테스트 해볼 수도 있다.
이렇게 테스트도 끗
'Rust 연구 노트 > "EzyTutors" 프로젝트' 카테고리의 다른 글
[Rust/rust-analyzer] 문제없는 sqlx에 대해 오류로 감지하는 rust-analyzer 버그 (7) | 2024.10.14 |
---|---|
[오류 해결] failed to run custom build command for openssl-sys (5) | 2024.09.06 |
[Rust/도서 리뷰] <Rust 서버, 서비스, 앱 만들기> (0) | 2024.06.30 |