electron-squirrel-startup
Windows에서 Electron 앱을 설치하면 이상한 일이 벌어진다. 설치 과정에서 앱이 실행되고, 업데이트할 때도 실행되고, 삭제할 때조차 앱이 한 번 실행된다. 이게 Squirrel이라는 Windows 설치/업데이트 프레임워크의 동작 방식이다.
문제는 이 "설치 중 실행"이 일반적인 앱 실행과 동일하게 app.ready 이벤트를 발생시킨다는 점이다. 윈도우를 만들고, 트레이 아이콘을 등록하고, 서버에 연결하는 초기화 로직이 전부 실행된다. 설치 화면에서 갑자기 앱 창이 뜨고, 삭제하는데 앱이 켜지는 황당한 상황이 발생한다.
electron-squirrel-startup은 이 문제를 해결하는 아주 작은 라이브러리다. Squirrel 이벤트로 실행된 건지 판별해서, 맞으면 바로가기 생성/삭제 같은 필요한 처리를 하고 true를 반환한다. 개발자는 이 값을 확인해서 즉시 app.quit()을 호출하면 된다.
Squirrel이 뭔가
Squirrel은 GitHub에서 만든 Windows/macOS용 설치·자동 업데이트 프레임워크다. Electron 생태계에서는 주로 Windows 쪽에서 쓰인다. Electron Forge의 @electron-forge/maker-squirrel이 바로 이 Squirrel 기반으로 Windows 설치 파일(.exe)을 만든다.
Squirrel의 핵심 설계 철학은 "설치에 관리자 권한이 필요 없다"는 것이다. 일반적인 Windows 설치 프로그램(MSI, NSIS)은 Program Files에 설치하면서 UAC 팝업을 띄우지만, Squirrel은 사용자의 %LocalAppData% 폴더에 설치한다. 관리자 권한 없이도 설치와 업데이트가 가능하다.
Squirrel의 디렉토리 구조
%LocalAppData%/MyApp/
├── MyApp.exe ← Update.exe (런처)
├── packages/ ← .nupkg 업데이트 파일
├── app-1.0.0/ ← 버전 1.0.0 실제 파일
│ ├── MyApp.exe ← 실제 Electron 앱
│ └── resources/
├── app-1.1.0/ ← 버전 1.1.0 (업데이트 후)
│ ├── MyApp.exe
│ └── resources/
└── staging/ ← 업데이트 준비 영역
최상위 MyApp.exe는 실제 앱이 아니라 Update.exe의 복사본이다. 이 런처가 가장 최신 app-x.x.x 폴더를 찾아서 그 안의 진짜 앱을 실행한다. 업데이트는 새 버전 폴더를 만들고 런처가 가리키는 경로만 바꾸는 방식이라, 업데이트 중에도 기존 버전이 돌아가고 있을 수 있다.
Squirrel 이벤트의 동작 원리
Squirrel은 설치·업데이트·삭제의 각 단계에서 앱을 특정 커맨드라인 인자와 함께 실행한다.
| 이벤트 | 커맨드라인 인자 | 발생 시점 |
|---|---|---|
--squirrel-install | 최초 설치 완료 직후 | |
--squirrel-updated | 업데이트 적용 후 새 버전 첫 실행 | |
--squirrel-uninstall | 앱 삭제 직전 | |
--squirrel-obsolete | 업데이트 후 구버전이 교체될 때 | |
--squirrel-firstrun | 설치 후 사용자가 처음 실행 |
핵심은 --squirrel-install, --squirrel-updated, --squirrel-uninstall, --squirrel-obsolete 네 가지다. 이 인자가 있으면 "사용자가 앱을 실행한 게 아니라 Squirrel이 내부적으로 실행한 것"이므로, 일반적인 앱 초기화를 하면 안 된다.
예를 들어 --squirrel-install 단계에서 해야 할 일은:
- 바탕화면 바로가기 생성
- 시작 메뉴 등록
- 파일 연결(file association) 설정
--squirrel-uninstall에서는:
- 바로가기 삭제
- 시작 메뉴 항목 제거
- 레지스트리 정리
이 처리를 하고 즉시 종료해야 한다. 안 그러면 설치 프로그램이 앱 종료를 기다리느라 설치가 멈춘다.
electron-squirrel-startup의 내부 구현
이 라이브러리의 코드는 놀라울 정도로 간단하다. 실제로 하는 일은 딱 세 가지:
process.argv에서--squirrel-접두사 인자를 찾는다- 발견하면
Update.exe를 실행해서 바로가기 생성/삭제를 처리한다 true를 반환한다
핵심 로직을 단순화하면 이런 구조다:
const path = require('path');
const { spawn } = require('child_process');
const app = require('electron').app;
const run = function(args, done) {
const updateExe = path.resolve(
path.dirname(process.execPath),
'..',
'Update.exe'
);
spawn(updateExe, args, { detached: true })
.on('close', done);
};
const check = function() {
const squirrelCommand = process.argv[1];
switch (squirrelCommand) {
case '--squirrel-install':
case '--squirrel-updated':
// 바로가기 생성
run(['--createShortcut=' + path.basename(process.execPath)], app.quit);
return true;
case '--squirrel-uninstall':
// 바로가기 삭제
run(['--removeShortcut=' + path.basename(process.execPath)], app.quit);
return true;
case '--squirrel-obsolete':
app.quit();
return true;
}
return false;
};
module.exports = check();
Update.exe는 Squirrel이 제공하는 실행 파일로, --createShortcut과 --removeShortcut 옵션을 지원한다. 라이브러리가 직접 바로가기를 만드는 게 아니라 Update.exe에 위임하는 것이다. 이렇게 하면 Squirrel 버전이 바뀌어도 바로가기 경로나 형식이 자동으로 맞춰진다.
주목할 점은 module.exports = check()다. import하는 순간 즉시 실행된다. 그래서 사용하는 쪽에서는 반환값만 확인하면 된다.
사용법
import started from 'electron-squirrel-startup';
if (started) {
app.quit();
}
이게 전부다. 이 코드는 반드시 앱의 최상단, 다른 어떤 초기화보다 먼저 와야 한다. BrowserWindow 생성, IPC 핸들러 등록, 트레이 아이콘 설정 같은 건 전부 이 검사 이후에 해야 한다.
// ✅ 올바른 순서
import { app, BrowserWindow } from 'electron';
import started from 'electron-squirrel-startup';
if (started) {
app.quit();
}
// 여기서부터 일반 앱 초기화
const createWindow = () => {
const mainWindow = new BrowserWindow({ ... });
mainWindow.loadURL('...');
};
app.whenReady().then(createWindow);
// ❌ 잘못된 순서 - Squirrel 이벤트에서도 윈도우가 생성됨
import { app, BrowserWindow } from 'electron';
const createWindow = () => { ... };
app.whenReady().then(createWindow);
// 너무 늦음 - 이미 윈도우가 만들어졌을 수 있음
import started from 'electron-squirrel-startup';
if (started) app.quit();
Squirrel 이벤트별 상세 동작
--squirrel-install
최초 설치가 완료된 직후 실행된다. 이 시점에 바탕화면과 시작 메뉴에 바로가기를 만든다. electron-squirrel-startup이 Update.exe --createShortcut을 호출해서 처리한다.
사용자 입장에서는 설치 프로그램이 돌아가는 동안 잠깐 앱이 뜨는 것처럼 보일 수 있다. electron-squirrel-startup이 없으면 실제로 앱 창이 열린다.
--squirrel-updated
업데이트가 적용되고 새 버전의 앱이 처음 실행될 때 발생한다. 바로가기를 다시 생성하는데, 이건 앱 실행 파일의 경로가 app-1.0.0에서 app-1.1.0으로 바뀌었기 때문이다. 바로가기가 가리키는 대상을 새 경로로 업데이트해야 한다.
--squirrel-uninstall
삭제 직전에 실행된다. 바로가기를 제거하고 정리 작업을 수행한다. 이 이벤트를 처리하지 않으면 앱은 삭제됐는데 바탕화면에 깨진 바로가기가 남는다.
--squirrel-obsolete
업데이트 후 구버전의 앱이 교체될 때 발생한다. 구버전이 아직 실행 중일 수 있기 때문에, 이 이벤트를 받으면 그냥 즉시 종료하면 된다.
커스텀 처리가 필요한 경우
electron-squirrel-startup의 기본 동작(바로가기 생성/삭제)으로 충분하지 않은 경우가 있다. 예를 들어:
- 파일 확장자 연결 (
.myapp파일을 더블클릭하면 앱이 열리게) - 프로토콜 핸들러 등록 (
myapp://URL 스킴) - Windows 레지스트리에 설정 저장
- 설치 후 초기 데이터 다운로드
이런 경우 라이브러리를 쓰지 않고 직접 처리할 수 있다:
import { app } from 'electron';
import { spawn } from 'child_process';
import path from 'path';
function handleSquirrelEvent(): boolean {
const squirrelCommand = process.argv[1];
if (!squirrelCommand?.startsWith('--squirrel-')) return false;
const appFolder = path.resolve(process.execPath, '..');
const rootFolder = path.resolve(appFolder, '..');
const updateExe = path.resolve(rootFolder, 'Update.exe');
const exeName = path.basename(process.execPath);
switch (squirrelCommand) {
case '--squirrel-install':
case '--squirrel-updated':
// 바로가기 생성
spawn(updateExe, ['--createShortcut', exeName], { detached: true });
// 커스텀: 프로토콜 핸들러 등록
app.setAsDefaultProtocolClient('myapp');
// 커스텀: 파일 연결 설정
// Windows 레지스트리 작업 등
setTimeout(app.quit, 1500); // 처리 완료 대기
return true;
case '--squirrel-uninstall':
// 바로가기 삭제
spawn(updateExe, ['--removeShortcut', exeName], { detached: true });
// 커스텀: 프로토콜 핸들러 해제
app.removeAsDefaultProtocolClient('myapp');
// 커스텀: 사용자 데이터 정리 (선택적)
setTimeout(app.quit, 1500);
return true;
case '--squirrel-obsolete':
app.quit();
return true;
}
return false;
}
if (handleSquirrelEvent()) {
// Squirrel 이벤트 처리 중이므로 앱 초기화 건너뜀
} else {
// 일반 앱 실행
app.whenReady().then(createWindow);
}
setTimeout을 쓰는 이유는 spawn이 비동기이기 때문이다. Update.exe가 바로가기를 만드는 동안 앱이 먼저 종료되면 바로가기가 안 만들어질 수 있다.
macOS와의 차이
macOS에서는 이런 처리가 필요 없다. macOS 앱은 .app 번들 형태로 배포되는데, 설치는 그냥 /Applications에 드래그하거나 DMG에서 복사하는 것이고, 삭제는 .app을 휴지통에 넣으면 끝이다. 앱이 실행되는 시점이 명확하게 "사용자가 열었을 때"뿐이다.
Squirrel.Mac도 존재하지만, macOS의 설치/삭제 과정에서 앱을 실행하는 메커니즘이 없기 때문에 electron-squirrel-startup 같은 라이브러리가 필요 없다. macOS의 자동 업데이트는 보통 autoUpdater 모듈이나 electron-updater로 처리한다.
Linux도 마찬가지다. .deb나 .rpm 패키지 매니저가 설치를 관리하고, 바로가기(.desktop 파일)는 패키지 스크립트(postinst, prerm)에서 처리한다.
결국 electron-squirrel-startup은 Windows 전용 문제에 대한 해결책이다. 크로스 플랫폼 Electron 앱에서 최상단에 넣어도 Windows가 아니면 Squirrel 인자가 없으므로 false를 반환하고 정상 실행된다.
Electron Forge와의 관계
Electron Forge로 프로젝트를 생성하면 electron-squirrel-startup이 자동으로 포함된다. forge.config.ts에서 @electron-forge/maker-squirrel을 사용하면 Squirrel 기반 Windows 설치 파일이 만들어지고, 템플릿의 main.ts에 이미 이 라이브러리의 체크 코드가 들어가 있다.
// Electron Forge 템플릿이 생성하는 기본 코드
import started from 'electron-squirrel-startup';
if (started) {
app.quit();
}
Forge가 아닌 electron-builder를 쓴다면 상황이 다르다. electron-builder는 기본적으로 NSIS 설치 프로그램을 만드는데, NSIS는 Squirrel과 다른 방식으로 동작해서 electron-squirrel-startup이 필요 없다. electron-builder에서 Squirrel 타겟을 명시적으로 선택한 경우에만 필요하다.
흔한 실수
1. 체크를 안 넣음
// ❌ Squirrel 체크 없음
const createWindow = () => { ... };
app.whenReady().then(createWindow);
설치할 때 앱 창이 뜬다. 사용자가 당황한다.
2. 체크 위치가 늦음
// ❌ 이미 이벤트 리스너가 등록된 후
app.on('ready', createWindow);
import started from 'electron-squirrel-startup';
if (started) app.quit();
ESM에서는 import가 호이스팅되니까 이 예시는 실제로 문제가 안 될 수 있지만, CJS에서 require를 쓰면 순서대로 실행되므로 문제가 된다. 어느 쪽이든 최상단에 두는 게 안전하다.
3. 개발 중에는 안 보이는 문제
개발 환경에서는 electron .로 직접 실행하니까 --squirrel- 인자가 없다. 그래서 이 코드가 없어도 개발 중에는 아무 문제 없다. 빌드해서 설치 파일을 만들고 실제로 설치해봐야 문제가 드러난다. Windows 가상 머신이나 실제 Windows 환경에서 설치 테스트를 해야 하는 이유다.
정리
- Squirrel은 설치/업데이트/삭제 시 앱을 커맨드라인 인자와 함께 실행하므로, 이를 감지해서 일반 초기화를 건너뛰어야 한다
- import 즉시 실행되는 구조라 반환값만 확인하면 되고, 반드시 다른 초기화 코드보다 먼저 위치해야 한다
- Windows 전용 문제이며, Electron Forge + maker-squirrel 조합에서 자동 포함된다 (NSIS 기반 electron-builder에서는 불필요)
관련 문서
- Electron Forge + Vite 플러그인 - Forge 빌드 시스템
- Electron Forge Makers - 플랫폼별 패키징
- Electron contextBridge - IPC 보안