⚡ Electron4. 네이티브 통합03 · Menu · MenuItem · Tray

03 · Menu · MenuItem · Tray

이 문서가 답하는 질문: macOS 메뉴바 · Windows 시스템 트레이 · 우클릭 컨텍스트 메뉴를 한 API로 다루되 어디서 OS 철학이 갈라지는가? API: Menu, MenuItem, Tray, globalShortcut OS 차이: macOS는 항상 보이는 메뉴바(top bar), Windows/Linux는 창마다 메뉴 + 트레이. 같은 코드 ≠ 같은 경험.


한 줄 답 (Pyramid Top)

Electron의 Menu세 가지 다른 컨텍스트에 같은 객체를 끼워 넣는다 — macOS 메뉴바(앱 단위) · Windows 창 메뉴(창 단위) · 우클릭 팝업 메뉴(임의 위치). 그리고 TraymacOS의 status barWindows/Linux의 system tray겉모습은 비슷하지만 OS 모델이 다른 자리에 아이콘을 박는다. 그래서 플랫폼별 분기 + role 기반 표준 항목 활용이 메뉴 코드의 80%를 차지한다.


Why — 왜 OS별 분기가 메뉴에서 가장 심한가

웹은 OS 메뉴를 그릴 수 없다. 데스크톱 앱은 반드시 그려야 한다. 그리고 OS마다 메뉴의 역할이 다르다.

macOSWindowsLinux (GNOME/KDE)
메뉴바 위치화면 상단 — 항상 보임, 앱 전환 시 바뀜각 창의 타이틀바 아래각 창(GNOME은 hamburger로 바뀌는 추세)
메뉴바 단위 (창이 없어도 메뉴는 존재) (창 닫으면 메뉴도 사라짐)
첫 항목앱 이름 (Quit/Preferences가 여기)FileFile
표준 단축키Cmd+C/V/W/Q/, (, = Preferences)Ctrl+C/V/W/F4/Alt-mnemonicCtrl+C/V/W/Q
트레이Status Bar (우상단) — 항상 메뉴System Tray (우하단) — 클릭 동작 + 메뉴StatusNotifier — GNOME 기본 미지원
아이콘 색라이트/다크에 따라 자동 반전 (template)풀컬러 PNG/ICO풀컬러

같은 Menu 객체로 세 군데에 끼워 넣되, OS별 분기는 피할 수 없다. macOS와 Windows를 같은 메뉴 트리로 만들면 어느 한 쪽이 어색해진다.


How — 어떻게 동작하는가

import { app, Menu } from 'electron';
 
const template = [
  // macOS는 첫 항목이 *앱 메뉴*
  ...(process.platform === 'darwin' ? [{
    label: app.name,
    submenu: [
      { role: 'about' },
      { type: 'separator' },
      { role: 'services' },
      { type: 'separator' },
      { role: 'hide' },
      { role: 'hideOthers' },
      { role: 'unhide' },
      { type: 'separator' },
      { role: 'quit' },
    ],
  }] : []),
  {
    label: 'File',
    submenu: [
      { label: 'New', accelerator: 'CmdOrCtrl+N', click: () => createWindow() },
      { label: 'Open...', accelerator: 'CmdOrCtrl+O', click: () => openFile() },
      { type: 'separator' },
      process.platform === 'darwin' ? { role: 'close' } : { role: 'quit' },
    ],
  },
  {
    label: 'Edit',
    submenu: [
      { role: 'undo' }, { role: 'redo' },
      { type: 'separator' },
      { role: 'cut' }, { role: 'copy' }, { role: 'paste' },
      ...(process.platform === 'darwin' ? [
        { role: 'pasteAndMatchStyle' },
        { role: 'delete' },
        { role: 'selectAll' },
        { type: 'separator' },
        { label: 'Speech', submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }] },
      ] : [
        { role: 'delete' },
        { type: 'separator' },
        { role: 'selectAll' },
      ]),
    ],
  },
  { role: 'viewMenu' },     // 표준 View 메뉴(reload/zoom/fullscreen)
  { role: 'windowMenu' },   // 표준 Window 메뉴(minimize/zoom/front)
  {
    label: 'Help',
    submenu: [
      { label: 'Learn More', click: () => shell.openExternal('https://myapp.dev') },
    ],
  },
];
 
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);

Role 메뉴 항목 — 직접 click 핸들러 쓰지 말 것

role: 'copy'OS 표준 단축키 + 표준 동작 + 표준 i18n을 한 번에 준다. 직접 accelerator: 'CmdOrCtrl+C', click: ...로 작성하면 macOS의 Edit > Copy가 비활성화 상태일 때의 dim 처리도 사라진다.

자주 쓰는 role:

role동작
undo redo cut copy paste pasteAndMatchStyle delete selectAll표준 편집
reload forceReload toggleDevToolsDevTools / 새로고침
resetZoom zoomIn zoomOut화면 배율
togglefullscreen전체 화면
minimize close quit창/앱
about services hide hideOthers unhidemacOS 표준
appMenu fileMenu editMenu viewMenu windowMenu전체 표준 submenu
recentDocuments clearRecentDocumentsmacOS Recent (app.addRecentDocument)

role 우선 + click은 최후 수단. role로 안 되는 것만 직접 작성.

accelerator 표기 — CmdOrCtrl 패턴

{ accelerator: 'CmdOrCtrl+Shift+P' }
  • CommandOrControl (또는 CmdOrCtrl): macOS는 Cmd, Windows/Linux는 Ctrl로 자동 매핑.
  • Cmd/Command: macOS만.
  • Ctrl/Control: 전체.
  • Alt · Option: 같은 것.
  • Shift, Super (Win/Linux), Meta.
  • 키: A-Z, 0-9, F1-F24, Space, Tab, Enter, Escape, Up/Down/Left/Right, Plus, numadd, …

컨텍스트 메뉴 — Renderer 우클릭에 반응

// main.js
import { Menu } from 'electron';
 
mainWindow.webContents.on('context-menu', (event, params) => {
  const tpl = [
    { role: 'copy', enabled: params.selectionText.length > 0 },
    { role: 'paste', enabled: params.editFlags.canPaste },
    { type: 'separator' },
    { label: 'Inspect', click: () => mainWindow.webContents.inspectElement(params.x, params.y) },
  ];
  Menu.buildFromTemplate(tpl).popup({ window: mainWindow });
});

Renderer에서 직접 메뉴 객체를 만들지 못한다. oncontextmenu를 막고 IPC로 Main에 위치만 보내고, Main이 popup()을 호출한다. 또는 Renderer 측에서 HTML 메뉴를 직접 그리는 패턴(e.g. Slack)을 쓴다.

보편 패턴: electron-context-menu

import contextMenu from 'electron-context-menu';
contextMenu({
  showSaveImageAs: true,
  showCopyImageAddress: true,
  showInspectElement: !app.isPackaged,
});

VS Code · Slack · Discord 등이 이걸 기반으로 커스터마이즈. 직접 만들면 이미지/링크/스펠체크 항목을 빼먹기 쉬움.

Tray — 트레이/메뉴바 아이콘

import { app, Tray, Menu, nativeImage } from 'electron';
 
let tray = null;
app.whenReady().then(() => {
  // macOS는 template image (자동 흑백 반전)
  const icon = nativeImage.createFromPath('/path/to/iconTemplate.png');
  if (process.platform === 'darwin') icon.setTemplateImage(true);
 
  tray = new Tray(icon);
  tray.setToolTip('MyApp');
 
  const ctx = Menu.buildFromTemplate([
    { label: 'Open', click: () => mainWindow.show() },
    { label: 'New Note', accelerator: 'CmdOrCtrl+Shift+N', click: () => newNote() },
    { type: 'separator' },
    { label: 'Quit', role: 'quit' },
  ]);
  tray.setContextMenu(ctx);
 
  // 클릭 동작 (Windows에서 일반적)
  tray.on('click', () => mainWindow.show());
  // macOS는 *왼쪽 클릭에 메뉴*가 표준
});

macOS의 함정: tray반드시 모듈 스코프 변수로 저장. GC되면 아이콘이 사라진다. 위 코드의 let tray = null이 그 이유.

아이콘 크기

OS권장 크기형식
macOS16×16 (1x), 32×32 (2x). @2x suffixPNG (template은 흑백+알파)
Windows16×16, 32×32 (ICO 다중 임베드)ICO 권장
Linux22×22, 24×24PNG
# macOS 다중 해상도
icon.png      # 16x16
icon@2x.png   # 32x32
 
# Windows ICO 생성
magick icon.png -define icon:auto-resize=16,24,32,48,256 icon.ico

globalShortcut — 앱 외부에서도 들리는 단축키

import { app, globalShortcut } from 'electron';
 
app.whenReady().then(() => {
  const ok = globalShortcut.register('CommandOrControl+Shift+Space', () => {
    mainWindow.show();
  });
  if (!ok) console.error('failed to register'); // 이미 다른 앱이 잡고 있음
});
 
app.on('will-quit', () => globalShortcut.unregisterAll());

전역 단축키는 OS 모든 앱이 공유하는 자원이다. 충돌 가능성이 항상 있다 — register의 반환값이 false면 사용자에게 알리고 다른 키를 권하기.

macOS Dock 메뉴

if (process.platform === 'darwin') {
  app.dock.setMenu(Menu.buildFromTemplate([
    { label: 'New Window', click: () => createWindow() },
    { label: 'Compose', click: () => composeNew() },
  ]));
}

Dock 아이콘 우클릭 시 나오는 메뉴. 기본 항목(Show/Quit)은 OS가 추가 — 중복 작성 금지.

Windows JumpList (참고)

if (process.platform === 'win32') {
  app.setUserTasks([
    {
      program: process.execPath,
      arguments: '--new-window',
      iconPath: process.execPath,
      iconIndex: 0,
      title: 'New Window',
      description: 'Create a new window',
    },
  ]);
}

작업표시줄 아이콘 우클릭의 Recent + Tasks 영역. Slack의 “Sign in to other team” 같은 항목이 여기.


What — 구체 사양 / CLI

언제 메뉴를 설정하나

app.whenReady().then(() => {
  Menu.setApplicationMenu(buildMenu());
});

*app.whenReady*이전엔 호출 안 됨. 동적 변경은 새 Menu 객체를 만들고 setApplicationMenu를 다시 호출 — 기존 객체의 mutation은 공식적으로 지원 안 됨.

메뉴 항목 enable/disable

상태에 따라 메뉴를 그때그때 다시 빌드하는 게 표준. MenuItem.enabled = false를 런타임에 바꾸려면 menu 전체를 재빌드하는 게 안전.

다국어 메뉴

import { app } from 'electron';
const locale = app.getLocale(); // 'ko-KR' 등
 
const T = {
  'File': locale.startsWith('ko') ? '파일' : 'File',
  // ...
};

또는 i18next 같은 라이브러리 사용. role 메뉴는 OS가 자동 번역하니 직접 label을 쓰면 오히려 깨진다. role은 label 생략이 default.

트레이가 동작하지 않는 Linux 환경

  • GNOME 3.26+: 트레이가 제거됨. gnome-shell-extension-appindicator 같은 확장이 있어야 보임.
  • Ubuntu: 22.04부터 GNOME default — 마찬가지.
  • KDE Plasma: StatusNotifierItem으로 정상 작동.

Slack/Discord/Spotify 모두 Linux 사용자에겐 트레이를 옵션으로 권한다. 반드시 fallback UI(메인 창에서 상태 확인)를 줄 것.

트레이 알림 (Windows balloon, deprecated)

tray.displayBalloon — Windows 10까지의 풍선 알림. Windows 11에선 toast 알림이 표준. Notification API로 통합 (→ 04-notifications-and-dialogs).


What-if — 잘못 다루면 어떻게 깨지는가

1) macOS에서 Cmd+C가 안 됨

// ❌
Menu.setApplicationMenu(Menu.buildFromTemplate([
  { label: 'File', submenu: [...] },
  // Edit 메뉴를 빼먹음
]));

macOS는 Edit 메뉴의 role 항목이 있어야 Cmd+C/V/X입력 필드에서도 동작한다. role 메뉴를 빼면 단축키 자체가 안 먹음. (Renderer의 document.execCommand키 이벤트가 메뉴에 먼저 안 잡혀야 동작.)

대응: 항상 { role: 'editMenu' }나 그에 해당하는 submenu를 포함.

2) tray = new Tray(...)변수가 GC됨

function setup() {
  const t = new Tray(icon); // ❌ 지역 변수 — 함수 끝나면 GC
}

잠깐 보이다가 사라진다. 모듈/클래스 스코프에 저장 필수.

3) Windows에서 Tray 아이콘이 흐릿함

PNG 16×16을 그냥 쓰면 DPI 200%에서 흐릿. ICO 다중 해상도 임베드 또는 @2x suffix로 nativeImage가 알아서 선택하게.

4) globalShortcut 충돌

CommandOrControl+Shift+PVS Code가 이미 잡고 있다. 다른 단축키를 권하거나 사용자 설정 가능하게.

5) Menu 객체 mutation

menu.items[0].submenu.items[2].enabled = false; // ❌ 작동 안 함

다시 빌드해서 setApplicationMenu 호출이 정답.

6) 컨텍스트 메뉴의 i18n 누락

{ label: 'Copy' } // ❌ macOS 한국어 사용자 → '복사'여야 함
{ role: 'copy' }  // ✅ OS가 자동 번역

7) macOS Dock에서 닫힌 창을 다시 열기

// macOS는 모든 창이 닫혀도 앱이 살아있음
app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

이거 없으면 Dock 클릭 시 아무 반응 없음 — macOS 사용자가 가장 헷갈리는 버그.

8) Linux 트레이 없는 환경에 Tray-only

GNOME 사용자에게 어떤 UI도 안 보임 — 종료도 못 함. 항상 메인 창을 띄울 수 있는 다른 길(CLI flag, deep link 등) 제공.


Insight — 흥미로운 이야기

“VS Code는 자체 메뉴 시스템을 새로 만들었다”

VS Code 1.40부터 Windows에서 Custom Title Bar 옵션이 default — 이건 OS 메뉴를 안 쓰고 HTML로 직접 그린 메뉴. Webview 안에서 메뉴를 그리면 i18n·테마·접근성을 다 직접 해결해야 하지만 VS Code 테마 시스템과 통합되는 이점이 더 컸다. 그래서 macOS만 OS 메뉴, Windows/Linux는 HTML 메뉴. 한 앱 안에 두 메뉴 시스템이 공존한다.

“트레이는 macOS와 Windows에서 철학이 다르다”

macOS Status Bar는 시스템 정보의 자리 — 시계·배터리·Wi-Fi가 함께 사는 곳. 왼쪽 클릭에 메뉴가 표준 (Spotlight 같은 시각 액션 없음). Windows System Tray는 백그라운드 앱의 알림 영역왼쪽 클릭은 앱을 띄우고, 오른쪽 클릭이 메뉴가 표준 (Skype/Discord/Steam). Electron의 Tray.on('click') 동작이 macOS와 Windows에서 반대라는 사실을 모르고 짜면 한쪽이 어색해진다.

“globalShortcut의 가장 유명한 충돌”

Cmd+Space(Spotlight) · Cmd+Tab(앱 전환) · Cmd+Q(Quit) 등은 OS가 잡고 있어서 register 자체가 실패. 하지만 Ctrl+Space는 macOS에서 입력기 전환이라 한국어 사용자에게 잡으면 한영 전환이 깨진다. 글로벌 단축키는 사용자 설정 가능하게.

“Mac App Store의 sandbox는 Tray를 어렵게 만든다”

sandbox 빌드는 globalShortcut의 일부 키 등록이 막힘. NSStatusItem(Tray)은 동작하지만 백그라운드에서 안 죽고 살아남는 LaunchAgent 등록은 별도 entitlement 필요. 완전한 트레이 앱을 MAS에 올리려면 디자인 변경이 필요할 때가 많다.


요약 + Mermaid

Menu/MenuItem/TraymacOS 메뉴바·Windows 트레이·우클릭 메뉴라는 겉모습 비슷한 세 자리를 다룬다. 하지만 OS 모델이 달라서 role 항목 활용 + 플랫폼 분기가 코드의 80%다. Tray는 GC를 막을 변수 보관, macOS Edit role 누락은 Cmd+C 깨짐, Linux GNOME은 트레이 없음 fallback 필수 — 세 함정이 메뉴 코드의 가장 흔한 사고.