04 · Notification · dialog
이 문서가 답하는 질문: OS 알림과 파일 선택·메시지 박스 같은 시스템 모달을 어떻게 띄우고, 어떻게 메인 이벤트 루프를 안 멈추는가? API:
Notification,dialog.showOpenDialog,dialog.showSaveDialog,dialog.showMessageBox,dialog.showErrorBox공통 함정: 동기 API는 Main을 멈춘다 — 모든 호출은 async/Promise 버전을 쓸 것.
한 줄 답 (Pyramid Top)
Electron
Notification은 Web Notification API를 그대로 확장해 OS 네이티브 알림을 띄우고,dialog.*는 OS 표준 파일 선택·메시지 박스를 모달로 띄운다. 모든dialog는 Promise 버전(async)을 default로 — 동기 버전은 Renderer 전체와 Main 이벤트 루프를 동시에 freeze시킨다. 그리고Notification은 OS별 권한 모델이 달라서 — Windows는 AppUserModelID가 없으면 아이콘 없는 임시 알림이 뜨고, macOS는 시스템 설정에서 사용자가 끌 수 있음.
Why — 왜 동기 dialog가 위험한가
웹의 alert/confirm/prompt는 Renderer의 이벤트 루프만 멈춘다 — Main(브라우저 자체)은 계속 돈다.
Electron의 dialog.showMessageBoxSync 같은 Sync 버전은 Main 프로세스의 이벤트 루프를 멈춘다. Main이 멈추면 모든 BrowserWindow의 모든 IPC가 멈춘다 — 사용자가 보기엔 앱 전체가 죽은 것처럼 보임.
| API | 동작 | 권장 |
|---|---|---|
dialog.showMessageBox(window, opts) → Promise | async, Main 안 멈춤 | ✅ default |
dialog.showMessageBoxSync(window, opts) → number | sync, Main 멈춤 | ❌ startup error 같은 예외 케이스만 |
dialog.showOpenDialog(...) → Promise | async | ✅ |
dialog.showOpenDialogSync(...) → string[] | sync | ❌ |
dialog.showSaveDialog(...) → Promise | async | ✅ |
dialog.showErrorBox(title, content) | sync, 단순 에러 박스 | △ Crash handler 정도 |
모든 새 코드는 Promise 버전. sync는 부팅 실패 알림 등 Main이 멈춰도 상관없는 자리에서만.
또한 Electron Notification은 웹 표준 Notification API의 확장이다. Renderer에서도 new Notification(...)을 쓸 수 있지만 — Main에서 띄우는 게 권장이다. 이유:
- Renderer의
Notification은 Chromium 권한 모델을 거친다 —Notification.permission이'denied'면 안 뜸. - Main의
Notification은 OS의 알림 채널에 직접 보낸다 — 더 신뢰성 있음. - Renderer가 죽어도 예약된 알림은 살아남음 (Main이 띄우니까).
How — 어떻게 동작하는가
Notification — 기본 사용
import { Notification } from 'electron';
const n = new Notification({
title: 'Build complete',
body: 'Your project compiled in 12s.',
icon: '/path/to/icon.png',
silent: false, // 사운드
urgency: 'normal', // 'normal' | 'critical' | 'low' (Linux)
timeoutType: 'default', // 'default' | 'never' (Linux/Windows)
actions: [ // macOS 10.13+
{ type: 'button', text: 'Show' },
],
closeButtonText: 'Dismiss', // macOS
hasReply: true, // macOS — 인라인 답장 입력
replyPlaceholder: 'Reply...',
});
n.on('click', () => mainWindow.show());
n.on('reply', (_e, reply) => console.log('user replied:', reply));
n.on('action', (_e, index) => console.log('button', index));
n.on('close', () => {/* 사용자 dismiss */});
n.on('show', () => {/* 표시됨 */});
n.show();Notification.isSupported()
if (!Notification.isSupported()) {
// headless Linux 또는 OS 알림 비활성화
return;
}서버에서 Electron을 돌리는 경우(예: 자동 빌드)는 항상 false. 가드 필수.
Windows의 AppUserModelID
Windows 10+에서 알림 아이콘과 그룹화가 제대로 되려면 AppUserModelID 설정 필요.
import { app } from 'electron';
if (process.platform === 'win32') {
app.setAppUserModelId('com.mycompany.myapp');
}안 하면 알림에 Electron 아이콘이 뜨거나, 알림 센터에서 사용자가 끄지 못함. electron-builder는 대부분 자동 설정하지만 dev 모드에선 직접 호출 필요.
또한 Windows는 Toast 알림의 등록에 NOTIFICATION_REGISTRY가 필요할 때가 있다 — electron-builder의 nsis 빌드가 처리.
macOS의 사용자 거부
macOS는 앱이 처음 알림을 띄우려 할 때 시스템이 권한 prompt를 띄운다. 거부하면 영구 거부 — 사용자가 시스템 설정 > 알림에서 직접 켜야 다시 보임. 코드로는 조용히 안 보임.
// macOS 권한 상태 확인 (참고)
import { systemPreferences } from 'electron';
// 알림은 별도 API가 없음 — 그냥 호출해보고 안 뜨면 거부.권한이 거부됐다는 걸 코드로 알 길이 없다. “알림 안 옴” 사용자 보고는 시스템 설정 안내 페이지로 유도.
dialog.showOpenDialog — 파일/디렉토리 선택
import { dialog, BrowserWindow } from 'electron';
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Import data',
defaultPath: app.getPath('downloads'),
buttonLabel: 'Import',
filters: [
{ name: 'Spreadsheets', extensions: ['xlsx', 'csv'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: [
'openFile', // 파일 선택
'multiSelections', // 다중 선택
// 'openDirectory', // 디렉토리 선택 (위와 같이 쓰면 OS에 따라 동작 다름)
// 'createDirectory', // macOS만 — 새 폴더 만들기 버튼
// 'showHiddenFiles',
// 'dontAddToRecent', // Windows — 최근 항목에 안 추가
],
});
if (result.canceled) return;
console.log(result.filePaths); // ['/Users/raw/Downloads/data.xlsx']
filters는 Windows에선 강제, macOS는 그레이아웃만. 사용자가 All Files로 우회 가능. 서버측 검증 필수.
dialog.showSaveDialog
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Export report',
defaultPath: path.join(app.getPath('documents'), 'report.pdf'),
filters: [{ name: 'PDF', extensions: ['pdf'] }],
});
if (canceled || !filePath) return;
await writeFile(filePath, pdfBuffer);dialog.showMessageBox — 표준 메시지 박스
const { response, checkboxChecked } = await dialog.showMessageBox(mainWindow, {
type: 'question', // 'none'|'info'|'error'|'question'|'warning'
buttons: ['Save', "Don't Save", 'Cancel'],
defaultId: 0, // Enter 키로 선택될 버튼
cancelId: 2, // Esc 키로 선택될 버튼
title: 'Unsaved changes',
message: 'Save changes before closing?',
detail: 'Your changes will be lost otherwise.',
checkboxLabel: "Don't ask me again",
checkboxChecked: false,
icon: appIcon,
noLink: false, // Windows: 버튼을 링크 스타일로 안 만듦
});
// response = 0,1,2 (buttons index)dialog.showErrorBox — 단순 에러 (sync OK)
dialog.showErrorBox('Fatal error', 'Could not load configuration file.');Crash report나 startup failure 같은 Main 멈춰도 상관없는 경우 한정.
Sheet (macOS) — 부모 창 종속 모달
// macOS는 parent window를 주면 sheet 스타일(슬라이드 다운)
const r = await dialog.showMessageBox(mainWindow, { ... });
// parent 없이 호출하면 app-modal (어떤 창도 안 막힘, 시스템 모달)
const r2 = await dialog.showMessageBox({ ... });parent 없이 호출하면 전역 모달이 되어 사용자 혼란. 반드시
mainWindow나 현재 포커스된 창을 전달.
What — 구체 사양 / CLI
Notification의 OS별 차이
| 축 | macOS | Windows 10+ | Linux |
|---|---|---|---|
| 권한 | 시스템 설정 (앱별) | App 등록 + Focus Assist | libnotify |
| 표시 위치 | 우상단 | 우하단 → 알림 센터 | 우상단 (DE에 따라) |
| 아이콘 | icon 옵션 + 앱 아이콘 | icon + AppUserModelID 등록된 아이콘 | icon |
답장 (hasReply) | ✅ | ❌ | ❌ |
액션 버튼 (actions) | ✅ (10.13+) | △ (xml 토스트 필요) | ✅ |
사운드 (silent: false) | OS default | OS default | OS default |
만료 (timeoutType) | 없음 (10초 후 사라짐) | ‘never’면 알림 센터에 영구 | ✅ |
dialog의 OS별 차이
| 축 | macOS | Windows | Linux |
|---|---|---|---|
| 파일 picker | NSOpenPanel | IFileOpenDialog | GTK / Qt |
| 다중 선택 + 디렉토리 동시 | ✅ (둘 다 properties에) | ❌ (택 1) | ❌ |
| 메시지 박스 sheet | parent 있으면 sheet | parent 있으면 owner | parent 있으면 modal |
| 버튼 순서 | OK 오른쪽 (macOS HIG) | OK 왼쪽 (Windows) | OK 오른쪽 |
| 기본 단축키 | Cmd+. = Cancel | Esc = Cancel | Esc = Cancel |
Cancel 버튼 텍스트는 OS HIG를 따르되 cancelId로 어느 버튼이 Esc/Cmd+.에 매핑되는지 명시.
알림 통합 — Push 알림과의 관계
Electron Notification은 로컬 알림만. 원격 push(Firebase/APNs)는 별도 — Main에서 WebSocket으로 받고 Notification으로 띄움이 표준 패턴.
ws.on('message', (msg) => {
const { title, body } = JSON.parse(msg);
new Notification({ title, body }).show();
});Notification “Do Not Disturb”
- macOS: Focus 모드 또는 시스템 설정 > 알림 > 차단 시간. 코드로 상태 조회 불가.
- Windows: Focus Assist.
systemPreferences.getUserDefault('NSGlobalDomain')같은 우회로 일부 가능. - 사용자가 DnD 중이면 알림이 알림 센터로 직행 — 시각적으로 안 뜸. 정상 동작이니 가드 불필요.
dialog.showOpenDialog — 동기 동작이 필요한 유일한 경우
// 앱 시작 전 라이선스 키 입력 다이얼로그가 *반드시* 결과를 기다려야
// 이후 코드가 동작할 때만 sync 사용 가능
const r = dialog.showOpenDialogSync({ ... });거의 모든 경우 async + state machine이 정답.
메시지 박스 i18n
buttons: ['저장', '저장 안 함', '취소']처럼 직접 한국어. role 메뉴와 달리 자동 번역 없음. app.getLocale()로 분기.
What-if — 잘못 다루면 어떻게 깨지는가
1) showMessageBoxSync로 모든 창 freeze
// ❌ Renderer가 IPC로 confirm을 요청
ipcMain.handle('confirm', (_e, msg) => {
return dialog.showMessageBoxSync(mainWindow, { message: msg }); // sync!
});다른 창의 모든 IPC도 그동안 멈춤. 비디오/오디오 재생이 공식적으로는 안 멈춰야 하지만 Renderer GC가 멈춰서 메모리 폭증 가능.
대응: async Promise 버전. Renderer는 await window.api.confirm(msg)로 자연스럽게 기다림.
2) Windows에서 알림 아이콘이 Electron 로고
app.setAppUserModelId 누락. dev에서만 발생, packaged에선 정상인 경우가 많아 사용자 보고 후 발견. app.whenReady 이전에 호출.
3) macOS에서 권한 거부 후 알림 없음
사용자가 처음 prompt에서 “허용 안 함” 누름. 이후 코드로 상태 확인 불가. 알림 안 옴 사용자 보고가 90% 이걸로 분류.
대응: 최초 알림에 명확한 의미를 — “허용하면 빌드 완료 알림을 보냅니다” 같은 맥락 있는 첫 번째 알림. 그래야 사용자가 허용을 누름.
4) dialog.showOpenDialog에서 다중 선택 + 디렉토리 동시
properties: ['openFile', 'openDirectory', 'multiSelections']
// macOS: 둘 다 선택 가능
// Windows: openDirectory만 들음 (파일 모드 잠김)OS별 동작이 다른 옵션 조합. 둘이 필요하면 두 개 별도 다이얼로그 또는 macOS만 동시로 fork.
5) filters를 서버측 검증 대신으로 신뢰
.exe 차단을 위해 filters: [{ extensions: ['png','jpg'] }]로 했는데 — 사용자가 All Files로 전환 가능. 반드시 fileTypeFromBuffer 같은 내용 기반 검증 (→ 01-foundations).
6) Notification 객체 GC
function notify() {
new Notification({ title: 'x' }).show(); // ❌ 변수 미보관
}show() 직후 GC되면 click 이벤트가 안 들림. 액션이 필요한 알림은 변수에 저장.
7) dialog를 Renderer에서 직접 (구버전)
remote 모듈 시대엔 remote.dialog.showOpenDialog로 Renderer에서 직접 호출 가능. Electron 14+에서 제거됨. 반드시 IPC.
8) 알림에 민감 정보 그대로
new Notification({
title: 'Slack',
body: 'admin@example.com: 비밀번호를 공유합니다 ...' // ❌
});OS 알림 센터에 영구 저장되고 잠금화면에서도 보임. 민감 정보는 내용 숨김이 default — macOS는 시스템 설정에서 켤 수 있음이지만 앱이 안 보이게 만드는 게 안전.
new Notification({
title: 'Slack',
body: 'New message',
// OS가 잠금화면에서 내용 숨기는 옵션이 OS별로 다름
});Insight — 흥미로운 이야기
“Slack의 알림 아이콘이 점이 되는 이유”
macOS Dock의 빨간 점 + 숫자 배지는
app.setBadgeCount(n)로 설정. macOS만 OS 차원에서 지원 — Windows는 Taskbar overlay icon으로 별도 작업. Slack은 읽지 않은 메시지 수를 이걸로 표현. Notification 없이도 정보 전달이 가능한 자리다.
“Discord의 알림 사운드는 OS가 아닌 자체 재생”
Electron
Notification의silent: false는 OS default 사운드를 쓴다. Discord는 자체 .ogg 파일을 Renderer의<audio>로 재생 — 알림이 사라져도 사운드는 남게 하고 볼륨/음소거 자체 설정을 가능하게 했다. 알림 사운드를 OS에 위임할지 직접 재생할지가 UX 결정 포인트.
“dialog가 부모 창을 받으면 macOS에서 슬라이드 다운”
NSOpenPanel을 windowSheet로 띄우면 부모 창 타이틀바에서 슬라이드. 부모 없이 띄우면 화면 중앙 floating panel. macOS HIG는 내 작업의 결과로 뜨는 모달은 sheet, 시스템 알림은 panel이라 권장. parent를 항상 명시가 macOS 네이티브한 UX의 핵심.
“알림 권한 거부를 우회하는 못된 방법”
macOS 사용자가 거부한 후에도
AppleScript로 osascript을 통해 sound나 dialog를 띄우는 앱이 있었다. 이건 App Store에서 즉시 reject 사유. 권한 거부는 존중이 default — 거부됐는데 띄우려고 노력하면 Privacy 정책에 어긋난다.
요약 + Mermaid
Notification은 웹 표준의 Electron 확장으로 OS 네이티브 알림을 띄우고,dialog.*는 OS 표준 모달을 띄운다. 모든 dialog는 Promise 버전이 default — sync는 Main 전체를 freeze시킨다. Windows는 AppUserModelID, macOS는 권한 거부의 영구성, Notification 객체 GC가 세 함정. 알림은 민감 정보 노출 위험도 — 잠금화면에 내용을 그대로 띄우지 말 것.