Electron — 웹 기술로 데스크톱 앱 만들기
VS Code, Slack, Discord — 전부 웹 기술로 만든 데스크톱 앱이다. Hello World 앱이 150MB가 넘는데도 왜 이 방식을 선택하는 걸까?
Electron — 웹 기술로 데스크톱 앱 만들기
VS Code를 쓸 때마다 느끼는 게 있어요. "이거 진짜 데스크톱 앱 맞아?" 싶을 정도로 웹 느낌이 강하다는 거예요. 실제로 VS Code는 Electron으로 만들어졌습니다. Slack, Discord, Figma 데스크톱 클라이언트도 마찬가지고요. 웹 기술만으로 크로스 플랫폼 데스크톱 앱을 만든다는 건 꽤 매력적인 이야기인데, 어떻게 동작하는지, 어떤 한계가 있는지 한 번 정리해보겠습니다.
Electron이란
Electron은 Chromium(렌더링 엔진)과 Node.js(런타임)를 하나로 묶어서, HTML/CSS/JavaScript로 데스크톱 앱을 만들 수 있게 해주는 프레임워크입니다. GitHub에서 Atom 에디터를 만들기 위해 시작한 프로젝트인데, 지금은 사실상 웹 기반 데스크톱 앱의 표준이 되었어요.
대표적인 Electron 앱들을 보면 규모가 어마어마합니다.
| 앱 | 용도 |
|---|---|
| VS Code | 코드 에디터 |
| Slack | 메신저 |
| Discord | 음성/텍스트 채팅 |
| Notion | 노트/문서 |
| Figma (Desktop) | 디자인 툴 |
핵심 아이디어는 단순해요. 브라우저 창 하나가 곧 앱 윈도우이고, 그 안에서 웹 페이지를 띄우면 그게 데스크톱 앱이 됩니다. 다만 일반 브라우저와 다르게 Node.js API를 통해 파일 시스템, OS 알림, 시스템 트레이 같은 네이티브 기능에 접근할 수 있어요.
아키텍처 — 메인 프로세스와 렌더러 프로세스
Electron 앱은 크게 두 종류의 프로세스로 나뉩니다. 이 구조를 이해하지 못하면 Electron을 제대로 쓸 수 없어요.
┌──────────────────────────────────────────────┐
│ Electron App │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 메인 프로세스 (Main) │ │
│ │ - app 라이프사이클 │ │
│ │ - BrowserWindow 생성/관리 │ │
│ │ - 시스템 API (파일, 메뉴, 트레이) │ │
│ │ - Node.js 전체 API 사용 가능 │ │
│ └────────┬───────────────┬───────────────┘ │
│ │ IPC 통신 │ │
│ ┌────────▼──────┐ ┌──────▼────────┐ │
│ │ 렌더러 프로세스 │ │ 렌더러 프로세스 │ │
│ │ (BrowserWindow)│ │ (BrowserWindow)│ │
│ │ - 웹 페이지 │ │ - 웹 페이지 │ │
│ │ - Chromium │ │ - Chromium │ │
│ └───────────────┘ └───────────────┘ │
└──────────────────────────────────────────────┘
Chromium이 멀티 프로세스 아키텍처를 쓰는 것과 같은 맥락이에요. 하나의 메인 프로세스가 전체 앱을 관장하고, 각 윈도우(탭)마다 별도의 렌더러 프로세스가 돌아갑니다. 렌더러 하나가 죽어도 앱 전체가 뻗지 않는 구조예요.
메인 프로세스
메인 프로세스는 앱의 진입점입니다. main.js(또는 main.ts) 파일에서 시작되며, Node.js 환경에서 실행돼요.
const { app, BrowserWindow } = require('electron');
let mainWindow;
app.whenReady().then(() => {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.loadFile('index.html');
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
메인 프로세스가 담당하는 일은 명확합니다.
app라이프사이클 관리:ready,window-all-closed,activate같은 이벤트를 통해 앱의 생명주기를 제어해요BrowserWindow생성: 각 윈도우를 만들고, 크기/위치/옵션 등을 설정합니다- 시스템 API 접근:
Menu,Tray,dialog,Notification같은 OS 수준의 기능을 사용할 수 있어요 - 네이티브 모듈:
fs,child_process,os등 Node.js의 모든 모듈을 자유롭게 쓸 수 있습니다
macOS에서 window-all-closed 이벤트에 app.quit()을 바로 호출하지 않는 건, macOS의 관례(윈도우를 다 닫아도 앱은 Dock에 남아있는 것)를 따르기 위해서예요.
렌더러 프로세스
렌더러 프로세스는 사실상 Chromium 브라우저 탭 하나라고 보면 됩니다. HTML을 렌더링하고, CSS를 적용하고, JavaScript를 실행해요. React, Vue, Svelte 같은 프레임워크를 그대로 가져다 쓸 수 있다는 게 Electron의 가장 큰 강점이에요.
하지만 중요한 차이가 하나 있습니다. 예전 Electron에서는 렌더러 프로세스에서 Node.js API를 직접 쓸 수 있었어요. require('fs')로 파일을 읽고, require('child_process')로 시스템 명령을 실행하고. 편리하긴 한데, 보안적으로 이건 재앙이에요.
웹 페이지에서 Node.js API에 직접 접근할 수 있다는 건, XSS 공격 하나만 성공해도 사용자 컴퓨터의 파일 시스템을 통째로 털 수 있다는 뜻이니까요.
그래서 최신 Electron에서는 기본적으로 contextIsolation: true, nodeIntegration: false가 설정되어 있습니다. 렌더러 프로세스는 순수한 웹 환경처럼 동작하고, Node.js 기능이 필요하면 preload 스크립트 와 IPC 를 통해서만 접근해야 해요.
IPC 통신 — 프로세스 간 다리 놓기
메인 프로세스와 렌더러 프로세스는 서로 다른 프로세스입니다. 직접 메모리를 공유하지 않아요. 그럼 렌더러에서 파일을 읽고 싶으면 어떻게 해야 할까요? IPC(Inter-Process Communication) 를 씁니다.
preload 스크립트와 contextBridge
preload 스크립트는 렌더러 프로세스가 로드되기 전에 실행되는 특별한 스크립트예요. 여기서 contextBridge를 사용해 렌더러에 노출할 API를 명시적으로 정의합니다.
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
saveFile: (filePath, content) => ipcRenderer.invoke('save-file', filePath, content),
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback),
});
이렇게 하면 렌더러에서는 window.electronAPI.readFile(...) 형태로만 접근할 수 있어요. Node.js API 전체를 노출하는 게 아니라, 필요한 기능만 골라서 안전하게 열어주는 방식입니다.
메인 프로세스에서 핸들러 등록
// main.js
const { ipcMain } = require('electron');
const fs = require('fs').promises;
ipcMain.handle('read-file', async (event, filePath) => {
// 경로 검증 로직 필요
const content = await fs.readFile(filePath, 'utf-8');
return content;
});
ipcMain.handle('save-file', async (event, filePath, content) => {
await fs.writeFile(filePath, content, 'utf-8');
return { success: true };
});
IPC 통신 패턴 정리
| 패턴 | 방향 | API | 용도 |
|---|---|---|---|
| 단방향 (fire-and-forget) | 렌더러 → 메인 | ipcRenderer.send / ipcMain.on | 알림, 로그 |
| 양방향 (request-response) | 렌더러 → 메인 → 렌더러 | ipcRenderer.invoke / ipcMain.handle | 파일 읽기, DB 조회 |
| 메인 → 렌더러 | 메인 → 렌더러 | webContents.send / ipcRenderer.on | 업데이트 알림, 상태 변경 |
invoke/handle 패턴은 Promise 기반이라 async/await와 자연스럽게 붙습니다. 옛날 방식인 sendSync는 메인 프로세스를 블로킹하기 때문에 절대 쓰면 안 돼요.
보안
Electron 보안은 결국 "렌더러 프로세스를 얼마나 격리할 것인가"로 귀결됩니다.
필수 설정 세 가지
new BrowserWindow({
webPreferences: {
nodeIntegration: false, // 렌더러에서 Node.js 직접 접근 차단
contextIsolation: true, // preload와 렌더러의 JS 컨텍스트 분리
sandbox: true, // 렌더러를 Chromium 샌드박스 안에서 실행
},
});
nodeIntegration: false: 렌더러에서require()를 쓸 수 없게 만듭니다. 이게true면 XSS 한 방에 전체 시스템이 뚫려요contextIsolation: true: preload 스크립트와 웹 페이지의 JavaScript 실행 컨텍스트를 완전히 분리합니다.window객체를 공유하지 않기 때문에 프로토타입 오염 공격을 막을 수 있어요sandbox: true: Chromium의 샌드박스를 활성화해서 렌더러 프로세스의 시스템 접근을 OS 레벨에서 제한합니다
CSP(Content Security Policy) 설정
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
CSP를 제대로 설정하지 않으면 외부 스크립트 주입이 가능해집니다. eval() 사용도 막아야 하고, 인라인 스크립트도 가능하면 제한하는 게 좋아요. Electron 공식 문서에서도 CSP 설정을 강력히 권장하고 있습니다.
추가로 주의할 점 몇 가지가 더 있어요.
- 원격 콘텐츠를 로드할 때는
webSecurity를 비활성화하지 마세요 shell.openExternal()에 사용자 입력을 그대로 넣지 마세요 — 악의적인 URI 스킴으로 시스템 명령 실행이 가능합니다<webview>태그 대신BrowserView나 iframe을 사용하세요
빌드와 배포
개발이 끝나면 실제 배포 가능한 설치 파일로 만들어야 합니다. electron-builder 가 사실상 표준 도구예요.
// package.json
{
"build": {
"appId": "com.example.myapp",
"mac": {
"target": "dmg",
"category": "public.app-category.developer-tools"
},
"win": {
"target": "nsis"
},
"linux": {
"target": "AppImage"
}
}
}
자동 업데이트
electron-updater 모듈을 쓰면 앱이 새 버전을 감지하고 자동으로 업데이트하게 만들 수 있어요. GitHub Releases나 S3 같은 스토리지에 빌드 결과물을 올려놓으면, 앱이 주기적으로 업데이트 서버를 확인합니다.
const { autoUpdater } = require('electron-updater');
autoUpdater.checkForUpdatesAndNotify();
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});
코드 사이닝
macOS와 Windows 모두 서명되지 않은 앱은 경고를 띄우거나 아예 실행을 막습니다. macOS는 Apple Developer 인증서가 필요하고, Windows는 EV 코드 사이닝 인증서를 써야 SmartScreen 경고를 피할 수 있어요. 무시하고 배포하면 사용자들이 "이 앱 바이러스 아니에요?" 하면서 지워버립니다.
성능 이슈 — Electron이 무거운 이유
Electron 하면 빠지지 않는 비판이 "무겁다"는 거예요. 틀린 말은 아닙니다.
왜 무거운가
- Chromium 번들링: 앱 하나에 Chromium 전체가 포함됩니다. Hello World 앱도 150MB가 넘어요. 사용자 입장에서는 Chrome 브라우저 하나를 추가로 설치하는 것과 비슷합니다
- 메모리 사용량: 프로세스마다 별도의 V8 인스턴스가 뜹니다. Slack 하나 켜면 메모리를 500MB~1GB 먹는 건 흔한 일이에요
- 시작 속도: Chromium을 띄우고 웹 페이지를 로드해야 하니까 네이티브 앱에 비해 체감 시작 속도가 느립니다
최적화 전략
무겁다고 손 놓고 있을 수는 없으니 할 수 있는 건 해야 해요.
- 지연 로딩: 안 쓰는 모듈은 필요할 때
import()로 불러옵니다 - 윈도우 재사용: 새 윈도우를 자주 만들지 않고 기존 윈도우의 콘텐츠를 교체해요
- V8 스냅샷:
v8-compile-cache등을 사용해 JavaScript 파싱 시간을 줄입니다 - 백그라운드 스로틀링: 보이지 않는 윈도우의 리소스 사용을 제한합니다 (
backgroundThrottling옵션) - 번들 최소화: webpack이나 esbuild로 코드를 트리 쉐이킹하고, 불필요한 node_modules를 제거해요
VS Code가 이 정도 규모의 앱이면서도 비교적 쾌적하게 돌아가는 건, 이런 최적화를 극한까지 밀어붙였기 때문입니다. 아무 생각 없이 만들면 메모리 괴물이 탄생해요.
대안 프레임워크 비교
Electron의 무거움이 싫다면 대안이 몇 가지 있습니다.
| 항목 | Electron | Tauri | Flutter Desktop | .NET MAUI |
|---|---|---|---|---|
| 언어 | JavaScript/TypeScript | Rust + JS/TS | Dart | C#/XAML |
| 렌더링 엔진 | Chromium (번들) | OS 웹뷰 (시스템) | Skia (자체) | 네이티브 UI |
| 번들 크기 | 150MB+ | 2~10MB | 20~30MB | 플랫폼 의존 |
| 메모리 | 높음 | 낮음 | 중간 | 중간 |
| 생태계 | 매우 성숙 | 성장 중 | 모바일에서 강세 | MS 생태계 |
| 크로스 플랫폼 | Win/Mac/Linux | Win/Mac/Linux | Win/Mac/Linux | Win/Mac/(Linux 제한적) |
Flutter Desktop은 모바일 앱을 이미 Flutter로 만들었을 때 데스크톱까지 확장하는 시나리오에 적합합니다. .NET MAUI는 기존에 WPF나 Xamarin을 쓰던 팀에서 넘어가는 경우가 많아요. 순수하게 데스크톱 앱만 놓고 보면, 결국 Electron vs Tauri가 주요 비교 대상입니다.
Tauri vs Electron — 제대로 비교
Tauri는 Electron의 직접적인 대안으로 가장 많이 언급되는 프레임워크입니다. 핵심 차이를 파고들어 보겠습니다.
번들 크기
Electron은 Chromium을 통째로 앱에 포함시킵니다. Tauri는 OS에 내장된 웹뷰(macOS의 WebKit, Windows의 WebView2, Linux의 WebKitGTK)를 사용해요. 그래서 Tauri 앱의 설치 파일은 2~10MB 수준으로 극적으로 작습니다.
다만 OS 웹뷰를 쓴다는 건 플랫폼마다 렌더링 엔진이 다르다는 뜻이기도 해요. Electron은 어디서든 같은 Chromium이니까 크로스 브라우저 이슈를 신경 쓸 필요가 거의 없는 반면, Tauri는 WebKit과 Blink의 차이를 고려해야 할 수 있습니다.
메모리와 성능
Tauri의 백엔드는 Rust로 작성됩니다. Rust는 GC가 없고 메모리 안전성을 컴파일 타임에 보장하기 때문에, 런타임 오버헤드가 극히 적어요. 같은 기능의 앱이라면 Tauri가 메모리를 훨씬 적게 씁니다.
보안 모델
Tauri는 보안에서 확실한 우위를 가집니다.
- 기본적으로 모든 API가 비활성화 상태예요. 필요한 것만
tauri.conf.json에서 명시적으로 허용해야 합니다 - Rust 백엔드이므로 메모리 취약점(버퍼 오버플로, use-after-free) 발생 가능성이 근본적으로 낮아요
- IPC 레벨에서 커맨드 허용 목록(allowlist)을 관리합니다
Electron은 Node.js 기반이라 nodeIntegration 실수 한 번이면 전체 시스템이 위험해질 수 있는 반면, Tauri는 설계 자체가 최소 권한 원칙을 따릅니다.
그래서 뭘 써야 하나
Electron을 선택하는 경우가 아직 많은 이유는 생태계입니다. npm 패키지를 그대로 갖다 쓸 수 있고, 레퍼런스도 압도적으로 많고, 채용 시장에서도 Electron 경험을 요구하는 곳이 훨씬 많아요. Tauri는 성능과 보안은 좋지만, Rust를 팀 전체가 배워야 한다는 진입 장벽이 있습니다.
주의할 점
Q. Electron이 무거운 근본적인 이유는?
Chromium 렌더링 엔진 전체를 앱에 포함시키기 때문입니다. 각 BrowserWindow마다 독립된 렌더러 프로세스(V8 인스턴스 포함)가 생성되므로 메모리 사용량이 프로세스 수에 비례해서 증가해요. 결국 "브라우저를 하나 더 실행하는 것"이나 마찬가지인 셈입니다.
Q. 메인 프로세스와 렌더러 프로세스 간에 데이터를 공유하려면?
IPC를 통해 메시지를 주고받는 수밖에 없습니다. 공유 메모리 같은 건 없고, ipcRenderer.invoke로 요청을 보내면 메인 프로세스의 ipcMain.handle이 응답하는 구조예요. 데이터는 직렬화(structured clone algorithm)를 거쳐 전달되기 때문에, 대용량 데이터를 자주 주고받으면 성능 병목이 생길 수 있습니다. 이 경우 SharedArrayBuffer나 MessagePort를 활용하는 방법도 있어요.
Q. Electron에서 네이티브 모듈(C++ addon)을 쓸 수 있나?
가능합니다. node-gyp이나 prebuild로 빌드한 네이티브 모듈을 메인 프로세스에서 사용할 수 있어요. 다만 Electron이 사용하는 Node.js 버전과 ABI(Application Binary Interface)가 맞아야 합니다. electron-rebuild 도구를 써서 Electron의 Node.js 버전에 맞게 네이티브 모듈을 다시 빌드하는 과정이 필요해요. better-sqlite3이나 sharp 같은 라이브러리가 대표적인 예시입니다.
Q. contextIsolation이 꺼져 있으면 어떤 공격이 가능한가?
preload 스크립트가 노출한 API의 프로토타입 체인을 렌더러 쪽 코드에서 조작할 수 있습니다. 예를 들어 Array.prototype.push를 덮어쓰면 preload 내부 로직을 가로챌 수 있어요. contextIsolation이 켜져 있으면 preload와 렌더러가 별도의 JavaScript 세계에서 돌아가기 때문에 이런 프로토타입 오염이 불가능합니다.
파생 개념
Electron을 이해하려면 결국 주변 기술들도 알아야 해요.
- Node.js: Electron 메인 프로세스의 런타임입니다. 이벤트 루프, 모듈 시스템, 스트림 등 Node.js 핵심 개념을 모르면 메인 프로세스 코드를 제대로 짤 수 없어요
- 웹 보안 (CSP / XSS): Electron 보안은 결국 웹 보안의 연장선입니다. CSP 헤더를 왜 써야 하는지, XSS가 Electron에서 왜 더 위험한지 알아야 해요
- 크로스 플랫폼 개발: 하나의 코드베이스로 여러 OS를 지원한다는 건 OS별 차이(파일 경로 구분자, 윈도우 관리 방식, 코드 사이닝)를 추상화한다는 뜻입니다. 이 추상화가 어디서 깨지는지 아는 것도 중요해요
- 프로세스 간 통신 (IPC): Electron의 IPC만이 아니라 OS 수준의 IPC 메커니즘(파이프, 소켓, 공유 메모리)과 연결지어 이해하면 깊이 있는 이해가 가능합니다