02 · app 라이프사이클
이 문서가 답하는 질문: Electron 앱은 정확히 언제 살아나고, 어떤 순서로 죽으며, 왜 macOS는 창을 다 닫아도 안 죽는가? Windows Squirrel 인스톨러가 왜 첫 실행에서 바로 종료해야 하는가?
한 줄 답 (Pyramid Top)
app모듈은 프로세스 전체의 라이프사이클을 가진 EventEmitter다. 웹은 브라우저가 알아서 처리하지만, Electron은ready → activate → window-all-closed → before-quit → will-quit → quit의 흐름을 개발자가 OS별로 직접 분기해야 한다 — 그리고 macOS / Windows / Linux 세 곳에서 같은 코드가 다 다르게 행동한다.
Why — 왜 OS별로 종료 규약이 다른가
세 OS의 철학이 다르다.
| OS | 창 다 닫음 = 종료? | 이유 |
|---|---|---|
| Windows | ✅ 보통 종료 | ”프로그램은 창이다” — Win16 시절 멘탈 모델 |
| macOS | ❌ 안 종료, dock에 남음 | NeXTSTEP 1989년부터 “앱은 도큐먼트가 아니라 서비스” |
| Linux (GNOME/KDE) | ✅ 보통 종료 (앱 따라) | X11/Wayland 자체에 강한 규약 없음 |
Electron은 이 세 규약을 자동으로 통합하지 않는다 — 대신 모든 이벤트를 노출하고 개발자에게 분기하라고 시킨다. 그 결과 모든 튜토리얼에 똑같이 박혀 있는 12줄 코드가 생겼다.
// 거의 모든 Electron 앱의 시작 코드 — "macOS 패턴"
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});이 두 핸들러를 둘 다 빠뜨리지 않는 것이 macOS 사용자 경험의 출발선이다.
How — 전체 이벤트 시퀀스
1) 시작부터 종료까지 한 그림
2) 주요 이벤트 표 (Main 프로세스)
| 이벤트 | 언제 발생 | preventDefault 가능? | 주요 용도 |
|---|---|---|---|
will-finish-launching | 앱 시작 직후, ready 전 | ❌ | macOS의 open-file/open-url 등록 (이 시점에 등록 안 하면 첫 launch 시 누락) |
ready | Electron 내부 초기화 완료 | ❌ | 첫 창 만들기, 메뉴/Tray 초기화 |
activate | macOS dock 클릭, Windows 작업표시줄 (드물게) | ❌ | 창이 없으면 다시 만들기 |
browser-window-created | 새 창 생성 직후 | ❌ | 창 공통 설정 후처리 (메뉴 추가 등) |
web-contents-created | webContents 생성 직후 | ❌ | 보안 핸들러(setWindowOpenHandler, will-navigate 차단) 일괄 적용 |
window-all-closed | 모든 BrowserWindow가 닫혔을 때 | ❌ | 종료 분기 (macOS 패턴) |
before-quit | 종료 시도 시작 | ✅ | “저장하지 않은 변경사항” 확인 |
will-quit | 모든 창이 닫힌 후, 종료 직전 | ✅ | 마지막 cleanup |
quit | 프로세스가 실제로 종료될 때 | ❌ | 이미 늦음. cleanup은 will-quit에서. |
second-instance | 두 번째 인스턴스 실행 시도 시 (single-instance lock 활성화 상태) | ❌ | 기존 창 포커스 + 인자 전달 |
open-url (macOS) | 커스텀 protocol로 앱이 호출됨 | ❌ | 딥링크 처리 (06) |
open-file (macOS) | Finder에서 파일을 앱에 드롭 | ❌ | 파일 연결 |
3) 최소 코드 — 모든 라이프사이클을 다룬 부트스트랩
const { app, BrowserWindow } = require('electron');
const path = require('node:path');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200, height: 800,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
sandbox: true,
},
});
mainWindow.once('ready-to-show', () => mainWindow.show());
mainWindow.loadFile('index.html');
mainWindow.on('closed', () => { mainWindow = null; });
}
// (1) ready — 진입점
app.whenReady().then(() => {
createWindow();
// (2) macOS: dock 클릭 시 창 재생성
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
// (3) 종료 분기
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// (4) 종료 직전 — 저장 다이얼로그 등
app.on('before-quit', async (event) => {
if (hasUnsavedChanges()) {
event.preventDefault();
const choice = await showConfirmDialog();
if (choice === 'discard') app.exit(0); // 강제 종료
}
});
// (5) 마지막 cleanup
app.on('will-quit', () => {
closeDatabaseConnections();
flushLogs();
});
app.quit()vsapp.exit(0)—
quit()—before-quit/will-quit발생, 취소 가능. 정상 종료.exit(0)— 이벤트 무시하고 즉시 종료. 인스톨러나 강제 종료에만.
4) Single Instance — “한 번에 하나만”
기본적으로 Electron 앱을 두 번 실행하면 두 프로세스가 동시에 돈다. 보통은 기존 인스턴스를 깨우고 새 인자를 전달하고 싶다.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit(); // 두 번째 인스턴스는 즉시 종료
} else {
app.on('second-instance', (event, argv, workingDirectory) => {
// 기존 창을 깨움
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
// argv에서 딥링크나 파일 경로 추출
const deepLink = argv.find(a => a.startsWith('myapp://'));
if (deepLink) handleDeepLink(deepLink);
});
app.whenReady().then(createWindow);
}- Windows에서 protocol handler를 등록하면 필수. 안 그러면
myapp://한 번 클릭에 앱이 새로 뜬다. - macOS는 OS가 자체적으로 single-instance를 강제하지만, 그래도 lock을 거는 게 플랫폼 통일성 측면에서 권장.
What — OS별 동작 매트릭스
| 시나리오 | Windows | macOS | Linux |
|---|---|---|---|
| 창 닫기 (X) | window-all-closed → quit | window-all-closed → 대기 (dock에 남음) | 보통 quit |
| 두 번째 실행 | 별 인스턴스 (lock 없으면) | OS가 기존을 활성화 (보통) | 별 인스턴스 |
| dock/작업표시줄 클릭 | 창이 살아 있으면 포커스 | activate 이벤트 + 창 없으면 재생성 패턴 | 환경따라 다름 |
| Cmd/Ctrl+Q | (Alt+F4) → before-quit | before-quit | (Ctrl+Q) 동일 |
| 시스템 종료 | will-quit 발생 | will-quit 발생 | will-quit 발생 |
| 강제 종료 (Task Manager) | 이벤트 없이 죽음 | 동일 | 동일 |
함정: “강제 종료에도 cleanup 하고 싶다”는 욕심은 위험하다 — 프로세스가 SIGKILL로 죽으면 그 어떤 핸들러도 안 돈다. 영속화는 수시 저장으로 풀어야 한다.
What-if — 흔한 함정
| 함정 | 증상 | 해법 |
|---|---|---|
macOS에서 window-all-closed에 무조건 app.quit() | dock 아이콘이 죽음, 사용자가 “왜 매번 새로 켜야 하지?” | if (platform !== 'darwin') app.quit() |
activate에서 창 재생성 안 함 | macOS에서 dock 클릭해도 창이 안 뜸 | activate 핸들러 필수 |
| Single instance lock 없이 protocol 등록 | 딥링크 1번에 앱 N개 뜸 | requestSingleInstanceLock |
before-quit에서 await 없이 dialog | 다이얼로그 뜨기 전에 앱 죽음 | event.preventDefault() 먼저, 그 다음 dialog |
will-quit에서 IPC 사용 | 렌더러는 이미 죽음 — IPC 못 보냄 | will-quit는 순수 Node만 |
app.whenReady() 전에 BrowserWindow 생성 | ”GPU not initialized” 등 | 반드시 whenReady().then(...) 안에서 |
How (Windows Squirrel) — 인스톨러 훅
electron-squirrel-startup은 Windows 전용. Squirrel 인스톨러는 설치/제거 중에 앱을 잠깐 실행해서 바로가기를 만들거나 지운다 — 그때 바로 종료해야 한다.
// main.js 최상단 — Electron 임포트 전에 처리
if (require('electron-squirrel-startup')) {
app.quit(); // ← 인스톨러가 호출한 경우. 보통 앱 로딩 안 함.
return;
}내부적으로 다음 argv를 본다:
--squirrel-install: 처음 설치--squirrel-updated: 업데이트--squirrel-uninstall: 제거--squirrel-obsolete: 이전 버전 폐기
이 인자 중 하나라도 있으면 바로 죽어야 인스톨러가 다음 단계로 넘어간다. 안 죽으면 설치 중간에 멈춤.
자세한 패키징은 06 패키징에서.
How (macOS dock-click) — activate의 진짜 의미
- macOS는 “앱은 죽지 않고, 창이 죽는다” 모델.
activate는 dock 클릭 외에도 Spotlight에서 앱 검색 → 엔터 시에도 발생.- 모든 창이 있는데도 activate가 와서 굳이 새 창 만들 필요는 없다 —
getAllWindows().length === 0가드가 필수.
Insight — before-quit은 비동기를 지원하지 않는 함정
공식 문서엔 한 줄로 적혀 있지만 1만 번 밟힌 함정 —
before-quit핸들러는 동기 함수만 허용한다.event.preventDefault()를 그 순간 부르지 않으면 종료가 진행된다. 그래서 비동기 dialog를 쓰려면 반드시 sync로 막은 다음 비동기 작업을 시작하고, 끝나면app.exit()을 직접 호출하는 패턴이 표준이다.let isQuitting = false; app.on('before-quit', (event) => { if (isQuitting) return; // 이미 처리 중이면 통과 if (!hasUnsavedChanges()) return; event.preventDefault(); // 일단 막고 showSaveDialog().then(result => { // 비동기 if (result === 'save') saveAndExit(); else if (result === 'discard') { isQuitting = true; app.quit(); } // cancel이면 그냥 막혀 있음 }); });이 패턴 없이는 Cmd+Q를 누르는 순간 변경사항이 날아간다. VSCode·Slack도 같은 코드 구조.
다음 문서
- 창 콘텐츠가 살아 있는 동안 무슨 일이 일어나는가 → 03 webContents.
- 종료 시 쿠키·세션을 어떻게 정리하는가 → 04 session.
한 단락 요약
app모듈의 라이프사이클은ready → activate → window-all-closed → before-quit → will-quit → quit의 시퀀스이고, 각 노드에서 OS마다 다르게 분기해야 한다. macOS의 “창이 다 닫혀도 앱은 산다”, Windows의 Squirrel 인스톨러 첫 실행 바로 종료, single-instance lock + second-instance argv 전달 이 세 가지가 실무에서 가장 자주 잊히는 세 가지다.