junyeokk
Blog
Electron·2025. 12. 16

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의 디렉토리 구조

text
%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의 내부 구현

이 라이브러리의 코드는 놀라울 정도로 간단하다. 실제로 하는 일은 딱 세 가지:

  1. process.argv에서 --squirrel- 접두사 인자를 찾는다
  2. 발견하면 Update.exe를 실행해서 바로가기 생성/삭제를 처리한다
  3. true를 반환한다

핵심 로직을 단순화하면 이런 구조다:

javascript
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하는 순간 즉시 실행된다. 그래서 사용하는 쪽에서는 반환값만 확인하면 된다.

사용법

typescript
import started from 'electron-squirrel-startup';

if (started) {
  app.quit();
}

이게 전부다. 이 코드는 반드시 앱의 최상단, 다른 어떤 초기화보다 먼저 와야 한다. BrowserWindow 생성, IPC 핸들러 등록, 트레이 아이콘 설정 같은 건 전부 이 검사 이후에 해야 한다.

typescript
// ✅ 올바른 순서
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);
typescript
// ❌ 잘못된 순서 - 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-startupUpdate.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 레지스트리에 설정 저장
  • 설치 후 초기 데이터 다운로드

이런 경우 라이브러리를 쓰지 않고 직접 처리할 수 있다:

typescript
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-startupWindows 전용 문제에 대한 해결책이다. 크로스 플랫폼 Electron 앱에서 최상단에 넣어도 Windows가 아니면 Squirrel 인자가 없으므로 false를 반환하고 정상 실행된다.

Electron Forge와의 관계

Electron Forge로 프로젝트를 생성하면 electron-squirrel-startup이 자동으로 포함된다. forge.config.ts에서 @electron-forge/maker-squirrel을 사용하면 Squirrel 기반 Windows 설치 파일이 만들어지고, 템플릿의 main.ts에 이미 이 라이브러리의 체크 코드가 들어가 있다.

typescript
// 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. 체크를 안 넣음

typescript
// ❌ Squirrel 체크 없음
const createWindow = () => { ... };
app.whenReady().then(createWindow);

설치할 때 앱 창이 뜬다. 사용자가 당황한다.

2. 체크 위치가 늦음

typescript
// ❌ 이미 이벤트 리스너가 등록된 후
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에서는 불필요)

관련 문서