Electron 앱에서 Rust로 네이티브 성능 확보하기 — N-API 바인딩 실전 가이드
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를 선택한 이유:
- 컴파일 타임에 메모리 안전성 보장 (use-after-free 불가능)
- napi-rs는 Node.js N-API와 완벽하게 호환
- C++보다 빌드 속도 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
핵심 포인트:
crate-type = ["cdylib"]— Node.js가 require()할 수 있는 네이티브 바이너리 생성napi8feature — 최신 Node.js API 지원ringbuf— 락 프리 버퍼는 오디오 처리에서 필수. 뮤텍스 쓰면 지연 발생- Release 프로필에서
lto = true— 링크타임 최적화로 추가 성능 5-10% 향상
코드 예시: 오디오 버퍼 처리
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.3s | 512MB | 있음 (반복 후 2GB) |
| C++ (개선됨) | 11.8s | 480MB | 있음 (반복 후 1.2GB) |
| Rust (napi-rs) | 1.1s | 220MB | 없음 (반복 후 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와의 인터페이스에서는 신경을 써야 한다:
- 큰 버퍼는 ExternalBuffer 사용 — 복사를 피하고 V8이 메모리를 직접 관리하도록
- Arc 사용 — 여러 스레드에서 같은 데이터 공유 시
- drop 명시 — 큰 구조체는 사용 끝나면 명시적으로 해제
예시:
#[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와 상호작용할 때는 더욱 그렇다.
키 포인트:
- 메모리 안전성 = 프로덕션 안정성
- napi-rs는 이미 충분히 성숙함
- 10배 성능 개선은 과장이 아님
- 크로스 컴파일이 정말 쉬움
Electron 개발자라면 성능이 필요한 부분에서는 Rust를 고려하기를 강력히 권한다. use-after-free 디버깅으로 보내는 시간이 확실히 줄어들 것이다.