Node.js fs 모듈: 파일 읽기·쓰기, 디렉터리 관리, 스트림, 파일 감시, 에러 처리
이 글의 핵심
동기·콜백·Promise 방식의 fs API 차이, 파일과 디렉터리 관리, 큰 파일을 스트림으로 다루는 이유, fs.watch 주의점, 에러 처리 패턴을 정리합니다.
들어가며
fs 모듈이란?
fs (File System) 모듈은 Node.js에서 파일과 디렉토리를 다루는 내장 모듈입니다.
디스크에서 읽고 쓰는 일은 네트워크와 마찬가지로 시간이 걸리는 I/O라서, 서버 코드에서는 readFile 같은 비동기 API를 쓰는 것이 기본입니다. 동기(readFileSync)는 스레드 전체가 멈춘 것처럼 다음 요청을 못 받을 수 있으니, 초기화 스크립트나 CLI처럼 짧게 쓸 때만 쓰는 편이 안전합니다.
주요 기능:
- 파일 읽기/쓰기
- 디렉토리 생성/삭제
- 파일 정보 조회
- 파일 감시 (watch)
- 스트림 처리 세 가지 API 스타일:
- 동기 (Sync): 작업이 끝날 때까지 대기
- 비동기 (Callback): 콜백 함수로 결과 처리
- 비동기 (Promise): Promise로 결과 처리 (권장)
동기, 콜백, Promise로 파일 읽기
동기 방식
const fs = require('fs');
try {
const data = fs.readFileSync('file.txt', 'utf8');
console.log('파일 내용:', data);
} catch (err) {
console.error('에러:', err.message);
}
console.log('다음 코드');
// 출력 순서: 파일 내용 → 다음 코드
주의: 동기 방식은 서버를 블로킹하므로 서버 코드에서는 사용 금지!
비동기 (Callback)
const fs = require('fs');
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error('에러:', err.message);
return;
}
console.log('파일 내용:', data);
});
console.log('다음 코드');
// 출력 순서: 다음 코드 → 파일 내용
비동기 (Promise) - 권장
Promise 기반 API는 async/await와 함께 사용하여 가장 깔끔한 코드를 작성할 수 있습니다:
// fs.promises: Promise 기반 API
const fs = require('fs').promises;
async function readFile() {
try {
// await: Promise가 완료될 때까지 대기
// 코드는 동기처럼 보이지만 실제로는 비동기
// 다른 작업을 블로킹하지 않음
const data = await fs.readFile('file.txt', 'utf8');
// 'utf8': 텍스트 인코딩 지정
// 생략하면 Buffer 객체 반환
console.log('파일 내용:', data);
// data: 파일의 텍스트 내용 (string)
} catch (err) {
// 파일이 없거나 권한 문제 등 에러 발생 시
console.error('에러:', err.message);
// 에러 코드로 세부 처리
if (err.code === 'ENOENT') {
console.error('파일이 존재하지 않습니다');
} else if (err.code === 'EACCES') {
console.error('파일 접근 권한이 없습니다');
}
}
}
// async 함수 호출
readFile();
// 함수가 즉시 반환되고 파일 읽기는 백그라운드에서 진행
console.log('다음 코드');
// 출력 순서: "다음 코드" → "파일 내용: ..."
여러 파일 순차 읽기:
async function readMultipleFiles() {
try {
// 순차 실행 (하나씩)
const file1 = await fs.readFile('file1.txt', 'utf8');
const file2 = await fs.readFile('file2.txt', 'utf8');
const file3 = await fs.readFile('file3.txt', 'utf8');
console.log('모든 파일 읽기 완료');
} catch (err) {
console.error('에러:', err.message);
}
}
여러 파일 병렬 읽기 (더 빠름):
async function readFilesParallel() {
try {
// Promise.all: 모든 Promise를 동시에 실행
const [file1, file2, file3] = await Promise.all([
fs.readFile('file1.txt', 'utf8'),
fs.readFile('file2.txt', 'utf8'),
fs.readFile('file3.txt', 'utf8')
]);
// 3개 파일을 동시에 읽음 (병렬)
// 가장 느린 파일이 끝나면 모두 완료
console.log('모든 파일 읽기 완료');
} catch (err) {
// 하나라도 실패하면 전체 실패
console.error('에러:', err.message);
}
}
인코딩
// 텍스트 파일 (UTF-8)
const text = await fs.readFile('text.txt', 'utf8');
// 바이너리 파일 (Buffer)
const buffer = await fs.readFile('image.png');
console.log(buffer); // <Buffer 89 50 4e 47 ...>
// Buffer를 문자열로 변환
const str = buffer.toString('utf8');
// Buffer를 Base64로 변환
const base64 = buffer.toString('base64');
파일 쓰기와 JSON 파일 처리
기본 쓰기
const fs = require('fs').promises;
async function writeFile() {
try {
// 파일 쓰기 (덮어쓰기)
await fs.writeFile('output.txt', 'Hello, Node.js!', 'utf8');
console.log('파일 쓰기 완료');
// 파일 추가 (append)
await fs.appendFile('output.txt', '\n추가 내용', 'utf8');
console.log('내용 추가 완료');
} catch (err) {
console.error('에러:', err.message);
}
}
writeFile();
JSON 파일 처리
JSON 파일을 읽고 쓰는 유틸리티 함수입니다:
const fs = require('fs').promises;
// JSON 읽기 함수
async function readJSON(filename) {
try {
// 1. 파일을 텍스트로 읽기
const data = await fs.readFile(filename, 'utf8');
// data: JSON 형식의 문자열
// 2. JSON 문자열을 JavaScript 객체로 변환
return JSON.parse(data);
// JSON.parse(): 문자열 → 객체
} catch (err) {
// 에러 코드별 처리
if (err.code === 'ENOENT') {
// ENOENT: Error NO ENTry (파일 없음)
return null; // 파일이 없으면 null 반환
}
// 다른 에러는 상위로 전파
throw err;
}
}
// JSON 쓰기 함수
async function writeJSON(filename, data) {
// 1. JavaScript 객체를 JSON 문자열로 변환
const json = JSON.stringify(data, null, 2);
// JSON.stringify(값, replacer, 들여쓰기)
// null: replacer 없음 (모든 속성 포함)
// 2: 들여쓰기 2칸 (가독성 향상)
// 2. JSON 문자열을 파일에 쓰기
await fs.writeFile(filename, json, 'utf8');
}
// 사용 예제
async function main() {
// 1. 데이터 준비
const users = [
{ id: 1, name: '홍길동', age: 25 },
{ id: 2, name: '김철수', age: 30 }
];
// 2. JSON 파일로 저장
await writeJSON('users.json', users);
console.log('users.json 저장 완료');
// 저장된 파일 내용:
// [
// {
// "id": 1,
// "name": "홍길동",
// "age": 25
// },
// {
// "id": 2,
// "name": "김철수",
// "age": 30
// }
// ]
// 3. JSON 파일 읽기
const loadedUsers = await readJSON('users.json');
console.log(loadedUsers);
// [
// { id: 1, name: '홍길동', age: 25 },
// { id: 2, name: '김철수', age: 30 }
// ]
// 4. 데이터 수정 후 다시 저장
loadedUsers[0].age = 26;
await writeJSON('users.json', loadedUsers);
console.log('users.json 업데이트 완료');
}
main().catch(console.error);
// catch: main 함수에서 발생한 에러 처리
실전 예시: 설정 파일 관리:
// config.json 읽기/쓰기
const CONFIG_FILE = 'config.json';
async function loadConfig() {
const config = await readJSON(CONFIG_FILE);
// 파일이 없으면 기본 설정 반환
return config || {
port: 3000,
host: 'localhost',
debug: false
};
}
async function saveConfig(config) {
await writeJSON(CONFIG_FILE, config);
}
// 사용
const config = await loadConfig();
config.port = 8080; // 포트 변경
await saveConfig(config); // 저장
stat, 존재 확인, 복사·이동·삭제
파일 정보 (stat)
const fs = require('fs').promises;
async function fileInfo(filename) {
try {
const stats = await fs.stat(filename);
console.log('파일 크기:', stats.size, 'bytes');
console.log('생성 시간:', stats.birthtime);
console.log('수정 시간:', stats.mtime);
console.log('접근 시간:', stats.atime);
console.log('파일인가?', stats.isFile());
console.log('디렉토리인가?', stats.isDirectory());
console.log('심볼릭 링크인가?', stats.isSymbolicLink());
} catch (err) {
console.error('에러:', err.message);
}
}
fileInfo('file.txt');
파일 존재 확인
const fs = require('fs').promises;
async function fileExists(filename) {
try {
await fs.access(filename);
return true;
} catch {
return false;
}
}
// 사용
if (await fileExists('file.txt')) {
console.log('파일 존재');
} else {
console.log('파일 없음');
}
access로 존재 여부를 먼저 확인하고 그다음에 파일을 여는 방식은 짧은 스크립트에서는 괜찮지만, 두 호출 사이에 다른 프로세스가 파일을 지우거나 만들 수 있는 경쟁 조건(TOCTOU)이 있습니다. Node.js 공식 문서도 open·readFile 전에 access를 호출하지 말고, 바로 작업을 시도한 뒤 ENOENT 에러를 처리하라고 권합니다. 존재 확인이 정말 목적일 때만 이 함수를 쓰고, 읽거나 쓸 거라면 곧바로 readFile을 호출해 err.code === 'ENOENT'로 분기하는 편이 더 안전하고 시스템 호출도 한 번 줄어듭니다. 참고로 동기 API에는 fs.existsSync가 있지만 Promise API에는 exists가 없습니다. 콜백 버전 fs.exists는 오래전에 폐기 예정(deprecated)이 되었기 때문입니다.
파일 작업
const fs = require('fs').promises;
async function fileOperations() {
try {
// 파일 복사
await fs.copyFile('source.txt', 'destination.txt');
console.log('파일 복사 완료');
// 파일 이름 변경 / 이동
await fs.rename('old-name.txt', 'new-name.txt');
console.log('파일 이름 변경 완료');
// 파일 삭제
await fs.unlink('file-to-delete.txt');
console.log('파일 삭제 완료');
// 파일 권한 변경 (Linux/macOS)
await fs.chmod('file.txt', 0o755);
} catch (err) {
console.error('에러:', err.message);
}
}
fileOperations();
디렉토리 생성, 읽기, 재귀 탐색, 삭제
디렉토리 생성
const fs = require('fs').promises;
const path = require('path');
async function createDirectories() {
try {
// 단일 디렉토리
await fs.mkdir('new-folder');
// 중첩 디렉토리 (recursive)
await fs.mkdir('parent/child/grandchild', { recursive: true });
console.log('디렉토리 생성 완료');
} catch (err) {
if (err.code === 'EEXIST') {
console.log('디렉토리가 이미 존재합니다');
} else {
console.error('에러:', err.message);
}
}
}
createDirectories();
디렉토리 읽기
const fs = require('fs').promises;
const path = require('path');
async function listFiles(directory) {
try {
const files = await fs.readdir(directory);
console.log(`${directory} 내용:`);
for (const file of files) {
console.log(`- ${file}`);
}
} catch (err) {
console.error('에러:', err.message);
}
}
// 상세 정보 포함
async function listFilesDetailed(directory) {
try {
const files = await fs.readdir(directory, { withFileTypes: true });
for (const file of files) {
const type = file.isDirectory() ? '[DIR]' : '[FILE]';
console.log(`${type} ${file.name}`);
}
} catch (err) {
console.error('에러:', err.message);
}
}
listFiles('.');
listFilesDetailed('.');
재귀적 디렉토리 탐색
const fs = require('fs').promises;
const path = require('path');
async function walkDirectory(directory, callback) {
const files = await fs.readdir(directory, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(directory, file.name);
if (file.isDirectory()) {
await walkDirectory(fullPath, callback);
} else {
await callback(fullPath);
}
}
}
// 사용: 모든 .js 파일 찾기
async function findJSFiles(directory) {
const jsFiles = [];
await walkDirectory(directory, async (filePath) => {
if (path.extname(filePath) === '.js') {
jsFiles.push(filePath);
}
});
return jsFiles;
}
// 실행
findJSFiles('./src').then(files => {
console.log('JS 파일:', files);
});
디렉토리 삭제
const fs = require('fs').promises;
async function removeDirectory() {
try {
// 빈 디렉토리 삭제
await fs.rmdir('empty-folder');
// 디렉토리와 내용 모두 삭제 (recursive)
await fs.rm('folder-with-files', { recursive: true, force: true });
console.log('디렉토리 삭제 완료');
} catch (err) {
console.error('에러:', err.message);
}
}
removeDirectory();
읽기·쓰기 스트림, 파이프, 압축
읽기 스트림
const fs = require('fs');
const readStream = fs.createReadStream('large-file.txt', {
encoding: 'utf8',
highWaterMark: 64 * 1024 // 64KB 청크
});
let totalSize = 0;
readStream.on('data', (chunk) => {
totalSize += chunk.length;
console.log(`청크 받음: ${chunk.length} bytes`);
});
readStream.on('end', () => {
console.log(`총 ${totalSize} bytes 읽음`);
});
readStream.on('error', (err) => {
console.error('에러:', err.message);
});
쓰기 스트림
const fs = require('fs');
const writeStream = fs.createWriteStream('output.txt');
writeStream.write('첫 번째 줄\n');
writeStream.write('두 번째 줄\n');
writeStream.write('세 번째 줄\n');
writeStream.end('마지막 줄\n');
writeStream.on('finish', () => {
console.log('파일 쓰기 완료');
});
writeStream.on('error', (err) => {
console.error('에러:', err.message);
});
파이프 (Pipe)
const fs = require('fs');
// 파일 복사
fs.createReadStream('input.txt')
.pipe(fs.createWriteStream('output.txt'));
// 진행률 표시
const readStream = fs.createReadStream('large-file.txt');
const writeStream = fs.createWriteStream('copy.txt');
let totalSize = 0;
readStream.on('data', (chunk) => {
totalSize += chunk.length;
process.stdout.write(`\r복사 중: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
});
readStream.on('end', () => {
console.log('\n복사 완료');
});
readStream.pipe(writeStream);
압축
const fs = require('fs');
const zlib = require('zlib');
// 파일 압축
fs.createReadStream('input.txt')
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream('input.txt.gz'));
// 파일 압축 해제
fs.createReadStream('input.txt.gz')
.pipe(zlib.createGunzip())
.pipe(fs.createWriteStream('output.txt'));
// 여러 변환 체이닝
const { Transform } = require('stream');
class UpperCaseTransform extends Transform {
_transform(chunk, encoding, callback) {
this.push(chunk.toString().toUpperCase());
callback();
}
}
fs.createReadStream('input.txt')
.pipe(new UpperCaseTransform())
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream('output.txt.gz'));
스트림을 쓰는 핵심 이유는 메모리와 백프레셔(backpressure) 입니다. readFile은 파일 전체를 하나의 Buffer로 올리므로 수 GB짜리 파일에서는 메모리가 그만큼 필요하고, Buffer 최대 크기를 넘으면 “ERR_FS_FILE_TOO_LARGE” 같은 에러로 아예 읽지 못합니다. 스트림은 highWaterMark 크기의 청크만 들고 있다가, 쓰기 쪽이 느려서 write()가 false를 반환하면 pipe가 읽기를 잠시 멈추고 drain 이벤트가 오면 재개합니다. 위의 “쓰기 스트림” 예제처럼 write()를 반복 호출할 때 반환값을 무시하면 이 조절이 작동하지 않아, 대량 데이터를 쓰는 루프에서 메모리가 계속 늘어납니다.
UpperCaseTransform에는 한글 파일에서 드러나는 함정이 하나 있습니다. 청크 경계는 바이트 기준으로 잘리므로 UTF-8로 3바이트인 한글 한 글자가 두 청크에 걸칠 수 있고, 이때 chunk.toString()은 깨진 문자(�)를 만들어 냅니다. 영어 텍스트로 테스트하면 전혀 재현되지 않다가 한글 데이터에서만 가끔 글자가 깨지는 버그가 이것입니다. string_decoder 모듈의 StringDecoder로 남는 바이트를 다음 청크까지 들고 있게 하거나, createReadStream에 encoding: 'utf8'을 지정해 스트림이 경계를 처리하게 하면 해결됩니다. 또한 위 예제들처럼 .pipe()를 이어 붙이면 중간 스트림의 에러가 다음 스트림으로 전파되지 않으므로, 실무에서는 아래 FAQ에서 설명하는 pipeline을 쓰는 것이 좋습니다.
fs.watch, watchFile, chokidar로 파일 감시
fs.watch
const fs = require('fs');
const watcher = fs.watch('watched-folder', { recursive: true }, (eventType, filename) => {
console.log(`이벤트: ${eventType}`);
console.log(`파일: ${filename}`);
});
// 감시 중지
setTimeout(() => {
watcher.close();
console.log('감시 중지');
}, 60000);
fs.watchFile (폴링 방식)
const fs = require('fs');
fs.watchFile('file.txt', { interval: 1000 }, (curr, prev) => {
console.log('파일 변경됨');
console.log('이전 수정 시간:', prev.mtime);
console.log('현재 수정 시간:', curr.mtime);
});
// 감시 중지
setTimeout(() => {
fs.unwatchFile('file.txt');
}, 60000);
chokidar (권장)
npm install chokidar
const chokidar = require('chokidar');
const watcher = chokidar.watch('src/**/*.js', {
ignored: /(^|[\/\\])\../, // 숨김 파일 제외
persistent: true
});
watcher
.on('add', path => console.log(`파일 추가: ${path}`))
.on('change', path => console.log(`파일 변경: ${path}`))
.on('unlink', path => console.log(`파일 삭제: ${path}`))
.on('addDir', path => console.log(`디렉토리 추가: ${path}`))
.on('unlinkDir', path => console.log(`디렉토리 삭제: ${path}`))
.on('error', error => console.error(`에러: ${error}`))
.on('ready', () => console.log('초기 스캔 완료'));
// 감시 중지
setTimeout(() => {
watcher.close();
}, 60000);
fs.watch는 운영체제의 알림 기능(Linux inotify, macOS FSEvents, Windows ReadDirectoryChangesW)을 그대로 노출하기 때문에 플랫폼마다 동작이 다릅니다. 에디터가 파일을 저장할 때 “임시 파일에 쓰고 rename”하는 방식을 쓰면 change 대신 rename 이벤트가 오거나, 한 번 저장에 이벤트가 두세 번 연달아 오는 일이 흔합니다. filename 인자가 null로 오는 플랫폼도 있고, recursive: true는 Linux에서 Node.js 20 이전까지 지원되지 않았습니다. 그래서 직접 fs.watch를 쓸 때는 이벤트를 수십~수백 ms 모아 한 번만 처리하는 디바운스가 사실상 필수입니다.
fs.watchFile은 stat을 주기적으로 호출하는 폴링 방식이라 느리고 CPU를 쓰지만, 네트워크 드라이브나 Docker 볼륨처럼 OS 알림이 전달되지 않는 환경에서는 오히려 이쪽만 동작합니다. chokidar는 이런 차이를 흡수하고 중복 이벤트를 정리해 주기 때문에 권장되지만, Linux에서 감시 대상이 많으면 inotify 한도에 걸려 “ENOSPC: System limit for number of file watchers reached” 에러가 날 수 있습니다. node_modules처럼 큰 디렉터리를 ignored로 빼는 것이 먼저이고, 그래도 부족하면 fs.inotify.max_user_watches 커널 값을 올려야 합니다.
백업, 로그 관리, 파일 검색, CSV 처리 예제
파일 백업 시스템
const fs = require('fs').promises;
const path = require('path');
class BackupManager {
constructor(sourceDir, backupDir) {
this.sourceDir = sourceDir;
this.backupDir = backupDir;
}
async backup() {
try {
// 백업 디렉토리 생성
await fs.mkdir(this.backupDir, { recursive: true });
// 타임스탬프
const timestamp = new Date().toISOString().replace(/:/g, '-');
const backupPath = path.join(this.backupDir, `backup-${timestamp}`);
await fs.mkdir(backupPath);
// 파일 복사
const files = await fs.readdir(this.sourceDir);
for (const file of files) {
const sourcePath = path.join(this.sourceDir, file);
const destPath = path.join(backupPath, file);
const stats = await fs.stat(sourcePath);
if (stats.isFile()) {
await fs.copyFile(sourcePath, destPath);
console.log(`백업: ${file}`);
}
}
console.log(`백업 완료: ${backupPath}`);
return backupPath;
} catch (err) {
console.error('백업 실패:', err.message);
throw err;
}
}
async restore(backupPath) {
try {
const files = await fs.readdir(backupPath);
for (const file of files) {
const sourcePath = path.join(backupPath, file);
const destPath = path.join(this.sourceDir, file);
await fs.copyFile(sourcePath, destPath);
console.log(`복원: ${file}`);
}
console.log('복원 완료');
} catch (err) {
console.error('복원 실패:', err.message);
throw err;
}
}
}
// 사용
async function main() {
const manager = new BackupManager('./data', './backups');
// 백업
const backupPath = await manager.backup();
// 복원
// await manager.restore(backupPath);
}
main();
로그 파일 관리
const fs = require('fs');
const path = require('path');
class Logger {
constructor(logDir = './logs') {
this.logDir = logDir;
this.currentLogFile = null;
this.writeStream = null;
this.ensureLogDir();
this.rotateLog();
}
ensureLogDir() {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
}
rotateLog() {
// 현재 날짜로 로그 파일 생성
const date = new Date().toISOString().split('T')[0];
const logFile = path.join(this.logDir, `app-${date}.log`);
if (this.currentLogFile !== logFile) {
if (this.writeStream) {
this.writeStream.end();
}
this.currentLogFile = logFile;
this.writeStream = fs.createWriteStream(logFile, { flags: 'a' });
}
}
log(level, message) {
this.rotateLog();
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [${level}] ${message}\n`;
// 콘솔 출력
console.log(logMessage.trim());
// 파일 저장
this.writeStream.write(logMessage);
}
info(message) {
this.log('INFO', message);
}
error(message) {
this.log('ERROR', message);
}
warn(message) {
this.log('WARN', message);
}
async cleanup(daysToKeep = 7) {
try {
const files = await fs.promises.readdir(this.logDir);
const now = Date.now();
const maxAge = daysToKeep * 24 * 60 * 60 * 1000;
for (const file of files) {
const filePath = path.join(this.logDir, file);
const stats = await fs.promises.stat(filePath);
if (now - stats.mtime.getTime() > maxAge) {
await fs.promises.unlink(filePath);
console.log(`오래된 로그 삭제: ${file}`);
}
}
} catch (err) {
console.error('정리 실패:', err.message);
}
}
}
// 사용
const logger = new Logger('./logs');
logger.info('서버 시작');
logger.error('데이터베이스 연결 실패');
logger.warn('메모리 사용량 높음');
// 7일 이상 된 로그 삭제
logger.cleanup(7);
파일 검색
const fs = require('fs').promises;
const path = require('path');
async function searchFiles(directory, pattern) {
const results = [];
async function search(dir) {
const files = await fs.readdir(dir, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
await search(fullPath);
} else {
// 파일명 패턴 매칭
if (pattern.test(file.name)) {
const stats = await fs.stat(fullPath);
results.push({
path: fullPath,
name: file.name,
size: stats.size,
modified: stats.mtime
});
}
}
}
}
await search(directory);
return results;
}
// 사용: 모든 .md 파일 찾기
async function main() {
const mdFiles = await searchFiles('./src', /\.md$/);
console.log(`${mdFiles.length}개 파일 발견:`);
mdFiles.forEach(file => {
console.log(`- ${file.name} (${file.size} bytes)`);
});
}
main();
CSV 파일 처리
const fs = require('fs');
const readline = require('readline');
async function processCSV(filename) {
const fileStream = fs.createReadStream(filename);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
const results = [];
let isFirstLine = true;
let headers = [];
for await (const line of rl) {
if (isFirstLine) {
headers = line.split(',');
isFirstLine = false;
continue;
}
const values = line.split(',');
const obj = {};
headers.forEach((header, index) => {
obj[header.trim()] = values[index]?.trim() || '';
});
results.push(obj);
}
return results;
}
// 사용
async function main() {
const data = await processCSV('users.csv');
console.log('데이터:', data);
// [
// { name: '홍길동', age: '25', email: '[email protected]' },
// { name: '김철수', age: '30', email: '[email protected]' }
// ]
}
main();
ENOENT 등 에러 코드와 안전한 파일 작업
에러 코드
const fs = require('fs').promises;
async function handleErrors() {
try {
const data = await fs.readFile('nonexistent.txt', 'utf8');
} catch (err) {
switch (err.code) {
case 'ENOENT':
console.error('파일을 찾을 수 없습니다');
break;
case 'EACCES':
console.error('권한이 없습니다');
break;
case 'EISDIR':
console.error('디렉토리입니다');
break;
case 'ENOTDIR':
console.error('디렉토리가 아닙니다');
break;
case 'EEXIST':
console.error('이미 존재합니다');
break;
default:
console.error('에러:', err.message);
}
}
}
handleErrors();
안전한 파일 작업
const fs = require('fs').promises;
const path = require('path');
async function safeFileOperation(filename, operation) {
const tempFile = `${filename}.tmp`;
try {
// 임시 파일에 작업
await operation(tempFile);
// 성공하면 원본 파일로 이동
await fs.rename(tempFile, filename);
console.log('작업 완료');
} catch (err) {
// 실패하면 임시 파일 삭제
try {
await fs.unlink(tempFile);
} catch {}
console.error('작업 실패:', err.message);
throw err;
}
}
// 사용
safeFileOperation('data.json', async (tempFile) => {
const data = { users: [] };
await fs.writeFile(tempFile, JSON.stringify(data, null, 2), 'utf8');
});
이 “임시 파일에 쓰고 rename” 패턴이 필요한 이유는 writeFile이 원자적이지 않기 때문입니다. writeFile은 파일을 먼저 비운 뒤 내용을 쓰므로, 쓰는 도중에 프로세스가 죽거나 디스크가 가득 차면 설정 파일이 빈 파일이나 반쯤 쓰인 JSON으로 남습니다. 다음 실행에서 JSON.parse가 “Unexpected end of JSON input”으로 실패하는 상황이 바로 이것입니다. 같은 파일 시스템 안의 rename은 POSIX에서 원자적으로 동작하므로, 다른 프로세스는 항상 이전 내용이나 새 내용 중 하나만 보게 됩니다. 다만 임시 파일을 다른 드라이브(/tmp 등)에 만들면 rename이 “EXDEV: cross-device link not permitted”로 실패하므로, 위 코드처럼 원본과 같은 디렉터리에 두어야 합니다. Windows에서는 대상 파일을 다른 프로세스가 열고 있으면 rename이 EPERM으로 실패할 수 있다는 점도 알아 두세요.
버퍼 크기, 병렬 처리, 메모리 효율
버퍼 크기 조정
const fs = require('fs');
// 작은 버퍼 (느림)
const stream1 = fs.createReadStream('file.txt', {
highWaterMark: 16 * 1024 // 16KB
});
// 큰 버퍼 (빠름, 메모리 많이 사용)
const stream2 = fs.createReadStream('file.txt', {
highWaterMark: 256 * 1024 // 256KB
});
병렬 처리
const fs = require('fs').promises;
// ❌ 순차 처리 (느림)
async function sequential(files) {
const results = [];
for (const file of files) {
const data = await fs.readFile(file, 'utf8');
results.push(data);
}
return results;
}
// ✅ 병렬 처리 (빠름)
async function parallel(files) {
const promises = files.map(file => fs.readFile(file, 'utf8'));
return Promise.all(promises);
}
// 사용
const files = ['file1.txt', 'file2.txt', 'file3.txt'];
// 순차: 각 파일 읽기 시간의 합
await sequential(files);
// 병렬: 대략 가장 느린 파일 하나의 시간
await parallel(files);
Promise.all로 병렬화하면 대기 시간이 겹쳐서 빨라지지만, 파일이 수천 개라면 한꺼번에 파일을 모두 열게 되어 “EMFILE: too many open files” 에러가 날 수 있습니다. Linux의 기본 파일 디스크립터 한도(ulimit -n)가 1024인 환경이 흔하기 때문입니다. 또 libuv의 스레드 풀은 기본 4개라서, 동시에 수천 개의 요청을 던져도 실제 디스크 작업은 4개씩 처리되고 나머지는 대기열에 쌓일 뿐입니다. 파일 수가 많을 때는 p-limit 같은 라이브러리나 직접 만든 청크 루프로 동시 실행 개수를 수십 개 정도로 제한하는 편이 안정적입니다.
메모리 효율
const fs = require('fs');
// ❌ 전체 파일을 메모리에 로드 (대용량 파일에 부적합)
async function inefficient(filename) {
const data = await fs.promises.readFile(filename, 'utf8');
return data.split('\n').length;
}
// ✅ 스트림 사용 (메모리 효율적)
async function efficient(filename) {
return new Promise((resolve, reject) => {
let lineCount = 0;
const stream = fs.createReadStream(filename);
stream.on('data', (chunk) => {
lineCount += chunk.toString().split('\n').length - 1;
});
stream.on('end', () => {
resolve(lineCount);
});
stream.on('error', reject);
});
}
경로, 인코딩, 동기 함수, 핸들 누수 문제
상대 경로 문제
// ❌ 상대 경로 (현재 작업 디렉토리 기준)
fs.readFileSync('file.txt'); // 실행 위치에 따라 다름
// ✅ 절대 경로 (__dirname 사용)
const path = require('path');
const filePath = path.join(__dirname, 'file.txt');
fs.readFileSync(filePath);
제가 가장 자주 보는 fs 관련 버그가 이 경로 문제입니다. 로컬에서 node app.js로 실행할 때는 잘 되다가, npm 스크립트나 PM2, systemd, cron으로 다른 디렉터리에서 실행하는 순간 “ENOENT: no such file or directory, open ‘file.txt‘“가 납니다. 상대 경로는 스크립트 위치가 아니라 process.cwd()(프로세스를 실행한 위치) 기준으로 해석되기 때문입니다. 한 가지 더 주의할 점은 ES 모듈("type": "module" 또는 .mjs)에서는 __dirname이 정의되어 있지 않아 “ReferenceError: __dirname is not defined in ES module scope”가 난다는 것입니다. 이때는 path.dirname(fileURLToPath(import.meta.url))로 구하거나, Node.js 20.11 이상이라면 import.meta.dirname을 쓰면 됩니다.
인코딩 누락
// ❌ 인코딩 없음 (Buffer 반환)
const data = await fs.readFile('file.txt');
console.log(data); // <Buffer 48 65 6c 6c 6f>
// ✅ 인코딩 지정
const data = await fs.readFile('file.txt', 'utf8');
console.log(data); // "Hello"
서버 코드에서 동기 함수 사용
// ❌ 동기 함수 (서버 블로킹)
app.get('/file', (req, res) => {
const data = fs.readFileSync('file.txt', 'utf8');
res.send(data);
});
// ✅ 비동기 함수
app.get('/file', async (req, res) => {
try {
const data = await fs.promises.readFile('file.txt', 'utf8');
res.send(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
파일 핸들 누수
// ❌ 스트림을 닫지 않음
const stream = fs.createReadStream('file.txt');
// ... 끝까지 읽지 않고 중간에 버리면 파일 디스크립터가 남음
// (끝까지 읽으면 기본값 autoClose: true로 자동으로 닫힘)
// ✅ 명시적으로 닫기
const stream = fs.createReadStream('file.txt');
stream.on('end', () => {
stream.close();
});
// ✅ 또는 pipeline 사용 (자동으로 정리)
const { pipeline } = require('stream');
const util = require('util');
const pipelineAsync = util.promisify(pipeline);
await pipelineAsync(
fs.createReadStream('input.txt'),
fs.createWriteStream('output.txt')
);
존재 확인, 디렉토리 비우기, 크기 확인 유틸
파일 존재 확인 패턴
const fs = require('fs').promises;
// 패턴 1: access 사용
async function exists1(filename) {
try {
await fs.access(filename);
return true;
} catch {
return false;
}
}
// 패턴 2: stat 사용
async function exists2(filename) {
try {
await fs.stat(filename);
return true;
} catch {
return false;
}
}
// 패턴 3: EAFP (Easier to Ask for Forgiveness than Permission)
async function readFileSafe(filename) {
try {
return await fs.readFile(filename, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
return null; // 파일 없음
}
throw err; // 다른 에러는 재발생
}
}
디렉토리 비우기
const fs = require('fs').promises;
const path = require('path');
async function emptyDirectory(directory) {
try {
const files = await fs.readdir(directory);
await Promise.all(
files.map(async (file) => {
const filePath = path.join(directory, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
await fs.rm(filePath, { recursive: true });
} else {
await fs.unlink(filePath);
}
})
);
console.log('디렉토리 비우기 완료');
} catch (err) {
console.error('에러:', err.message);
}
}
emptyDirectory('./temp');
파일 크기 확인
const fs = require('fs').promises;
const path = require('path');
async function getDirectorySize(directory) {
let totalSize = 0;
async function calculate(dir) {
const files = await fs.readdir(dir, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
await calculate(fullPath);
} else {
const stats = await fs.stat(fullPath);
totalSize += stats.size;
}
}
}
await calculate(directory);
return totalSize;
}
// 사용
async function main() {
const size = await getDirectorySize('./src');
console.log(`디렉토리 크기: ${(size / 1024 / 1024).toFixed(2)} MB`);
}
main();
파일 시스템 API 요약
- fs 모듈: 파일과 디렉토리 관리
- 세 가지 API: 동기(Sync), 비동기(Callback), 비동기(Promise)
- 권장 방식:
fs.promises사용 (async/await) - 스트림: 대용량 파일 처리, 메모리 효율
- 파일 감시:
fs.watch()또는chokidar - 에러 처리: 에러 코드 확인, try-catch
API 비교
| 작업 | 동기 | 비동기 (Callback) | 비동기 (Promise) |
|---|---|---|---|
| 파일 읽기 | readFileSync | readFile | promises.readFile |
| 파일 쓰기 | writeFileSync | writeFile | promises.writeFile |
| 파일 삭제 | unlinkSync | unlink | promises.unlink |
| 디렉토리 생성 | mkdirSync | mkdir | promises.mkdir |
선택 가이드
동기 방식 사용:
-
CLI 도구
-
초기화 코드 (서버 시작 전)
-
간단한 스크립트 비동기 방식 사용 (권장):
-
웹 서버
-
API 서버
-
동시 처리가 필요한 경우 스트림 사용:
-
메모리에 한 번에 올리기 부담스러운 대용량 파일
-
실시간 처리
-
메모리 제한이 있는 경우
다음 단계
추천 학습 자료
공식 문서:
-
Node.js stream 모듈 패키지:
-
chokidar - 파일 감시
-
fs-extra - fs 확장
-
glob - 파일 패턴 매칭
자주 묻는 질문 (FAQ)
Q. 스트림으로 파일을 복사할 때 pipe 대신 pipeline을 쓰는 이유는 무엇인가요?
A. pipe만 쓰면 중간에 에러가 나도 연결된 스트림이 자동으로 닫히지 않아 파일 핸들이 남는 누수가 생길 수 있습니다. stream.pipeline은 어느 단계에서든 에러가 나면 모든 스트림을 정리하고 하나의 콜백이나 Promise로 에러를 전달합니다. 그래서 파일 복사나 압축처럼 여러 스트림을 잇는 작업에서는 util.promisify(pipeline) 또는 stream/promises의 pipeline을 쓰는 편이 안전합니다.