⚡ Electron4. 네이티브 통합01 · fs · path · userData

01 · fs · path · userData

이 문서가 답하는 질문: Electron에서 파일 읽고 쓰는 코드는 어디에 살아야 하고, 어느 경로에 써야 하며, 왜 그게 웹 File System Access API보다 위험한가? API: Node fs/fs/promises, path, Electron app.getPath OS 차이: Windows AppData\Roaming · macOS Library/Application Support · Linux ~/.config


한 줄 답 (Pyramid Top)

Electron의 fsMain 프로세스의 Node가 가진 OS 사용자 전체 권한으로 동작한다 — 브라우저의 sandbox와 본질적으로 다르다. 그래서 *“어디에 쓸 것인가”*는 코드 한 줄이 아니라 app.getPath('userData') 같은 OS별 표준 위치에 위임해야 하고, macOS notarization 이후엔 Documents·Desktop·Downloads마저 entitlement 없이는 못 읽는다.


Why — 왜 fs 호출을 Main에 가둬야 하는가

웹의 File System Access API(Chrome 86+)는 사용자가 한 디렉토리를 직접 고른 그 순간만 권한이 부여된다 — 오리진 단위로 격리되고, 세션이 끝나면 사라진다.

Electron의 Node fs는 정반대다.

브라우저 File System AccessElectron Node fs
권한 부여 시점사용자가 picker에서 디렉토리 직접 선택앱이 시작되는 그 순간
권한 범위사용자가 디렉토리만OS 사용자가 접근 가능한 전부
권한 만료탭 닫으면 사라짐 (또는 3rd visit prompt)영원 (앱이 살아있는 한)
거부 방법사용자가 prompt에서 거부없음chmod/sandbox 외엔 못 막음
XSS 영향그 origin만사용자 홈 전체 (~/.ssh/id_rsa 포함)

Renderer에 nodeIntegration:truefs를 노출하던 Electron 1.x 시대는 — XSS 한 번이 OS 명령 실행과 같았다. 그래서 모든 fs는 Main에서, Renderer는 IPC로 키만 던진다가 절대 룰이 됐다.


How — 어떻게 동작하는가

app.getPath 표 — OS별 실제 경로

app.getPath(name)OS별 표준 위치를 돌려준다. 직접 os.homedir() + '/.config/MyApp' 같이 쓰지 말 것.

namemacOSWindowsLinux
home/Users/rawC:\Users\raw/home/raw
appData~/Library/Application Support%APPDATA% = C:\Users\raw\AppData\Roaming~/.config (XDG)
userData~/Library/Application Support/MyApp%APPDATA%\MyApp~/.config/MyApp
sessionDatauserData와 동일 (default)같음같음
temp/var/folders/... ($TMPDIR)%TEMP% = ...\AppData\Local\Temp/tmp
desktop~/DesktopC:\Users\raw\Desktop~/Desktop
documents~/DocumentsC:\Users\raw\Documents~/Documents
downloads~/DownloadsC:\Users\raw\Downloads~/Downloads
music/pictures/videos~/MusicC:\Users\raw\MusicXDG dirs
logs~/Library/Logs/MyAppuserData\logsuserData/logs
crashDumpsuserData/Crashpad같음같음
exeMyApp.app/Contents/MacOS/MyAppMyApp.exe빌드 산출 위치
moduleElectron 바이너리 위치같음같음

userData만이 “내 앱이 마음대로 쓸 수 있는” 디렉토리다. 나머지는 사용자 영역 — 함부로 쓰면 백업 도구·검색·privacy에 영향을 준다.

최소 패턴 — Main 쪽

// main.js
import { app, ipcMain } from 'electron';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, normalize, sep } from 'node:path';
 
// 1) userData 안의 안전한 base 경로
const baseDir = join(app.getPath('userData'), 'storage');
await mkdir(baseDir, { recursive: true });
 
// 2) 키를 path로 변환할 때 traversal 방지
function safePath(key) {
  // key는 영숫자·하이픈만 허용
  if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
    throw new Error('invalid key');
  }
  const p = normalize(join(baseDir, `${key}.json`));
  // baseDir 밖으로 빠져나갔는지 한 번 더 확인
  if (!p.startsWith(baseDir + sep)) {
    throw new Error('path traversal blocked');
  }
  return p;
}
 
ipcMain.handle('storage:get', async (_e, key) => {
  try {
    const buf = await readFile(safePath(key), 'utf8');
    return JSON.parse(buf);
  } catch (e) {
    if (e.code === 'ENOENT') return null;
    throw e;
  }
});
 
ipcMain.handle('storage:set', async (_e, key, value) => {
  await writeFile(safePath(key), JSON.stringify(value), 'utf8');
});

최소 패턴 — Preload 쪽 (Renderer는 키만)

// preload.js
import { contextBridge, ipcRenderer } from 'electron';
 
contextBridge.exposeInMainWorld('storage', {
  get: (key) => ipcRenderer.invoke('storage:get', key),
  set: (key, value) => ipcRenderer.invoke('storage:set', key, value),
});
// renderer (React 등)
await window.storage.set('settings', { theme: 'dark' });
const s = await window.storage.get('settings'); // { theme: 'dark' }

Renderer는 경로를 모른다 — 키(영숫자)만 안다. 그래서 경로를 가지고 장난칠 여지가 없다.

큰 파일 — Stream으로

수백 MB의 임포트 파일은 readFile로 한 번에 들이면 메모리가 터진다. createReadStream + IPC MessagePort(transferable)로 청크 전송이 표준.

// main.js
import { createReadStream } from 'node:fs';
ipcMain.handle('file:import', async (_e, absPath) => {
  // absPath는 dialog에서 받은 결과만 신뢰 (사용자가 직접 고른 경로)
  return new Promise((res, rej) => {
    const chunks = [];
    createReadStream(absPath, { highWaterMark: 1 << 20 }) // 1MB
      .on('data', (c) => chunks.push(c))
      .on('end', () => res(Buffer.concat(chunks)))
      .on('error', rej);
  });
});

What — 구체 사양 / CLI

파일 존재 확인

import { access, constants } from 'node:fs/promises';
 
async function exists(p) {
  try {
    await access(p, constants.F_OK);
    return true;
  } catch {
    return false;
  }
}

fs.exists는 deprecated. access가 표준.

원자적 쓰기 — tmp → rename

writeFile중간에 프로세스가 죽으면 빈 파일이 남는다. JSON 설정 파일은 항상 tmp 쓰고 rename.

import { writeFile, rename } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
 
async function atomicWriteJson(target, value) {
  const tmp = `${target}.${randomUUID()}.tmp`;
  await writeFile(tmp, JSON.stringify(value), 'utf8');
  await rename(tmp, target); // 같은 파일시스템 안에서는 원자적
}

rename은 같은 디스크 / 파일시스템 안에서만 원자적이다. tmp는 target과 같은 디렉토리에 둘 것.

파일 변경 감지 — fs.watch vs chokidar

도구백엔드한계
fs.watchmacOS FSEvents · Linux inotify · Windows ReadDirectoryChangesWOS별 동작이 미묘하게 다름 (recursive 옵션, 임시 파일)
chokidar위를 normalize, fallback polling의존성 추가, 더 안정적

VS Code는 자체 native watcher를 따로 만들었다 — fs.watch가 macOS 모노레포에서 몇만 개 inotify watch를 잡아먹는 문제 때문.

macOS notarization 후의 fs 권한

macOS Catalina+부터는 notarized 앱이라도 ~/Documents·~/Desktop·~/Downloads·iCloud Drive에 접근하면 TCC(Transparency, Consent, Control) prompt가 뜬다. 거부되면 EPERM.

Info.plist목적 문자열 필수:

<key>NSDocumentsFolderUsageDescription</key>
<string>MyApp이 문서를 열기 위해 필요합니다.</string>
<key>NSDesktopFolderUsageDescription</key>
<string>MyApp이 데스크톱의 파일을 표시합니다.</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>MyApp이 다운로드 폴더의 파일을 읽습니다.</string>

추가로 Hardened Runtime + entitlement:

{/* entitlements.plist */}
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>

electron-builder mac.entitlements 옵션으로 이 파일을 가리킨다. 빠지면 Gatekeeper는 통과해도 fs 호출이 런타임에 EPERM.

Windows의 경로 함정

// macOS에서만 동작하는 코드
const p = '/Users/raw/foo.txt'; // ❌
 
// 모든 OS에서 동작
import { join } from 'node:path';
const p = join(app.getPath('home'), 'foo.txt'); // ✅
  • Windows는 \/ 둘 다 받지만 대소문자 무시. Foo.txtfoo.txt가 같은 파일.
  • 경로 길이 260자 제한(MAX_PATH) — Long Path Aware 켜야 우회. electron-builder는 default로 켬.
  • 드라이브 prefix (C:)가 segment 1개로 취급join('C:', 'foo')C:foo(상대), C:\foo가 아니다.

Linux의 XDG 디렉토리

$ echo $XDG_CONFIG_HOME  # 보통 비어있음 → ~/.config가 default
$ echo $XDG_DATA_HOME    # ~/.local/share
$ echo $XDG_CACHE_HOME   # ~/.cache

app.getPath('userData')XDG_CONFIG_HOME이 있으면 그걸 따른다. 직접 ~/.config/MyApp을 박지 말 것.


What-if — 잘못 다루면 어떻게 깨지는가

1) Path traversal

// ❌ 위험
ipcMain.handle('read', (_e, key) => readFile(join(baseDir, key)));
 
// 공격: key = "../../../../etc/passwd"
// 결과: /etc/passwd 가 Renderer로 흘러간다

대응은 위 safePath() 패턴 — whitelist + normalize 후 baseDir 시작 확인.

2) nodeIntegration:true로 Renderer fs 노출

Electron 1.x ~ 4.x 시절 default. XSS 한 번이 require('child_process').exec('rm -rf ~') 한 줄로 끝났다. 5.x부터 default false, 12.x부터 권장되지 않음. 새 프로젝트에서 절대 켜지 말 것.

3) macOS sandbox + userData 충돌

mas (Mac App Store) 빌드는 sandbox 강제. userData컨테이너 안의 별도 위치가 되어 비-sandbox 빌드와 데이터가 분리된다. 사용자가 Direct download → App Store 재설치 시 데이터가 사라진 것처럼 보임. 둘 다 지원하려면 마이그레이션 로직이 필요.

4) 권한 거부 시 조용히 빈 데이터

try {
  const data = await readFile(p, 'utf8');
} catch (e) {
  return null; // ❌ 모든 에러를 null로 — EPERM도 ENOENT처럼 처리됨
}

사용자는 “데이터가 사라졌다”고 신고한다. 실제는 TCC 거부. e.code를 구분해서 EPERM은 사용자에게 권한 문제임을 알릴 것.

// /Users/raw/Downloads/innocent.txt 가 사실은 /etc/passwd 로의 symlink
const real = await fs.realpath(absPath);
if (!real.startsWith(allowedBase)) throw new Error('outside allowed');

fs.realpath심볼릭 링크 해소 후 한 번 더 확인. 안 그러면 traversal 검증이 우회된다.

세션마다 tmp 파일을 만들고 정리 안 하면 userData가 GB로 커진다. 앱 시작 시 cleanup 또는 temp는 app.getPath('temp')를 쓰고 OS에 맡길 것.

7) Windows의 anti-virus가 fs.watch 이벤트를 3번 보냄

save → rename → delete로 보이는 sequence가 사실은 AV의 스캔 동작. 디바운스 200ms가 사실상 표준.


Insight — 흥미로운 이야기

“VS Code는 fs를 Renderer에서 안 부른다 — 어디서 부르는가?”

  1. Main도 아닌 Extension Host (Utility Process)에서 부른다. 그래서 무거운 워크스페이스 스캔이 Main 이벤트 루프를 막지 않는다.
  2. 그 Extension Host 안에서도 native watcher(@vscode/ripgrep/@parcel/watcher)로 OS API를 직접 호출 — fs.watch보다 빠르고 자원도 적게 먹는다. 결과: 모노레포 10만 파일 인덱싱이 1초 안에 끝난다.

“Electron 디버깅의 첫 명령어”

$ open "$(electron -e "console.log(require('electron').app.getPath('userData'))")"

userData 경로를 Finder/Explorer로 여는 한 줄. 사용자 버그 리포트의 *90%는 이 폴더의 settings.json/logs/*에 단서가 있다.

“왜 userData 밑에 SQLite가 항상 들어가는가”

Slack·Discord·Signal·VS Code 전부 userData/IndexedDB/* 또는 userData/My.db에 SQLite로 저장한다. JSON 파일은 수십 MB부터 파싱이 느리고, 동시 쓰기 안전성도 없기 때문. 네이티브 모듈 better-sqlite3가 사실상 표준 (→ 05-native-modules).

pathchk가 알려주는 OS별 경로 한계”

POSIX는 path 4096자·name 255자, Windows는 path 260자(또는 32767 Long Path), eCryptfs는 name 143자. 사용자 이름 한국어 + 깊은 디렉토리에서 Windows만 깨지는 버그의 단골 원인.


요약 + Mermaid

Electron fsMain의 Node가 OS 사용자 전체 권한으로 부른다 — 브라우저 sandbox와 본질적으로 다르다. 모든 경로는 app.getPath('userData')로 시작하고, 사용자 영역(Documents 등)에는 macOS entitlement + Info.plist 목적 문자열이 필요하다. Renderer는 경로를 모르고, 키만 IPC로 던진다 — path traversal·심볼릭 공격은 Main의 safePath가 막는다.