03 · Menu · MenuItem · Tray
이 문서가 답하는 질문: macOS 메뉴바 · Windows 시스템 트레이 · 우클릭 컨텍스트 메뉴를 한 API로 다루되 어디서 OS 철학이 갈라지는가? API:
Menu,MenuItem,Tray,globalShortcutOS 차이: macOS는 항상 보이는 메뉴바(top bar), Windows/Linux는 창마다 메뉴 + 트레이. 같은 코드 ≠ 같은 경험.
한 줄 답 (Pyramid Top)
Electron의
Menu는 세 가지 다른 컨텍스트에 같은 객체를 끼워 넣는다 — macOS 메뉴바(앱 단위) · Windows 창 메뉴(창 단위) · 우클릭 팝업 메뉴(임의 위치). 그리고Tray는 macOS의 status bar와 Windows/Linux의 system tray에 겉모습은 비슷하지만 OS 모델이 다른 자리에 아이콘을 박는다. 그래서 플랫폼별 분기 + role 기반 표준 항목 활용이 메뉴 코드의 80%를 차지한다.
Why — 왜 OS별 분기가 메뉴에서 가장 심한가
웹은 OS 메뉴를 그릴 수 없다. 데스크톱 앱은 반드시 그려야 한다. 그리고 OS마다 메뉴의 역할이 다르다.
| 축 | macOS | Windows | Linux (GNOME/KDE) |
|---|---|---|---|
| 메뉴바 위치 | 화면 상단 — 항상 보임, 앱 전환 시 바뀜 | 각 창의 타이틀바 아래 | 각 창(GNOME은 hamburger로 바뀌는 추세) |
| 메뉴바 단위 | 앱 (창이 없어도 메뉴는 존재) | 창 (창 닫으면 메뉴도 사라짐) | 창 |
| 첫 항목 | 앱 이름 (Quit/Preferences가 여기) | File | File |
| 표준 단축키 | Cmd+C/V/W/Q/, (, = Preferences) | Ctrl+C/V/W/F4/Alt-mnemonic | Ctrl+C/V/W/Q |
| 트레이 | Status Bar (우상단) — 항상 메뉴 | System Tray (우하단) — 클릭 동작 + 메뉴 | StatusNotifier — GNOME 기본 미지원 |
| 아이콘 색 | 라이트/다크에 따라 자동 반전 (template) | 풀컬러 PNG/ICO | 풀컬러 |
같은 Menu 객체로 세 군데에 끼워 넣되, OS별 분기는 피할 수 없다. macOS와 Windows를 같은 메뉴 트리로 만들면 어느 한 쪽이 어색해진다.
How — 어떻게 동작하는가
Menu.buildFromTemplate — 기본 빌더
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 toggleDevTools | DevTools / 새로고침 |
resetZoom zoomIn zoomOut | 화면 배율 |
togglefullscreen | 전체 화면 |
minimize close quit | 창/앱 |
about services hide hideOthers unhide | macOS 표준 |
appMenu fileMenu editMenu viewMenu windowMenu | 전체 표준 submenu |
recentDocuments clearRecentDocuments | macOS 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 | 권장 크기 | 형식 |
|---|---|---|
| macOS | 16×16 (1x), 32×32 (2x). @2x suffix | PNG (template은 흑백+알파) |
| Windows | 16×16, 32×32 (ICO 다중 임베드) | ICO 권장 |
| Linux | 22×22, 24×24 | PNG |
# macOS 다중 해상도
icon.png # 16x16
icon@2x.png # 32x32
# Windows ICO 생성
magick icon.png -define icon:auto-resize=16,24,32,48,256 icon.icoglobalShortcut — 앱 외부에서도 들리는 단축키
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+P는 VS 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/Tray는 macOS 메뉴바·Windows 트레이·우클릭 메뉴라는 겉모습 비슷한 세 자리를 다룬다. 하지만 OS 모델이 달라서 role 항목 활용 + 플랫폼 분기가 코드의 80%다. Tray는 GC를 막을 변수 보관, macOS Edit role 누락은 Cmd+C 깨짐, Linux GNOME은 트레이 없음 fallback 필수 — 세 함정이 메뉴 코드의 가장 흔한 사고.