Node.js · 기본
Node.js로 만드는 작은 API
Node.js 파일 입출력 - fs/promises 로 JSON 읽고 쓰기와 원자적 저장, 스트림 (Node.js API 3단원)
파일이 없을 때와 깨졌을 때를 구분해 JSON 을 읽고, 임시 파일과 rename 으로 안전하게 저장한다. 실행 위치에 따라 달라지는 상대 경로 함정과 큰 파일 줄 단위 읽기를 다룬다.
개발자 · 원고 갱신
이 단원에서 배우는 것
메모 API는 메모를 어딘가에 남겨야 한다. 실무라면 데이터베이스를 쓰겠지만, 이 책은 설치 없이 끝까지 가기 위해 JSON 파일 하나를 저장소로 쓴다. 파일은 가장 단순한 저장소이면서 실패하는 방법이 많은 저장소다. 파일이 아직 없을 때, 내용이 깨졌을 때, 쓰다가 프로그램이 죽었을 때를 미리 다뤄 둔다.
node:fs/promises의readFile/writeFile/mkdir/rename으로 JSON을 읽고 쓴다.- "파일 없음"과 "그 밖의 실패"를 구분하고, 깨진 JSON을 알아볼 수 있는 오류로 바꾼다.
- 임시 파일에 다 쓴 뒤 이름을 바꿔 끼우는 원자적 쓰기를 익힌다.
- 상대 경로가 "실행한 위치" 기준이라는 함정을 확인한다.
- 큰 파일을 한 번에 읽지 않고 스트림으로 흘려 읽는다.
문제 상황
메모를 data/memos.json에 저장하는 코드를 인터넷 예제대로 이렇게 썼다.
const memos = JSON.parse(fs.readFileSync('data/memos.json'));
처음 실행하면 파일이 없어서 죽는다. 폴더를 옮겨서 실행하면 또 죽는다. 저장 도중 전원이 나가자 반쯤 쓰인 파일이 남아 그다음부터는 서버가 아예 뜨지 않는다. 동기 함수라서 파일이 커질수록 모든 요청이 느려진다(2단원). 하나씩 고친다. 예제는 ch03 폴더에 둔다.
완성 코드
1. 메모 파일 모듈: memo-file.mjs
// memo-file.mjs — 메모 목록을 JSON 파일로 읽고 쓴다
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
export async function loadMemos(file) {
let text;
try {
text = await readFile(file, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return []; // 파일이 아직 없으면 빈 목록으로 시작
throw err; // 권한 문제 등은 숨기지 않는다
}
try {
return JSON.parse(text);
} catch (err) {
throw new Error(`${file} 의 JSON 이 깨졌습니다: ${err.message}`, { cause: err });
}
}
export async function saveMemos(file, memos) {
await mkdir(dirname(file), { recursive: true }); // data 폴더가 없으면 만든다
const temp = `${file}.tmp`;
await writeFile(temp, JSON.stringify(memos, null, 2) + '\n', 'utf8');
await rename(temp, file); // 다 쓴 뒤에 한 번에 바꿔치기
}
2. 써 보기: files.mjs
// files.mjs — memo-file.mjs 를 써 보는 연습 스크립트
import { join } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { loadMemos, saveMemos } from './memo-file.mjs';
const file = join(import.meta.dirname, 'data', 'memos.json');
const first = await loadMemos(file);
console.log('처음 읽은 메모 수:', first.length);
await saveMemos(file, [
{ id: 1, title: 'Node 설치 확인', done: true },
{ id: 2, title: '모듈 복습', done: false },
]);
const again = await loadMemos(file);
console.log('저장 후 읽은 메모:', again.map((m) => m.title));
await writeFile(file, '{"id": 1, "title": ', 'utf8'); // 일부러 파일을 망가뜨린다
try {
await loadMemos(file);
} catch (err) {
console.log('읽기 실패:', err.message.replace(import.meta.dirname, '.'));
}
3. 경로 함정: cwd-trap.mjs와 notes.txt
// cwd-trap.mjs — 상대 경로는 "파일 위치"가 아니라 "실행한 위치" 기준이다
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
console.log('실행한 위치 끝부분:', process.cwd().split('/').at(-1));
const safe = await readFile(join(import.meta.dirname, 'notes.txt'), 'utf8');
console.log('파일 위치 기준으로 읽기:', safe.trim());
const risky = await readFile('notes.txt', 'utf8');
console.log('상대 경로로 읽기:', risky.trim());
같은 폴더에 notes.txt를 만들고 ch03 폴더에 있는 메모 한 줄을 적어 둔다.
4. 큰 파일 흘려 읽기: count-errors.mjs
// count-errors.mjs — 큰 로그 파일을 한 번에 읽지 않고 줄 단위로 흘려 읽는다
import { createReadStream } from 'node:fs';
import { stat, writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
const file = process.argv[2] ?? 'app.log';
if (process.argv.includes('--make')) { // 연습용 로그 만들기: 20만 줄
const lines = [];
for (let i = 1; i <= 200_000; i++) {
const level = i % 50 === 0 ? 'ERROR' : i % 7 === 0 ? 'WARN' : 'INFO';
lines.push(`2026-09-24T09:00:00Z ${level} request #${i} handled`);
}
await writeFile(file, lines.join('\n') + '\n', 'utf8');
}
const { size } = await stat(file);
const counts = { INFO: 0, WARN: 0, ERROR: 0 };
const rl = createInterface({ input: createReadStream(file, 'utf8'), crlfDelay: Infinity });
for await (const line of rl) {
const level = line.split(' ')[1];
if (level in counts) counts[level] += 1;
}
console.log(`파일 크기: ${(size / 1024 / 1024).toFixed(1)}MB`);
console.log('레벨별 줄 수:', counts);
줄별 해설
fs/promises를 쓰는 이유
Node.js의 파일 함수는 세 벌이 있다. readFileSync(동기), readFile(path, callback)(콜백), node:fs/promises의 readFile(Promise). 서버 코드에서는 이벤트 루프를 막지 않고 await로 읽을 수 있는 마지막 것을 기본으로 쓴다. 동기 함수는 서버가 뜨기 전 한 번 읽는 설정 파일처럼 "어차피 기다려야 하는" 곳에서만 쓴다.
| 방식 | 예 | 이벤트 루프 |
|---|---|---|
| 동기 | readFileSync(path) | 다 읽을 때까지 막는다 |
| 콜백 | readFile(path, cb) | 막지 않는다 |
| Promise | await readFile(path) | 막지 않는다 · 이 책의 기본 |
파일이 없을 때와 나머지 실패를 나눈다
let text;
try {
text = await readFile(file, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return []; // 파일이 아직 없으면 빈 목록으로 시작
throw err; // 권한 문제 등은 숨기지 않는다
}
실패한 파일 작업은 err.code에 원인을 짧은 문자열로 담는다. ENOENT(그런 파일이나 폴더 없음), EACCES(권한 없음), EISDIR(폴더를 파일처럼 열었음) 등이다. 파일이 아직 없는 것은 "메모가 0개"라는 정상 상황이므로 빈 배열로 바꾼다. 그 밖의 실패는 다시 던진다. 권한 오류까지 빈 배열로 바꾸면, 서버는 멀쩡히 뜨는데 메모가 전부 사라진 것처럼 보이고, 다음 저장 때 빈 목록으로 파일을 덮어쓰는 사고로 이어진다.
| err.code | 뜻 | 이 책의 처리 |
|---|---|---|
ENOENT | 파일이나 폴더가 없다 | 읽기: 빈 목록으로 시작 |
EACCES | 권한이 없다 | 다시 던진다 |
EISDIR | 폴더를 파일처럼 열었다 | 다시 던진다 |
EXDEV | 디스크를 넘어 rename | 임시 파일을 같은 폴더에 |
try {
return JSON.parse(text);
} catch (err) {
throw new Error(`${file} 의 JSON 이 깨졌습니다: ${err.message}`, { cause: err });
}
}
JSON.parse의 오류 메시지에는 "몇 번째 글자에서 무엇을 기대했는지"만 있고 어느 파일인지는 없다. 파일 경로를 붙인 새 오류로 바꿔 던지고, 원래 오류는 cause에 달아 둔다. 로그를 보는 사람이 바로 원인 파일을 연다.
원자적 쓰기: 다 쓴 다음 바꿔 끼운다
export async function saveMemos(file, memos) {
await mkdir(dirname(file), { recursive: true }); // data 폴더가 없으면 만든다
const temp = `${file}.tmp`;
await writeFile(temp, JSON.stringify(memos, null, 2) + '\n', 'utf8');
await rename(temp, file); // 다 쓴 뒤에 한 번에 바꿔치기
}
writeFile로 원래 파일을 바로 덮어쓰면, 쓰는 도중에 프로세스가 죽었을 때 앞부분만 있는 파일이 남는다. 그래서 같은 폴더의 임시 파일에 끝까지 쓴 다음 rename으로 원래 이름에 덮어씌운다. 같은 디스크 안의 rename은 운영체제가 한 번에 처리하므로, 다른 사람이 보는 파일은 "옛날 내용 전체" 아니면 "새 내용 전체" 둘 중 하나다. mkdir의 recursive: true는 폴더가 이미 있어도 오류를 내지 않고, 중간 폴더까지 만들어 준다.
그림 3-1. 바로 덮어쓰기와 원자적 쓰기
JSON.stringify(memos, null, 2)의 세 번째 인자 2는 들여쓰기 칸 수다. 사람이 열어 보고 git 변경 내역에서 줄 단위로 비교하기 좋다.
경로는 파일 기준으로 만든다
const file = join(import.meta.dirname, 'data', 'memos.json');
readFile('notes.txt') 같은 상대 경로는 node 명령을 실행한 폴더(process.cwd())를 기준으로 찾는다. 코드 파일이 어디 있는지와는 상관없다. 서버를 배포 도구나 서비스 관리자로 띄우면 실행 폴더가 내 예상과 다른 경우가 흔하다. import.meta.dirname(이 파일이 있는 폴더)에서 출발해 join으로 이으면 어디서 실행해도 같은 파일을 찾는다. join은 운영체제에 맞는 구분자(/ 또는 \)를 쓰므로 문자열을 직접 이어 붙이는 것보다 안전하다.
그림 3-2. 상대 경로와 파일 기준 경로
스트림으로 흘려 읽기
const rl = createInterface({ input: createReadStream(file, 'utf8'), crlfDelay: Infinity });
for await (const line of rl) {
readFile은 파일 전체를 메모리에 올린다. 몇 GB짜리 로그라면 메모리가 모자라고, 다 읽을 때까지 첫 줄도 볼 수 없다. createReadStream은 파일을 64KB 정도의 조각으로 나눠 흘려보내고, readline이 그 조각을 줄 단위로 다시 잘라 준다. for await는 줄이 준비될 때마다 한 번씩 돈다. 한 번에 메모리에 있는 것은 조각 몇 개뿐이다. crlfDelay: Infinity는 윈도우 줄바꿈(\r\n)을 한 줄바꿈으로 다루게 한다. 5단원에서 HTTP 요청 본문을 읽을 때도 for await (const chunk of req)로 같은 방식을 쓴다.
실제 실행 결과
$ node files.mjs
처음 읽은 메모 수: 0
저장 후 읽은 메모: [ 'Node 설치 확인', '모듈 복습' ]
읽기 실패: ./data/memos.json 의 JSON 이 깨졌습니다: Unexpected end of JSON input
처음에는 파일이 없어 0개, 저장 뒤에는 두 개가 읽혔다. 일부러 끊은 파일은 파일 경로와 JSON 오류 위치가 함께 나온다. 파일 경로는 원고에 컴퓨터 경로가 남지 않도록 스크립트에서 .으로 바꿔 출력했다.
$ node cwd-trap.mjs
실행한 위치 끝부분: ch03
파일 위치 기준으로 읽기: ch03 폴더에 있는 메모
상대 경로로 읽기: ch03 폴더에 있는 메모
이번에는 한 단계 위 폴더(node-book)에서 같은 파일을 실행했다.
$ node ch03/cwd-trap.mjs
실행한 위치 끝부분: node-book
파일 위치 기준으로 읽기: ch03 폴더에 있는 메모
node:internal/fs/promises:1360
return new FileHandle(await PromisePrototypeThen(
^
Error: ENOENT: no such file or directory, open 'notes.txt'
at async open (node:internal/fs/promises:1360:25)
at async readFile (node:internal/fs/promises:2149:14)
at async file:///home/me/node-book/ch03/cwd-trap.mjs:10:15 {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: 'notes.txt'
}
Node.js v26.4.0
파일 기준 경로는 여전히 잘 읽지만, 상대 경로는 node-book/notes.txt를 찾다가 ENOENT로 실패했다. 코드는 한 글자도 바뀌지 않았다. 실행한 위치만 바뀌었다.
$ node count-errors.mjs app.log --make
파일 크기: 9.4MB
레벨별 줄 수: { INFO: 168000, WARN: 28000, ERROR: 4000 }
약 9MB, 20만 줄 파일을 한 줄씩 흘려 읽어 레벨별로 셌다. 50줄마다 ERROR, 7줄마다 WARN(둘 다 해당하면 ERROR)을 넣었으므로 4000, 28000(7의 배수 28571개 중 350의 배수 571개는 ERROR), 나머지 168000이 맞다.
실무에서 자주 틀리는 것
1. 모든 오류를 빈 값으로 삼킨다
catch { return []; }는 편하지만, 권한 오류와 디스크 오류까지 "데이터 없음"으로 바꾼다. 예상한 실패(ENOENT)만 골라서 처리하고 나머지는 올려 보낸다.
2. 먼저 확인하고 나중에 연다
if (existsSync(file)) { await readFile(file) }처럼 확인한 뒤 여는 코드는, 확인과 열기 사이에 파일이 지워지면 여전히 실패한다. 그냥 열어 보고 ENOENT를 처리하는 편이 짧고 정확하다.
3. 인코딩을 빼먹는다
readFile(file)처럼 인코딩을 주지 않으면 문자열이 아니라 바이트 묶음인 Buffer가 온다. includes나 split 결과가 이상하면 제일 먼저 'utf8'을 줬는지 본다(연습 문제 2번).
4. 임시 파일을 다른 디스크에 만든다
원자적 쓰기의 임시 파일을 /tmp 같은 다른 위치에 만들면 rename이 디스크를 넘어가면서 실패(EXDEV)하거나 원자성이 사라진다. 임시 파일은 대상 파일과 같은 폴더에 만든다.
연습 문제
saveMemos가 임시 파일 없이writeFile(file, ...)한 줄로 저장하게 바꾸면, 평소에는 결과가 같다. 어떤 상황에서 차이가 나는가? 그때loadMemos는 무엇을 던지는가?console.log(await readFile(join(import.meta.dirname, 'notes.txt')))를 실행하면 무엇이 찍히는가?.length는 글자 수와 같은가?count-errors.mjs를 고쳐 첫 번째 ERROR 줄의 줄 번호도 출력하게 하라.
정답과 해설
- 쓰는 도중에 프로세스가 죽거나(강제 종료, 전원 차단) 디스크가 가득 차면 앞부분만 쓰인 파일이 남는다. 다음 실행에서
loadMemos는 파일 경로가 붙은JSON 이 깨졌습니다오류를 던지고, 서버는 메모를 하나도 읽지 못한다. 임시 파일 방식에서는 쓰다 죽어도.tmp파일만 망가지고 원래 파일은 그대로다. <Buffer 63 68 30 33 20 ed 8f b4 ...>같은 바이트 목록이 찍힌다.63 68 30 33이ch03,ed 8f b4가폴이다..length는 글자 수가 아니라 바이트 수다. UTF-8에서 영문·숫자·공백은 1바이트, 한글 한 글자는 3바이트라서ch03 폴더에 있는 메모와 줄바꿈(모두 15글자)은 29바이트다(검증 스크립트로 확인). 문자열로 쓰려면readFile(path, 'utf8')처럼 인코딩을 준다.for await바깥에 줄 번호 변수 두 개를 두고, 안에서 세면 된다. 검증 스크립트에서 같은 방식으로 세어 첫 ERROR가 50번째 줄임을 확인했다.let lineNo = 0; let firstError = null; for await (const line of rl) { lineNo += 1; const level = line.split(' ')[1]; if (level in counts) counts[level] += 1; if (level === 'ERROR' && firstError === null) firstError = lineNo; } console.log('첫 ERROR 줄 번호:', firstError);
이제 재료가 모였다. 다음 단원에서 node:http로 서버를 띄우고, 주소에 따라 다른 답을 돌려주는 라우팅을 직접 만든다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.