Axum + SQLx + PostgreSQL + JWT + Docker Compose 프로덕션 스택: 컴파일 타임 SQL 검증부터 컨테이너 배포까지
저도 Actix-Web으로 Rust 백엔드를 시작했다가 Axum으로 넘어온 케이스인데, 솔직히 그 선택이 틀리지 않았다고 느낍니다. 막상 옮기고 나서 제일 먼저 든 생각은 "왜 진작에 안 바꿨지?"였거든요. Tokio 팀이 직접 만들고 유지하는 Axum은 2024년 말 0.8 버전이 나오면서 완성도가 한 단계 올라갔고, Tower 미들웨어 생태계와의 통합이 실무에서 얼마나 편한지는 직접 써봐야 와닿는 부분이 있습니다.
이 글의 대상은 Rust 기초 문법을 알고, 다른 언어로 백엔드를 짜본 경험이 있는 개발자입니다. 소유권이나 라이프타임 개념은 한 번쯤 접했다고 가정하고 진행합니다. 다루는 내용은 Axum + SQLx + PostgreSQL + JWT + Docker Compose 조합으로 실제 서비스에 올릴 수 있는 수준의 백엔드를 구성하는 전 과정입니다. 이 스택의 핵심 가치는 컴파일 타임에 SQL 쿼리 오류와 타입 불일치를 모두 잡아내기 때문에, "배포하고 나서야 알게 되는" 데이터베이스 버그를 대폭 줄일 수 있다는 점입니다.
여기서 한 가지 솔직하게 말씀드리자면, 이 스택은 진입 장벽이 낮지 않습니다. Rust 2025 설문에 따르면 전체 개발자의 45.5%가 이미 Rust를 프로덕션에서 활용 중이지만, 동시에 복잡성에 대한 우려도 함께 높아졌습니다. Cloudflare, Discord, Amazon이 Axum + Tokio를 백엔드에 도입한 건 사실이지만, 그 배경에는 팀이 Rust에 익숙해지는 데 투자한 시간이 있습니다. 학습 비용을 감당할 의지가 있다면, 이 스택이 돌려주는 것은 상당합니다.
핵심 개념
Axum이 Actix-Web과 다른 결정적 차이
Axum의 가장 큰 특징은 자체 미들웨어 시스템을 만들지 않았다는 점입니다. 대신 Tower 생태계를 그대로 가져다 씁니다. 처음엔 이게 왜 장점인지 잘 안 와닿았는데, 실무에서 CORS, 레이트 리미팅, 압축, 인증 레이어를 조합하다 보면 이미 검증된 Tower 미들웨어를 플러그인처럼 가져다 쓸 수 있다는 게 얼마나 편한지 체감하게 됩니다. Actix-Web에서는 직접 구현하거나 별도 크레이트를 찾아야 했던 것들이 tower-http 한 줄로 끝나는 경우가 많습니다.
두 번째 특징은 타입 안전 추출자(Extractor) 패턴입니다. 요청에서 데이터를 꺼내는 로직을 FromRequestParts 트레이트로 캡슐화하면, 핸들러 함수 시그니처에 타입만 선언해도 Axum이 알아서 추출·검증합니다. JWT 인증이 바로 이 패턴을 활용하는 대표적인 사례입니다.
// 핸들러 시그니처만 봐도 "이 엔드포인트는 인증 필요"가 명확히 드러납니다
async fn get_profile(
claims: Claims, // JWT 추출자 — 토큰 없으면 자동으로 401
State(state): State<AppState>,
) -> impl IntoResponse {
// claims.sub에 이미 검증된 사용자 ID가 있음
}Tower: Rust 비동기 서비스와 미들웨어를 위한 추상화 라이브러리입니다.
Service트레이트 하나로 HTTP 서버, 클라이언트, 미들웨어 체인을 일관되게 구성할 수 있습니다. Axum은 Tower 위에 올려진 얇은 레이어라고 이해하면 됩니다.
SQLx가 ORM이 아닌 이유 — 그리고 그게 강점인 이유
SQLx는 ORM이 아닙니다. Raw SQL을 씁니다. 처음엔 "그럼 그냥 일반 DB 드라이버랑 뭐가 달라?"라고 생각할 수 있는데, 핵심 차이는 query!() 매크로입니다.
// 이 코드는 컴파일 시점에 DB에 실제로 연결해서 쿼리를 검증합니다
let user = sqlx::query_as!(
UserProfile,
"SELECT id, email, name, created_at FROM users WHERE id = $1",
user_id
)
.fetch_one(&pool)
.await?;cargo build를 실행하는 순간, SQLx가 DB에 연결해 users 테이블에 id, email, name, created_at 컬럼이 실제로 존재하는지, 타입이 맞는지를 검사합니다. 컬럼명 오타나 타입 불일치가 있으면 런타임 에러가 아닌 컴파일 에러로 잡힙니다. 저도 이걸 처음 경험했을 때 꽤 신선했습니다. SQL 오류는 보통 "배포하고 나서야 알게" 되는 경우가 많았으니까요.
| 방식 | 장점 | 단점 |
|---|---|---|
query!() 매크로 |
컴파일 타임 검증, 타입 자동 매핑 | 빌드 시 DB 연결 필요 |
query_as!() |
구조체로 직접 매핑 | 동일 |
query_unchecked!() |
DB 없이 빌드 가능 | 타입 안전성 포기 |
오프라인 모드 — CI에서 DB 없이 빌드하기
query!() 매크로의 유일한 불편함은 빌드 시 DB가 필요하다는 점입니다. CI 환경에서 매번 DB를 띄우기 번거롭다면, cargo sqlx prepare 명령으로 쿼리 메타데이터를 파일로 저장해두는 방법이 있습니다.
# 1. 로컬 DB가 실행 중인 상태에서 메타데이터 생성
cargo sqlx prepare
# 2. 생성된 .sqlx/ 디렉터리를 Git에 포함
git add .sqlx/
git commit -m "chore: sqlx 쿼리 메타데이터 추가"이후 CI에서는 SQLX_OFFLINE=true 환경변수 하나로 DB 없이 빌드가 됩니다.
# GitHub Actions 예시
- name: Build
env:
SQLX_OFFLINE: true
run: cargo build --release쿼리를 수정했을 때만 로컬에서 cargo sqlx prepare를 다시 실행해 커밋하면 됩니다. .sqlx/ 디렉터리가 없으면 오프라인 모드가 동작하지 않으니, 반드시 Git에 포함해야 합니다.
JWT 인증 두 가지 패턴 — 어떤 걸 선택할까
Axum에서 JWT를 적용하는 방법은 크게 두 가지입니다.
패턴 1 — 추출자(Extractor) 방식: 특정 핸들러에만 인증이 필요할 때 유용합니다. 핸들러 파라미터에 Claims 타입을 선언하면 끝입니다.
패턴 2 — Tower 미들웨어 방식: 라우트 그룹 전체에 인증을 일괄 적용할 때 씁니다. route_layer()로 감싸면 그 아래 모든 엔드포인트에 자동으로 인증이 걸립니다.
// 패턴 2: 미들웨어로 라우트 그룹 전체를 보호
let protected_routes = Router::new()
.route("/profile", get(get_profile))
.route("/posts", post(create_post))
.route_layer(middleware::from_fn_with_state(
app_state.clone(),
jwt_auth_middleware,
));
let public_routes = Router::new()
.route("/auth/login", post(login))
.route("/auth/register", post(register))
.route("/health", get(health)); // 인증 불필요한 헬스체크
let app = Router::new()
.merge(protected_routes)
.merge(public_routes)
.with_state(app_state);헬스체크 핸들러는 DB 연결 여부까지 확인하는 형태로 만들어두면 유용합니다.
async fn health(State(state): State<AppState>) -> impl IntoResponse {
match sqlx::query("SELECT 1").execute(&state.db).await {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}실무에서는 두 패턴을 함께 씁니다. 보호가 필요한 라우트 그룹에는 미들웨어를, 같은 엔드포인트에서 선택적으로 사용자 정보가 필요할 때(로그인한 사용자면 개인화, 아니면 기본 응답)는 Option<Claims> 추출자를 씁니다.
실전 적용
단계 1: 프로젝트 세팅과 AppState 구성
먼저 Cargo.toml에 필요한 의존성을 추가합니다. Axum 0.8 기준입니다.
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "chrono"] }
jsonwebtoken = "9"
axum-extra = { version = "0.9", features = ["typed-header"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenvy = "0.15"
chrono = { version = "0.4", features = ["serde"] }DB 스키마를 먼저 정의해두는 것이 좋습니다. sqlx migrate add create_users로 마이그레이션 파일을 생성한 뒤 아래 DDL을 추가합니다.
-- migrations/20240101000001_create_users.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);핵심은 AppState를 어떻게 설계하느냐입니다. PgPool 자체가 내부적으로 Arc를 사용하기 때문에 별도로 Arc<AppState>로 감쌀 필요가 없습니다. jwt_secret에 Arc<str>을 쓰는 이유는 AppState가 Clone될 때마다 문자열 버퍼를 복사하지 않고 참조 카운트만 증가시키기 위함입니다. String으로 써도 동작하지만, 요청마다 상태가 클론되는 환경에서는 Arc<str>이 더 효율적입니다.
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub jwt_secret: Arc<str>, // Clone 시 문자열 버퍼 복사 없이 참조 카운트만 증가
}
impl AppState {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set");
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(3))
.connect(&database_url)
.await?;
// 앱 시작 시 자동으로 마이그레이션 적용
sqlx::migrate!("./migrations").run(&db).await?;
Ok(Self {
db,
jwt_secret: jwt_secret.into(),
})
}
}| 구성 요소 | 역할 | 비고 |
|---|---|---|
PgPool |
커넥션 풀 관리 | 내부적으로 Arc 기반, Clone 비용 없음 |
max_connections |
동시 DB 연결 수 제한 | 과부하 방지 |
acquire_timeout |
커넥션 획득 대기 시간 | 지연 탐지용 |
sqlx::migrate!() |
시작 시 마이그레이션 자동 적용 | 별도 init 컨테이너 없이 처리 가능 |
단계 2: JWT 추출자 전체 구현
JWT Claims 추출자를 FromRequestParts<AppState>로 구현하는 전체 코드입니다. AppState를 직접 받기 때문에, 매 요청마다 환경변수를 읽는 대신 이미 파싱된 jwt_secret을 그대로 씁니다. 이게 중요한 이유는 std::env::var()가 매 요청마다 호출되면 불필요한 오버헤드가 생기는 데다, AppState에 시크릿을 담아둔 설계 의도와도 일치하기 때문입니다.
Rust 1.75부터 트레이트에서
async fn이 네이티브로 지원됩니다.#[async_trait]매크로 없이 아래처럼 바로 구현할 수 있습니다. 1.74 이하 환경이라면async-trait크레이트를 추가하고 각impl블록에#[async_trait]를 붙여주면 동일하게 동작합니다.
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
Json,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: i64, // 사용자 ID를 처음부터 i64로 저장 — 핸들러에서 파싱 불필요
pub exp: usize,
pub iat: usize,
}
#[derive(Debug)]
pub enum AuthError {
MissingToken,
InvalidToken,
ExpiredToken,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, message) = match self {
AuthError::MissingToken => (StatusCode::UNAUTHORIZED, "토큰이 필요합니다"),
AuthError::InvalidToken => (StatusCode::UNAUTHORIZED, "유효하지 않은 토큰입니다"),
AuthError::ExpiredToken => (StatusCode::UNAUTHORIZED, "만료된 토큰입니다"),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
// AppState를 직접 받아 jwt_secret에 접근 — 매 요청마다 env::var 호출 없음
impl FromRequestParts<AppState> for Claims {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
.await
.map_err(|_| AuthError::MissingToken)?;
let token_data = decode::<Claims>(
bearer.token(),
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&Validation::default(),
)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::ExpiredToken,
_ => AuthError::InvalidToken,
})?;
Ok(token_data.claims)
}
}핸들러에서는 이렇게 씁니다. claims.sub가 이미 i64이기 때문에 별도 파싱이 필요 없고, 파싱 실패 시 0을 반환하는 unwrap_or_default() 같은 함정도 없습니다.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct UserProfile {
pub id: i64,
pub email: String,
pub name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
pub async fn get_my_profile(
claims: Claims,
State(state): State<AppState>,
) -> impl IntoResponse {
let result = sqlx::query_as!(
UserProfile,
"SELECT id, email, name, created_at FROM users WHERE id = $1",
claims.sub // i64 타입 그대로 사용
)
.fetch_optional(&state.db)
.await;
match result {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}단계 3: 멀티스테이지 Dockerfile과 Docker Compose
빌드 시간이 길다는 Rust의 단점을 Docker 레이어 캐싱으로 어느 정도 완화할 수 있습니다. 핵심은 소스 코드보다 Cargo.toml과 Cargo.lock을 먼저 복사해 의존성 레이어를 별도로 캐싱하는 것입니다. 처음 빌드는 느려도, 소스만 바뀌었을 때 두 번째부터는 의존성 레이어를 그대로 재사용합니다.
musl libc: GNU libc 대신 musl로 정적 링크하면 외부 라이브러리 의존성 없이 바이너리 하나만으로 실행 가능합니다.
x86_64-unknown-linux-musl타겟이 바로 이 용도입니다. 덕분에 런타임 이미지를scratch(사실상 빈 이미지)로 쓸 수 있고, 최종 이미지 크기가 10~50MB 수준으로 줄어듭니다.
# ---- 빌드 스테이지 ----
FROM rust:alpine AS builder
# 버전을 고정하고 싶다면 rust:1.82-alpine 처럼 명시하는 것도 좋습니다
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
WORKDIR /app
# 의존성 레이어 캐싱: Cargo 파일만 먼저 복사해 의존성을 별도 레이어로 분리
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN rm -f target/x86_64-unknown-linux-musl/release/deps/api*
# 실제 소스 복사 및 빌드
COPY . .
ENV SQLX_OFFLINE=true # .sqlx/ 메타데이터 사용 — DB 없이 빌드
RUN cargo build --release --target x86_64-unknown-linux-musl
# ---- 런타임 스테이지 ----
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/api /api
COPY --from=builder /app/migrations /migrations
EXPOSE 3000
CMD ["/api"]# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://user:pass@db:5432/mydb
JWT_SECRET: ${JWT_SECRET} # .env에서 주입 — 평문 하드코딩 금지
RUST_LOG: info
depends_on:
db:
condition: service_healthy # 이 줄이 없으면 간헐적 연결 실패 발생
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: mydb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s # 초기 기동 시 데이터 파일 초기화에 걸리는 시간을 유예
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
pgdata:depends_on에 condition: service_healthy를 꼭 지정해야 합니다. 단순히 depends_on: [db]만 적으면 PostgreSQL 프로세스가 시작되는 시점일 뿐, 실제로 연결을 받을 준비가 된 시점이 아닙니다. start_period: 10s는 PostgreSQL이 처음 기동할 때 데이터 파일 초기화에 걸리는 시간을 헬스체크에서 유예해주는 옵션입니다. 이 설정 없이 배포하면 간헐적으로 앱이 DB 연결에 실패하고 종료되는 케이스가 생기는데, 저도 이걸 빼먹고 "왜 가끔 연결이 안 되지?"를 한동안 삽질한 기억이 있습니다.
장단점 분석
이 스택을 실무에서 써보면서 느낀 점을 솔직하게 정리합니다. 장점이 뚜렷한 만큼 트레이드오프도 분명하기 때문에, "우리 팀에 맞는가"를 판단하는 데 참고하시면 좋겠습니다.
장점
| 항목 | 내용 |
|---|---|
| 컴파일 타임 SQL 검증 | query!() 매크로가 빌드 시 DB에 연결해 쿼리 정확성 검증, 런타임 SQL 오류를 원천 차단 |
| 극한의 성능 | Rust 제로 비용 추상화 + Tokio 비동기 런타임으로 Go 대비 동등하거나 우월한 처리량, 메모리 사용량 대폭 감소 |
| Tower 미들웨어 생태계 | 레이트 리미팅, CORS, 압축, 인증을 검증된 컴포넌트로 조합 가능 |
| 타입 안전 상태 공유 | PgPool 자체가 Arc 기반이라 추가 래핑 없이 .with_state()로 안전하게 공유 |
| 초소형 컨테이너 이미지 | 멀티스테이지 빌드로 1GB+ 빌드 이미지를 10~50MB 런타임 이미지로 압축 |
| 메모리 안전성 | GC 없이 소유권 모델로 댕글링 포인터, 레이스 컨디션을 컴파일 타임에 방지 |
단점 및 주의사항
실무에서 가장 자주 마주치는 건 컴파일 시간 문제입니다. 첫 풀빌드에서 수 분을 기다리는 건 아무리 써도 익숙해지질 않더라고요. CI에서 sccache와 Docker 레이어 캐싱을 조합하면 이후 빌드는 의미 있게 빨라집니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 느린 컴파일 시간 | 초기 풀빌드 기준 수 분 소요 가능 | sccache + Docker 의존성 레이어 캐싱 |
query!() 빌드 의존성 |
컴파일 시 DB 연결이 필요해 CI 설정이 복잡해짐 | cargo sqlx prepare로 .sqlx/ 오프라인 스냅샷을 Git에 커밋 |
| JWT 시크릿 노출 위험 | 환경변수에 평문으로 넣으면 로그·덤프에서 노출 가능 | Docker Secrets 또는 AWS Secrets Manager / Vault 연동 |
| 학습 곡선 | 소유권·라이프타임 개념이 타 언어 대비 진입 장벽이 높음 | rustlings, The Rust Book으로 기초를 다진 뒤 접근 권장 |
| 마이그레이션 타이밍 | 앱 시작 시 마이그레이션 실행하면 롤백 중 문제 발생 가능 | 별도 init 컨테이너 또는 배포 파이프라인에서 분리 실행 고려 |
HS256 vs RS256:
jsonwebtoken의 기본 알고리즘은 HS256(대칭키)입니다. 마이크로서비스처럼 여러 서비스가 토큰을 검증해야 하는 환경에서는 비밀키를 공유하지 않아도 되는 RS256(비대칭키) 사용을 권장합니다.
실무에서 가장 흔한 실수
-
depends_on에condition: service_healthy누락: 단순히depends_on: [db]만 적으면 PostgreSQL 프로세스가 시작되는 시점이지, 연결 준비가 완료된 시점이 아닙니다. 헬스체크 조건 없이 배포하면 간헐적으로 앱이 DB 연결에 실패하고 종료됩니다. -
CI에서 오프라인 모드 준비 누락:
query!()매크로 때문에 빌드 단계에서 DB가 필요한데,.sqlx/디렉터리를 Git에 커밋하지 않은 상태로SQLX_OFFLINE=true를 설정하면 빌드 자체가 실패합니다. 로컬에서cargo sqlx prepare를 실행한 뒤 반드시 커밋해두는 것을 권장합니다. -
JWT 시크릿을
docker-compose.yml에 하드코딩:JWT_SECRET: supersecret처럼 컴포즈 파일에 직접 쓰면 Git 이력에 영원히 남습니다. 반드시.env파일 +.gitignore조합 또는 외부 시크릿 관리 도구를 사용하는 것을 권장합니다.
마치며
이 스택을 실제로 서비스에 올리고 나면 한 가지 체감하는 게 있습니다. SQL 오타, 타입 불일치, 동시성 버그가 배포 전에 전부 걸러지고 나면, 프로덕션 장애의 패턴 자체가 달라집니다. "쿼리가 왜 안 돼?"를 새벽에 디버깅하는 일이 줄어드는 대신, 비즈니스 로직 수준의 문제에 집중할 수 있게 됩니다. 진입 장벽이 없다고 하면 거짓말이지만, 컴파일러가 SQL 오류와 타입 불일치와 레이스 컨디션을 한꺼번에 막아주는 경험은 한 번 해보면 되돌아가기가 싫어지는 수준입니다.
지금 바로 시작해볼 수 있는 3단계:
-
환경 준비:
rustup target add x86_64-unknown-linux-musl로 musl 타겟을 추가하고,cargo install sqlx-cli --features postgres로 SQLx CLI를 설치합니다.cargo new my-api로 프로젝트를 만든 뒤 위의Cargo.toml의존성을 추가하는 것부터 시작할 수 있습니다. -
로컬 DB 연결 확인:
docker run -p 5432:5432 -e POSTGRES_PASSWORD=pass -e POSTGRES_DB=mydb postgres:16-alpine으로 PostgreSQL을 띄우고,.env파일에DATABASE_URL=postgres://postgres:pass@localhost:5432/mydb와JWT_SECRET=dev-secret을 작성합니다.sqlx database create && sqlx migrate run으로 마이그레이션을 적용한 뒤cargo run으로 앱을 실행해curl http://localhost:3000/health로 DB 연결까지 확인할 수 있습니다. -
컨테이너화: 위의 멀티스테이지
Dockerfile과docker-compose.yml을 프로젝트 루트에 추가하기 전에,cargo sqlx prepare && git add .sqlx/로 오프라인 메타데이터를 먼저 커밋해둡니다. Dockerfile 내SQLX_OFFLINE=true덕분에 컨테이너 빌드 시 DB 없이도 통과됩니다. 이후docker compose up --build를 실행하면 DB 헬스체크가 통과한 뒤 앱이 뜨는 것을 확인할 수 있습니다.
참고 자료
- JWT Authentication in Rust using Axum Framework 2025 | CodevoWeb
- Getting started with REST API in Rust using Axum, SQLx, PostgreSQL, Redis, JWT, Docker | SHEROZ.COM
- GitHub — sheroz/axum-rest-api-sample
- GitHub — wpcodevo/rust-axum-jwt-rs256
- Rust CRUD Rest API, using Axum, SQLx, Postgres, Docker and Docker Compose | DEV Community
- The Ultimate Guide to Axum: From Hello World to Production in Rust (2025) | Shuttle
- Building Production Web Services with Rust and Axum | DASRoot
- A Guide to Rust ORMs in 2025 | Shuttle
- SQLx GitHub (launchbadge/sqlx)
- SQLx integration in Axum | mo8it.com
- axum/examples/sqlx-postgres — Official Axum Repo
- axum/examples/jwt — Official Axum Repo
- JWT Authentication with Axum | Shuttle Docs
- Building a Custom Authentication Layer in Axum | Leapcell
- Authentication with Axum | mattrighetti (2025-05-03)
- How to Build Tower Middleware for Auth and Logging in Axum | OneUptime
- How to Instrument Rust Axum Applications with OpenTelemetry | OneUptime
- Rust Async in Production: Tokio, Axum, High-Performance APIs in 2026
- Rust 2025 Survey: 45.5% Adoption | ByteIota
- Axum in 2026 — Health Score & Ecosystem Analysis | Stackwise
- GitHub — koskeller/axum-postgres-template
- GitHub — JohnScience/axum-docker-compose-sqlx
- Rust Web Frameworks in 2026: Axum vs Actix Web vs Rocket | Medium