Rust로 Node.js 네이티브 모듈 작성해보기

배경

이미지 리사이징 API의 응답 시간이 평균 800ms로 측정되었다. Sharp 라이브러리를 사용 중이었지만, 특정 필터 적용 로직이 순수 JavaScript로 구현되어 있어 병목이었다.

Rust로 해당 부분을 재작성하면 성능 개선이 가능할 것으로 판단했다.

Neon 선택

neon은 Rust로 안전한 Node.js 네이티브 모듈을 작성할 수 있게 해주는 프레임워크다. N-API를 기반으로 하여 Node 버전 변경에도 안정적이다.

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

Rust 구현

use neon::prelude::*;

fn apply_filter(mut cx: FunctionContext) -> JsResult<JsArray> {
    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::<u8>()
    });
    
    let mut pixels = data.to_vec();
    
    // 필터 로직 (예: 간단한 그레이스케일)
    for chunk in pixels.chunks_mut(4) {
        let avg = (chunk[0] as u32 + chunk[1] as u32 + chunk[2] as u32) / 3;
        chunk[0] = avg as u8;
        chunk[1] = avg as u8;
        chunk[2] = avg as u8;
    }
    
    let js_array = JsArray::new(&mut cx, pixels.len() as u32);
    for (i, &byte) in pixels.iter().enumerate() {
        let js_byte = cx.number(byte as f64);
        js_array.set(&mut cx, i as u32, js_byte)?;
    }
    
    Ok(js_array)
}

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

Node.js에서 사용

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

app.post('/filter', async (req, res) => {
  const { buffer, width, height } = req.body;
  const result = applyFilter(buffer, width, height);
  res.send(Buffer.from(result));
});

결과

평균 응답 시간이 800ms에서 240ms로 개선되었다. 빌드 파이프라인 복잡도는 증가했지만, 성능 개선이 명확해 유지하기로 결정했다.

소유권 시스템 때문에 초반에 컴파일 에러와 싸웠지만, 한번 통과하면 런타임 크래시가 없다는 점이 인상적이었다.