Electron 네이티브 계층 — OS 통합과 C++ 바인딩

BrowserWindow·dialog·Tray 같은 Electron API가 실제로는 OS 네이티브 API를 호출하는 C++ 바인딩이라는 점과, 직접 만든 네이티브 노드 모듈을 Electron ABI에 맞춰 재빌드해야 하는 이유를 정리한다.

Seobway · · 13분

JS API는 빙산의 일각

new BrowserWindow({ width: 800, height: 600 }) 한 줄을 쓰면 화면에 창이 뜬다. 너무 당연해서 그 아래에서 무슨 일이 일어나는지 생각해 볼 일이 별로 없다.

하지만 electron 모듈에서 가져오는 app, BrowserWindow, dialog, Tray, Notification 같은 객체들은 사실 JavaScript로 구현된 것이 아니다. 이들은 모두 메인 프로세스 전용 API이며[1], 내부적으로는 Electron이 C++로 작성한 객체에 대한 얇은 바인딩이다.

%% desc: JS의 BrowserWindow 생성 호출이 V8/C++ 바인딩을 거쳐 OS별 네이티브 위젯 시스템과 커널까지 내려가는 경로
flowchart TD
  JS["JS 코드 (main process)\nnew BrowserWindow({...})"]
  BRIDGE["V8 ↔ C++ 바인딩\n(gin / mate)"]
  CPP["Electron C++ 레이어\nelectron::api::BrowserWindow\n→ electron::NativeWindow"]
  WIN["Windows\nWin32 / HWND"]
  MAC["macOS\nCocoa / NSWindow"]
  LINUX["Linux\nGTK / X11·Wayland"]
  KERNEL["운영체제 커널"]

  JS --> BRIDGE --> CPP
  CPP -->|#ifdef OS_WIN| WIN
  CPP -->|#ifdef OS_MAC| MAC
  CPP -->|#ifdef OS_LINUX| LINUX
  WIN --> KERNEL
  MAC --> KERNEL
  LINUX --> KERNEL

핵심은 이렇다. Electron = Chromium(렌더링) + Node.js(런타임) + Electron 고유 C++ 코드(OS 통합). electron 모듈은 이 마지막 C++ 코드층으로 들어가는 입구일 뿐이며, 같은 한 줄의 JS가 OS마다 전혀 다른 네이티브 위젯을 만들어낸다.


OS 통합 API — 어떤 JS가 어떤 OS를 건드리나

대표적인 OS 통합 API와 그 아래에서 실제로 호출되는 네이티브 대상을 표로 정리하면 다음과 같다.

JS API 프로세스 건드리는 OS 기능 내부 네이티브 대상
app Main 앱 생명주기, 단일 인스턴스 락, 기본 프로토콜 핸들러, Dock/Taskbar OS 세션·런루프, NSApplication(mac), 작업표시줄(win)
BrowserWindow Main OS 창 생성·이동·최소화·투명·진동(vibrancy) NSWindow(mac) / HWND(win) / GTK·X11·Wayland 표면(linux)
Menu / Menu.setApplicationMenu Main 네이티브 메뉴바, 컨텍스트 메뉴 NSMenu(mac, 화면 상단) / 창 내부 메뉴(win·linux)
Tray Main 시스템 알림 영역(트레이/상태표시줄) 아이콘·메뉴 상태표시줄(mac) / 시스템 트레이(win) / StatusNotifierItem·GtkStatusIcon(linux)
Notification Main OS 알림 센터(토스트/배너) UNUserNotificationCenter(mac) / WinRT Toast(win) / libnotify(linux)
dialog Main 네이티브 파일 열기/저장·메시지박스·에러 NSOpenPanel/NSSavePanel(mac) / IFileDialog(win) / GTK 다이얼로그(linux)
nativeImage Main+Renderer 트레이·Dock·앱 아이콘 비트맵, HiDPI, 템플릿 이미지 NSImage(mac) / HBITMAP(win)
clipboard Main+Renderer OS 클립보드 읽기/쓰기(텍스트·이미지·HTML) NSPasteboard(mac) / Clipboard API(win) / X11·Wayland 셀렉션
globalShortcut Main 앱이 비활성일 때도 듣는 전역 단축키 RegisterHotKey(win) / Carbon·Cocoa 이벤트탭(mac)
powerMonitor Main 절전/복귀·화면잠금·배터리·유휴 상태 전원 관리 이벤트(WM_POWERBROADCAST, IOKit 등)
screen Main 디스플레이 개수·해상도·DPI·배치·커서 위치 디스플레이 서버/그래픽 드라이버 질의

각 API의 동작 차이는 공식 문서에 명시돼 있다.[2][3][4][5][6]

BrowserWindow — 가장 대표적인 "C++ → 플랫폼 창"

BrowserWindowBaseWindow를 확장한 클래스이며, appready 이벤트 이후에만 생성할 수 있다. 렌더 스택 초기화에 의존하기 때문이다. 옵션 중 상당수가 특정 OS에서만 의미를 갖는다는 점이, 내부에 C++ 플랫폼 분기가 존재한다는 직접적인 증거다.

// 메인 프로세스
const { BrowserWindow } = require('electron')

const win = new BrowserWindow({
  width: 800,
  height: 600,
  frame: false,                 // 네이티브 프레임 제거 (모든 OS, 동작은 상이)
  transparent: true,            // 투명 창 (컴포지터 의존)
  titleBarStyle: 'hiddenInset', // macOS 전용 타이틀바 스타일
  vibrancy: 'sidebar',          // macOS 전용 — NSVisualEffectView 블러
})

win.setVibrancy(type[, options])는 문서에 macOS 전용으로 명시돼 있다. Cocoa의 NSVisualEffectView를 직접 제어하는 호출이라, Windows·Linux에는 대응 개념이 없어 그냥 무시된다. win.getOpacity()도 마찬가지로 "On Linux, always returns 1"이라고 적혀 있다[2] — 같은 API라도 OS별 백엔드의 능력에 따라 결과가 달라진다는 뜻이다.

dialog — 네이티브 파일 선택기

dialog.showOpenDialog, dialog.showSaveDialog, dialog.showMessageBox는 브라우저의 <input type="file">이 아니라 OS가 직접 그리는 파일 선택기·메시지박스를 띄운다.

const { dialog } = require('electron')

const result = await dialog.showOpenDialog({
  properties: ['openFile', 'openDirectory', 'multiSelections'],
})

// showMessageBox → Promise<{ response: number, checkboxChecked: boolean }>

properties 옵션을 조합해 동작을 정의하는데(openFile, openDirectory, multiSelections 등), 일부 조합은 OS에 따라 제한된다. showHiddenFiles 옵션은 macOS/_Windows_에서만 동작하고 Linux에서는 Deprecated 상태다[3] — 여기서도 플랫폼 백엔드 차이가 그대로 드러난다.

Tray — 시스템 알림 영역

const { app, Tray, Menu, nativeImage } = require('electron')

app.whenReady().then(() => {
  const icon = nativeImage.createFromPath('icon.png')
  const tray = new Tray(icon)
  tray.setContextMenu(Menu.buildFromTemplate([{ label: 'Quit', role: 'quit' }]))
})

Linux에서는 트레이 아이콘이 기본적으로 StatusNotifierItem 스펙을 사용하며, 데스크톱 환경이 이를 지원하지 않으면 GtkStatusIcon으로 폴백한다[4]. click 이벤트가 좌클릭과 더블클릭 중 무엇으로 발생하는지도 환경마다 다른데, StatusNotifierItem 스펙 자체가 "무엇이 activation인지"를 규정하지 않기 때문이다[7]. 즉 같은 tray.on('click') 코드가 OS/데스크톱 환경에 따라 다르게 트리거될 수 있다.

Notification — OS 알림 센터로 직결

const { Notification } = require('electron')

new Notification({ title: '빌드 완료', body: 'DXF 변환이 끝났습니다.' }).show()

문서에는 명확한 프로세스 경계가 있다. 렌더러에서 알림을 보내려면 웹 표준 Notifications API를 써야 하고, 메인 프로세스에서는 Notification 클래스를 쓴다.

macOS에서는 알림이 UNUserNotificationCenter(UserNotifications 프레임워크) 위에서 동작한다. 옵션 id는 macOS의 UNNotificationRequest.identifier, Windows의 토스트 Tag로 매핑되고, groupId는 macOS의 threadIdentifier, Windows의 토스트 Group으로 매핑된다[5]. JS에서 던진 옵션 객체의 필드 하나하나가 OS별 네이티브 객체의 필드로 그대로 변환되는 셈이다.

nativeImage — 아이콘 비트맵 추상화

트레이·Dock·앱 아이콘에 쓰이는 이미지를 OS 비트맵으로 변환해 주는 계층이다.


네이티브 노드 모듈(.node) — C/C++ 코드를 JS로 끌어올리기

내장 API로 부족한 경우(OS별 특수 SDK 사용, 고성능 수치 연산, 기존 C++ 라이브러리 재사용 등)에는 직접 네이티브 애드온을 작성한다. 결과물은 .node 확장자의 동적 라이브러리이며, require('./addon.node')로 일반 JS 모듈처럼 로드할 수 있다.

노출 메커니즘: V8 / Node-API

C++ 코드가 JS에 함수를 노출하는 방식은 크게 두 가지다.

  1. V8 API 직접 사용NODE_MODULE() 매크로로 등록한다. V8 내부 타입(Local<Object> 등)에 직접 의존하기 때문에, V8/Node 버전이 바뀌면 깨지기 쉽다.
  2. Node-API(N-API) 사용 — ABI가 안정적인 C 인터페이스를 사용한다. Node/Electron 버전이 바뀌어도 재컴파일 없이 동작할 여지가 있다(아래 ABI 안정성 절 참고). C++ 친화적인 래퍼로는 node-addon-api가 있다.

Electron 공식 문서는 애드온 작성을 "Native Node.js Addons 위에 구축하는 것"으로 설명하며, Node-API 애드온 + context-aware 선언을 권장 패턴으로 제시한다[8].

빌드 도구 체인

네이티브 애드온을 실제로 컴파일·배포하기 위한 도구들은 역할이 조금씩 다르다.

도구 역할
node-gyp binding.gyp 명세를 읽어 플랫폼별로 C++을 컴파일하는 가장 기본적인 도구
CMake.js CMake 기반 프로젝트를 위한 대안 빌드 도구
node-pre-gyp node-gyp 기반 + 빌드된 바이너리를 임의 서버(예: Amazon S3)에 업로드/다운로드
prebuild node-gyp 또는 CMake.js 빌드를 지원, 바이너리를 GitHub Releases에만 업로드
prebuildify node-gyp 기반, 빌드 바이너리를 npm 패키지에 동봉해 설치 즉시 사용 가능(런타임 빌드 불필요)

이 도구들의 차이는 한 줄로 요약하면 "누가, 언제, 어디서 빌드 바이너리를 만드느냐"의 문제다[8][9].

최소 C++ 애드온 흐름 (Node-API / context-aware)

간단한 Node-API 애드온의 골격은 다음과 같다.

// addon.cc — context-aware 애드온의 골격
#include <node_api.h>

napi_value Method(napi_env env, napi_callback_info info) {
  napi_value greeting;
  napi_create_string_utf8(env, "world", NAPI_AUTO_LENGTH, &greeting);
  return greeting;
}

// context-aware: NODE_MODULE_INITIALIZER 매크로로 초기화 함수를 선언
NAPI_MODULE_INIT(/* env, exports */) {
  napi_value fn;
  napi_create_function(env, nullptr, 0, Method, nullptr, &fn);
  napi_set_named_property(env, exports, "hello", fn);
  return exports;
}
# binding.gyp — node-gyp 빌드 명세
{
  "targets": [{
    "target_name": "addon",
    "sources": ["addon.cc"]
  }]
}
// 사용
const addon = require('./build/Release/addon.node')
console.log(addon.hello()) // → "world"


왜 Electron용으로 재빌드해야 하는가 — Node ABI vs Electron ABI

증상

Node.js용으로 컴파일된 네이티브 모듈을 Electron에서 그대로 require하면 이런 오류를 만나게 된다.

Error: The module '/path/to/native/module.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION ... This version of Node.js requires
NODE_MODULE_VERSION ... Please try re-compiling or re-installing the module.

원인: ABI(Application Binary Interface)가 다르다

공식 문서는 이렇게 설명한다.

"Electron has a different application binary interface (ABI) from a given Node.js binary (due to differences such as using Chromium's BoringSSL instead of OpenSSL), the native modules you use will need to be recompiled for Electron."[9]

같은 버전 번호의 Node.js와 Electron이라도, 내장된 V8 버전, 컴파일러/링커 설정, 암호 라이브러리(BoringSSL vs OpenSSL) 등이 달라 바이너리 인터페이스가 일치하지 않을 수 있다. 네이티브 모듈은 특정 V8/Node 헤더와 심볼에 링크된 기계어이므로, 컴파일 시점의 ABI와 런타임 ABI가 어긋나면 로드 자체가 실패한다. ABI라는 개념 자체에 대한 배경은 위키백과 문서를 참고할 만하다[10].

%% desc: 동일한 소스로 빌드된 네이티브 애드온이 Node.js 빌드와 Electron 빌드에서 서로 다른 ABI(V8 버전·암호 라이브러리·NODE_MODULE_VERSION) 때문에 호환되지 않는 구조
flowchart LR
  SRC["addon.cc\n(동일한 소스)"]

  subgraph NODE["Node.js 빌드"]
    NV8["V8 (Node 버전)"]
    NSSL["OpenSSL"]
    NVER["NODE_MODULE_VERSION = a"]
  end

  subgraph ELECTRON["Electron 빌드"]
    EV8["V8 (Electron 버전)"]
    ESSL["BoringSSL"]
    EVER["NODE_MODULE_VERSION = b"]
  end

  SRC -->|node-gyp build| NODE
  SRC -->|electron-rebuild| ELECTRON

  NODE -->|"require() in Electron"| FAIL["로드 실패:\ncompiled against different version"]
  ELECTRON -->|"require() in Electron"| OK["정상 로드"]

해결: @electron/rebuild

npm install 이후 매번 Electron ABI에 맞춰 네이티브 모듈을 재컴파일해 주면 된다.

npm install --save-dev @electron/rebuild

# 매 npm install 이후 실행
./node_modules/.bin/electron-rebuild

# Windows에서 문제가 있으면
.\node_modules\.bin\electron-rebuild.cmd

@electron/rebuild는 내부적으로 node-gyp를 호출하지만, Electron의 헤더·버전·아키텍처(--target, --dist-url, --arch 등)를 자동으로 지정해 빌드한다[9]. 즉 같은 소스 코드를 Node가 아니라 Electron 런타임을 겨냥해 다시 컴파일하는 것이 핵심이다.


플랫폼별 차이 요약

지금까지 살펴본 OS별 차이를 영역별로 한눈에 정리하면 다음과 같다.

영역 Windows macOS Linux
창 백엔드 Win32 / HWND Cocoa / NSWindow GTK + X11·Wayland
메뉴 창 내부 메뉴바 화면 상단 글로벌 메뉴바(NSMenu) 창 내부(DE에 따라 글로벌)
트레이 시스템 트레이 상태표시줄(메뉴바 우측) StatusNotifierItem → GtkStatusIcon 폴백
알림 WinRT Toast (Tag/Group) UNUserNotificationCenter (identifier/threadIdentifier) libnotify 계열
파일 다이얼로그 IFileDialog NSOpenPanel/NSSavePanel GTK 다이얼로그
투명/블러 DWM 합성 vibrancy(NSVisualEffectView) 컴포지터 의존
아이콘 HBITMAP/ICO NSImage + template image 테마 아이콘
창 투명도 조회 실제 값 실제 값 getOpacity() 항상 1
암호 라이브러리(ABI 영향) BoringSSL(Electron 공통) 동일 동일

macOS 전용 기능(vibrancy, titleBarStyle, template image)과 Linux 트레이 폴백, 투명도 조회 차이는 모두 공식 문서에 명시된 사항이다.


실무 체크리스트

지금까지의 내용을 실제 Electron 앱 개발에 적용한다면 이렇게 정리할 수 있다.

지금까지는 메인 프로세스가 OS와 직접 대화하는 경로를 봤다. 다음 글에서는 시선을 안쪽으로 돌려, 메인 프로세스와 렌더러 프로세스가 안전하게 데이터를 주고받는 Electron IPC와 보안 모델 — contextBridge, Context Isolation, Sandbox →을 살펴본다.


참고

  1. [1] app — Electron Documentation
  2. [2] BrowserWindow — Electron Documentation
  3. [3] dialog — Electron Documentation
  4. [4] Tray — Electron Documentation
  5. [5] Notification — Electron Documentation
  6. [6] nativeImage — Electron Documentation
  7. [7] StatusNotifierItem Specification — freedesktop.org
  8. [8] Native Code and Electron — Electron Documentation
  9. [9] Using Native Node Modules — Electron Documentation
  10. [10] Application binary interface — Wikipedia

관련 글