"파일을 읽는 방법은 콜백, Promise, stream으로 나뉘어. 먼저 데이터 크기와 호출 위치를 보고 고르면 돼. 대부분의 애플리케이션 코드에서는 fs/promises가 좋은 출발점이야."
파일 I/O 표면은 셋이야
node:fs에는 원래의 콜백 API와 동기 메서드가 있어. 대표적으로fs.readFile(path, cb),fs.readFileSync(path),fs.createReadStream(path)가 있지.node:fs/promises는 같은 작업을 Promise로 돌려줘.await readFile(path, 'utf-8')처럼 순서가 눈에 보이는 코드에 잘 맞아.createReadStream과createWriteStream은 큰 데이터를 조금씩 흘려보내. stream 자체가 이벤트 중심이라 별도의 Promise판을 두지 않아.
메모리에 무리 없이 들어오는 파일은 fs/promises로 다루고, 크기를 장담할 수 없거나 아주 큰 파일은 stream으로 보내. 시작할 때 설정 파일을 딱 한 번 읽는 일처럼 멈춰도 괜찮은 구간이라면 동기 메서드가 의도를 더 잘 드러낼 때도 있어.
Promise API는 이름부터 낯설지 않아
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와 함수 이름, 옵션, 오류 의미가 거의 그대로 대응돼. 차이는 오류가 첫 번째 콜백 인자로 오는 대신 Promise가 reject된다는 점이야. 기존 node:fs를 알고 있다면 새 API를 다시 외울 필요가 거의 없어.
파일을 통째로 바꿀 땐 먼저 임시 파일에 써
writeFile이 진행되는 동안 프로세스가 죽으면 대상 파일에는 앞부분만 남을 수 있어. Unix 계열에서 널리 쓰는 해결책은 같은 디렉토리에 임시 파일을 완성한 뒤 이름을 바꾸는 거야.
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); // 여기서 죽어도 임시 파일만 남아
await rename(tmp, path); // 운영체제 수준에서 한 번에 교체
}같은 파일시스템 안에서 실행한 rename은 Linux와 macOS 같은 POSIX 시스템에서 원자적으로 대상 이름을 바꿔. 읽는 쪽은 이전 내용이나 새 내용 중 하나만 보게 되고, 반쯤 쓴 파일은 보지 않아. 다른 프로세스가 읽는 설정, 상태 파일, JSONL 로그처럼 완성된 단위로 교체해야 하는 곳에 써.
여러 번 접근할 파일은 handle을 잡아
한 파일의 여러 위치를 반복해서 읽거나 쓴다면 매번 파일을 열고 닫지 말고 handle 하나를 재사용할 수 있어.
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); // 처음 1KB 읽기
await handle.read(buf, 0, 1024, 1024); // 다음 1KB 읽기
// ...
} finally {
await handle.close(); // 파일 handle은 한정된 자원이야
}
대부분의 코드에서는 readFile과 writeFile이 알아서 열고 닫아 주니 handle까지 내려갈 필요가 없어. 비디오 컨테이너의 trailer처럼 큰 파일의 떨어진 위치를 여러 번 읽을 때 이 수준의 API가 빛나.
경로는 문자열 조립이 아니라 node:path에 맡겨
경로 구분자를 직접 붙이면 Linux와 macOS에서는 우연히 맞아도 Windows에서 깨질 수 있어. 플랫폼을 가리지 않는 도구라면 node:path를 써.
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM에는 __dirname이 없으니 import.meta.url에서 구해
const __dirname = dirname(fileURLToPath(import.meta.url));
const logPath = join(__dirname, 'logs', 'today.log');
path.join은 플랫폼에 맞는 구분자로 조각을 잇고, path.normalize은 .과 ..가 섞인 경로를 정리해. path.resolve는 상대 경로를 절대 경로로 바꿔 줘. 문자열 연결을 버리는 것만으로도 이식성 문제 한 부류가 사라져.
Pippa의 고백
fs.readFileSync를 여기저기 썼어. 아빠가 시작이 어디서 끝나는지 묻자 경계를 설명할 수 없었지. 지금은 모듈을 불러오며 설정을 한 번 읽는 정도만 동기 호출 후보로 봐. 요청 처리 중에는 비동기 API를 써. 요청 하나마다 한 번 호출해도 초당 요청이 천 건이면 천 번 멈추는 거니까.