rust | axum

Rust와 Axum으로 구현하는 Clean 아키텍처 API — DI, 트랜잭션, JWT 인증

Rust와 Axum 프레임워크로 Clean 아키텍처 기반 Todo API를 구현하는 방법을 정리합니다. 라우팅, sqlx 트랜잭션, 생성자 DI, JWT 인증 AOP, utoipa API 문서화까지 단계별로 코드와 함께 설명합니다.

Mimul
MimulMay 10, 2023 · 13 min read · Last Updated:

Rust를 공부하면서 실제 서비스에 필요한 기능들을 하나씩 조합해 axum-rusty 프로젝트를 만들었습니다. Clean Architecture 기반으로 로그인, Todo API를 구현한 프로젝트로, Rust와 Axum 생태계를 실전에서 어떻게 활용하는지 궁금한 분들을 위해 핵심 구현 포인트를 정리합니다.

핵심 라이브러리 구현 포인트

1. Axum 구현 포인트

  • Axum의 특징은 매크로가 없는 API로 요청을 라우팅한다. Extractor를 사용하여 요청을 선언적으로 분석하며, 최소한의 보일러플레이트로 응답을 생성한다. 미들웨어, 서비스, 유틸리티는 tower 및 tower-http 생태계를 최대한 활용한다. Tokio 기반이라 비동기 Rust 생태계와 궁합이 잘 맞는다.

  • Axum은 Router를 정의하고 .nest() 함수로 중첩시킬 수 있다. DI 컨테이너 개념을 주입해 modules를 호출할 수 있게 해준다. 자세한 내용은 DI 섹션에서 설명한다.

let todo_router = Router::new()
    .route("/", get(find_todo).post(create_todo))
    .route("/:id", get(get_todo).patch(update_todo).put(upsert_todo).delete(delete_todo),);

let app = Router::new()
    .nest("/:v/hc", hc_router)
    .nest("/:v/todos", todo_router)
    //.layer(Extension(modules));
    .with_state(modules);
  • 핸들러 파라미터로 Path, Query, Body(JSON)에서 요청 값을 받을 수 있다. JSON 요청의 경우 Serde의 Deserialize가 구현된 구조체라면 Axum이 자동으로 값을 채운다. 또한 크레이트와 결합해 필드값 유효성 검사를 추가할 수 있다.
#[derive(Deserialize, Debug, Validate)]
#[serde(rename_all = "camelCase")]
pub struct JsonCreateTodo {
    #[validate(
        length(min = 1, message = "`title` is empty."),
        required(message = "`title` is null.")
    )]
    pub title: Option<String>,
    #[validate(required(message = "`description` is null."))]
    pub description: Option<String>,
}
  • JSON 응답 시 Serialize를 사용해 구조체를 자동으로 JSON 형식으로 변환한다.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JsonTodo {
    pub id: String,
    pub title: String,
    pub description: String,
    pub status: JsonTodoStatus,
    pub created_at: String,
    pub updated_at: String,
}

2. sqlx 구현 포인트

  • sqlx는 DB 연결, 쿼리 구성, 트랜잭션을 지원하며 CLI 명령어로 데이터베이스 생성, 마이그레이션 실행 등 초기 셋팅도 도와준다.
> sqlx database create
> sqlx migrate run
  • DB Pool은 Arc 방식을 사용해 병렬 처리에서도 안전하게 동작한다. Arc<Pool<Postgres>>는 멀티스레드 환경에서 atomic 연산으로 참조 카운팅을 수행한다.
#[derive(Clone)]
pub struct Db(pub(crate) Arc<Pool<Postgres>>);

impl Db {
    pub async fn new() -> Db {
        let pool = PgPoolOptions::new()
            .max_connections(10)
            .connect(&env::var("DATABASE_URL").unwrap_or_else(|_| panic!("DATABASE_URL must be set!")), )
            .await
            .unwrap_or_else(|_| {
                panic!("Cannot connect to the database. Please check your configuration.")
            });
        Db(Arc::new(pool))
    }
}
  • 쿼리 구현에서 SELECT는 query_as!, 갱신(INSERT, UPDATE, DELETE)은 query!를 사용한다.
let _ = query("insert into todos (id, title, description) values ($1, $2, $3)")
    .bind(todo.id)
    .bind(todo.title)
    .bind(todo.description)
    .execute(&*pool)
    .await?;

let sql = r#"
    select t.id as id, t.title as title, t.description as description, ts.id as status_id, ts.code as status_code, ts.name as status_name,
    t.created_at as created_at, t.updated_at as updated_at
    from  todos as t
    inner join todo_statuses as ts on ts.id = t.status_id
    where t.id = $1
"#;

let stored_todo = query_as::<_, StoredTodo>(sql)
    .bind(id)
    .fetch_one(&*pool)
    .await?;
Ok(stored_todo.try_into()?)

query_as로 SELECT를 구현할 경우 결과를 저장할 구조체를 #[derive(FromRow)]로 정의해야 한다.

#[derive(FromRow, Debug)]
pub struct StoredTodo {
    pub id: String,
    pub title: String,
    pub description: String,
    pub status_id: String,
    pub status_code: String,
    pub status_name: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

SQL을 파일로 분리해 관리할 수 있는 query_file!이나 query_file_as!도 있다.

3. Rust 구현 포인트

  • DTO 변환에는 From/TryFrom을 구현한다. 레이어드 아키텍처에서 하위 레이어가 상위 레이어에 의존하지 않는 규칙을 지키면서 레이어 간 객체 변환을 깔끔하게 처리할 수 있다.

  • anyhow로 오류를 처리한다. 함수의 Result 타입은 anyhow::Result로 하고, 커스텀 에러 타입을 정의할 때는 thiserror로 확장한다.

  • FOR 루프나 패턴 매칭보다 filter, map 등 어댑터를 사용한다. 변수 스코프를 좁혀 Rust 특유의 소유권과 라이프타임 문제 없이 코딩하기 쉬워진다.

  • 로깅에는 tracing을 사용한다. 단순 env_logger는 ERROR 로그에 Request 정보가 없어 여러 요청 중 어떤 요청이 에러였는지 추적하기 어렵고, 비동기 실행 시 로그 순서가 뒤섞인다. tracing을 사용하면 tracing_subscriber 초기화 후 tracing::info!(), tracing::error!()로 프로세스 내부를 체계적으로 추적할 수 있다.

아키텍처

레이어 구조와 의존 관계를 설계하는 방식이 이 프로젝트의 핵심이다. 각 레이어의 역할을 명확히 분리하고, Rust의 타입 시스템으로 의존 규칙을 컴파일 타임에 강제한다.

1. 레이어별 담당 기능

  • controller: 라우터와 서버 구동, 요청/응답 전처리, 에러 모델 정의, JSON 직렬화/역직렬화
  • usecase: 애플리케이션 처리에 필요한 비즈니스 로직
  • domain: 도메인 모델 생성, 각종 산출 로직
  • infra: 외부 서비스 연계 레이어, DB 접속 및 쿼리 로직

2. Rust에서 레이어드 아키텍처 구현

Cargo.toml을 정의해 하위 계층에서 상위 계층을 호출하는 위반을 방지할 수 있다. 예를 들어 아래처럼 usecase 레이어는 domain과 infra 경로만 정의되어 있어 controller를 호출할 수 없다.

[package]
name = "todo-usecase"
version = "0.1.0"
edition = "2021"

[dependencies]
todo-domain = { path = "../todo-domain" }
todo-infra = { path = "../todo-infra" }

3. Dependency Injection

DI는 모듈의 재사용성, 테스트 용이성, 유지보수성을 높인다. 여기서는 생성자 인젝션을 사용했고, 구조체에 의존을 주입하고 싶은 필드를 기술해 내부에서 사용하는 방식이다.

TodoUseCase 생성자에 Repository를 주입해 controller → usecase → domain repository → infra repository로 이어지는 의존 흐름을 만든다.

pub struct TodoUseCase<R: RepositoriesModuleExt> {
    repositories: Arc<R>,
}

impl<R: RepositoriesModuleExt> TodoUseCase<R> {
    pub fn new(repositories: Arc<R>) -> Self {
        Self { repositories }
    }
    ....
}

4. DIP(의존 관계 역전)

느슨한 결합을 유지하려면 DIP(Dependency Inversion Principle)이 중요하다. 상위 레벨 정책의 구현 코드는 하위 레벨 세부 사항에 의존해서는 안 된다는 원칙이다.

domain과 infra에 DIP가 적용되어 있다. domain의 Repository에는 트레이트만 있고 실제 구현은 infra에서 이루어진다. 덕분에 데이터 소스가 변경되어도 도메인 레이어나 애플리케이션 레이어 구현에는 영향이 없다.

도메인의 Repository는 인터페이스만 구성한다.

#[async_trait]
pub trait TodoRepository {
    async fn get(&self, id: &Id<Todo>) -> anyhow::Result<Option<Todo>>;
    async fn find(&self, status: Option<TodoStatus>) -> anyhow::Result<Option<Vec<Todo>>>;
    async fn insert(&self, source: NewTodo) -> anyhow::Result<Todo>;
    async fn update(&self, source: UpdateTodo) -> anyhow::Result<Todo>;
    async fn upsert(&self, source: UpsertTodo) -> anyhow::Result<Todo>;
    async fn delete(&self, id: &Id<Todo>) -> anyhow::Result<Option<Todo>>;
}

구현은 infra에 존재한다. DatabaseRepositoryImpl은 각 도메인 모델에 대한 Repository 구현체다.

#[async_trait]
impl TodoRepository for DatabaseRepositoryImpl<Todo> {
    async fn get(&self, id: &Id<Todo>) -> anyhow::Result<Option<Todo>> {
        let pool = self.db.0.clone();
        let sql = r#"
            select t.id as id, t.title as title, t.description as description, ts.id as status_id, ts.code as status_code, ts.name as status_name,
                t.created_at as created_at, t.updated_at as updated_at
            from  todos as t
            inner join todo_statuses as ts on ts.id = t.status_id
            where t.id = $1
        "#;
        let stored_todo = query_as::<_, StoredTodo>(sql)
            .bind(id.value.to_string())
            .fetch_one(&*pool)
            .await
            .ok();

        match stored_todo {
            Some(st) => Ok(Some(st.try_into()?)),
            None => Ok(None),
        }
    }
    ...
}

5. 모듈

Modules 구조체를 DI 컨테이너로 만들어 의존 관계를 한눈에 파악할 수 있게 했다. Axum에서 DI를 구현하는 방법은 ExtensionState 두 가지가 있는데, State는 타입 세이프하고 Extension은 그렇지 않다.

impl Modules {
    pub async fn new() -> Self {
        let db = Db::new().await;
        let repositories_module = Arc::new(RepositoriesModule::new(db.clone()));
        let health_check_use_case = HealthCheckUseCase::new(HealthCheckRepository::new(db));
        let todo_use_case = TodoUseCase::new(repositories_module.clone());

        Self {
            health_check_use_case,
            todo_use_case,
        }
    }
}

6. Auth AOP

API 호출마다 사용자를 인증하고 권한 레벨까지 확장할 수 있도록 JWT Token 기반 Auth AOP를 구현했다.

pub async fn auth(
    modules: State<Arc<Modules>>,
    mut req: Request,
    next: Next,
) -> Result<impl IntoResponse, AppError> {
    let auth_header = req
        .headers()
        .get(http::header::AUTHORIZATION)
        .and_then(|header| header.to_str().ok())
        .and_then(|header| {
            if header.starts_with("Bearer ") {
                header.strip_prefix("Bearer ")
            } else {
                error!("auth_header not found");
                None
            }
        });
    let auth_header = match auth_header {
        Some(header) => header,
        None => return Err(InvalidJwt("auth_header not found".to_string())),
    };

    match authorize_current_user(auth_header, &modules).await {
        Ok(current_user) => {
            req.extensions_mut().insert(current_user);
            return Ok(next.run(req).await);
        }
        Err(err) => {
            error!("error authorizing user: {:?}", err);
            return Err(InvalidJwt(err.to_string()));
        }
    }

    async fn authorize_current_user(
        auth_token: &str,
        modules: &Modules,
    ) -> Result<UserView, AppError> {
        let claims = decode::<TokenClaims>(
            auth_token,
            &jsonwebtoken::DecodingKey::from_secret(modules.constants.jwt_key.as_ref()),
            &jsonwebtoken::Validation::default(),
        );

        match claims {
            Ok(claims) => {
                let user_id = claims.claims.sub;
                let user_view = modules.user_use_case().get_user(user_id).await;
                match user_view {
                    Ok(user_view) => match user_view {
                        Some(uv) => Ok(uv.into()),
                        None => Err(InvalidJwt("user not found".to_string())),
                    },
                    Err(err) => {
                        error!("Unexpected error: {:?}", err);
                        Err(InvalidJwt(err.to_string()))
                    }
                }
            }
            Err(err) => {
                error!("Error decoding token: {:?}", err);
                Err(InvalidJwt(err.to_string()))
            }
        }
    }
}

DB 트랜잭션

sqlx에서 Pool과 Transaction을 모두 받을 수 있도록 sqlx::Acquire 트레이트를 아래와 같이 구현한다.

pub trait PostgresAcquire<'c>: Acquire<'c, Database = Postgres> + Send {}
impl<'c, T> PostgresAcquire<'c> for T
where
    T: Acquire<'c, Database = Postgres> + Send,
{}

레포지토리 함수에 executor: impl PostgresAcquire<'_> 파라미터를 추가하면 usecase 레이어 수준에서 데이터베이스 트랜잭션 제어가 가능해진다.

API 문서화

Rust로 구현한 API 문서를 쉽게 만들려면 utoipa 크레이트가 유용하다. actix-web, axum, warp, tide, rocket 등 다양한 웹 프레임워크를 지원한다.

API를 문서화하려면 먼저 TodoApi의 문서(paths와 components의 schemas)를 정의하고 Router에 merge한다.

let mut openapi = OpenApiBuilder::default()
    .info(Info::new("axum-rusty API", "1.0.0"))
    .build();
openapi.merge(TodoOpenApi::openapi());
...
let app = Router::new()
.merge(SwaggerUi::new("/swagger-ui").url("/swagger.json", openapi))
...

#[derive(utoipa::OpenApi)]
#[openapi(
    paths(get_todo, find_todo, create_todo, update_todo, upsert_todo),    components(schemas(JsonCreateTodo, TodoQuery, JsonUpdateTodoContents, JsonUpsertTodoContents, ApiResponse<Value>)),    tags((name = "Todo")))]

각 API별로 utoipa::path를 지정한다. 요청/응답이 JSON 구조체이면 ToSchema, URL 쿼리 파라미터이면 IntoParams를 지정하면 된다.

#[utoipa::path(
    get,
    path = "/v1/todos/{id}",
    operation_id = stringify!(get_todo),
    responses(
    (status = OK, description = "Get one todo successfully", body = ApiResponse<Value>)),
    tag = "Todo",)]
pub async fn get_todo(
    _: ApiVersion,
    Path((_v, id)): Path<(ApiVersion, String)>,
    modules: State<Arc<Modules>>,
) -> Result<(StatusCode, Json<ApiResponse<Value>>), AppError> {

Axum의 타입 세이프한 라우팅, sqlx의 트랜잭션 제어, 생성자 DI 패턴, JWT 인증 미들웨어, utoipa 문서화까지 Clean 아키텍처를 Rust로 구현하는 핵심 패턴을 정리했습니다. 전체 코드는 axum-rusty에서 확인할 수 있습니다.


Mimul

Written byMimul
Mimul is a programmer, technologist, exercise enthusiast and more.
Connect

Related ArticlesView All