Rust로 Node.js 네이티브 모듈 작성하기 - neon 사용기

배경

대용량 이미지 리사이징 API의 응답 속도가 느려서 프로파일링을 돌려봤다. sharp 라이브러리를 쓰고 있었지만, 동시 요청이 많을 때 CPU 병목이 심했다. 이참에 Rust로 직접 작성해보기로 했다.

neon 설정

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

neon-cli가 프로젝트 구조를 자동으로 만들어준다. native/src/lib.rs에 Rust 코드를 작성하고, npm run build하면 Node 모듈로 컴파일된다.

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 data = cx.borrow(&buffer, |data| data.as_slice().to_vec());
    
    let img = image::load_from_memory(&data)
        .or_else(|e| cx.throw_error(e.to_string()))?;
    
    let resized = img.resize(width, height, FilterType::Lanczos3);
    let mut output = Vec::new();
    resized.write_to(&mut output, image::ImageOutputFormat::Jpeg(90))
        .or_else(|e| cx.throw_error(e.to_string()))?;
    
    let mut result = cx.buffer(output.len() as u32)?;
    cx.borrow_mut(&mut result, |data| {
        data.as_mut_slice().copy_from_slice(&output);
    });
    
    Ok(result)
}

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

Node.js에서 사용

const { resizeImage } = require('../native');

app.post('/resize', async (req, res) => {
  const buffer = req.file.buffer;
  const resized = resizeImage(buffer, 800, 600);
  res.type('image/jpeg').send(resized);
});

결과

동일한 이미지 처리 작업 기준으로 약 40% 성능 향상이 있었다. sharp도 네이티브 바인딩을 쓰지만, 우리 유즈케이스에 맞게 최적화하니 더 빨랐다.

Rust의 메모리 안전성 덕분에 segfault 걱정 없이 네이티브 모듈을 작성할 수 있다는 점도 좋았다. C++로 작성했다면 디버깅에 시간을 더 썼을 것 같다.