Claude Code로 Electron 데스크톱 앱 개발하기: IPC, preload, 패키징, 자동 업데이트
Claude Code로 Electron 앱을 실무형으로 구현합니다. contextIsolation, preload, IPC, 파일 접근, 패키징과 자동 업데이트까지 다룹니다.
Electron은 하나의 Web UI를 Windows, macOS, Linux 데스크톱 앱으로 배포할 수 있게 해 줍니다. 하지만 Electron 앱은 일반 웹사이트보다 훨씬 강한 권한을 가집니다. 로컬 파일을 열고, 네이티브 대화상자를 띄우고, 메뉴와 자동 업데이트까지 다룰 수 있기 때문입니다.
Claude Code는 이런 프로젝트에서 빠른 구현 worker로 쓸 때 효과적입니다. 단, 한 번에 전체 앱을 맡기기보다 IPC 하나, preload API 하나, 파일 접근 규칙 하나, 패키징 설정 하나처럼 작은 단위로 맡겨야 합니다. 그래야 사람이 권한 경계와 보안 리스크를 검토할 수 있습니다.
Electron의 기본 방향은 명확합니다. contextIsolation을 유지하고, renderer의 nodeIntegration을 끄고, preload에서 필요한 API만 노출하고, main process에서 입력을 다시 검증합니다. 공식 문서는 Context Isolation, IPC 튜토리얼, 배포 가이드, autoUpdater API를 기준으로 확인하세요.
Tauri와의 선택 기준은 Tauri 개발 가이드를, preload와 renderer의 타입 설계는 TypeScript 팁을 함께 보면 좋습니다.
먼저 세 계층을 분리한다
| 계층 | 역할 | Claude Code에 맡기기 좋은 작업 | 리뷰 포인트 |
|---|---|---|---|
| main process | BrowserWindow, OS API, 파일, 메뉴, 업데이트 | IPC handler를 하나씩 추가 | 입력 검증, 권한, 예외 처리, 경로 제한 |
| preload | renderer에 노출할 최소 다리 | contextBridge로 타입 있는 API 작성 | 원본 ipcRenderer가 노출되지 않았는지 |
| renderer | UI, 상태, 폼, 표시 | 화면과 상호작용 구현 | Node/Electron API를 직접 쓰지 않는지 |
이 경계를 지키면 Electron 앱은 리뷰하기 쉬워집니다. renderer는 파일 시스템을 직접 몰라도 됩니다. 사용자가 버튼을 누르면 preload의 제한된 함수가 호출되고, main process가 검증 후 OS 작업을 수행합니다.
flowchart LR
User["사용자 조작"] --> Renderer["Renderer\nHTML / React"]
Renderer --> Preload["Preload\ncontextBridge"]
Preload --> IPC["IPC channel\n검증된 payload"]
IPC --> Main["Main process\nOS와 파일 접근"]
Main --> Result["Typed result"]
Result --> Renderer
Main --> Update["패키징 / 자동 업데이트"]
복사해서 실행하는 최소 예제
먼저 React 없이 Electron + TypeScript만으로 구조를 확인합니다. 이 뼈대가 안정적이면 Vite나 React를 얹어도 흔들리지 않습니다.
mkdir electron-secure-notes
cd electron-secure-notes
npm init -y
npm install --save-dev electron typescript @types/node
npm pkg set main="dist/main.js"
npm pkg set scripts.dev="tsc -p tsconfig.json && electron ."
mkdir src
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node", "electron"]
},
"include": ["src/**/*.ts"]
}
main process는 OS 권한을 가진 유일한 계층입니다. 파일 경로와 확장자를 여기서 검증합니다.
// src/main.ts
import { app, BrowserWindow, dialog, ipcMain, session } from "electron";
import fs from "node:fs/promises";
import path from "node:path";
type OpenedTextFile = {
filePath: string;
content: string;
};
type SaveTextRequest = {
filePath: string;
content: string;
};
const allowedExtensions = new Set([".txt", ".md", ".json"]);
const allowedRoots = new Set<string>();
function createWindow() {
const win = new BrowserWindow({
width: 1100,
height: 760,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
});
win.loadFile(path.join(__dirname, "index.html"));
}
function registerCsp() {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self'; script-src 'self'; style-src 'self'"
]
}
});
});
}
function isInsideRoot(root: string, target: string) {
const relative = path.relative(root, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function assertOpenedPath(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
if (!allowedExtensions.has(extension)) {
throw new Error("Unsupported file type.");
}
return filePath;
}
async function assertAllowedFile(filePath: string) {
const resolved = path.resolve(filePath);
const extension = path.extname(resolved).toLowerCase();
if (!allowedExtensions.has(extension)) {
throw new Error("Only txt, md, and json files are allowed.");
}
const insideAllowedRoot = Array.from(allowedRoots).some((root) =>
isInsideRoot(root, resolved)
);
if (!insideAllowedRoot) {
throw new Error("Open the file with the dialog before saving it.");
}
return resolved;
}
ipcMain.handle("dialog:openTextFile", async (): Promise<OpenedTextFile | null> => {
const result = await dialog.showOpenDialog({
title: "Open text file",
properties: ["openFile"],
filters: [{ name: "Text", extensions: ["txt", "md", "json"] }]
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
const filePath = path.resolve(result.filePaths[0]);
const safePath = await assertOpenedPath(filePath);
allowedRoots.add(path.dirname(safePath));
return {
filePath: safePath,
content: await fs.readFile(safePath, "utf8")
};
});
ipcMain.handle("file:saveText", async (_event, payload: SaveTextRequest) => {
if (
!payload ||
typeof payload.filePath !== "string" ||
typeof payload.content !== "string" ||
payload.content.length > 1_000_000
) {
throw new Error("Invalid save request.");
}
const filePath = await assertAllowedFile(payload.filePath);
await fs.writeFile(filePath, payload.content, "utf8");
return { ok: true };
});
app.whenReady().then(() => {
registerCsp();
createWindow();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
preload는 renderer에 필요한 기능만 공개합니다. ipcRenderer 자체를 넘기지 않습니다.
// src/preload.ts
import { contextBridge, ipcRenderer } from "electron";
type OpenedTextFile = {
filePath: string;
content: string;
};
type SaveTextRequest = {
filePath: string;
content: string;
};
contextBridge.exposeInMainWorld("desktop", {
openTextFile: () =>
ipcRenderer.invoke("dialog:openTextFile") as Promise<OpenedTextFile | null>,
saveTextFile: (payload: SaveTextRequest) => {
if (
!payload ||
typeof payload.filePath !== "string" ||
typeof payload.content !== "string"
) {
throw new Error("Invalid save payload.");
}
return ipcRenderer.invoke("file:saveText", payload) as Promise<{ ok: boolean }>;
}
});
renderer는 노출된 API만 호출합니다.
// src/renderer.ts
const openButton = document.querySelector<HTMLButtonElement>("#open");
const saveButton = document.querySelector<HTMLButtonElement>("#save");
const editor = document.querySelector<HTMLTextAreaElement>("#editor");
const status = document.querySelector<HTMLParagraphElement>("#status");
let currentFilePath: string | null = null;
if (!openButton || !saveButton || !editor || !status) {
throw new Error("Required UI elements are missing.");
}
openButton.addEventListener("click", async () => {
const file = await window.desktop.openTextFile();
if (!file) {
status.textContent = "Open canceled.";
return;
}
currentFilePath = file.filePath;
editor.value = file.content;
status.textContent = `Opened: ${file.filePath}`;
});
saveButton.addEventListener("click", async () => {
if (!currentFilePath) {
status.textContent = "Open a file first.";
return;
}
await window.desktop.saveTextFile({
filePath: currentFilePath,
content: editor.value
});
status.textContent = `Saved: ${currentFilePath}`;
});
Claude Code에 줄 프롬프트
기존 Electron + TypeScript 앱에 Markdown 열기/저장 기능을 추가하세요.
제약:
- renderer에 Node.js API를 노출하지 않기
- contextIsolation: true, nodeIntegration: false, sandbox: true 유지
- preload는 window.desktop.openTextFile/saveTextFile만 노출
- raw ipcRenderer나 generic invoke(channel, payload)를 만들지 않기
- 파일은 md/txt/json만 허용
- 기존 auto update 코드는 변경하지 않기
완료 조건:
- npm run typecheck 통과
- main/preload/renderer 변경점을 짧게 설명
- 보안 리뷰 포인트 3개 제시
이 정도 크기의 작업이면 사람이 diff를 따라갈 수 있습니다. 반대로 “Electron 메모 앱과 자동 업데이트까지 전부 만들어줘”는 리뷰 범위가 너무 넓습니다.
실제 사용 사례 3가지
첫 번째는 사내 Markdown 노트 앱입니다. 서버에 올리지 않고 로컬 문서를 편집할 수 있어야 할 때 Electron이 잘 맞습니다. 최근 파일 목록을 저장하더라도 본문을 캐시하지 말고, 시작 시 경로와 확장자를 다시 검증해야 합니다.
두 번째는 고객 지원용 로그 뷰어입니다. 로그 파일은 크고 민감한 정보가 섞일 수 있습니다. main process에서 페이지 단위로 읽고, renderer에는 필요한 구간만 보내야 합니다. 복사나 내보내기 기능에는 이메일, bearer token, 고객 ID 마스킹을 넣습니다.
세 번째는 데스크톱 AI 프롬프트 워크벤치입니다. 로컬 템플릿, 클립보드, 외부 API를 함께 다루므로 API key를 renderer에 보내면 안 됩니다. main process만 key를 읽고, renderer에는 연결 상태와 결과만 반환합니다.
실패 사례와 피하는 방법
가장 흔한 실패는 renderer가 보낸 임의 경로를 main process가 그대로 읽는 것입니다. UI가 안전해 보여도 renderer는 신뢰하지 않습니다. 확장자, 크기, 허용된 루트 디렉터리를 main에서 검증합니다.
두 번째는 preload에서 window.api.invoke(channel, payload) 같은 범용 통로를 여는 것입니다. 짧아 보이지만 모든 권한 심사를 우회하기 쉽습니다. 기능 하나에 메서드 하나가 더 안전합니다.
세 번째는 개발 환경 경로가 패키징 후 깨지는 문제입니다. src 경로를 직접 읽거나 asar 내부에 쓰려고 하면 설치본에서 실패합니다. 쓰기 데이터는 app.getPath("userData") 아래에 둡니다.
네 번째는 배포 직전에 자동 업데이트를 처음 붙이는 것입니다. 서명, 업데이트 feed, GitHub Release 권한, Windows 설치 방식은 CI에서 미리 시험해야 합니다. 배포 파이프라인은 CI/CD 설정 가이드와 함께 설계하세요.
패키징과 자동 업데이트
Electron Forge를 쓴다면 다음부터 시작할 수 있습니다.
npm install --save-dev @electron-forge/cli @electron-forge/maker-squirrel @electron-forge/maker-zip @electron-forge/maker-deb @electron-forge/maker-rpm
npx electron-forge import
// forge.config.ts
import type { ForgeConfig } from "@electron-forge/shared-types";
const config: ForgeConfig = {
packagerConfig: {
asar: true,
icon: "assets/icon"
},
rebuildConfig: {},
makers: [
{
name: "@electron-forge/maker-squirrel",
config: {
name: "secure_notes"
}
},
{
name: "@electron-forge/maker-zip",
platforms: ["darwin"]
},
{
name: "@electron-forge/maker-deb",
config: {}
},
{
name: "@electron-forge/maker-rpm",
config: {}
}
]
};
export default config;
Electron의 내장 autoUpdater는 주로 macOS와 Windows를 대상으로 합니다. Linux는 보통 배포판의 패키지 관리자를 따릅니다. macOS 자동 업데이트에는 서명이 필요하고, Windows는 MSIX인지 Squirrel.Windows인지에 따라 동작이 달라집니다.
// src/update.ts
import { app, autoUpdater, BrowserWindow } from "electron";
function sendStatus(win: BrowserWindow, status: string) {
win.webContents.send("update:status", status);
}
export function configureAutoUpdate(win: BrowserWindow) {
if (!app.isPackaged) {
sendStatus(win, "updates disabled in development");
return;
}
if (process.platform === "linux") {
sendStatus(win, "use the distribution package manager on Linux");
return;
}
const owner = "YOUR_GITHUB_OWNER";
const repo = "YOUR_GITHUB_REPO";
const feedUrl = `https://update.electronjs.org/${owner}/${repo}/${process.platform}-${process.arch}/${app.getVersion()}`;
autoUpdater.setFeedURL({ url: feedUrl });
autoUpdater.on("checking-for-update", () => sendStatus(win, "checking"));
autoUpdater.on("update-available", () => sendStatus(win, "available"));
autoUpdater.on("update-not-available", () => sendStatus(win, "not available"));
autoUpdater.on("update-downloaded", () => sendStatus(win, "downloaded"));
autoUpdater.on("error", (error) => sendStatus(win, `error: ${error.message}`));
setTimeout(() => {
try {
autoUpdater.checkForUpdates();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sendStatus(win, `error: ${message}`);
}
}, 10_000);
}
Claude Code에 이 코드를 맡길 때는 signing identity, update feed, release 권한, secret 주입 방식이 아직 미설정인지도 README에 남기게 하세요.
마무리 확인
Claude Code는 Electron 개발 속도를 크게 올릴 수 있습니다. 그러나 품질은 renderer, preload, main process의 경계가 얼마나 분명한지에 달려 있습니다. 파일 접근은 main에서 검증하고, 업데이트는 개발 모드에서 꺼 두며, 패키징은 릴리스 직전이 아니라 초기에 확인해야 합니다.
ClaudeCodeLab은 Electron/Tauri 설계, IPC 보안 리뷰, 자동 업데이트 흐름 정리, Claude Code 팀 교육을 지원합니다. PoC가 있거나 기존 Electron 앱을 개선하려는 경우 현재 구조와 배포 목표를 공유하면 검토가 빨라집니다.
이 글의 내용을 실제로 시험할 때는 npm run dev가 통과하는지, renderer가 Electron이나 Node를 import하지 않는지, 임의 경로를 읽지 못하는지, 패키징 후 파일 경로가 유지되는지, 서명과 feed 준비 전에는 자동 업데이트를 운영 기능으로 취급하지 않는지 확인하세요.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.