Skip to main content
Summary

프로젝트 목표 정하기

지금까지는 Electron의 기능을 장별로 나누어 살펴보았다. 마지막 장에서는 이 기능들을 하나의 제품 구조로 묶는다. 예제로 만들 앱은 메모와 파일 첨부를 관리하는 데스크톱 앱이다.

이 앱은 다음 흐름을 제공한다.

  • 메모를 만들고 수정하고 삭제한다.

  • 메모에 파일을 첨부한다.

  • 제목과 본문으로 메모를 검색한다.

  • 앱 설정을 저장한다.

  • 단축키와 트레이로 빠르게 접근한다.

  • 다크 모드를 지원한다.

  • 패키징 후 첫 버전을 배포하고 업데이트 경로를 준비한다.

중요한 것은 기능의 개수가 아니다. 각 기능을 어느 프로세스에 둘지, 어떤 데이터가 경계를 넘어가는지, 출시 전에 무엇을 확인해야 하는지를 경험하는 것이 목표다.

실전 프로젝트로 완성하는 데스크톱 앱AI 생성 이미지

요구사항을 기능 단위로 나누기

먼저 화면 기준이 아니라 책임 기준으로 요구사항을 나눈다.

메모 기능은 사용자 인터페이스, 저장소, 검색 로직으로 나뉜다. renderer는 목록과 편집 화면을 보여 준다. main은 저장 요청을 검증하고 storage 계층에 위임한다. storage는 실제 파일이나 데이터베이스를 읽고 쓴다.

파일 첨부는 더 엄격하게 다룬다. renderer가 사용자의 로컬 경로를 직접 다루지 않게 한다. 사용자가 파일을 선택하면 main에서 파일을 앱 데이터 디렉터리로 복사하고, renderer에는 첨부 파일의 ID, 원래 파일명, 크기, MIME 타입 정도만 전달한다. 실제 파일 경로를 UI 상태에 그대로 남기지 않으면, 나중에 로그나 오류 메시지로 민감한 경로가 새어 나갈 가능성도 줄일 수 있다.

설정은 테마, 시작 시 트레이 최소화 여부, 자동 업데이트 채널 같은 값을 다룬다. 설정 파일은 app.getPath('userData') 아래에 둔다. 설치 디렉터리에 쓰려고 하면 권한 오류가 나거나 업데이트 때 덮어써질 수 있다.

단축키, 메뉴, 트레이는 모두 main이 중심이 된다. 사용자가 메뉴에서 "새 메모"를 누르면 main이 현재 창의 renderer로 이벤트를 보낸다. renderer는 그 이벤트를 받아 UI 상태를 바꾼다.

폴더 구조 설계하기

실전 프로젝트에서는 파일을 "기능별로 아무 곳에나" 두기 시작하면 빠르게 복잡해진다. 다음처럼 프로세스와 책임을 기준으로 나누면 경계가 선명해진다.

src/
  main/
    main.js
    windows/
      createMainWindow.js
    ipc/
      notes.js
      attachments.js
      settings.js
    menu.js
    tray.js
    shortcuts.js
    updater.js
  preload/
    index.cjs
  renderer/
    app.js
    views/
    components/
    styles/
  storage/
    notesStore.js
    settingsStore.js
    fileStore.js
  shared/
    channels.js
    validators.js

main은 앱 생명주기, 창, 메뉴, 트레이, 자동 업데이트, IPC handler를 관리한다. preload는 renderer에 노출할 API를 정의한다. renderer는 화면과 사용자 입력을 담당한다. storage는 로컬 데이터 저장 방식을 숨긴다. 나중에 JSON 파일에서 SQLite로 바꾸더라도 renderer와 IPC 계약은 크게 흔들리지 않아야 한다.

shared에는 채널 이름과 검증 함수를 둔다. 문자열 채널을 여러 파일에 직접 쓰면 오타를 찾기 어렵다.

// src/shared/channels.js
export const CHANNELS = {
  NOTES_LIST: 'notes:list',
  NOTES_SAVE: 'notes:save',
  NOTES_DELETE: 'notes:delete',
  ATTACHMENTS_ADD: 'attachments:add',
  SETTINGS_GET: 'settings:get',
  SETTINGS_UPDATE: 'settings:update',
  UI_NEW_NOTE: 'ui:new-note'
};

다만 한 가지는 기억해야 한다. sandbox: true로 preload를 실행하면 preload는 완전한 Node.js 환경이 아니다. 빌드 도구로 preload를 하나의 파일로 번들링하지 않는다면, preload 안에서 프로젝트의 다른 파일을 자유롭게 import하는 방식이 동작하지 않을 수 있다. 그래서 이 장의 preload 예제는 CommonJS 형식의 단일 파일처럼 작성한다. 실제 프로젝트에서 Vite, webpack, electron-vite 같은 도구를 쓴다면 shared/channels.js를 preload 번들에 포함시키는 방식으로 중복을 줄이면 된다.

보안 기본값으로 창 만들기

프로젝트의 시작점은 안전한 BrowserWindow다. 개발 중 편의를 위해 renderer에 Node.js 권한을 열어 두면 이후에 구조를 고치기 어렵다. 처음부터 제한된 구조로 만든다.

// src/main/windows/createMainWindow.js
import { BrowserWindow } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export function createMainWindow() {
  const win = new BrowserWindow({
    width: 1100,
    height: 760,
    minWidth: 860,
    minHeight: 560,
    show: false,
    webPreferences: {
      preload: path.join(__dirname, '../../preload/index.cjs'),
      nodeIntegration: false,
      contextIsolation: true,
      sandbox: true,
      webSecurity: true
    }
  });

  win.once('ready-to-show', () => win.show());
  return win;
}

여기서 process.cwd()를 기준으로 preload 경로를 만들지 않은 이유도 있다. 개발 중에는 현재 작업 디렉터리가 프로젝트 루트라서 잘 동작하는 것처럼 보일 수 있지만, 패키징 후에는 실행 위치가 달라질 수 있다. 창을 만드는 파일의 위치를 기준으로 preload 경로를 잡아 두면 개발 환경과 배포 환경의 차이를 줄일 수 있다.

이 설정은 renderer를 일반 웹 페이지에 가깝게 유지한다. 파일 시스템, 업데이트, 트레이 같은 권한은 main에 남겨 둔다. renderer가 필요한 기능은 preload가 제공하는 좁은 API를 통해 요청한다.

preload에서 앱 API 정의하기

preload는 renderer가 사용할 수 있는 공개 계약이다. 여기서 ipcRenderer 전체를 넘기지 않는다. 기능 단위 메서드만 노출한다.

// src/preload/index.cjs
const { contextBridge, ipcRenderer } = require('electron');

const CHANNELS = Object.freeze({
  NOTES_LIST: 'notes:list',
  NOTES_SAVE: 'notes:save',
  NOTES_DELETE: 'notes:delete',
  ATTACHMENTS_ADD: 'attachments:add',
  SETTINGS_GET: 'settings:get',
  SETTINGS_UPDATE: 'settings:update',
  UI_NEW_NOTE: 'ui:new-note'
});

contextBridge.exposeInMainWorld('desktop', {
  notes: {
    list: () => ipcRenderer.invoke(CHANNELS.NOTES_LIST),
    save: (note) => ipcRenderer.invoke(CHANNELS.NOTES_SAVE, note),
    delete: (id) => ipcRenderer.invoke(CHANNELS.NOTES_DELETE, { id })
  },
  attachments: {
    add: (noteId) => ipcRenderer.invoke(CHANNELS.ATTACHMENTS_ADD, { noteId })
  },
  settings: {
    get: () => ipcRenderer.invoke(CHANNELS.SETTINGS_GET),
    update: (patch) => ipcRenderer.invoke(CHANNELS.SETTINGS_UPDATE, patch)
  },
  onNewNoteRequested: (callback) => {
    if (typeof callback !== 'function') return () => {};

    const listener = () => callback();
    ipcRenderer.on(CHANNELS.UI_NEW_NOTE, listener);
    return () => ipcRenderer.removeListener(CHANNELS.UI_NEW_NOTE, listener);
  }
});

반환값으로 해제 함수를 주는 방식도 중요하다. renderer 화면이 바뀔 때 listener가 계속 쌓이면 메모리 누수와 중복 실행이 생긴다.

또 하나 주의할 점은 이벤트 객체를 renderer로 넘기지 않는 것이다. 위 예제의 listener는 Electron의 event 객체를 버리고 callback()만 호출한다. 이렇게 하면 renderer가 IPC 내부 객체에 의존하지 않고, preload가 정한 계약 안에서만 동작한다.

저장소 계층 만들기

첫 버전에서는 JSON 파일과 첨부 파일 디렉터리 조합으로 시작할 수 있다. 메모 수가 많고 정렬, 필터, 전문 검색이 중요해지면 SQLite로 옮긴다. 저장소 계층을 분리해 두면 이 변경이 쉬워진다.

// src/storage/notesStore.js
import { app } from 'electron';
import fs from 'node:fs/promises';
import path from 'node:path';

const dataDir = path.join(app.getPath('userData'), 'data');
const notesPath = path.join(dataDir, 'notes.json');

async function readNotes() {
  try {
    const text = await fs.readFile(notesPath, 'utf8');
    const notes = JSON.parse(text);
    return Array.isArray(notes) ? notes : [];
  } catch (error) {
    if (error.code === 'ENOENT') return [];
    throw error;
  }
}

async function writeNotes(notes) {
  await fs.mkdir(dataDir, { recursive: true });
  const tempPath = `${notesPath}.tmp`;
  await fs.writeFile(tempPath, JSON.stringify(notes, null, 2), 'utf8');
  await fs.rename(tempPath, notesPath);
}

export const notesStore = { readNotes, writeNotes };

임시 파일에 먼저 쓰고 rename으로 교체하면 저장 도중 앱이 종료되었을 때 파일이 절반만 기록될 가능성을 줄일 수 있다. 그래도 JSON 파일 하나가 모든 데이터를 책임지는 구조에는 한계가 있다. 파일이 손상되었을 때를 대비해 백업 파일을 남기거나, 데이터가 커지면 SQLite처럼 트랜잭션을 제공하는 저장소로 옮길 계획을 함께 세워 두는 편이 좋다.

IPC handler에 검증 넣기

main의 IPC handler는 renderer에서 온 값을 신뢰하지 않는다. 같은 앱 내부 요청이라도 XSS, preload 오용, 개발 중 실수로 잘못된 payload가 들어올 수 있다.

// src/main/ipc/notes.js
import { ipcMain } from 'electron';
import { randomUUID } from 'node:crypto';
import { CHANNELS } from '../../shared/channels.js';
import { notesStore } from '../../storage/notesStore.js';

function isValidId(value) {
  return typeof value === 'string' && value.length > 0 && value.length <= 100;
}

function validateNote(input) {
  if (!input || typeof input !== 'object') throw new Error('Invalid note');

  if (input.id !== undefined && !isValidId(input.id)) {
    throw new Error('Invalid note id');
  }

  if (typeof input.title !== 'string' || input.title.length > 120) {
    throw new Error('Invalid title');
  }

  if (typeof input.body !== 'string' || input.body.length > 200_000) {
    throw new Error('Invalid body');
  }

  if (
    input.attachmentIds !== undefined &&
    (!Array.isArray(input.attachmentIds) || !input.attachmentIds.every(isValidId))
  ) {
    throw new Error('Invalid attachments');
  }
}

export function registerNotesIpc() {
  ipcMain.handle(CHANNELS.NOTES_LIST, async () => {
    return notesStore.readNotes();
  });

  ipcMain.handle(CHANNELS.NOTES_SAVE, async (_event, note) => {
    validateNote(note);

    const notes = await notesStore.readNotes();
    const now = new Date().toISOString();
    const id = note.id || randomUUID();
    const existing = notes.find((item) => item.id === id);

    const saved = {
      id,
      title: note.title.trim() || '제목 없음',
      body: note.body,
      attachmentIds: Array.isArray(note.attachmentIds) ? note.attachmentIds : [],
      createdAt: existing?.createdAt || now,
      updatedAt: now
    };

    const next = notes.filter((item) => item.id !== saved.id).concat(saved);
    await notesStore.writeNotes(next);
    return saved;
  });

  ipcMain.handle(CHANNELS.NOTES_DELETE, async (_event, payload) => {
    if (!payload || !isValidId(payload.id)) {
      throw new Error('Invalid note id');
    }

    const notes = await notesStore.readNotes();
    const next = notes.filter((item) => item.id !== payload.id);
    await notesStore.writeNotes(next);
    return { ok: true };
  });
}

예제에서는 이해하기 쉽게 검증 함수를 직접 작성했지만, 실제 제품에서는 Zod, Valibot, Joi 같은 스키마 검증 도구를 써도 좋다. 중요한 것은 renderer가 넘긴 값을 그대로 저장하거나 파일 경로로 사용하지 않는 것이다.

실제 제품에서는 sender 검증도 추가한다. 허용한 창에서 온 요청인지, 원격 페이지가 아닌 앱 내부 페이지에서 온 요청인지 확인해야 한다. 예를 들어 event.senderFrame.url이 앱이 로드한 URL인지 확인하고, 예상하지 못한 창이나 외부 페이지에서 온 IPC 요청은 거부한다.

기능을 단계적으로 통합하기

한 번에 모든 기능을 붙이면 문제가 생겼을 때 원인을 찾기 어렵다. 다음 순서로 통합한다.

  1. 앱 시작과 메인 창 생성

  2. 메모 목록 읽기와 저장

  3. 파일 첨부 복사와 첨부 목록 표시

  4. 검색 기능

  5. 설정 저장과 다크 모드

  6. 메뉴와 단축키

  7. 트레이와 백그라운드 동작

  8. 자동 업데이트 준비

검색은 처음에는 메모 배열을 읽어 제목과 본문을 필터링하는 방식으로 충분하다. 다만 메모가 많아질 가능성이 있다면 검색 함수를 renderer에만 두지 말고 storage 또는 별도 검색 계층으로 분리한다. 그래야 나중에 SQLite FTS나 인덱싱 방식으로 바꾸기 쉽다.

다크 모드는 사용자 설정과 운영체제 설정을 함께 고려한다. 설정값을 system, light, dark 세 가지로 두면 사용자가 명시적으로 선택할 수도 있고 운영체제 설정을 따를 수도 있다. main에서는 nativeTheme.themeSource를 설정하고, renderer는 CSS 변수로 색상을 바꾼다.

단축키는 메뉴 accelerator로 먼저 제공한다. 예를 들어 CmdOrCtrl+N은 새 메모, CmdOrCtrl+S는 저장에 연결한다. 앱이 포커스를 잃어도 동작해야 하는 기능이 아니라면 globalShortcut을 쓰지 않는다. 전역 단축키는 다른 앱의 단축키와 충돌할 수 있고, 사용자가 예상하지 못한 순간에 동작할 수 있기 때문이다.

트레이는 앱이 계속 실행 중이라는 사실을 사용자가 이해할 수 있게 만들어야 한다. 창을 닫았을 때 종료할지, 트레이로 숨길지 설정에서 선택하게 하고, 트레이 메뉴에는 "열기"와 "종료"를 반드시 둔다.

출시 전 보안 체크리스트

첫 릴리스 전에 다음 항목을 실제 설정과 코드에서 확인한다.

  • nodeIntegration은 false인가?

  • contextIsolation은 true인가?

  • sandbox는 가능한 창에서 켜져 있는가?

  • renderer에 ipcRenderer, fs, path, child_process가 직접 노출되지 않는가?

  • preload는 필요한 메서드만 좁게 노출하는가?

  • IPC payload는 타입, 길이, ID 형식을 검증하는가?

  • IPC 요청이 허용된 창과 허용된 URL에서 왔는지 확인하는가?

  • 파일 경로는 사용자가 고른 위치 또는 앱 데이터 디렉터리 안으로 제한되는가?

  • 외부 URL은 허용한 프로토콜과 도메인만 여는가?

  • 새 창 열기와 페이지 이동은 setWindowOpenHandler, will-navigate 등으로 통제하는가?

  • 첨부 파일을 실행하지 않고, 열 때는 운영체제 기본 앱에 위임하는가?

  • Content Security Policy가 배포 HTML에 적용되어 있는가?

  • 개발용 DevTools, 테스트 서버 URL, 임시 로그가 배포 빌드에 남아 있지 않은가?

보안 점검은 마지막에 한 번 하는 절차가 아니다. 새 IPC 채널을 만들 때마다 함께 적용해야 하는 개발 습관이다.

출시 전 성능 체크리스트

성능도 기능 완성 후에만 보는 항목이 아니다. 다음 기준으로 첫 버전을 점검한다.

  • 앱 시작 시 첫 창이 늦게 뜨지 않는가?

  • 자동 업데이트 확인, 첨부 파일 스캔, 검색 인덱싱이 첫 화면 표시를 막지 않는가?

  • 저장 버튼을 눌렀을 때 UI가 멈추지 않는가?

  • IPC로 대용량 본문이나 첨부 파일 자체를 반복 전송하지 않는가?

  • 창을 닫고 다시 열 때 listener와 timer가 중복 등록되지 않는가?

  • 메모 목록이 많아졌을 때 renderer가 전체 DOM을 한 번에 그리지 않는가?

문제가 보이면 추측으로 고치지 않는다. DevTools Performance, Memory 탭과 main 프로세스 로그로 병목을 확인한 뒤 수정한다.

첫 버전 배포 흐름

배포는 다음 순서로 준비한다.

  1. productName, appId, 아이콘, 버전을 확정한다.

  2. 개발용 설정과 배포용 설정을 분리한다.

  3. Windows, macOS, Linux 중 지원할 운영체제를 정한다.

  4. 코드 서명 인증서와 macOS 공증 절차를 준비한다.

  5. 설치 파일을 만든다.

  6. 새 환경에서 설치, 실행, 업데이트, 삭제를 테스트한다.

  7. GitHub Releases 또는 사내 배포 서버에 산출물을 올린다.

  8. 자동 업데이트 메타데이터가 올바른지 확인한다.

GitHub Releases는 공개 제품이나 소규모 팀에 적합하다. 릴리스 노트, 버전 태그, 설치 파일을 한곳에서 관리할 수 있다. 사내 배포 서버는 접근 제어, 배포 승인, 내부망 환경, 별도 감사 로그가 필요한 조직에 적합하다.

자동 업데이트는 "새 버전이 있으면 바로 설치"로 끝나지 않는다. 사용자가 글을 쓰는 중이라면 재시작을 미뤄야 한다. 업데이트 다운로드 실패, 네트워크 차단, 손상된 릴리스, 긴급 롤백도 고려해야 한다. 첫 버전에서는 업데이트 확인, 다운로드, 설치 안내, 재시작 버튼까지의 흐름을 명확히 만드는 데 집중한다.

완성 기준

이 프로젝트가 완성되었다는 기준은 화면이 그럴듯해 보이는 것이 아니다. 다음 질문에 답할 수 있어야 한다.

  • 이 기능은 main, preload, renderer 중 어디에 있어야 하는가?

  • renderer가 어떤 권한을 갖고 있고, 어떤 권한은 갖지 않는가?

  • 데이터는 어디에 저장되며 손상되었을 때 어떻게 복구할 수 있는가?

  • 사용자가 앱을 닫았을 때 실제로 종료되는가, 백그라운드에 남는가?

  • 새 버전을 배포했을 때 기존 사용자가 안전하게 업데이트할 수 있는가?

Electron 개발은 웹 화면을 데스크톱 창에 넣는 일에서 시작하지만, 제품으로 완성하려면 운영체제와 보안 경계, 저장소, 배포 정책까지 함께 설계해야 한다. 이 장의 예제 앱을 기준으로 기능을 하나씩 바꾸어 보라. 메모 앱이 작업 관리 도구가 될 수도 있고, 로그 뷰어가 될 수도 있으며, 사내 운영 도구가 될 수도 있다. 구조가 흔들리지 않는다면 기능은 계속 확장할 수 있다.

책 목록

Electron으로 배우는 데스크톱 앱 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기