⚡ Electron2. 창 & 라이프사이클05 · 창 상태와 다중 창

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 차이

OSTray 위치아이콘 권장 사이즈특이점
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 아이콘이 dimapp.dock.show() 추가
close: e.preventDefault() + app.isQuitting 누락종료가 안 됨종료 메뉴에서 app.isQuitting = true 먼저
Tray 객체를 함수 안 지역 변수로GC되어 사라지는 트레이전역 변수로 보존
다중 창에서 같은 key 중복 등록창 누수if (windows.has(key)) ... 먼저
닫혔는데 Map에서 안 지움getAllWindows()와 Map이 어긋남closed 이벤트에서 delete
모든 창에 같은 preloadpreload 코드가 무거우면 시작 느림공통 + 화면별 분리

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배 이상 차이 나는 라이브러리의 전형.


다음 문서


한 단락 요약

창 상태는 직접 저장해야 하고 — electron-window-state가 디팩토 표준. 다중 창은 Map<key, BrowserWindow> 로 추적하고, Tray“창은 숨고 앱은 산다” 패턴의 OS 통로다. 닫기 = 숨기기, isQuitting 플래그, Tray 객체 전역 보존 — 이 세 패턴이 대부분의 백그라운드 데스크톱 앱의 골격이다.