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

배경

이미지 처리 API 서버에서 특정 포맷 변환 로직이 병목이었다. sharp 라이브러리로는 우리가 원하는 알파 채널 처리가 불가능했고, 순수 JS 구현은 너무 느렸다. Rust를 학습할 겸 neon으로 네이티브 모듈을 작성하기로 했다.

neon 설정

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

프로젝트 구조는 간단했다. native/src/lib.rs에 Rust 코드를 작성하고, lib/index.js에서 import하는 방식이다.

Rust 구현

use neon::prelude::*;
use image::{DynamicImage, ImageBuffer};

fn process_image(mut cx: FunctionContext) -> JsResult<JsBuffer> {
    let buffer = cx.argument::<JsBuffer>(0)?;
    let width = cx.argument::<JsNumber>(1)?.value(&mut cx) as u32;
    
    let data = cx.borrow(&buffer, |data| {
        data.as_slice::<u8>().to_vec()
    });
    
    let img = image::load_from_memory(&data)
        .or_else(|e| cx.throw_error(e.to_string()))?;
    
    let resized = img.resize(width, width, image::imageops::Lanczos3);
    let bytes = resized.to_bytes();
    
    let mut result = cx.buffer(bytes.len() as u32)?;
    cx.borrow_mut(&mut result, |data| {
        data.as_mut_slice().copy_from_slice(&bytes);
    });
    
    Ok(result)
}

register_module!(mut cx, {
    cx.export_function("processImage", process_image)
});

성능 비교

1000장 처리 기준:

  • 기존 JS 구현: 14.3초
  • sharp: 3.2초
  • Rust 네이티브: 2.1초

절대적인 속도보다 알파 채널 처리 커스터마이징이 가능해진 게 더 중요했다.

배포 시 주의사항

neon은 빌드 시점에 네이티브 바이너리를 생성한다. Docker 이미지에서는 rust 툴체인을 포함시켜야 했다.

FROM node:16
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"

CI/CD 빌드 시간이 늘어난 건 트레이드오프였다. 하지만 런타임 성능이 더 중요한 케이스였기에 감수할 만했다.

소감

Rust의 소유권 개념이 처음엔 어려웠지만, 컴파일러 에러 메시지가 친절해서 학습 곡선이 생각보다 가팔랐다. 앞으로 성능 크리티컬한 부분은 Rust로 분리하는 패턴을 더 활용할 예정이다.