GC 없이도 안전하게 — Rust + Axum으로 고성능 REST API 서버 구축하기 (Tokio · SQLx · 실전 코드 포함)
솔직히 말하면, 처음 Rust를 접했을 때 "이걸 굳이 배워야 하나?" 싶었습니다. Go도 빠르고, Node.js도 충분히 쓸 만한데, 왜 이렇게 어려운 언어를 써야 하냐고요. 그런데 Discord가 Go로 작성된 읽음 상태(Read States) 서비스를 Rust로 재작성한 사례를 보고 나서 생각이 바뀌었습니다. Go의 GC 일시정지 탓에 5백만 명의 동시 사용자 환경에서 2분마다 10~50ms씩 레이턴시 스파이크가 발생했고, Rust로 전환하고 나서야 그 스파이크가 사라지면서 메모리 사용량도 절반 이하로 줄었다는 이야기였습니다. Cloudflare가 인터넷 트래픽의 20%를 Rust 기반 플랫폼으로 전환 중이라는 소식까지 겹치자, 이건 그냥 "핫한 언어" 수준이 아니라는 게 느껴졌습니다.
이 글에서는 Rust의 소유권 시스템이 GC 없이도 메모리 안전성을 보장하는 원리, 그리고 Axum + Tokio + SQLx 스택으로 프로덕션 수준의 REST API를 어떻게 구성하는지를 실제 코드와 함께 살펴볼 수 있습니다. 대상은 Go나 Node.js를 쓰면서 성능 한계나 GC 문제를 체감하고 있는 백엔드 개발자입니다. Rust를 당장 도입해야 할지 고민 중이라면, 이 글을 통해 판단 기준을 가져가시면 됩니다.
핵심 개념
소유권(Ownership) — GC 없는 메모리 안전성의 비밀
Go나 Java를 쓰다 보면 GC가 가끔 우리를 배신하는 순간이 있습니다. Discord 사례가 딱 그 경우였죠. Rust는 이 문제를 근본부터 다르게 접근합니다. GC가 아예 없는 대신, 컴파일러가 코드를 분석해서 각 값의 수명을 추론하고 적절한 시점에 메모리를 자동으로 해제합니다. 이 규칙을 강제하는 게 바로 소유권 시스템과 빌림 검사기입니다.
fn main() {
let s1 = String::from("hello"); // s1이 "hello"를 소유
let s2 = s1; // 소유권이 s2로 이동(move)
// println!("{}", s1); // 컴파일 에러! s1은 더 이상 유효하지 않음
println!("{}", s2); // 정상 동작
} // s2가 스코프를 벗어나면 메모리 자동 해제처음엔 이 규칙이 굉장히 답답하게 느껴집니다. 저도 처음엔 "왜 이렇게 사용을 막아놓냐"고 생각했는데, 시간이 지나면서 이게 런타임 크래시를 컴파일 타임 에러로 바꿔주는 강력한 안전망이라는 걸 체감하게 됩니다.
빌림 검사기(Borrow Checker): Rust 컴파일러의 핵심 구성 요소로, 데이터 레이스와 댕글링 포인터를 컴파일 단계에서 원천 차단합니다. Microsoft가 2019년 발표한 자료에 따르면, C/C++ 보안 버그의 약 70%가 바로 이 종류의 메모리 오류입니다.
Tokio — 비동기 I/O의 심장
Rust의 async/await는 언어 자체에 런타임이 포함되어 있지 않습니다. 그래서 별도의 비동기 런타임이 필요한데, 그게 바로 Tokio입니다. 단순히 "Node.js 이벤트 루프의 멀티스레드 버전"이라고 설명하면 오해가 생길 수 있는데, 정확히는 work-stealing 스케줄러 기반의 M:N 스레드 모델입니다. 수천 개의 경량 태스크를 OS 스레드 풀 위에서 효율적으로 스케줄링하고, CPU-bound 작업과 I/O-bound 작업을 구분해서 처리하는 구조입니다.
#[tokio::main]
async fn main() {
// 이 매크로 하나가 Tokio 런타임을 초기화하고
// main 함수를 비동기로 실행해줍니다
println!("비동기 서버 시작!");
}Zero-cost abstractions: Rust의 이터레이터, 제네릭 같은 고수준 추상화는 컴파일 후 저수준 코드와 동일한 성능을 냅니다.
async/await의 경우 내부적으로 상태 머신으로 컴파일되지만, GC나 힙 할당 오버헤드 없이 동작한다는 점이 핵심입니다. "편리하게 쓰되, GC 비용은 없다"는 게 Rust의 핵심 철학입니다.
Axum — 왜 하필 Axum인가
Axum은 Tokio 팀이 직접 관리하고 Tower 생태계와 자연스럽게 통합돼 현업에서 채택이 꾸준히 늘고 있는 프레임워크입니다. Actix-web이 순수 처리량 벤치마크에서 더 높게 나오는 경우도 있지만, Axum은 미들웨어 조합 방식이 훨씬 일관적이고 Tower 생태계 자산을 그대로 가져다 쓸 수 있다는 점에서 팀 단위 프로젝트에 어울립니다. 라우터 타입이 명시적이라 처음엔 코드가 좀 길어 보이지만, 그게 오히려 유지보수할 때 큰 도움이 됩니다.
실전 적용
예시 1: Axum으로 기본 REST API 서버 구성하기
먼저 Cargo.toml에 의존성을 추가합니다. 버전 불일치로 막히는 경우가 많아서 처음부터 명시해두는 것을 권장합니다.
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"그다음, 기본 라우팅 구조를 잡아봅니다. get_user는 아직 DB 연동 없이 경로 파라미터 id를 그대로 응답에 반영하는 최소 예시입니다. 다음 예시에서 실제 DB 조회로 교체됩니다.
use axum::{
routing::{get, post},
Router, Json,
extract::Path,
http::StatusCode,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct User {
id: i32,
name: String,
email: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
// GET /users/:id — 다음 예시에서 DB 조회로 교체됩니다
async fn get_user(Path(id): Path<i32>) -> Result<Json<User>, StatusCode> {
Ok(Json(User {
id,
name: "Alice".into(),
email: "alice@example.com".into(),
}))
}
// POST /users
async fn create_user(Json(payload): Json<CreateUser>) -> (StatusCode, Json<User>) {
let user = User {
id: 42,
name: payload.name,
email: payload.email,
};
(StatusCode::CREATED, Json(user))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("서버 실행 중: http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}실제로 코드를 보면 Go의 net/http보다 선언적이라는 느낌이 납니다. 각 요소가 어떤 역할을 하는지 짚어보면 이렇습니다.
| 코드 요소 | 역할 |
|---|---|
#[derive(Serialize, Deserialize)] |
Serde가 자동으로 JSON 변환 코드를 생성 |
Path(id): Path<i32> |
URL 파라미터를 타입 안전하게 추출 |
Json(payload): Json<CreateUser> |
요청 바디를 자동으로 역직렬화 |
Result<Json<User>, StatusCode> |
성공은 Ok(Json(...)), 실패는 Err(StatusCode::...)로 타입 수준에서 명확히 분리. ? 연산자로 에러를 자동 전파할 수 있습니다 |
예시 2: SQLx로 DB 연동 — 컴파일 타임 SQL 검증
팀에서 ORM 대신 SQLx를 선택하게 된 결정적 이유는 쿼리 투명성이었습니다. ORM이 내부적으로 어떤 SQL을 날리는지 모르는 채 운영하다가 N+1 문제로 고생한 경험이 있는 분이라면 공감하실 텐데, SQLx는 SQL을 그대로 쓰되 컴파일 타임에 실제 DB에 연결해서 쿼리를 검증합니다. 오타가 난 컬럼명이나 타입 불일치를 배포 전에 잡아주는 거죠.
Cargo.toml에 SQLx를 추가합니다.
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }use sqlx::PgPool;
use axum::extract::State;
#[derive(sqlx::FromRow, Serialize)]
struct User {
id: i32,
name: String,
email: String,
}
// 앱 상태로 DB 커넥션 풀을 공유
#[derive(Clone)]
struct AppState {
db: PgPool,
}
async fn get_user_from_db(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<Json<User>, StatusCode> {
// query_as!는 컴파일 타임에 실제 DB에 연결해 쿼리를 검증합니다
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
id
)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
user.map(Json).ok_or(StatusCode::NOT_FOUND)
}
#[tokio::main]
async fn main() {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 필요");
let pool = PgPool::connect(&database_url).await.expect("DB 연결 실패");
let state = AppState { db: pool };
let app = Router::new()
.route("/users/:id", get(get_user_from_db))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}PostgreSQL의 SERIAL/INT4 타입은 Rust에서 i32에 대응합니다. 간혹 u32로 쓰는 경우가 있는데, SQLx가 컴파일 타임에 타입 불일치를 잡아주니 이 부분도 자연스럽게 검증됩니다.
예시 3: Tower 미들웨어로 타임아웃·로깅 추가하기
앞 예시의 get_user_from_db 핸들러와 AppState를 그대로 조합해서 미들웨어 레이어를 추가하는 예시입니다. Axum이 Tower 생태계를 그대로 활용하기 때문에, 타임아웃·압축·로깅을 레고처럼 조합할 수 있다는 게 이 부분에서 가장 체감이 큰 장점입니다.
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "timeout", "compression-gzip"] }
tracing-subscriber = "0.3"use axum::Router;
use tower::ServiceBuilder;
use tower_http::{
timeout::TimeoutLayer,
trace::TraceLayer,
compression::CompressionLayer,
};
use std::time::Duration;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 필요");
let pool = PgPool::connect(&database_url).await.expect("DB 연결 실패");
let state = AppState { db: pool };
let app = Router::new()
.route("/users/:id", get(get_user_from_db))
.with_state(state)
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.layer(CompressionLayer::new()),
);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}장단점 분석
장점
실제로 써보면 이 중 가장 체감이 큰 건 첫 번째, 예측 가능한 레이턴시입니다. GC가 없다는 게 단순히 "빠르다"는 말이 아니라, P99 레이턴시가 흔들리지 않는다는 뜻이니까요.
| 항목 | 내용 |
|---|---|
| 예측 가능한 레이턴시 | GC Stop-the-World가 없으니 레이턴시 스파이크 자체가 발생하지 않습니다 |
| 메모리 안전성 | C/C++ 보안 버그의 70%(Microsoft 2019 기준)를 차지하는 메모리 오류를 컴파일 타임에 제거합니다 |
| 최고 수준의 처리량 | TechEmpower 벤치마크 전체 언어 중 최상위권, 동일 트래픽에 컨테이너 수를 크게 줄일 수 있습니다 |
| 운영 비용 절감 | 메모리 사용량이 낮아 컨테이너 수를 줄이면 클라우드 비용이 눈에 띄게 줄어드는 사례들이 보고되고 있습니다 |
| 국가 사이버보안 권장 | 미국 NSA·CISA가 공식적으로 Rust를 메모리 안전 언어로 권장합니다 |
단점 및 주의사항
단점 표를 보면 공통 주제가 하나 있습니다. 바로 "시간"입니다. 학습 시간, 컴파일 시간, 팀 온보딩 시간. 성능 이점이 크더라도 이 비용을 감수할 상황인지가 도입 여부의 핵심입니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 가파른 학습 곡선 | 소유권·빌림 시스템은 GC 경험자에게 패러다임 전환을 요구합니다 | Rust Book + 작은 CLI 프로젝트로 시작하는 것을 권장합니다 |
| 느린 컴파일 | 대규모 프로젝트에서 빌드 시간이 Go보다 길 수 있습니다 | cargo check, sccache, 증분 빌드 설정으로 개선할 수 있습니다 |
| 빠른 프로토타이핑 불리 | MVP 수준의 빠른 개발엔 Go·Node.js가 더 적합합니다 | 성능 크리티컬한 서비스에만 선택적으로 도입하는 것이 현실적입니다 |
| 팀 온보딩 비용 | Rust 숙련 개발자 채용 시장이 작습니다 | 내부 교육 + 페어 프로그래밍으로 점진적으로 역량을 쌓는 방식이 효과적입니다 |
Stop-the-World: GC가 메모리를 정리할 때 애플리케이션 실행을 잠깐 멈추는 현상입니다. JVM, Go 등 GC 기반 언어에서 발생하며, 실시간성이 중요한 서비스에서 레이턴시 스파이크의 원인이 됩니다.
실무에서 가장 흔한 실수
실전에서 Rust 코드를 리뷰해보면 반복되는 패턴이 있습니다. 빌림 검사기와 싸우다 지쳐서 편법을 택하는 경우들인데, 대부분 초반의 개념 이해 단계에서 잡힙니다.
clone()을 남발해서 성능 이점을 스스로 깎아내리기: 빌림 검사기가 에러를 내면 일단.clone()을 붙여서 해결하는 습관이 생기는데, 이렇게 되면 Rust를 쓰는 이유가 반감됩니다. 소유권 규칙을 이해하고 설계 단계부터 데이터 흐름을 고려하는 게 중요합니다.unwrap()남용: 빠른 개발을 위해unwrap()을 쓰다가 프로덕션에서 패닉이 나는 경우입니다.?연산자와Result타입으로 에러를 명시적으로 전파하는 습관을 들이는 게 좋습니다. 핸들러 반환 타입을Result<Json<User>, StatusCode>로 선언해두면?만으로 에러 전파가 깔끔하게 됩니다.- 동기 블로킹 코드를 async 컨텍스트에서 실행하기:
std::thread::sleep()이나 무거운 CPU 작업을 Tokio 태스크 안에서 직접 쓰면 이벤트 루프를 블록합니다.tokio::time::sleep()과tokio::task::spawn_blocking()을 구분해서 사용하는 것을 권장합니다.
마치며
Rust는 "더 빠른 언어"가 아니라 "성능과 안전성을 동시에 포기하지 않는 언어"입니다. 레이턴시 예측 가능성이 중요하거나, 메모리 안전성이 비즈니스 크리티컬한 영역이거나, 운영 비용을 극단적으로 줄여야 하는 상황이라면 진지하게 고려해볼 만합니다. 반대로, 빠른 프로토타이핑이 목표이거나 팀의 Rust 경험이 전무하다면 Go를 유지하는 편이 현실적인 선택입니다. 모든 서비스에 Rust가 답은 아닙니다.
지금 바로 시작해볼 수 있는 3단계를 소개합니다.
- rustup.rs에서 Rust를 설치한 뒤 Axum Hello World를 실행해보기:
cargo new my-api-server로 새 프로젝트를 만들고, 이 글의Cargo.toml스니펫을 복사해서 첫 번째 예시 코드를 실행해보시면 됩니다. - TechEmpower 벤치마크 결과를 직접 확인해보기: Axum, Actix-web, Go, Node.js가 실제로 어느 정도 차이가 나는지 숫자로 확인하면 도입 결정에 도움이 됩니다.
- 기존 서비스 중 성능 병목이 있는 엔드포인트 하나를 Rust로 작성해보기: 전체 마이그레이션보다 마이크로서비스 하나, 혹은 사이드카 서비스를 Rust로 작성해보는 방식이 팀의 온보딩 부담을 줄이면서 실전 경험을 쌓는 현실적인 방법입니다.
다음 글에서는 SQLx + PostgreSQL + JWT 인증까지 갖춘 Axum 프로덕션 스택 전체를 Docker로 배포하는 방법을 다룰 예정입니다. 이번 글의 예시 코드가 그대로 이어지니, 시리즈로 따라오시면 완성된 프로덕션 보일러플레이트를 손에 넣을 수 있습니다.
다음 글: SQLx + PostgreSQL + JWT 인증 + Docker Compose로 구성하는 Axum 프로덕션 스택 완전 가이드
참고 자료
- Is Rust Still Surging in 2026? | ZenRows
- Rust Web Frameworks in 2026: Axum vs Actix vs Rocket | Medium
- Rust Web Frameworks in 2025: Axum vs Actix vs Rocket Benchmark | Markaicode
- Rewriting in Rust: When It Makes Sense (Discord, Cloudflare, Amazon) | Nandann
- Cloudflare Open Sources tokio-quiche for QUIC/HTTP3 | InfoQ
- Enterprise Rust 2025: Framework Analysis | Jason Grey
- Rust Crates to Watch in 2025: Tokio, Axum, SQLx | Medium
- Creating a REST API with Axum + SQLx | Hashnode
- Getting started with Axum + PostgreSQL + Redis + JWT + Docker | Sheroz.com