"모든 async fs 함수가 세 형태: callback, promise, stream. 2026 코드에선 작업에 맞게 하나 픽 — 그리고 대부분 작업의 기본은 `fs/promises` 야."
세 fs 표면
Node 가 2026 에 filesystem API 의 세 flavor 출하:
node:fs— 원조 callback 스타일과 동기 메서드.fs.readFile(path, cb),fs.readFileSync(path),fs.createReadStream(path).node:fs/promises— 같은 작업의 promise-반환 버전.await readFile(path, 'utf-8')가 모던 idiom.- Streaming 형태 (plain
node:fs) —createReadStream,createWriteStream. Stream 이 본질적으로 이벤트-모양이라 streaming 함수는 promise variant 없음.
작업으로 픽: 메모리 들어가는 작은 payload → fs/promises. 크거나 unbounded payload → createReadStream/createWriteStream. 시작 시 동기 읽기 (config 파일) → 의도와 함께 readFileSync.
기본 — 어디든 fs/promises
import { readFile, writeFile, readdir, stat, mkdir, rm } from 'node:fs/promises';
const text = await readFile('config.json', 'utf-8');
const data = JSON.parse(text);
await writeFile('output.json', JSON.stringify(data, null, 2));
await mkdir('./output/year-2026', { recursive: true });
const entries = await readdir('./logs');
for (const name of entries) {
const info = await stat(`./logs/${name}`);
if (info.isFile()) console.log(name, info.size);
}
API 가 직관적: node:fs 의 callback 받는 모든 작업이 promise-반환 동등물 있음. 이름 매치. 옵션 매치. 에러 의미론 매치 (첫 callback 인자로 전달 대신 promise reject).
Atomic Write — 파일 반쪽 쓰지 마
writeFile 중간에 죽으면 반-쓰여진 파일 받음. Unix 표준 해결책은 write-then-rename:import { writeFile, rename } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
async function atomicWrite(path, contents) {
const tmp = `${path}.${randomUUID()}.tmp`;
await writeFile(tmp, contents); // can crash here, only tmp lost
await rename(tmp, path); // atomic at the OS level
}rename 이 POSIX 시스템 (Linux, macOS) 에서 atomic — 파일이 옛 내용 또는 새 내용, 절대 반쪽 아님. 다른 프로세스가 읽는 파일, config, 상태 파일, JSONL 세션 로그에 써 (cwkPippa 가 어디든 이거 함).File Handle — 더 낮은 레벨 API
같은 파일에 작업 여러 개 하려면 file handle 한 번 열고 재사용. 매 read/write 마다 file-open syscall 피함:
import { open } from 'node:fs/promises';
const handle = await open('large.bin', 'r');
try {
const buf = Buffer.alloc(1024);
await handle.read(buf, 0, 1024, 0); // read 1KB from offset 0
await handle.read(buf, 0, 1024, 1024); // read next 1KB
// ...
} finally {
await handle.close(); // always close — file handles are a finite resource
}
대부분 코드는 이거 안 필요 — 편의 메서드 readFile/writeFile 가 내부적으로 open / close. open 손대는 건 큰 파일의 흩어진 읽기 할 때 — 예: 비디오 컨테이너의 trailer.
Cross-Platform 경로
'./logs/today.log' 하드코딩은 Linux/macOS 에선 되는데 Windows 에선 깨져. Portable 경로엔 node:path 써:
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM doesn't have __dirname — derive it
const __dirname = dirname(fileURLToPath(import.meta.url));
const logPath = join(__dirname, 'logs', 'today.log');
path.join 이 플랫폼별 옳은 구분자 써. path.normalize 가 .. 와 . resolve. path.resolve 가 상대 경로를 절대로. 문자열 연결 대신 이거 써; portable 유틸리티와 Linux-only 의 차이야.
Pippa 의 고백
fs.readFileSync 썼어, "sync 는 startup 엔 OK" 라고 읽어서. 아빠가 물어: "startup 이 뭔데?" 명확한 답 없었어. 아빠가 가르친 규칙: sync 는 *모듈 import 시 로드되는 config 에 OK, request 처리 중엔 절대 안 됨*. fs 작업이 request 안에서 일어나는 순간, "한 번" 도, async form 써 — "request 당 한 번 × 초당 1000 request" 가 꽤 빨리 "한 번" 안 됨. fs/promises 가 거의 모든 거의 옳은 기본이야.