Skip to main content
Summary

로컬 파일을 다룬다는 의미

웹 앱에서 파일은 대부분 사용자가 업로드하거나 다운로드하는 대상이다. 브라우저는 보안상 임의의 로컬 경로를 읽고 쓸 수 없다. Electron 앱은 다르다. 사용자가 선택한 문서를 열고, 설정을 로컬에 저장하고, 임시 파일을 만들고, 필요하면 SQLite 같은 로컬 데이터베이스를 사용할 수 있다.

하지만 권한이 넓어진 만큼 구조를 더 조심해서 설계해야 한다. renderer에서 fs를 직접 호출하게 만들면 화면 코드가 곧 파일 시스템 권한을 갖는다. XSS나 외부 콘텐츠 삽입 문제가 생겼을 때 피해 범위가 커진다. 이 장에서도 원칙은 같다.

  • 파일 시스템 접근은 main 프로세스에서 처리한다.

  • renderer는 preload가 노출한 제한된 API만 호출한다.

  • renderer에서 넘어온 경로와 데이터는 신뢰하지 않는다.

  • 앱 내부 데이터와 사용자가 직접 고른 문서는 저장 위치를 분리한다.

이제 이전 장에서 만든 IPC 흐름을 실제 파일 읽기와 저장으로 연결해 보자.

파일 시스템과 로컬 데이터 저장AI 생성 이미지

dialog API로 파일과 폴더 선택하기

Electron의 dialog 모듈은 main 프로세스에서 파일 열기, 저장 위치 선택, 폴더 선택 같은 운영체제 기본 대화상자를 띄울 때 사용한다. showOpenDialog는 파일이나 폴더를 고를 때, showSaveDialog는 저장할 경로를 정할 때 사용한다. 사용자가 취소하면 canceled 값이 true가 된다. showOpenDialog에서는 filePaths 배열을 확인하고, showSaveDialog에서는 filePath 값이 있는지 확인한다.1

다음 코드는 마크다운 파일을 여는 예다.

// main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const fs = require('node:fs/promises');
const path = require('node:path');

ipcMain.handle('files:openMarkdown', async (event) => {
  const window = BrowserWindow.fromWebContents(event.sender);

  const result = await dialog.showOpenDialog(window, {
    title: '노트 열기',
    defaultPath: app.getPath('documents'),
    properties: ['openFile'],
    filters: [
      { name: 'Markdown', extensions: ['md', 'markdown', 'txt'] },
      { name: 'All Files', extensions: ['*'] }
    ]
  });

  if (result.canceled || result.filePaths.length === 0) {
    return null;
  }

  const filePath = result.filePaths[0];
  const content = await fs.readFile(filePath, 'utf8');

  return {
    path: filePath,
    name: path.basename(filePath),
    content
  };
});

저장 위치를 사용자가 직접 고르게 하려면 showSaveDialog를 사용한다.

ipcMain.handle('files:saveMarkdownAs', async (event, payload) => {
  if (!payload || typeof payload.content !== 'string') {
    throw new Error('저장할 내용이 올바르지 않습니다.');
  }

  // 너무 큰 데이터를 실수로 IPC에 태우지 않도록 제한한다.
  if (Buffer.byteLength(payload.content, 'utf8') > 5 * 1024 * 1024) {
    throw new Error('한 번에 저장할 수 있는 크기를 초과했습니다.');
  }

  const window = BrowserWindow.fromWebContents(event.sender);
  const result = await dialog.showSaveDialog(window, {
    title: '노트 저장',
    defaultPath: path.join(app.getPath('documents'), 'untitled.md'),
    filters: [
      { name: 'Markdown', extensions: ['md'] },
      { name: 'Text', extensions: ['txt'] }
    ]
  });

  if (result.canceled || !result.filePath) {
    return null;
  }

  await fs.writeFile(result.filePath, payload.content, 'utf8');

  return {
    path: result.filePath,
    name: path.basename(result.filePath)
  };
});

폴더 선택도 같은 방식이다. properties에 openDirectory를 넣으면 된다.

ipcMain.handle('files:chooseFolder', async (event) => {
  const window = BrowserWindow.fromWebContents(event.sender);

  const result = await dialog.showOpenDialog(window, {
    title: '작업 폴더 선택',
    defaultPath: app.getPath('documents'),
    properties: ['openDirectory']
  });

  if (result.canceled || result.filePaths.length === 0) {
    return null;
  }

  return result.filePaths[0];
});

여기서 중요한 점은 renderer가 경로를 직접 만들지 않는다는 것이다. 사용자가 고른 경로는 운영체제 대화상자를 통해 얻는다. 앱이 내부적으로 쓰는 경로는 app.getPath()와 path.join()으로 만든다.

preload로 안전한 파일 API 노출하기

main에 handler를 만들었다면 preload에서 renderer가 사용할 API를 제한해서 노출한다.

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

contextBridge.exposeInMainWorld('desktopFiles', {
  openMarkdown: () => ipcRenderer.invoke('files:openMarkdown'),
  saveMarkdownAs: (content) =>
    ipcRenderer.invoke('files:saveMarkdownAs', { content }),
  chooseFolder: () => ipcRenderer.invoke('files:chooseFolder')
});

renderer에서는 fs, path, ipcRenderer를 알 필요가 없다.

// renderer.js
async function openNote() {
  const file = await window.desktopFiles.openMarkdown();
  if (!file) return;

  editor.value = file.content;
  currentFileName.textContent = file.name;
}

async function saveNoteAs() {
  const saved = await window.desktopFiles.saveMarkdownAs(editor.value);
  if (!saved) return;

  currentFileName.textContent = saved.name;
}

이 구조는 코드가 조금 늘어나지만 책임이 분명하다. renderer는 사용자 인터페이스와 입력 상태를 다룬다. main은 파일 시스템 권한을 가진다. preload는 둘 사이에 필요한 기능만 연결한다.

앱 데이터는 어디에 저장해야 할까

Electron 앱을 패키징하면 설치 디렉터리에 파일을 쓰면 안 된다. 설치 위치는 운영체제 정책, 권한, 업데이트 방식에 따라 쓰기 불가능할 수 있다. 또한 앱을 업데이트하거나 다시 설치할 때 사라질 수 있다.

Electron은 운영체제별 표준 위치를 app.getPath(name)으로 제공한다. userData는 앱 설정 파일을 저장하는 기본 위치이며, 기본적으로 앱 이름이 붙은 전용 디렉터리를 가리킨다. 공식 문서는 userData 아래에 앱 전용 하위 폴더를 만들어 쓰는 방식을 권장한다. Chromium이 사용하는 Cache, GPUCache, Local Storage 같은 하위 디렉터리와 충돌하지 않기 위해서다.2

경로 이름사용 예주의점
userData설정, 작은 앱 상태, 로컬 DB 파일큰 첨부 파일을 무조건 넣지 않는다. 일부 환경에서는 백업 대상이 될 수 있다.
documents사용자가 직접 관리할 문서의 기본 위치앱 내부 설정 파일을 여기에 숨겨 저장하지 않는다.
temp변환 중간 파일, 미리보기 캐시언제든 삭제될 수 있다고 가정한다.
downloads내보내기 결과, 내려받은 파일사용자가 볼 수 있는 파일에 적합하다.

작업 노트 앱이라면 다음처럼 나눌 수 있다.

  • 사용자가 직접 저장한 .md 파일: 사용자가 고른 위치

  • 창 크기, 테마, 최근 파일 목록: userData 아래 앱 전용 하위 폴더의 settings.json

  • 검색 인덱스나 임시 변환 결과: temp 또는 앱 내부 캐시 폴더

  • 많은 노트의 메타데이터와 태그: userData 아래 앱 전용 하위 폴더의 SQLite 파일

예를 들어 앱 전용 데이터 디렉터리는 이렇게 만든다.

const fs = require('node:fs/promises');
const path = require('node:path');
const { app } = require('electron');

async function getAppDataDir() {
  const dir = path.join(app.getPath('userData'), 'app-data');
  await fs.mkdir(dir, { recursive: true });
  return dir;
}

문자열로 app.getPath('userData') + '/app-data'처럼 연결하지 말고 path.join()을 사용하자. Windows와 macOS/Linux는 경로 구분자가 다르다. path.join()은 플랫폼에 맞게 경로를 만든다.

JSON 설정 파일 만들기

설정처럼 작고 구조가 단순한 데이터는 JSON 파일로 충분한 경우가 많다. 예를 들어 테마, 마지막으로 연 파일, 창 크기 정도는 데이터베이스까지 필요하지 않다.

const DEFAULT_SETTINGS = {
  theme: 'system',
  recentFiles: [],
  windowBounds: { width: 1000, height: 700 }
};

function createDefaultSettings() {
  return {
    ...DEFAULT_SETTINGS,
    recentFiles: [...DEFAULT_SETTINGS.recentFiles],
    windowBounds: { ...DEFAULT_SETTINGS.windowBounds }
  };
}

async function getSettingsPath() {
  const dir = await getAppDataDir();
  return path.join(dir, 'settings.json');
}

async function readSettings() {
  const filePath = await getSettingsPath();

  try {
    const raw = await fs.readFile(filePath, 'utf8');
    const saved = JSON.parse(raw);
    const defaults = createDefaultSettings();

    return {
      ...defaults,
      ...saved,
      windowBounds: {
        ...defaults.windowBounds,
        ...(saved.windowBounds || {})
      }
    };
  } catch (error) {
    if (error.code === 'ENOENT') {
      return createDefaultSettings();
    }
    throw error;
  }
}

async function writeJsonAtomic(filePath, data) {
  const tempPath = `${filePath}.${process.pid}.tmp`;
  const json = JSON.stringify(data, null, 2);

  await fs.writeFile(tempPath, json, 'utf8');
  await fs.rename(tempPath, filePath);
}

async function writeSettings(settings) {
  const filePath = await getSettingsPath();
  await writeJsonAtomic(filePath, settings);
}

writeJsonAtomic은 임시 파일에 먼저 쓴 뒤 rename으로 교체한다. 저장 중 앱이 종료되거나 전원이 꺼졌을 때 원본 파일이 중간 상태로 깨질 가능성을 줄이기 위한 방식이다. 모든 파일 시스템에서 완벽한 보장은 아니지만, 설정 파일 저장에는 실용적인 방어선이 된다.

이 설정 API도 renderer에 직접 열어 주지 않는다.

ipcMain.handle('settings:read', async () => {
  return readSettings();
});

ipcMain.handle('settings:update', async (event, patch) => {
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
    throw new Error('설정 값이 올바르지 않습니다.');
  }

  const current = await readSettings();
  const next = {
    ...current,
    ...patch
  };

  await writeSettings(next);
  return next;
});

실무에서는 여기서 한 단계 더 검증해야 한다. theme은 허용된 문자열인지, recentFiles는 배열인지, 배열 길이가 너무 길지 않은지 확인한다. windowBounds도 숫자 범위를 확인하는 편이 좋다. renderer에서 온 객체를 그대로 JSON에 저장하면 이후 앱 시작 시 잘못된 설정 때문에 부팅이 실패할 수 있다.

JSON, SQLite, IndexedDB, key-value 저장소 선택 기준

로컬 저장 방식은 "무엇이 더 고급인가"가 아니라 "데이터 모양과 접근 방식에 맞는가"로 고른다.

JSON 설정 파일은 설정, 최근 파일 목록, 작은 상태 저장에 맞다. 사람이 열어 확인하기 쉽고 구현 비용이 낮다. 대신 데이터가 커지고 일부 항목만 자주 수정해야 할 때는 비효율적이다. 파일 전체를 읽고 다시 써야 하기 때문이다.

key-value 저장소는 설정 항목이 많아질 때 유용하다. theme = dark, editor.fontSize = 14처럼 키 단위로 읽고 쓰는 데이터에 잘 맞는다. 단, 비밀번호나 토큰 같은 민감 정보를 넣을 때는 별도의 보안 저장소 사용을 검토해야 한다. 암호화 옵션이 있더라도 암호화 키를 앱 코드나 같은 디스크에 두면 보호 수준에는 한계가 있다.

SQLite는 데이터가 표 형태이고 검색, 정렬, 필터링, 트랜잭션이 필요할 때 선택한다. 수천 개 이상의 노트, 태그, 폴더, 첨부 파일 메타데이터를 다뤄야 한다면 JSON보다 SQLite가 관리하기 쉽다. Electron에서는 UI가 멈추지 않도록 DB 작업을 main 프로세스의 비동기 흐름이나 별도 worker에서 처리하는 구조가 적합하다. 오래 걸리는 쿼리나 대량 마이그레이션을 renderer에서 동기적으로 처리하면 입력 지연이 생길 수 있다.

IndexedDB는 renderer에서 사용할 수 있는 브라우저 저장소다. 웹 프런트엔드 라이브러리와 궁합이 좋고, 오프라인 캐시나 화면 중심 데이터 저장에는 편하다. 다만 Electron 앱의 핵심 데이터 저장소로 사용할 때는 세션 데이터, 원본 URL, 창 구성과의 관계를 이해해야 한다. main에서 직접 관리해야 하는 백업, 마이그레이션, 파일 위치 제어가 중요하다면 SQLite나 파일 기반 저장소가 더 예측 가능하다.

정리하면 다음과 같다.

저장 방식적합한 데이터피해야 할 상황
JSON 파일작은 설정, 창 상태, 최근 목록데이터가 크거나 동시 수정이 잦은 경우
key-value 저장소환경설정, 기능 플래그, 단순 앱 상태관계형 조회, 복잡한 검색이 필요한 경우
SQLite노트 목록, 태그, 검색 대상 메타데이터아주 작은 설정만 저장하는 경우
IndexedDBrenderer 중심 캐시, 웹 코드 재사용main에서 파일 위치와 백업을 엄격히 관리해야 하는 경우
사용자 파일문서, 내보내기 결과, 외부 편집 가능한 파일앱 내부 설정이나 숨겨진 상태 저장

배포 후 자주 만나는 파일 문제

개발 중에는 파일 저장이 잘 되다가 배포 후 문제가 생기는 경우가 많다. 원인은 대부분 운영체제 차이다.

첫째, 권한 오류를 처리해야 한다. EACCES, EPERM은 권한이 없거나 다른 프로그램이 파일을 잠근 경우에 발생할 수 있다. ENOENT는 경로가 없을 때, ENOSPC는 디스크 공간이 부족할 때 발생한다. 사용자가 이해할 수 있는 메시지로 바꾸어 보여 주자. "저장 실패"보다 "이 위치에 쓸 권한이 없습니다. 다른 폴더를 선택하세요."가 낫다.

둘째, 경로를 문자열로 조립하지 않는다. 항상 path.join(), path.resolve(), path.relative()를 사용한다. 특정 폴더 아래만 접근하게 제한하려면 다음처럼 검사한다.

function assertInsideBaseDir(baseDir, targetPath) {
  const relative = path.relative(baseDir, targetPath);

  if (
    relative === '..' ||
    relative.startsWith(`..${path.sep}`) ||
    path.isAbsolute(relative)
  ) {
    throw new Error('허용되지 않은 경로입니다.');
  }
}

이 검사는 사용자가 직접 고른 문서 경로가 아니라, 앱이 특정 작업 폴더 안의 파일만 다루도록 제한해야 할 때 유용하다. 예를 들어 플러그인이나 템플릿 이름을 받아 파일 경로를 만들 때는 ../ 같은 상위 경로 이동을 막아야 한다.

셋째, 한글 파일명과 인코딩을 고려한다. 파일 내용은 가능하면 utf8로 읽고 쓴다. 파일명은 운영체제마다 유니코드 정규화 방식이 다를 수 있으므로, 파일명을 비교할 때 단순 문자열 비교에만 의존하지 않는 편이 안전하다. 특히 macOS와 Windows를 오가며 동기화되는 폴더에서는 같은 글자로 보이는 이름이 내부적으로 다를 수 있다.

넷째, 사용자가 고른 파일과 앱 내부 파일을 구분한다. 사용자가 고른 파일은 사용자가 소유한 문서다. 앱은 내용을 덮어쓰기 전에 저장 위치와 파일명을 명확히 보여 주는 편이 좋다. 반대로 앱 내부 DB나 설정 파일은 사용자가 직접 만지는 문서 폴더에 두지 않는다.

이 장에서 정한 저장 원칙

파일 시스템은 Electron 앱의 강점이지만, renderer에 권한을 넘기는 순간 위험해진다. 안전한 구조는 일관된다.

  • dialog는 main에서 열고, 선택 결과만 renderer에 돌려준다.

  • fs는 main에서 사용한다.

  • preload는 기능 단위 API만 노출한다.

  • 앱 내부 데이터는 app.getPath('userData') 아래 앱 전용 하위 폴더에 저장한다.

  • 사용자가 직접 관리할 문서는 documents, downloads, 사용자가 선택한 폴더를 사용한다.

  • 저장 방식은 데이터 크기, 검색 필요성, 백업 정책, UI 성능을 기준으로 고른다.

다음 단계부터는 이렇게 저장한 데이터를 데스크톱 경험과 연결한다. 메뉴에서 "열기"와 "저장"을 실행하고, 단축키로 같은 IPC를 호출하고, 트레이나 네이티브 기능을 통해 앱의 사용 흐름을 확장할 수 있다.

참고

1Electron dialog API

2Electron app API - app.getPath

책 목록

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

전체 목차 보기

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

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

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

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

참여하기