02 — Main 프로세스
한 줄 답: Main은 앱 전체에 단 하나 존재하는 오케스트레이터다 — Node.js 전권을 가지고,
app인스턴스를 통해 OS 이벤트를 받고,BrowserWindow를 띄워 Renderer를 자식으로 두고, 시스템 트레이·메뉴·자동 업데이트를 책임진다. 이게 죽으면 앱이 죽는다.
Why — 왜 단 하나의 Main이 필요한가
세 가지 이유.
- OS와의 진입점이 하나여야 한다. macOS는 dock 클릭, Windows는 .exe 실행, Linux는 .desktop 파일 — 모두 한 프로세스 시작으로 앱을 본다. 그 프로세스가 Main이다.
- 공유 자원(파일·DB·로그)에 단일 라이터가 필요하다. 두 Renderer가 같은 SQLite 파일을 동시에 쓰면 락 충돌이 난다. Main이 중개자 역할을 하면 자동으로 직렬화된다.
- Renderer는 언제든 죽을 수 있다. 죽었을 때 살아 있어서 다시 살릴 누군가가 필요하다 — 그게 Main이다. Main 자신이 죽으면 OS가 앱을 종료한다.
이건 설계 원칙이라기보다 Chromium에서 물려받은 구조다 — Browser Process 하나가 모든 Renderer를 부모로 둔다.
How — Main이 실제로 하는 일
1) app 인스턴스로 라이프사이클을 받는다
// main.js
const { app, BrowserWindow } = require('electron')
app.whenReady().then(() => {
createWindow()
})
app.on('window-all-closed', () => {
// macOS는 dock에 남아있는 게 관례
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
// macOS dock 클릭으로 다시 활성화될 때
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})app 객체는 EventEmitter다. 받을 수 있는 주요 이벤트.
| 이벤트 | 언제 |
|---|---|
ready (= whenReady()) | Electron 초기화 완료. 여기서부터 BrowserWindow 만들 수 있다. |
before-quit | 앱 종료 직전. 저장 안 한 데이터 처리 마지막 기회. |
will-quit | 모든 창이 닫힌 직후. |
window-all-closed | 마지막 창이 닫힘. macOS만 종료 안 하는 게 관례. |
activate | macOS dock 클릭. |
second-instance | requestSingleInstanceLock 후, 두 번째 실행이 시도됐을 때. |
render-process-gone | 어떤 Renderer가 죽었음 (자세히는 06). |
web-contents-created | 새 webContents 생성. 외부 URL 차단을 여기서 건다. |
app.whenReady()전에BrowserWindow를 만들지 마라. Chromium 초기화가 끝나기 전에 GPU·세션이 없어서 조용히 망가지는 케이스가 있다.
2) BrowserWindow로 Renderer를 띄운다
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // v12+ 디폴트 true
sandbox: true, // v20+ 디폴트 true
nodeIntegration: false, // v5+ 디폴트 false
},
})
win.loadFile('index.html')
// 또는 win.loadURL('http://localhost:3000') (개발)
}이 한 줄이 Renderer 프로세스 하나를 띄운다. 옵션의 의미는 03 Renderer·04 Preload·05 보안에서 깊이 다룬다.
3) Node API를 자유롭게 쓴다
Main은 Node.js 환경이다. fs, child_process, os, path, crypto, npm 모듈 전부 가능.
const fs = require('node:fs/promises')
const { exec } = require('node:child_process')
const os = require('node:os')
// 사용자 홈 디렉토리에 로그 파일 쓰기
await fs.writeFile(path.join(os.homedir(), '.myapp/log.txt'), 'hello')
// 외부 명령 실행 (조심! 인자 검증 필수)
exec('git --version', (err, stdout) => console.log(stdout))Renderer에는 이게 다 막혀 있다. 그래서 “파일을 읽어 화면에 보여줘”는 항상 Renderer가 Main에게 IPC로 요청 → Main이 fs.readFile → 결과를 Renderer에 보냄 패턴이 된다 (03 IPC & Bridge).
4) 시스템 트레이·메뉴·다이얼로그
OS의 프레임 바깥 영역도 모두 Main이 책임진다.
const { Tray, Menu, dialog } = require('electron')
// 시스템 트레이 (Windows tray, macOS menu bar)
const tray = new Tray('icon.png')
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Open', click: () => mainWindow.show() },
{ label: 'Quit', click: () => app.quit() },
]))
// 네이티브 파일 선택 다이얼로그
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Images', extensions: ['png', 'jpg'] }],
})이런 API는 모두 Main 전용이다. Renderer에서 부르면 undefined다. Renderer가 파일을 열고 싶으면 IPC로 Main에게 “다이얼로그 띄워줘”라고 부탁한다.
단일 인스턴스 — requestSingleInstanceLock
데스크톱 앱의 90%는 동시에 두 개 실행되면 안 된다. 같은 DB 파일을 두 인스턴스가 동시에 락 걸려고 하면 충돌한다.
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
// 이미 다른 인스턴스가 살아 있음 → 그 인스턴스가 일을 처리할 것
app.quit()
} else {
app.on('second-instance', (event, argv, workingDirectory) => {
// 사용자가 두 번째로 더블클릭했을 때 호출됨
// 기존 창을 띄워준다
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
// argv에 두 번째 실행에서 받은 인자(파일 경로 등)가 들어 있다
})
}핵심 동작:
- 첫 번째 실행:
requestSingleInstanceLock()이true→ 정상 진입. - 두 번째 실행: 같은 함수가
false반환 → 바로 종료. 그 직전에 첫 번째 인스턴스의second-instance이벤트가 발화된다 → 첫 번째 인스턴스가 기존 창을 띄우고 인자를 받는다.
이 패턴 빠뜨리면 Windows에서 파일 더블클릭 = 매번 새 앱이라는 어색한 UX가 된다. 가장 흔한 빠뜨림.
What — Main 프로세스 한 페이지 지도
What-if — Main에서 하지 말아야 할 것들
| 안티패턴 | 왜 나쁜가 | 대안 |
|---|---|---|
Main에서 fs.readFileSync(거대 파일) | 모든 창 freeze. Main은 전체 OS 이벤트 루프다. | fs.promises.readFile 또는 UtilityProcess |
| Main에서 무거운 압축·암호화 | 같은 이유 | UtilityProcess 또는 worker_threads |
| Main에서 React 같은 UI 라이브러리 import | 무의미. Main에는 DOM이 없다. | Renderer로 분리 |
| Main에 비즈니스 로직 전부 몰아넣기 | 단일 책임 위반. 테스트 어려움. | 도메인 로직은 별도 모듈로 분리, Main은 오케스트레이션만 |
app.requestSingleInstanceLock() 빼먹기 | 인스턴스 N개 → DB 락 충돌 | 모든 프로덕션 앱에서 필수 |
nodeIntegration: true 켜기 | Renderer에 Node 권한 노출 = XSS = RCE | 절대 켜지 말 것. preload + contextBridge 사용 |
외부 URL을 webContents.loadURL로 직접 로드 | 외부 사이트 JS가 Renderer 컨텍스트에서 실행 | shell.openExternal()로 시스템 브라우저로 보냄 |
마지막 항목은 가장 흔한 RCE 통로다.
<a href="https://evil.com">을 앱 안에서 열면 그 사이트가 Electron 컨텍스트에서 뜬다. 외부 링크는 항상event.preventDefault()+shell.openExternal(url)패턴 (05 보안).
Main 프로세스의 생명
Main 프로세스 종료 = 앱 종료. 그 종료는 다섯 단계로 일어난다.
1. 모든 창이 닫힘
2. app.emit('window-all-closed')
3. (macOS가 아니면) app.quit() 호출
4. app.emit('before-quit')
5. app.emit('will-quit')
6. 프로세스 exitbefore-quit에서 비동기 작업이 끝날 때까지 기다려야 한다면 event.preventDefault() + 직접 app.quit() 호출 패턴을 쓴다.
let isSaving = false
app.on('before-quit', async (event) => {
if (isSaving) return // 두 번째 호출은 통과시킴
event.preventDefault()
isSaving = true
await saveAllDirtyData() // 비동기 저장
app.quit() // 다시 종료 시도 → 이번엔 통과
})Insight — Main은 프로그램의 운영체제
Main 프로세스를 작은 OS로 보면 모든 게 정렬된다.
- OS가 프로세스를 관리하듯 → Main이 BrowserWindow를 관리한다.
- OS가 시스템 콜로 자원에 닿듯 → Renderer가 IPC로 Main의 Node API에 닿는다.
- OS가 권한을 분리하듯 → Main은 전권, Renderer는 sandbox.
- OS가 죽으면 시스템이 멎듯 → Main이 죽으면 앱이 멎는다.
이 비유가 정확한 이유는 — Chromium의 Browser Process가 처음부터 *“각 탭에게 작은 OS처럼 행동하는 프로세스”*로 설계됐기 때문이다. Electron은 그 비유를 데스크톱 앱으로 확장했을 뿐이다.
그래서 Main에 비즈니스 로직을 넣는 게 자연스러워 보이지만, 사실은 안티패턴이다. OS 안에 비즈니스 로직을 박지 않듯이, Main에는 오케스트레이션만 두고 도메인 로직은 별도 모듈(Node 스크립트)로 분리하는 게 좋다. 그래야 Main 외 환경(Node 테스트, CLI)에서도 같은 코드를 돌릴 수 있다.
요약
Main 프로세스는 앱 전체에 단 하나 존재하는 오케스트레이터다.
app이벤트로 OS 라이프사이클을 받고,BrowserWindow로 Renderer를 띄우고, Node API를 전권으로 쓰며, IPC로 Renderer의 요청을 받는다. 단일 인스턴스 보장(requestSingleInstanceLock), Renderer crash 복구, 외부 URL 차단 — 이 셋은 프로덕션 Main의 최소 의무다. Main이 죽으면 앱이 죽지만, Main에 너무 많은 걸 넣어도 앱이 무거워진다. 무거운 작업은 05 — Utility Process로 분리한다.