junyeokk
Blog
Electron·2025. 12. 16

Electron Forge + Vite 플러그인

Electron 앱을 만들 때 가장 귀찮은 부분은 코드 작성이 아니라 빌드 파이프라인 구성이다. Main 프로세스는 Node.js 환경이고, Renderer 프로세스는 브라우저 환경이며, Preload 스크립트는 그 사이 어딘가에 있다. 각각 번들링 전략이 다르고, 개발 중에는 HMR이 필요하고, 배포할 때는 플랫폼별 패키징까지 해야 한다. 이걸 직접 구성하면 Vite 설정 파일만 서너 개에, 빌드 스크립트도 따로 만들어야 한다.

Electron Forge는 이 전체 라이프사이클을 하나의 도구 체인으로 통합한다. 그리고 @electron-forge/plugin-vite는 그 안에서 Vite를 번들러로 사용할 수 있게 해주는 플러그인이다.


Electron Forge가 해결하는 문제

Electron 앱의 라이프사이클은 크게 네 단계로 나뉜다.

  1. 개발 — Main/Renderer/Preload를 동시에 빌드하면서 HMR 지원
  2. 패키징 — asar 아카이브로 묶고, 네이티브 모듈 리빌드
  3. 배포 파일 생성 — DMG, DEB, RPM, Squirrel 등 플랫폼별 설치 파일
  4. 배포 — S3, GitHub Releases 등에 업로드

이전에는 각 단계를 별도 도구로 처리했다. electron-builder가 패키징을 담당하고, Webpack이나 Vite가 번들링을 처리하고, 배포 스크립트를 따로 작성했다. Electron Forge는 이걸 하나의 CLI로 통합한다.

bash
# 프로젝트 생성
npx create-electron-app my-app --template=vite

# 개발 서버 실행
npm start  # = electron-forge start

# 패키징
npm run package  # = electron-forge package

# 배포 파일 생성
npm run make  # = electron-forge make

start, package, make, publish 네 개의 명령어가 전체 라이프사이클을 커버한다. 플러그인 시스템으로 각 단계의 구체적인 도구를 교체할 수 있는데, 번들러 자리에 Vite를 꽂는 것이 plugin-vite다.


forge.config.ts의 구조

Electron Forge의 설정은 forge.config.ts (또는 .js) 파일에 집중된다. 전체 빌드 파이프라인의 청사진이라고 보면 된다.

typescript
import type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerDMG } from '@electron-forge/maker-dmg';
import { MakerDeb } from '@electron-forge/maker-deb';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses';

const config: ForgeConfig = {
  packagerConfig: {
    asar: true,
    icon: './assets/icon',
  },
  makers: [
    new MakerSquirrel({}),
    new MakerDMG({}),
    new MakerDeb({}),
  ],
  plugins: [
    new VitePlugin({ /* ... */ }),
    new FusesPlugin({ /* ... */ }),
  ],
};

export default config;

크게 세 영역으로 나뉜다.

영역역할예시
packagerConfigelectron-packager에 전달되는 옵션asar, 아이콘, 코드 서명
makers플랫폼별 설치 파일 생성기DMG, DEB, Squirrel
plugins빌드/개발 과정을 확장하는 플러그인Vite, Fuses

이 구조 덕분에 "번들러는 Vite, 패키징은 asar, Windows 배포는 Squirrel, macOS는 DMG"처럼 각 단계를 독립적으로 구성할 수 있다.


Vite 플러그인의 동작 원리

멀티 엔트리 빌드

Electron 앱에는 최소 세 개의 진입점이 있다. Main 프로세스, Preload 스크립트, Renderer 프로세스. 일반 웹 앱은 엔트리가 하나지만, Electron에서는 각각 다른 환경에서 실행되므로 별도로 빌드해야 한다.

plugin-vite는 이걸 buildrenderer 두 배열로 분리한다.

typescript
new VitePlugin({
  build: [
    {
      entry: 'src/main.ts',
      config: 'vite.main.config.ts',
      target: 'main',
    },
    {
      entry: 'src/preload.ts',
      config: 'vite.preload.config.ts',
      target: 'preload',
    },
  ],
  renderer: [
    {
      name: 'main_window',
      config: 'vite.renderer.config.ts',
    },
  ],
})

build 배열: Main 프로세스와 Preload 스크립트처럼 Node.js 환경에서 실행되는 코드. Vite의 build.lib.entry로 빌드된다. 각 항목에 별도의 Vite 설정 파일을 지정할 수 있어서, Main은 Node.js 타겟으로, Preload는 Electron의 샌드박스 환경에 맞게 설정할 수 있다.

renderer 배열: 브라우저 환경에서 실행되는 UI 코드. 일반 Vite 웹 앱과 동일하게 동작한다. name 필드가 중요한데, 이 이름을 기반으로 전역 변수가 생성된다.

빌드 순서

내부적으로 Vite 플러그인은 별도의 Vite 프로세스를 각 타겟별로 스폰한다. 실행 순서는 이렇다.

  1. 먼저 모든 renderer 타겟을 병렬로 빌드
  2. 그다음 모든 build 타겟(Main, Preload)을 병렬로 빌드

Renderer를 먼저 빌드하는 이유는 Main 프로세스가 Renderer의 빌드 결과물 경로를 참조해야 하기 때문이다. 빌드 결과물은 .vite/build/ 디렉토리에 저장되며, package.jsonmain 필드가 이 경로를 가리켜야 한다.

json
{
  "main": ".vite/build/main.js"
}

빌드 동시성 제어

Vite는 빌드 시 상당한 메모리를 사용한다. 엔트리가 많은 앱에서 모든 빌드를 동시에 실행하면 OOM(Out of Memory)이 발생할 수 있다. Forge v7.9.0부터 concurrent 옵션으로 동시 빌드 수를 제한할 수 있다.

typescript
new VitePlugin({
  build: [/* ... */],
  renderer: [/* ... */],
  concurrent: 2  // 최대 2개 동시 빌드. false면 순차 실행
})

개발 서버와 HMR

electron-forge start를 실행하면 Vite 플러그인이 두 가지를 동시에 처리한다.

  1. Renderer용 Vite Dev Server: 일반 Vite 개발 서버와 동일. HMR 지원.
  2. Main/Preload 빌드: 파일 변경 감지 시 리빌드 후 Electron 프로세스 재시작.

핵심은 Main 프로세스에서 Renderer를 로드하는 방식이다. 개발 중에는 Dev Server URL을 사용하고, 프로덕션에서는 빌드된 파일을 로드해야 한다. Vite 플러그인은 이를 위해 전역 변수를 자동 주입한다.

typescript
// renderer 배열에서 name: 'main_window'로 지정하면
// 두 개의 전역 변수가 생성된다

declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
declare const MAIN_WINDOW_VITE_NAME: string;

변수 이름 규칙은 {NAME}_VITE_DEV_SERVER_URL{NAME}_VITE_NAME이다. NAME은 renderer의 name 필드를 대문자 + 스네이크 케이스로 변환한 것이다.

Main 프로세스에서 이렇게 사용한다.

typescript
const createWindow = () => {
  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
    },
  });

  // 개발 중: Vite Dev Server URL로 로드 (HMR 지원)
  if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
    mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
  } 
  // 프로덕션: 빌드된 파일 로드
  else {
    mainWindow.loadFile(
      path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)
    );
  }
};

개발 중에는 MAIN_WINDOW_VITE_DEV_SERVER_URLhttp://localhost:5173 같은 값이 되고, 프로덕션 빌드에서는 undefined가 된다. 이 패턴 덕분에 하나의 코드로 개발과 프로덕션을 모두 처리할 수 있다.


Vite 설정 파일 분리

각 타겟별로 Vite 설정 파일이 분리되는 이유는 실행 환경이 다르기 때문이다.

vite.main.config.ts

Main 프로세스는 Node.js 환경이므로 Node.js 내장 모듈과 Electron API를 사용할 수 있어야 한다.

typescript
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // Node.js 내장 모듈을 번들에 포함하지 않음
    rollupOptions: {
      external: ['electron'],
    },
  },
  resolve: {
    // Main 프로세스에서 사용하는 조건부 export
    conditions: ['node'],
  },
});

vite.preload.config.ts

Preload 스크립트도 Node.js 환경이지만, contextIsolation이 활성화된 상태에서는 제한된 API만 사용 가능하다. 보통 Main과 비슷하게 설정하되, 필요한 모듈만 포함한다.

typescript
import { defineConfig } from 'vite';

export default defineConfig({
  // Preload는 보통 간단하므로 추가 설정이 적다
});

vite.renderer.config.ts

Renderer는 일반 웹 앱과 동일하다. React, Vue 등의 프레임워크 플러그인을 여기에 추가한다.

typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

네이티브 모듈 처리

serialport, sqlite3 같은 네이티브 모듈은 C++ 바인딩을 포함하고 있어서 Vite로 번들링하면 문제가 생긴다. Vite가 이진 파일을 처리하지 못하거나, 번들링 과정에서 경로가 꼬이기 때문이다.

해결 방법은 해당 모듈을 external로 지정하는 것이다.

typescript
// vite.main.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      external: [
        'serialport',
        'sqlite3',
        'better-sqlite3',
      ],
    },
  },
});

external로 지정된 모듈은 번들에 포함되지 않고, 런타임에 require()로 로드된다. Electron Forge가 패키징할 때 node_modules에서 해당 모듈을 복사하고, 필요하면 @electron/rebuild로 네이티브 바인딩을 Electron의 Node.js 버전에 맞게 리빌드한다.


Webpack 플러그인과의 비교

Electron Forge는 원래 Webpack 플러그인(@electron-forge/plugin-webpack)을 먼저 지원했다. Vite 플러그인은 나중에 추가됐고, 몇 가지 차이점이 있다.

항목Webpack 플러그인Vite 플러그인
개발 서버 시작 속도느림 (전체 번들링 후 시작)빠름 (ESM 네이티브 로딩)
HMR 속도변경 모듈 + 의존 모듈 리빌드변경 모듈만 교체
설정 복잡도높음 (loader, plugin 체인)낮음 (합리적인 기본값)
생태계 성숙도높음성장 중 (실험적 상태)
멀티 엔트리entryPoints 배열build + renderer 분리

Vite의 가장 큰 장점은 개발 경험이다. Webpack은 전체 앱을 번들링한 후 서버를 시작하지만, Vite는 ESM을 네이티브로 활용해서 변경된 모듈만 즉시 교체한다. 프로젝트가 커질수록 이 차이가 극명해진다.

다만 Vite 플러그인은 아직 실험적(experimental) 상태로 표시되어 있다. v7.5.0부터 이 태그가 붙었는데, 마이너 버전에서도 breaking change가 있을 수 있다는 의미다. 프로덕션 프로젝트에서 사용할 때는 버전을 고정하고 릴리스 노트를 확인하는 것이 좋다.


실제 설정 예시

실제 프로젝트에서 사용하는 forge.config.ts를 보면, 플러그인과 Maker가 어떻게 조합되는지 알 수 있다.

typescript
import path from 'node:path';
import type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerDMG } from '@electron-forge/maker-dmg';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';

const iconsDir = path.resolve(__dirname, '../../assets/icons');

const config: ForgeConfig = {
  packagerConfig: {
    asar: true,
    icon: process.platform === 'win32'
      ? path.join(iconsDir, 'win/icon')
      : path.join(iconsDir, 'mac/icon'),
    osxSign: {},
    osxNotarize: {
      appleId: process.env.APPLE_ID!,
      appleIdPassword: process.env.APPLE_ID_PASSWORD!,
      teamId: 'XXXXXXXXXX',
    },
  },
  makers: [
    new MakerSquirrel({
      setupIcon: path.join(iconsDir, 'win/icon.ico'),
    }),
    new MakerDMG({
      icon: path.join(iconsDir, 'mac/icon.icns'),
    }),
    new MakerDeb({}),
    new MakerRpm({}),
  ],
  plugins: [
    new VitePlugin({
      build: [
        {
          entry: 'src/main.ts',
          config: 'vite.main.config.ts',
          target: 'main',
        },
        {
          entry: 'src/preload.ts',
          config: 'vite.preload.config.ts',
          target: 'preload',
        },
      ],
      renderer: [],
    }),
    new FusesPlugin({
      version: FuseVersion.V1,
      [FuseV1Options.RunAsNode]: false,
      [FuseV1Options.EnableCookieEncryption]: true,
      [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
      [FuseV1Options.EnableNodeCliInspectArguments]: false,
      [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
      [FuseV1Options.OnlyLoadAppFromAsar]: true,
    }),
  ],
};

export default config;

여기서 눈에 띄는 점은 renderer가 빈 배열이라는 것이다. 이 프로젝트는 키오스크 앱으로, Renderer가 외부 URL을 로드하거나 별도의 빌드 파이프라인을 사용하는 경우다. 모든 Electron 앱이 Renderer를 Vite로 번들링해야 하는 건 아니다.

packagerConfig에서는 플랫폼별 아이콘과 macOS 코드 서명/공증 설정이 들어간다. osxSignosxNotarize는 macOS에서 앱을 배포할 때 필수적인데, 서명이 없으면 Gatekeeper가 앱 실행을 차단한다.


Forge의 빌드 결과물 구조

electron-forge package를 실행하면 .vite/ 디렉토리에 빌드 결과물이 생성된다.

text
.vite/
├── build/
│   ├── main.js          # Main 프로세스 번들
│   └── preload.js       # Preload 스크립트 번들
└── renderer/
    └── main_window/
        ├── index.html    # Renderer HTML
        ├── assets/
        │   ├── index-abc123.js
        │   └── index-def456.css
        └── ...

build/ 디렉토리에는 build 배열의 결과물이, renderer/ 디렉토리에는 renderer 배열의 결과물이 들어간다. package.jsonmain 필드가 .vite/build/main.js를 가리키고 있어야 Electron이 앱을 시작할 수 있다.

electron-forge make까지 실행하면 out/ 디렉토리에 플랫폼별 설치 파일이 생성된다.

text
out/
├── my-app-linux-x64/          # 패키징된 앱
├── make/
│   ├── deb/x64/my-app.deb     # Debian 패키지
│   ├── rpm/x64/my-app.rpm     # RPM 패키지
│   └── squirrel.windows/      # Windows 설치 파일
│       ├── my-app-Setup.exe
│       └── RELEASES

electron-builder와의 차이

Electron 앱 패키징 도구로는 electron-builder도 널리 사용된다. 두 도구의 철학이 다르다.

electron-builder: 패키징에 특화. 번들러는 별도로 구성해야 한다. YAML 설정 파일(electron-builder.yml) 기반. auto-update 내장.

Electron Forge: 전체 라이프사이클 통합. 번들러를 플러그인으로 포함. TypeScript 설정 파일. Electron 공식 팀이 관리.

Electron Forge가 Electron 팀의 공식 도구라는 점이 중요하다. Electron의 새로운 기능(Fuses, contextIsolation 강화 등)이 Forge에 먼저 반영되는 경향이 있다. 장기적으로 Electron 생태계의 방향성과 맞춰가고 싶다면 Forge가 더 안전한 선택이다.


정리

Electron Forge + Vite 플러그인의 핵심은 "Electron 앱의 복잡한 빌드 파이프라인을 하나의 설정 파일로 관리한다"는 것이다. Main, Preload, Renderer 각각에 대해 적절한 Vite 빌드를 자동으로 구성하고, 개발 중에는 HMR을 제공하며, 배포 시에는 플랫폼별 설치 파일까지 생성한다.

직접 Vite + Electron을 연결하려고 하면 Dev Server URL 주입, 빌드 순서 관리, 네이티브 모듈 리빌드 등 신경 써야 할 것이 많다. Forge가 이 모든 것을 추상화해준다. 설정 파일 하나에 원하는 Maker와 Plugin을 꽂기만 하면 된다.


관련 문서