⚡ Electron1. 프로세스 모델02 — Main 프로세스

02 — Main 프로세스

한 줄 답: Main은 앱 전체에 단 하나 존재하는 오케스트레이터다 — Node.js 전권을 가지고, app 인스턴스를 통해 OS 이벤트를 받고, BrowserWindow를 띄워 Renderer를 자식으로 두고, 시스템 트레이·메뉴·자동 업데이트를 책임진다. 이게 죽으면 앱이 죽는다.


Why — 왜 단 하나의 Main이 필요한가

세 가지 이유.

  1. OS와의 진입점이 하나여야 한다. macOS는 dock 클릭, Windows는 .exe 실행, Linux는 .desktop 파일 — 모두 한 프로세스 시작으로 앱을 본다. 그 프로세스가 Main이다.
  2. 공유 자원(파일·DB·로그)에 단일 라이터가 필요하다. 두 Renderer가 같은 SQLite 파일을 동시에 쓰면 락 충돌이 난다. Main이 중개자 역할을 하면 자동으로 직렬화된다.
  3. 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만 종료 안 하는 게 관례.
activatemacOS dock 클릭.
second-instancerequestSingleInstanceLock 후, 두 번째 실행이 시도됐을 때.
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. 프로세스 exit

before-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로 분리한다.