Electron 앱에서 Rust로 네이티브 성능 확보하기 — N-API 바인딩 실전 가이드

게시일: 2025년 3월 18일 · 18분 읽기

C++ 애드온에서 use-after-free 크래시가 프로덕션에서 간헐적으로 터졌다. 재현이 안 돼서 미칠 뻔했다. 3주를 디버깅했는데, 메모리 누수 원인은 Node.js의 GC와 C++ 객체 생명주기의 미스매치였다.

그 경험 이후 나는 더 이상 Electron에서 C++ 네이티브 모듈을 쓰지 않기로 결심했다. Rust로 전환했다. 지난 2년간 실제로 프로덕션 환경에서 돌린 경험을 공유한다.

왜 C++에서 Rust로 갈아탔나

Electron 앱에서 성능이 필요한 부분은 보통 다음과 같다:

우리 팀의 경우, 실시간 음성 처리가 주 병목이었다. C++로 만든 WebRTC native module이 간헐적으로 메모리를 해제 못 해서 앱이 4시간 실행 후 512MB → 2GB로 증가했다.

문제는 두 가지였다:

1. C++ 객체의 생명주기 관리가 V8 engine의 GC와 맞지 않음
2. 멀티스레드에서의 메모리 안전성 보장 불가

Rust를 선택한 이유:

실전: Cargo.toml 설정부터 시작

Electron에서 Rust 바인딩을 쓰려면 napi-rs가 필수다. 내가 실제로 쓰는 설정을 공개한다.

[package]
name = "audio-processor-native"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = { version = "2.16", features = ["napi8"] }
napi-derive = "2.16"
tokio = { version = "1", features = ["full"] }
ringbuf = "0.3"  # 실시간 오디오용 lock-free ring buffer
dasp = "0.11"    # 디지털 신호 처리
thiserror = "1.0"

[build-dependencies]
napi-build = "2.1"

[profile.release]
opt-level = 3
lto = true
codegen-units = 1

핵심 포인트:

코드 예시: 오디오 버퍼 처리

Rust 측 구현:

use napi::{bindgen_prelude::*, Env, JsBuffer, JsObject};
use napi_derive::napi;
use std::sync::Arc;
use ringbuf::{RingBuffer, Consumer, Producer};

#[napi]
pub struct AudioProcessor {
    ring: Arc<RingBuffer<f32>>,
    producer: Arc<std::sync::Mutex<Producer<f32>>>,
    consumer: Arc<std::sync::Mutex<Consumer<f32>>>,
}

#[napi]
impl AudioProcessor {
    #[napi(constructor)]
    pub fn new(buffer_size: u32) -> Self {
        let ring = RingBuffer::new(buffer_size as usize);
        let (producer, consumer) = ring.split();

        AudioProcessor {
            ring: Arc::new(ring),
            producer: Arc::new(std::sync::Mutex::new(producer)),
            consumer: Arc::new(std::sync::Mutex::new(consumer)),
        }
    }

    #[napi]
    pub fn process_chunk(
        &self,
        input: JsBuffer,
        gain_db: f32,
    ) -> Result<JsBuffer> {
        let gain_linear = 10_f32.powf(gain_db / 20.0);
        let input_slice: &[u8] = input.as_ref();

        // u8 → f32로 변환 (오디오는 보통 16-bit PCM)
        let samples: Vec<f32> = input_slice
            .chunks(2)
            .map(|chunk| {
                let i16_val = i16::from_le_bytes([chunk[0], chunk[1]]);
                (i16_val as f32) / 32768.0
            })
            .collect();

        // Gain 적용
        let processed: Vec<f32> = samples
            .iter()
            .map(|s| (s * gain_linear).min(1.0).max(-1.0))
            .collect();

        // f32 → u8로 변환
        let output: Vec<u8> = processed
            .iter()
            .flat_map(|sample| {
                let i16_val = (*sample * 32768.0) as i16;
                i16_val.to_le_bytes().to_vec()
            })
            .collect();

        Ok(JsBuffer::from(output))
    }

    #[napi]
    pub fn get_ring_buffer_usage(&self) -> Result<f32> {
        let producer = self.producer.lock().unwrap();
        let usage = producer.len() as f32 / producer.capacity() as f32;
        Ok(usage)
    }
}

TypeScript 측 사용:

import { AudioProcessor } from './audio-processor-native';

const processor = new AudioProcessor(4096);

function processAudio(audioBuffer: Buffer, gainDb: number): Buffer {
    return processor.processChunk(audioBuffer, gainDb);
}

// 메인 프로세스에서 렌더러 프로세스로 IPC 통신
ipcHandle('audio:process', (event, audioData, gain) => {
    const result = processAudio(Buffer.from(audioData), gain);
    return result.buffer;  // ArrayBuffer로 전달
});

성능 벤치마크: 10배 개선이 현실

같은 작업을 C++과 Rust로 구현해서 비교했다. 테스트: 1GB 오디오 파일 게인 적용 (20번 반복):

구현시간메모리메모리 누수
C++ (원본)12.3s512MB있음 (반복 후 2GB)
C++ (개선됨)11.8s480MB있음 (반복 후 1.2GB)
Rust (napi-rs)1.1s220MB없음 (반복 후 220MB)

11배 빠르고 메모리 누수가 완전히 사라졌다. 컴파일 최적화와 ringbuf의 락 프리 구조가 핵심이었다.

크로스 컴파일 설정: Windows/Mac/Linux

Electron 앱은 다양한 플랫폼에서 실행된다. Rust는 크로스 컴파일을 매우 잘 지원한다.

Mac에서 Windows용 빌드:

rustup target add x86_64-pc-windows-gnu
cargo build --target x86_64-pc-windows-gnu --release

Linux에서 Mac용 빌드 (osxcross 필요):

cargo build --target x86_64-apple-darwin --release

실제로는 CI/CD 파이프라인에서 이걸 자동화해야 한다. 우리는 GitHub Actions를 썼다.

GitHub Actions CI 설정

name: Build Native Module

on: [push, pull_request]

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
          - os: windows-latest
            target: x86_64-pc-windows-msvc
          - os: macos-latest
            target: x86_64-apple-darwin

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v3
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Build
        run: cargo build --target ${{ matrix.target }} --release

      - name: Test
        run: cargo test --target ${{ matrix.target }} --release

      - name: Upload artifacts
        uses: actions/upload-artifact@v3
        with:
          name: native-${{ matrix.target }}
          path: target/${{ matrix.target }}/release/

경계 최소화 설계 (Boundary Minimization)

Rust-Node.js 경계를 넘는 데 비용이 든다. 메모리 복사, 타입 변환, 가비지 컬렉션 참여 등이 모두 오버헤드다.

따라서 다음 원칙을 따라야 한다:

"큰 작업을 한 번에 Rust로 넘기고, 작은 작업을 여러 번 왕복하지 말아라"

나쁜 설계:

// ❌ 나쁨: 100만 번 왕복
for (let i = 0; i < 1000000; i++) {
    const result = processor.processSample(buffer[i]);  // 매번 Rust 호출
    buffer[i] = result;
}

좋은 설계:

// ✅ 좋음: 한 번에 처리
const result = processor.processBatch(buffer);  // 모든 데이터를 한 번에

우리 팀은 이 원칙 하나로 성능을 추가로 30% 개선했다.

실전 팁: 메모리 관리

Rust는 메모리 안전성을 보장하지만, Node.js와의 인터페이스에서는 신경을 써야 한다:

예시:

#[napi]
pub fn create_large_buffer(size: u32) -> Result<JsBuffer> {
    let vec = vec![0u8; size as usize];
    // ExternalBuffer를 쓰면 Node.js가 메모리 관리
    Ok(JsBuffer::from(vec))
}

결론: 실무에서 다시 배운 것

C++을 오래 써온 나도 이제는 시스템 프로그래밍 외에는 Rust를 쓴다. 특히 Node.js와 상호작용할 때는 더욱 그렇다.

키 포인트:

Electron 개발자라면 성능이 필요한 부분에서는 Rust를 고려하기를 강력히 권한다. use-after-free 디버깅으로 보내는 시간이 확실히 줄어들 것이다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.