05 · 창 상태와 다중 창
이 문서가 답하는 질문: 사용자가 창 크기를 바꿔두면 다음 실행에도 그대로여야 한다. 어떻게 저장하나? 메인 창 + 설정 창 + Tray 호출 창 — 여러 창은 어떻게 한 곳에서 관리하나?
한 줄 답 (Pyramid Top)
창 상태(위치·크기·최대화 여부)는
will-resize/close이벤트에서 직접 디스크에 저장해야 한다 — 자동이 아니다. 다중 창은Map<key, BrowserWindow>로 직접 등록·해제하고, Tray는 “창은 숨어 있지만 앱은 떠 있는” 모드로 살기 위한 OS 측 통로다.
Why — 왜 직접 해야 하는가
웹의 window.open()은 위치 기억을 안 한다 — 그게 디폴트다. 데스크톱 사용자는 반대 기대를 가진다: “내가 어제 띄워둔 위치 그대로 떠야지”.
- macOS의 NSWindow는 Auto Save Frame을 OS가 기억해 주지만 Electron은 이를 사용하지 않는다 (Chromium 모델).
- Chromium 자체는 각 윈도우 위치를 프로필 단위로 기억하지만, Electron BrowserWindow는 그 위에서 노출 안 됨.
- 그래서 내가 직접 저장해야 하고, 그 패턴이 너무 흔해서
electron-window-state라는 표준 라이브러리가 거의 디팩토 표준이다.
How — 창 상태 저장
1) 수동 패턴 — 기본 골격
const Store = require('electron-store'); // 또는 fs로 JSON
const store = new Store();
function createWindow() {
const saved = store.get('mainWindow') ?? { width: 1200, height: 800 };
const win = new BrowserWindow({
x: saved.x, y: saved.y,
width: saved.width, height: saved.height,
show: false,
webPreferences: { /* secure 4 */ },
});
if (saved.isMaximized) win.maximize();
win.once('ready-to-show', () => win.show());
// 종료 시점에 위치/크기 저장
const save = () => {
const bounds = win.getBounds(); // x, y, width, height
store.set('mainWindow', {
...bounds,
isMaximized: win.isMaximized(),
});
};
win.on('close', save);
win.on('move', save); // 디바운스 권장
win.on('resize', save);
}디바운스 —
move/resize는 드래그 중 수십 번 발화한다.lodash.debounce(save, 250)로 디스크 쓰기 줄이기.
2) 라이브러리 패턴 — electron-window-state
const windowStateKeeper = require('electron-window-state');
function createWindow() {
const mainState = windowStateKeeper({
defaultWidth: 1200,
defaultHeight: 800,
});
const win = new BrowserWindow({
x: mainState.x, y: mainState.y,
width: mainState.width, height: mainState.height,
});
mainState.manage(win); // ← move/resize/close 자동 처리
}- 위 수동 코드를 한 줄로 줄이고, 모니터 사라짐 같은 엣지케이스도 처리.
- 디팩토 표준 — 거의 모든 Electron 보일러플레이트에 포함.
3) 모니터가 사라졌을 때 — 핵심 엣지케이스
사용자가 모니터를 분리하거나 해상도를 바꾸면 저장된 좌표가 화면 밖일 수 있다. 그러면 창이 안 보이는 자리에 뜬다.
const { screen } = require('electron');
function ensureVisible(saved) {
const displays = screen.getAllDisplays();
const visible = displays.some(d => {
const a = d.workArea;
return saved.x >= a.x && saved.y >= a.y &&
saved.x + saved.width <= a.x + a.width &&
saved.y + saved.height <= a.y + a.height;
});
if (!visible) {
const primary = screen.getPrimaryDisplay().workArea;
return { width: saved.width, height: saved.height,
x: primary.x + 50, y: primary.y + 50 };
}
return saved;
}
electron-window-state는 이 로직을 내장하고 있다. 직접 짜면 잊기 쉬운 한 줄.
How — 다중 창 관리
1) Map 패턴 — 모든 창 추적
const windows = new Map(); // key → BrowserWindow
function openWindow(key, opts) {
// 이미 있으면 포커스
if (windows.has(key)) {
const w = windows.get(key);
if (w.isMinimized()) w.restore();
w.focus();
return w;
}
const win = new BrowserWindow(opts);
windows.set(key, win);
win.on('closed', () => windows.delete(key));
return win;
}
openWindow('main', { width: 1200, height: 800, ... });
openWindow('settings', { width: 600, height: 400, parent: windows.get('main'), modal: true });
openWindow('about', { width: 400, height: 300 });- 모든 창에 key가 있어야 중복 방지가 된다.
- 종료할 때
for (const win of windows.values()) win.close();로 일괄 종료. getAllWindows()만으로는 역할을 모른다 — Map이 역할 ↔ 인스턴스를 잇는다.
2) 창 사이 통신 — Main이 중계자
ipcMain.on('update-prefs', (event, prefs) => {
// 모든 창에 브로드캐스트
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('prefs-changed', prefs);
}
});- Electron 창들은 직접 통신할 수 없다 — IPC는 renderer ↔ main만.
- Main이 중계자 역할. 03 IPC에서 본격.
- MessagePort를 쓰면 creator 단계에서만 main을 거치고 그 뒤는 직접 — 고급 패턴.
3) 다중 창 + 다중 세션
partition을 다르게 주면 완전 격리된 창들을 띄울 수 있다.
function openAccount(accountId) {
return openWindow(`account-${accountId}`, {
width: 1200, height: 800,
webPreferences: {
session: session.fromPartition(`persist:account-${accountId}`),
preload, contextIsolation: true, sandbox: true,
},
});
}
openAccount('alice');
openAccount('bob'); // 같은 origin, 다른 세션 — Slack 패턴04 session에서 다룬 partition을 다중 창 패턴과 결합한 형태.
How — Tray + 메인 창 토글
Tray(Windows의 시스템 트레이, macOS의 menu bar)는 창은 닫혀 있지만 앱은 떠 있는 모델을 위한 통로다. Slack·Discord·1Password가 모두 이 패턴.
1) 기본 구조
const { app, BrowserWindow, Tray, Menu, nativeImage } = require('electron');
let tray, mainWindow;
function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1200, height: 800,
show: false,
skipTaskbar: false, // 작업표시줄에 표시할지
webPreferences: { /* secure */ },
});
mainWindow.loadFile('index.html');
// 닫기 버튼 → 종료 대신 숨기기
mainWindow.on('close', (e) => {
if (!app.isQuitting) {
e.preventDefault();
mainWindow.hide();
}
});
}
function createTray() {
const icon = nativeImage.createFromPath('tray-icon.png');
tray = new Tray(icon);
tray.setToolTip('My App');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: '열기', click: () => toggleMainWindow() },
{ type: 'separator' },
{ label: '종료', click: () => { app.isQuitting = true; app.quit(); } },
]));
tray.on('click', () => toggleMainWindow());
}
function toggleMainWindow() {
if (!mainWindow) return createMainWindow();
if (mainWindow.isVisible()) mainWindow.hide();
else { mainWindow.show(); mainWindow.focus(); }
}
app.whenReady().then(() => {
createMainWindow();
createTray();
});
// macOS dock에서도 숨기고 싶다면
if (process.platform === 'darwin' && hideFromDock) {
app.dock.hide();
}2) 핵심 패턴 3가지
| 패턴 | 코드 | 의미 |
|---|---|---|
| 닫기 = 숨기기 | close 이벤트 preventDefault + hide() | ”사용자가 X를 눌러도 앱은 안 죽음” |
| isQuitting 플래그 | app.isQuitting 또는 let willQuit = false | ”진짜 종료할 때만 close 허용” |
| 클릭으로 토글 | tray.on('click', toggle) | 한 번 누르면 열고 다시 누르면 숨김 |
주의:
mainWindow = null로 해두지 마라 — Tray가 다시 띄울 때 어떤 창을 보여줄지 잊는다.null을 두려면 Map 패턴과 결합해서 키로 재생성.
3) OS별 Tray 차이
| OS | Tray 위치 | 아이콘 권장 사이즈 | 특이점 |
|---|---|---|---|
| Windows | 작업표시줄 우측 | 16×16 (보통은 32×32 제공, OS가 다운스케일) | 단색 아이콘이 보기 좋음 |
| macOS | 상단 menu bar 우측 | 22×22 + 44×44 (@2x) | template 이미지 필수 — 다크/라이트 자동 |
| Linux | 데스크톱 환경별 (Gnome은 기본 없음, KDE 있음) | 22×22 | 환경 따라 동작 다름 |
// macOS template image — 검정+알파만 사용해야 함
const icon = nativeImage.createFromPath('tray-iconTemplate.png');
icon.setTemplateImage(true);What-if — 흔한 함정
| 함정 | 증상 | 해법 |
|---|---|---|
move/resize 매 호출에 디스크 쓰기 | 드래그 중 SSD에 1000번 쓰기 | 디바운스 (200~500ms) |
| 저장된 좌표가 사라진 모니터 위 | 창이 안 보임 | screen.getAllDisplays()로 visible 검증 |
Tray에서 mainWindow.show() 후 macOS dock에서 안 뜸 | dock 아이콘이 dim | app.dock.show() 추가 |
close: e.preventDefault() + app.isQuitting 누락 | 종료가 안 됨 | 종료 메뉴에서 app.isQuitting = true 먼저 |
| Tray 객체를 함수 안 지역 변수로 | GC되어 사라지는 트레이 | 전역 변수로 보존 |
| 다중 창에서 같은 key 중복 등록 | 창 누수 | if (windows.has(key)) ... 먼저 |
닫혔는데 Map에서 안 지움 | getAllWindows()와 Map이 어긋남 | closed 이벤트에서 delete |
| 모든 창에 같은 preload | preload 코드가 무거우면 시작 느림 | 공통 + 화면별 분리 |
How (advanced) — 창 그룹과 z-order
A. parent + modal
const settings = new BrowserWindow({
parent: mainWindow,
modal: true,
width: 600, height: 400,
});- macOS는 모달 시트로 부모에 붙음.
- Windows는 별도 창이지만 항상 부모 위에.
B. alwaysOnTop
win.setAlwaysOnTop(true, 'pop-up-menu'); // macOS level 옵션- 음악 플레이어 미니창, 화상회의 PiP 등에 사용.
C. setVisibleOnAllWorkspaces (macOS)
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });- macOS의 Space(가상 데스크톱) 전환에도 따라가는 창.
D. setMovable(false) + setResizable(false)
- 키오스크·고정 UI 모드.
Insight — electron-window-state가 별 게 아닌데 디팩토 표준인 이유
“같은 4가지 엣지케이스를 매 앱이 다시 만들기 싫었다” —
창 상태 저장의 완전한 구현은 이 4가지를 모두 처리해야 한다: ① 디바운스, ② 모니터 사라짐, ③ 최대화 / 풀스크린 상태 분리, ④ 멀티 모니터에서의 논리적 좌표 vs 실제 픽셀.
처음 만드는 사람은 ①만 처리하고, 두 번째 만드는 사람은 ②까지 추가하고… 이 누적이
electron-window-state한 파일로 정착한 게 2014년. 십 년이 지난 지금도 기본기로 쓰인다. 표면적 단순함과 엣지케이스의 깊이가 4배 이상 차이 나는 라이브러리의 전형.
다음 문서
- 창 외부와의 연결고리 — 06 protocol & 딥링크.
- 창 사이 통신의 본격적 모델 — 03 IPC.
한 단락 요약
창 상태는 직접 저장해야 하고 —
electron-window-state가 디팩토 표준. 다중 창은Map<key, BrowserWindow>로 추적하고, Tray는 “창은 숨고 앱은 산다” 패턴의 OS 통로다. 닫기 = 숨기기, isQuitting 플래그, Tray 객체 전역 보존 — 이 세 패턴이 대부분의 백그라운드 데스크톱 앱의 골격이다.