⚡ Electron4. 네이티브 통합04 · Notification · dialog

04 · Notification · dialog

이 문서가 답하는 질문: OS 알림과 파일 선택·메시지 박스 같은 시스템 모달을 어떻게 띄우고, 어떻게 메인 이벤트 루프를 안 멈추는가? API: Notification, dialog.showOpenDialog, dialog.showSaveDialog, dialog.showMessageBox, dialog.showErrorBox 공통 함정: 동기 API는 Main을 멈춘다 — 모든 호출은 async/Promise 버전을 쓸 것.


한 줄 답 (Pyramid Top)

Electron NotificationWeb Notification API를 그대로 확장해 OS 네이티브 알림을 띄우고, dialog.*OS 표준 파일 선택·메시지 박스를 모달로 띄운다. 모든 dialogPromise 버전(async)을 default로 — 동기 버전은 Renderer 전체와 Main 이벤트 루프를 동시에 freeze시킨다. 그리고 NotificationOS별 권한 모델이 달라서 — Windows는 AppUserModelID가 없으면 아이콘 없는 임시 알림이 뜨고, macOS는 시스템 설정에서 사용자가 끌 수 있음.


Why — 왜 동기 dialog가 위험한가

웹의 alert/confirm/promptRenderer의 이벤트 루프만 멈춘다 — Main(브라우저 자체)은 계속 돈다.

Electron의 dialog.showMessageBoxSync 같은 Sync 버전Main 프로세스의 이벤트 루프를 멈춘다. Main이 멈추면 모든 BrowserWindow의 모든 IPC가 멈춘다 — 사용자가 보기엔 앱 전체가 죽은 것처럼 보임.

API동작권장
dialog.showMessageBox(window, opts) → Promiseasync, Main 안 멈춤✅ default
dialog.showMessageBoxSync(window, opts) → numbersync, Main 멈춤❌ startup error 같은 예외 케이스만
dialog.showOpenDialog(...) → Promiseasync
dialog.showOpenDialogSync(...) → string[]sync
dialog.showSaveDialog(...) → Promiseasync
dialog.showErrorBox(title, content)sync, 단순 에러 박스△ Crash handler 정도

모든 새 코드는 Promise 버전. sync는 부팅 실패 알림Main이 멈춰도 상관없는 자리에서만.

또한 Electron Notification웹 표준 Notification API의 확장이다. Renderer에서도 new Notification(...)을 쓸 수 있지만 — Main에서 띄우는 게 권장이다. 이유:

  • Renderer의 NotificationChromium 권한 모델을 거친다 — Notification.permission'denied'면 안 뜸.
  • Main의 NotificationOS의 알림 채널에 직접 보낸다 — 더 신뢰성 있음.
  • 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']

filtersWindows에선 강제, 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 reportstartup 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별 차이

macOSWindows 10+Linux
권한시스템 설정 (앱별)App 등록 + Focus Assistlibnotify
표시 위치우상단우하단 → 알림 센터우상단 (DE에 따라)
아이콘icon 옵션 + 앱 아이콘icon + AppUserModelID 등록된 아이콘icon
답장 (hasReply)
액션 버튼 (actions)✅ (10.13+)△ (xml 토스트 필요)
사운드 (silent: false)OS defaultOS defaultOS default
만료 (timeoutType)없음 (10초 후 사라짐)‘never’면 알림 센터에 영구

dialog의 OS별 차이

macOSWindowsLinux
파일 pickerNSOpenPanelIFileOpenDialogGTK / Qt
다중 선택 + 디렉토리 동시✅ (둘 다 properties에)❌ (택 1)
메시지 박스 sheetparent 있으면 sheetparent 있으면 ownerparent 있으면 modal
버튼 순서OK 오른쪽 (macOS HIG)OK 왼쪽 (Windows)OK 오른쪽
기본 단축키Cmd+. = CancelEsc = CancelEsc = 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) dialogRenderer에서 직접 (구버전)

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 Notificationsilent: 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가 세 함정. 알림은 민감 정보 노출 위험도 — 잠금화면에 내용을 그대로 띄우지 말 것.