Rust로 Node.js Native Module 만들어보기

배경

이미지 리사이징 API의 응답 속도가 느려 프로파일링을 해보니, sharp 라이브러리로 처리하는 부분에서 CPU 사용률이 높았다. 동시 요청이 많아지면 Node.js 이벤트 루프가 블로킹되는 문제가 발생했다.

Rust로 해당 로직을 재작성하면 성능 개선이 가능할 것 같아 시도해봤다.

Neon 설정

neon-bindings를 사용하면 Rust 코드를 Node.js에서 호출할 수 있다.

npm install -g neon-cli
neon new image-processor

생성된 프로젝트 구조는 Cargo 기반이며, lib.rs에 Node.js와 통신할 함수를 작성한다.

Rust 구현

use neon::prelude::*;
use image::imageops::FilterType;

fn resize_image(mut cx: FunctionContext) -> JsResult<JsBuffer> {
    let buffer = cx.argument::<JsBuffer>(0)?;
    let width = cx.argument::<JsNumber>(1)?.value(&mut cx) as u32;
    let height = cx.argument::<JsNumber>(2)?.value(&mut cx) as u32;
    
    let input = buffer.as_slice(&cx);
    let img = image::load_from_memory(input)
        .or_else(|e| cx.throw_error(e.to_string()))?;
    
    let resized = img.resize(width, height, FilterType::Lanczos3);
    let mut output: Vec<u8> = Vec::new();
    resized.write_to(&mut output, image::ImageOutputFormat::Png)
        .or_else(|e| cx.throw_error(e.to_string()))?;
    
    let mut js_buffer = cx.buffer(output.len() as u32)?;
    js_buffer.as_mut_slice(&mut cx).copy_from_slice(&output);
    
    Ok(js_buffer)
}

#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
    cx.export_function("resizeImage", resize_image)?;
    Ok(())
}

성능 비교

1000x1000 이미지를 400x400으로 리사이징하는 벤치마크 결과:

  • Sharp (Node.js): 평균 45ms
  • Rust Native Module: 평균 18ms

약 2.5배 빠른 속도를 확인했다. 다만 빌드 시간이 길어지고 배포 복잡도가 증가하는 트레이드오프가 있었다.

결론

성능이 중요한 특정 로직만 Rust로 분리하는 접근은 유효했다. 다만 팀 내 Rust 경험자가 없어 유지보수 부담을 고려해야 했다. 당장은 프로덕션 적용을 보류하고, 학습 목적으로 사이드 프로젝트에 먼저 적용하기로 했다.