01 · fs · path · userData
이 문서가 답하는 질문: Electron에서 파일 읽고 쓰는 코드는 어디에 살아야 하고, 어느 경로에 써야 하며, 왜 그게 웹 File System Access API보다 위험한가? API: Node
fs/fs/promises,path, Electronapp.getPathOS 차이: WindowsAppData\Roaming· macOSLibrary/Application Support· Linux~/.config
한 줄 답 (Pyramid Top)
Electron의
fs는 Main 프로세스의 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 Access | Electron Node fs |
|---|---|---|
| 권한 부여 시점 | 사용자가 picker에서 디렉토리 직접 선택 | 앱이 시작되는 그 순간 |
| 권한 범위 | 사용자가 그 디렉토리만 | OS 사용자가 접근 가능한 전부 |
| 권한 만료 | 탭 닫으면 사라짐 (또는 3rd visit prompt) | 영원 (앱이 살아있는 한) |
| 거부 방법 | 사용자가 prompt에서 거부 | 없음 — chmod/sandbox 외엔 못 막음 |
| XSS 영향 | 그 origin만 | 사용자 홈 전체 (~/.ssh/id_rsa 포함) |
Renderer에
nodeIntegration:true로fs를 노출하던 Electron 1.x 시대는 — XSS 한 번이 OS 명령 실행과 같았다. 그래서 모든 fs는 Main에서, Renderer는 IPC로 키만 던진다가 절대 룰이 됐다.
How — 어떻게 동작하는가
app.getPath 표 — OS별 실제 경로
app.getPath(name)은 OS별 표준 위치를 돌려준다. 직접 os.homedir() + '/.config/MyApp' 같이 쓰지 말 것.
name | macOS | Windows | Linux |
|---|---|---|---|
home | /Users/raw | C:\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 |
sessionData | userData와 동일 (default) | 같음 | 같음 |
temp | /var/folders/... ($TMPDIR) | %TEMP% = ...\AppData\Local\Temp | /tmp |
desktop | ~/Desktop | C:\Users\raw\Desktop | ~/Desktop |
documents | ~/Documents | C:\Users\raw\Documents | ~/Documents |
downloads | ~/Downloads | C:\Users\raw\Downloads | ~/Downloads |
music/pictures/videos | ~/Music 등 | C:\Users\raw\Music 등 | XDG dirs |
logs | ~/Library/Logs/MyApp | userData\logs | userData/logs |
crashDumps | userData/Crashpad | 같음 | 같음 |
exe | MyApp.app/Contents/MacOS/MyApp | MyApp.exe | 빌드 산출 위치 |
module | Electron 바이너리 위치 | 같음 | 같음 |
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.watch | macOS FSEvents · Linux inotify · Windows ReadDirectoryChangesW | OS별 동작이 미묘하게 다름 (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-buildermac.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.txt와foo.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 # ~/.cacheapp.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은 사용자에게 권한 문제임을 알릴 것.
5) Symlink 공격
// /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 검증이 우회된다.
6) fs.unlink 후 디렉토리만 남는 leak
세션마다 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에서 안 부른다 — 어디서 부르는가?”
- Main도 아닌 Extension Host (Utility Process)에서 부른다. 그래서 무거운 워크스페이스 스캔이 Main 이벤트 루프를 막지 않는다.
- 그 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
fs는 Main의 Node가 OS 사용자 전체 권한으로 부른다 — 브라우저 sandbox와 본질적으로 다르다. 모든 경로는app.getPath('userData')로 시작하고, 사용자 영역(Documents등)에는 macOS entitlement + Info.plist 목적 문자열이 필요하다. Renderer는 경로를 모르고, 키만 IPC로 던진다 — path traversal·심볼릭 공격은 Main의 safePath가 막는다.