왜 Node.js에서 Rust로 전환했나?

2년간 Node.js로 운영하던 API 서버가 트래픽 증가와 함께 한계에 부딪혔습니다. 특히 이미지 처리데이터 집계 같은 CPU 바운드 작업에서 심각한 병목이 발생했고, 메모리 사용량도 예측 불가능하게 튀는 문제가 있었습니다. 여러 대안을 검토한 끝에 Rust의 Tokio + Axum 조합을 선택했고, 결과적으로 동일 하드웨어에서 3배 이상의 처리량을 달성했습니다.

기존 Node.js 서버의 문제점

Node.js는 빠른 개발과 풍부한 생태계 덕분에 스타트업에서 많이 사용됩니다. 하지만 서비스가 성장하면서 다음과 같은 문제가 드러났습니다:

  • CPU 바운드 작업의 한계: Node.js는 싱글 스레드 기반입니다. 이미지 리사이징처럼 CPU를 많이 쓰는 작업이 들어오면 이벤트 루프가 블로킹되어 다른 모든 요청이 대기하게 됩니다. Worker Thread를 쓸 수 있지만, 구조가 복잡해지고 오버헤드가 생깁니다.
  • 메모리 스파이크: V8 엔진의 가비지 컬렉터(GC)가 예측 불가능한 타이밍에 동작합니다. 트래픽이 몰릴 때 GC가 돌면 메모리가 갑자기 2-3배 치솟고, 응답 시간도 튀었습니다.
  • 동시 연결 처리: 10,000개 이상의 동시 연결을 처리하면 급격한 성능 저하가 발생했습니다. 연결당 메모리 오버헤드가 누적되는 문제였습니다.
  • Cold Start: 컨테이너가 재시작되면 JIT 컴파일러가 다시 워밍업해야 해서 초기 응답이 느렸습니다. 오토스케일링 환경에서 이 문제가 두드러졌습니다.

왜 Rust인가? 다른 대안은 없었나?

Go, Java, C++도 검토했습니다. 각각의 장단점을 비교한 결과:

언어장점단점결론
Go쉬운 학습, 빠른 컴파일GC 존재, 제네릭 제한GC 때문에 탈락
Java성숙한 생태계, JIT 최적화메모리 사용량 높음, 무거움리소스 효율 탈락
C++최고 성능메모리 안전성 문제, 긴 개발 시간안전성 탈락
RustGC 없음, 메모리 안전, C++ 수준 성능가파른 학습 곡선선택

Rust를 선택한 핵심 이유는 "GC 없이도 메모리 안전"이었습니다. 소유권(Ownership) 시스템 덕분에 컴파일 타임에 메모리 버그를 잡아내면서도, 런타임에 GC 오버헤드가 없습니다.

Tokio + Axum 아키텍처 설계

왜 Axum인가?

Rust 웹 프레임워크는 여러 가지가 있습니다. Actix-web, Rocket, Warp, Axum 등을 비교했습니다:

프레임워크특징선택 이유
Actix-web가장 빠름, Actor 모델복잡한 구조, 매크로 의존
RocketRuby on Rails 스타일매크로 많음, 컴파일 느림
Warp함수형, 필터 기반체이닝 복잡
AxumTower 생태계, 타입 안전깔끔한 API, 매크로 최소화

Axum을 선택한 결정적 이유:

  • Tower 미들웨어 호환: Tower 생태계의 수많은 미들웨어를 그대로 사용 가능
  • 타입 안전한 라우팅: 잘못된 타입의 핸들러를 연결하면 컴파일 에러 발생
  • 매크로 최소화: Rocket처럼 매크로 범벅이 아니라, 일반 함수로 핸들러 작성
  • Tokio 공식 지원: Tokio 팀이 직접 개발하여 호환성 보장

프로젝트 구조 설계

대규모 프로젝트에서 유지보수하기 쉽도록, 도메인별로 모듈을 분리했습니다. 이 구조를 선택한 이유는:

  • routes/: URL과 핸들러 매핑만 담당. 비즈니스 로직 없음
  • handlers/: HTTP 요청/응답 처리. 서비스 레이어 호출
  • services/: 실제 비즈니스 로직. DB, 캐시 등 외부 의존성 처리
  • models/: 데이터 구조 정의. Serialize/Deserialize 트레잇 구현
RUST
// 프로젝트 디렉토리 구조
// 왜 이렇게 나눴나? → 각 레이어의 책임을 명확히 분리하기 위해서입니다.
// 테스트할 때도 각 레이어를 독립적으로 테스트할 수 있습니다.

src/
├── main.rs           // 진입점: 서버 설정과 라우터 조립만 담당
├── config.rs         // 환경 변수 로드, 설정 구조체 정의
├── routes/
│   ├── mod.rs        // 라우트 모듈 공개
│   ├── api.rs        // API 라우트 (/api/v1/*)
│   └── health.rs     // 헬스체크 (/health, /ready)
├── handlers/
│   ├── mod.rs
│   ├── users.rs      // 사용자 CRUD 핸들러
│   └── analytics.rs  // 분석 데이터 수집 핸들러
├── services/
│   ├── mod.rs
│   ├── user_service.rs   // 사용자 관련 비즈니스 로직
│   └── cache_service.rs  // Redis 캐싱 로직
├── models/
│   ├── mod.rs
│   └── user.rs       // User 구조체, DTO 정의
└── error.rs          // 커스텀 에러 타입, HTTP 에러 변환

이 구조의 장점:

  • 새 기능 추가 시 어디에 코드를 넣을지 명확함
  • handlers와 services를 분리하면 HTTP 없이 비즈니스 로직만 단위 테스트 가능
  • 팀원이 늘어나도 충돌 없이 병렬 개발 가능

단점:

  • 작은 프로젝트에서는 과도한 구조일 수 있음
  • 파일 개수가 많아져서 초기 셋업이 번거로움

main.rs 상세 설명

Rust에서 main 함수는 프로그램의 진입점입니다. 비동기 웹 서버를 만들려면 async main이 필요한데, Rust는 기본적으로 async main을 지원하지 않습니다. 그래서 #[tokio::main] 매크로를 사용합니다.

RUST
use axum::{Router, routing::get, routing::post};
use std::net::SocketAddr;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use sqlx::postgres::PgPoolOptions;

// #[tokio::main]이 하는 일:
// 1. Tokio 런타임을 생성
// 2. async fn main()을 일반 fn main()으로 변환
// 3. 런타임 위에서 비동기 코드 실행
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // ==========================================
    // 1단계: 환경 설정 로드
    // ==========================================
    // dotenvy는 .env 파일을 읽어서 환경 변수로 설정합니다.
    // .ok()를 붙인 이유: .env 파일이 없어도 에러 없이 진행
    // (프로덕션에서는 환경 변수를 직접 설정하므로)
    dotenvy::dotenv().ok();
    
    // 로깅 설정. RUST_LOG 환경 변수로 로그 레벨 조절 가능
    // 예: RUST_LOG=debug cargo run
    tracing_subscriber::init();

    // ==========================================
    // 2단계: 데이터베이스 연결 풀 생성
    // ==========================================
    // 왜 커넥션 풀인가?
    // - DB 연결은 생성 비용이 높음 (TCP 핸드셰이크, 인증 등)
    // - 매 요청마다 연결하면 성능 저하
    // - 풀에서 미리 만들어둔 연결을 재사용하면 빠름
    let database_url = std::env::var("DATABASE_URL")?;
    let pool = PgPoolOptions::new()
        .max_connections(50)  // 최대 동시 연결 수
        .min_connections(5)   // 최소 유지 연결 (항상 이만큼은 열어둠)
        .acquire_timeout(std::time::Duration::from_secs(3))  // 3초 안에 연결 못 얻으면 에러
        .connect(&database_url)
        .await?;
    
    // 왜 이 숫자들인가?
    // - max_connections(50): CPU 코어 수 * 2 + 여유분. 너무 많으면 DB에 부담
    // - min_connections(5): Cold start 방지. 트래픽 없을 때도 연결 유지
    // - acquire_timeout(3초): 풀이 가득 찼을 때 무한 대기 방지

    // ==========================================
    // 3단계: Redis 클라이언트 설정
    // ==========================================
    // Redis는 캐싱용. DB 쿼리 결과를 캐싱해서 응답 속도 향상
    let redis_client = redis::Client::open(
        std::env::var("REDIS_URL")?
    )?;

    // ==========================================
    // 4단계: 앱 상태 구성
    // ==========================================
    // AppState는 모든 핸들러에서 공유하는 상태입니다.
    // DB 풀, Redis 클라이언트 등을 담아서 의존성 주입처럼 사용
    let app_state = AppState {
        db: pool,
        redis: redis_client,
    };

    // ==========================================
    // 5단계: 라우터 구성
    // ==========================================
    // 라우터는 URL → 핸들러 매핑을 정의합니다.
    let app = Router::new()
        // 헬스체크: 로드밸런서가 서버 상태 확인용으로 호출
        .route("/health", get(health_check))
        
        // RESTful API 설계:
        // GET /users → 목록 조회
        // POST /users → 새 사용자 생성
        .route("/api/v1/users", get(list_users).post(create_user))
        
        // GET /users/:id → 특정 사용자 조회
        // PUT /users/:id → 특정 사용자 수정
        .route("/api/v1/users/:id", get(get_user).put(update_user))
        
        .route("/api/v1/analytics", post(track_event))
        
        // 미들웨어 레이어 (순서 중요: 아래에서 위로 실행됨)
        .layer(TraceLayer::new_for_http())  // 요청/응답 로깅
        .layer(CorsLayer::permissive())     // CORS 허용
        .with_state(app_state);             // 상태 주입

    // ==========================================
    // 6단계: 서버 시작
    // ==========================================
    // 0.0.0.0은 모든 네트워크 인터페이스에서 접속 허용
    // 127.0.0.1로 하면 로컬에서만 접속 가능
    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
    tracing::info!("Server listening on {}", addr);
    
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

핵심 포인트 정리:

  1. 커넥션 풀 설정: max_connections는 DB가 감당할 수 있는 범위 내에서 설정. PostgreSQL 기본 max_connections가 100이면, 여러 서버가 있을 경우 나눠 가져야 함
  2. 미들웨어 순서: .layer()는 스택처럼 쌓이므로, 먼저 추가한 게 나중에 실행됨. TraceLayer를 먼저 추가해야 CORS 처리 전후 모두 로깅됨
  3. graceful shutdown 미구현: 프로덕션에서는 SIGTERM 받으면 진행 중인 요청 완료 후 종료하는 로직 필요

핸들러 구현: 타입 안전의 힘

Axum의 가장 큰 장점은 타입 안전한 추출자(Extractor)입니다. Express.js에서는 req.query.page가 문자열인지 숫자인지 런타임에 확인해야 하지만, Axum에서는 컴파일 타임에 타입이 보장됩니다.

왜 타입 안전성이 중요한가?

실제로 겪었던 버그 사례:

JS
// Node.js에서 자주 발생하는 버그
app.get('/users', (req, res) => {
  const page = req.query.page;  // "1" (문자열!)
  const offset = (page - 1) * 10;  // NaN 또는 예상치 못한 값
  // 타입 에러가 런타임에서야 발견됨
});

Rust에서는 이런 버그가 컴파일 자체가 안 됩니다:

RUST
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Json,
};
use serde::{Deserialize, Serialize};

// ==========================================
// 요청 파라미터 정의
// ==========================================
// Deserialize 트레잇을 derive하면 Axum이 자동으로 쿼리 스트링을 파싱
// Option<T>는 선택적 파라미터를 의미
#[derive(Deserialize)]
pub struct UserQuery {
    page: Option<u32>,      // ?page=1 (없으면 None)
    limit: Option<u32>,     // ?limit=20
    search: Option<String>, // ?search=john
}
// 왜 u32인가? 
// - 페이지 번호는 음수가 될 수 없음
// - i32보다 의도가 명확함

// ==========================================
// 응답 구조체 정의
// ==========================================
// Serialize 트레잇으로 자동 JSON 변환
#[derive(Serialize)]
pub struct UserResponse {
    id: i64,
    name: String,
    email: String,
    created_at: chrono::DateTime<chrono::Utc>,
}
// 왜 created_at에 chrono를 쓰나?
// - Rust 표준 라이브러리에는 DateTime이 없음
// - chrono는 사실상 표준으로 쓰이는 날짜/시간 라이브러리
// - serde와 잘 통합됨

// ==========================================
// 핸들러 함수
// ==========================================
pub async fn list_users(
    // State 추출자: AppState에서 DB 풀을 가져옴
    State(state): State<AppState>,
    // Query 추출자: 쿼리 스트링을 UserQuery로 파싱
    Query(params): Query<UserQuery>,
) -> Result<Json<Vec<UserResponse>>, AppError> {
    // unwrap_or: None이면 기본값 사용
    let page = params.page.unwrap_or(1);
    
    // .min(100): limit이 100을 넘지 못하게 제한
    // 왜? 악의적 사용자가 limit=999999 보내면 DB 과부하
    let limit = params.limit.unwrap_or(20).min(100);
    
    let offset = (page - 1) * limit;

    // sqlx::query_as! 매크로의 장점:
    // 1. 컴파일 타임에 SQL 문법 검사 (DB에 연결해서 확인)
    // 2. 결과를 자동으로 UserResponse로 매핑
    // 3. SQL 인젝션 불가능 ($1, $2 바인딩)
    let users = sqlx::query_as!(
        UserResponse,
        r#"
        SELECT id, name, email, created_at
        FROM users
        WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%')
        ORDER BY created_at DESC
        LIMIT $2 OFFSET $3
        "#,
        params.search,     // $1: 검색어 (없으면 NULL)
        limit as i64,      // $2: 페이지 크기
        offset as i64      // $3: 시작 위치
    )
    .fetch_all(&state.db)
    .await?;  // 에러 발생 시 자동으로 AppError로 변환

    // Json()으로 감싸면 자동으로 JSON 응답 + Content-Type 헤더 설정
    Ok(Json(users))
}

이 코드의 장점:

  • 타입 안전: page가 문자열로 들어오면 자동으로 400 Bad Request
  • SQL 인젝션 불가: sqlx의 바인딩은 문자열 연결이 아니라 파라미터 바인딩
  • 컴파일 타임 SQL 검사: 테이블명 오타도 컴파일 에러로 잡힘
  • 자동 에러 처리: ? 연산자로 에러를 상위로 전파

주의할 점:

  • sqlx 컴파일 검사: DATABASE_URL 환경 변수가 있어야 컴파일됨. 없으면 sqlx prepare로 오프라인 모드 사용
  • N+1 문제: 연관 데이터 조회 시 주의. 이 예제에서는 단순 조회라 괜찮음

에러 핸들링: Rust답게

Node.js에서는 try-catch로 에러를 처리합니다. 하지만 catch 블록을 빼먹거나, 에러를 무시하는 실수가 자주 발생합니다. Rust는 Result 타입으로 에러 처리를 강제합니다.

왜 Result 타입인가?

RUST
// Result<T, E>는 두 가지 상태를 가짐:
// - Ok(T): 성공, T 타입의 값을 담고 있음
// - Err(E): 실패, E 타입의 에러를 담고 있음

// 예시: 파일 읽기
let content = std::fs::read_to_string("config.json");
// content는 Result<String, std::io::Error> 타입

// 이 Result를 처리하지 않으면 컴파일 경고!
// "unused Result that must be used"

에러를 무시할 수 없으니, 모든 에러 케이스를 명시적으로 처리해야 합니다.

커스텀 에러 타입 정의

RUST
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

// ==========================================
// 애플리케이션 에러 타입 정의
// ==========================================
// 모든 에러를 하나의 enum으로 통합
// 왜 enum인가? → 패턴 매칭으로 에러 종류별 처리 가능
#[derive(Debug)]
pub enum AppError {
    // DB 관련 에러
    Database(sqlx::Error),
    // 입력값 검증 실패
    Validation(String),
    // 리소스 없음 (404)
    NotFound(String),
    // 인증 실패 (401)
    Unauthorized,
    // 기타 에러 (500)
    Internal(anyhow::Error),
}

// ==========================================
// HTTP 응답으로 변환
// ==========================================
// IntoResponse 트레잇을 구현하면 핸들러에서 바로 반환 가능
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        // 에러 종류에 따라 다른 HTTP 상태 코드와 메시지 반환
        let (status, message) = match self {
            // DB 에러는 내부 에러로 처리 (상세 내용 숨김)
            AppError::Database(e) => {
                // 서버 로그에는 상세 에러 기록
                tracing::error!("Database error: {:?}", e);
                // 클라이언트에는 일반적인 메시지만
                (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
            }
            // 검증 에러는 사용자에게 상세 내용 전달
            AppError::Validation(msg) => {
                (StatusCode::BAD_REQUEST, msg)
            }
            // 404 에러
            AppError::NotFound(resource) => {
                (StatusCode::NOT_FOUND, format!("{} not found", resource))
            }
            // 인증 에러
            AppError::Unauthorized => {
                (StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
            }
            // 기타 내부 에러
            AppError::Internal(e) => {
                tracing::error!("Internal error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
            }
        };

        // JSON 형식으로 에러 응답
        let body = Json(json!({
            "error": message,
            "status": status.as_u16()
        }));

        (status, body).into_response()
    }
}

// ==========================================
// 자동 에러 변환 (From 트레잇)
// ==========================================
// sqlx::Error를 AppError로 자동 변환
// 덕분에 ? 연산자 사용 가능
impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        AppError::Database(err)
    }
}

// anyhow::Error도 자동 변환
impl From<anyhow::Error> for AppError {
    fn from(err: anyhow::Error) -> Self {
        AppError::Internal(err)
    }
}

이 설계의 장점:

  • 타입 안전한 에러 처리: 모든 에러 케이스를 enum으로 정의하면 패턴 매칭에서 누락 방지
  • 자동 변환: From 트레잇 덕분에 ? 연산자로 깔끔하게 에러 전파
  • 보안: DB 에러 상세 내용은 로그에만 남기고, 클라이언트에는 일반 메시지만 전달
  • 일관성: 모든 에러가 같은 JSON 형식으로 응답

성능 벤치마크 결과

동일한 API 스펙으로 Node.js(Express)와 Rust(Axum) 서버를 비교 테스트했습니다.

테스트 환경

  • 서버: AWS c5.xlarge (4 vCPU, 8GB RAM)
  • DB: RDS PostgreSQL r5.large
  • 테스트 도구: wrk (10 threads, 400 connections, 30초)
  • 테스트 API: GET /api/v1/users (DB 조회 + JSON 응답)
지표Node.js (Express)Rust (Axum)개선율
초당 요청 수 (RPS)12,00045,0003.75배
평균 지연시간23ms6ms3.8배
P99 지연시간87ms15ms5.8배
메모리 사용량512MB64MB8배
CPU 사용률95%60%37% 절감

P99 지연시간이 5.8배 개선된 이유:

  • Node.js는 GC가 돌 때 순간적으로 지연 발생
  • Rust는 GC가 없어서 지연 시간이 일정
  • P99(상위 1% 최악의 경우)에서 차이가 두드러짐

마이그레이션 과정에서 배운 교훈

  1. 학습 곡선은 가파르지만 가치 있음: 소유권 개념이 처음엔 어렵지만, 익숙해지면 "컴파일되면 동작한다"는 확신이 생김
  2. 점진적 전환 추천: 전체를 한번에 바꾸지 말고, 성능 병목 부분부터 Rust로 교체
  3. 에코시스템 확인 필수: 필요한 라이브러리가 있는지 먼저 확인. Rust 생태계가 Node.js만큼 풍부하진 않음
  4. 컴파일 시간 고려: Rust는 컴파일이 느림. CI/CD 파이프라인에서 캐싱 필수

결론: Rust가 정답일까?

Rust로의 전환은 모든 프로젝트에 정답은 아닙니다. 다음 조건에 해당하면 고려해볼 가치가 있습니다:

  • 추천하는 경우: CPU 집약적 작업, 엄격한 지연 시간 요구, 메모리 효율이 중요한 경우
  • 비추천하는 경우: 빠른 프로토타이핑, 팀의 Rust 경험 부족, 생태계 의존도 높은 경우

저희 팀은 Rust 전환으로 서버 비용 40% 절감P99 지연시간 75% 개선을 달성했습니다. 학습 비용을 감수할 가치가 충분했습니다.