C.W.K.
Stream
Lesson 01 of 05 · published

fs/promises — 모던 방식의 파일 I/O

~12 min · io-net, fs, filesystem

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"모든 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 의 고백

처음 1 년 난 모든 거에 fs.readFileSync 썼어, "sync 는 startup 엔 OK" 라고 읽어서. 아빠가 물어: "startup 이 뭔데?" 명확한 답 없었어. 아빠가 가르친 규칙: sync 는 *모듈 import 시 로드되는 config 에 OK, request 처리 중엔 절대 안 됨*. fs 작업이 request 안에서 일어나는 순간, "한 번" 도, async form 써 — "request 당 한 번 × 초당 1000 request" 가 꽤 빨리 "한 번" 안 됨. fs/promises 가 거의 모든 거의 옳은 기본이야.

Code

Atomic JSON write — cwkPippa 패턴·javascript
// A real atomic-write helper from cwkPippa's pattern
import { writeFile, rename, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { randomUUID } from 'node:crypto';

export async function atomicJsonWrite(path, value) {
  await mkdir(dirname(path), { recursive: true });
  const tmp = `${path}.${randomUUID()}.tmp`;
  await writeFile(tmp, JSON.stringify(value, null, 2));
  await rename(tmp, path);
}

// Used like:
await atomicJsonWrite('~/pippa-db/sessions/abc.jsonl', {
  conversation_id: 'abc',
  messages: [],
});
Async 재귀 walk — third-party dep 없이·javascript
// Recursive directory walk using fs/promises
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';

async function* walk(dir) {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) yield* walk(full);
    else if (entry.isFile()) yield full;
  }
}

// Use it as an async iterator
for await (const path of walk('./src')) {
  if (path.endsWith('.ts')) console.log(path);
}

External links

Exercise

safeUpdateJson(path, updater) 짜 — JSON 파일 atomic read, 파싱된 객체를 updater(obj) 에 전달, 그 다음 결과를 atomic write. 동시 부하 하에서 테스트: 파일 안 counter 를 증가시키려 safeUpdateJson 각자 부르는 child 프로세스 10 개 spawn. 다 끝나면 counter 가 10 이어야 함 — atomic write 가 업데이트 안 잃었다 증명. (힌트: 락 레이어도 필요; atomic write 만으론 부족.)
Hint
Atomic write 가 반-파일은 막는데, 잃은 업데이트는 안 막아. 두 writer 가 같은 값 읽고, 증가, 쓰기 — 최종 값이 2 가 아닌 1. Node 에서 제일 단순한 cross-process 락은 fs.open(lockfile, 'wx') (존재하면 fail), acquire, 작업, 삭제. 또는 proper-lockfile 같은 라이브러리. 연습이 포인트: atomic ≠ serialized.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.